2019-05-20 21:26:01 +02:00
//
// M y A p p s V i e w C o n t r o l l e r . s w i f t
// A l t S t o r e
//
2019-07-19 16:42:40 -07:00
// C r e a t e d b y R i l e y T e s t u t o n 7 / 1 6 / 1 9 .
2019-05-20 21:26:01 +02:00
// C o p y r i g h t © 2 0 1 9 R i l e y T e s t u t . A l l r i g h t s r e s e r v e d .
//
import UIKit
2020-03-20 15:36:37 -07:00
import MobileCoreServices
2020-09-08 12:29:44 -07:00
import Intents
2021-09-15 14:27:16 -07:00
import Combine
2023-03-02 15:27:31 -06:00
import UniformTypeIdentifiers
2019-07-19 16:42:40 -07:00
2020-09-03 16:39:08 -07:00
import AltStoreCore
2019-07-28 15:51:36 -07:00
import AltSign
2020-09-03 16:39:08 -07:00
import Roxas
2023-10-20 21:43:51 -04:00
import minimuxer
2019-07-28 15:51:36 -07:00
2019-08-20 19:06:03 -05:00
import Nuke
2019-07-19 16:42:40 -07:00
private let maximumCollapsedUpdatesCount = 2
2019-06-10 15:03:47 -07:00
2019-07-19 16:42:40 -07:00
extension MyAppsViewController
{
private enum Section : Int , CaseIterable
{
2019-07-24 15:01:33 -07:00
case noUpdates
2019-07-19 16:42:40 -07:00
case updates
2020-03-11 14:43:19 -07:00
case activeApps
case inactiveApps
2019-07-19 16:42:40 -07:00
}
}
2023-01-04 09:52:12 -05:00
final class MyAppsViewController : UICollectionViewController
2019-05-20 21:26:01 +02:00
{
2020-05-16 16:34:50 -07:00
private let coordinator = NSFileCoordinator ( )
2020-05-17 23:44:36 -07:00
private let operationQueue = OperationQueue ( )
2020-05-16 16:34:50 -07:00
2019-05-20 21:26:01 +02:00
private lazy var dataSource = self . makeDataSource ( )
2019-07-24 15:01:33 -07:00
private lazy var noUpdatesDataSource = self . makeNoUpdatesDataSource ( )
2019-07-19 16:42:40 -07:00
private lazy var updatesDataSource = self . makeUpdatesDataSource ( )
2020-03-11 14:43:19 -07:00
private lazy var activeAppsDataSource = self . makeActiveAppsDataSource ( )
private lazy var inactiveAppsDataSource = self . makeInactiveAppsDataSource ( )
2019-05-20 21:26:01 +02:00
2019-07-19 16:42:40 -07:00
private var prototypeUpdateCell : UpdateCollectionViewCell !
2019-07-28 15:51:36 -07:00
private var sideloadingProgressView : UIProgressView !
2019-05-20 21:26:01 +02:00
2019-07-19 16:42:40 -07:00
// S t a t e
private var isUpdateSectionCollapsed = true
private var expandedAppUpdates = Set < String > ( )
private var isRefreshingAllApps = false
2020-03-06 17:08:35 -08:00
private var refreshGroup : RefreshGroup ?
2019-07-28 15:51:36 -07:00
private var sideloadingProgress : Progress ?
2020-03-20 16:32:31 -07:00
private var dropDestinationIndexPath : IndexPath ?
2019-06-21 11:20:03 -07:00
2020-10-01 14:09:45 -07:00
private var _imagePickerInstalledApp : InstalledApp ?
2019-07-19 16:42:40 -07:00
// C a c h e
private var cachedUpdateSizes = [ String : CGSize ] ( )
2019-06-10 15:03:47 -07:00
2019-07-19 16:42:40 -07:00
private lazy var dateFormatter : DateFormatter = {
let dateFormatter = DateFormatter ( )
dateFormatter . dateStyle = . medium
dateFormatter . timeStyle = . none
return dateFormatter
} ( )
2019-06-06 12:56:13 -07:00
2019-07-19 16:42:40 -07:00
required init ? ( coder aDecoder : NSCoder )
2019-06-06 12:56:13 -07:00
{
2019-07-19 16:42:40 -07:00
super . init ( coder : aDecoder )
2019-06-06 12:56:13 -07:00
2019-07-30 17:00:04 -07:00
NotificationCenter . default . addObserver ( self , selector : #selector ( MyAppsViewController . didFetchSource ( _ : ) ) , name : AppManager . didFetchSourceNotification , object : nil )
2019-09-27 17:39:36 -07:00
NotificationCenter . default . addObserver ( self , selector : #selector ( MyAppsViewController . importApp ( _ : ) ) , name : AppDelegate . importAppDeepLinkNotification , object : nil )
2019-05-20 21:26:01 +02:00
}
2019-05-20 21:40:04 +02:00
2019-07-19 16:42:40 -07:00
override func viewDidLoad ( )
2019-05-20 21:40:04 +02:00
{
2019-07-19 16:42:40 -07:00
super . viewDidLoad ( )
2019-05-20 21:40:04 +02:00
2019-07-24 15:01:33 -07:00
// A l l o w s u s t o i n t e r c e p t d e l e g a t e c a l l b a c k s .
self . updatesDataSource . fetchedResultsController . delegate = self
2019-07-19 16:42:40 -07:00
self . collectionView . dataSource = self . dataSource
self . collectionView . prefetchDataSource = self . dataSource
2020-03-20 16:32:31 -07:00
self . collectionView . dragDelegate = self
self . collectionView . dropDelegate = self
self . collectionView . dragInteractionEnabled = true
2019-07-19 16:42:40 -07:00
self . prototypeUpdateCell = UpdateCollectionViewCell . instantiate ( with : UpdateCollectionViewCell . nib ! )
self . prototypeUpdateCell . contentView . translatesAutoresizingMaskIntoConstraints = false
2019-05-20 21:40:04 +02:00
2019-07-19 16:42:40 -07:00
self . collectionView . register ( UpdateCollectionViewCell . nib , forCellWithReuseIdentifier : " UpdateCell " )
self . collectionView . register ( UpdatesCollectionHeaderView . self , forSupplementaryViewOfKind : UICollectionView . elementKindSectionHeader , withReuseIdentifier : " UpdatesHeader " )
2020-03-11 14:43:19 -07:00
self . collectionView . register ( InstalledAppsCollectionHeaderView . self , forSupplementaryViewOfKind : UICollectionView . elementKindSectionHeader , withReuseIdentifier : " ActiveAppsHeader " )
self . collectionView . register ( InstalledAppsCollectionHeaderView . self , forSupplementaryViewOfKind : UICollectionView . elementKindSectionHeader , withReuseIdentifier : " InactiveAppsHeader " )
2019-07-28 15:51:36 -07:00
self . sideloadingProgressView = UIProgressView ( progressViewStyle : . bar )
self . sideloadingProgressView . translatesAutoresizingMaskIntoConstraints = false
2019-09-19 11:29:10 -07:00
self . sideloadingProgressView . progressTintColor = . altPrimary
2019-07-28 15:51:36 -07:00
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 ) ] )
}
2020-03-20 15:33:29 -07:00
if #available ( iOS 13 , * ) { }
else
{
self . registerForPreviewing ( with : self , sourceView : self . collectionView )
}
2019-05-20 21:40:04 +02:00
}
2019-07-24 12:23:54 -07:00
2019-08-28 11:13:22 -07:00
override func viewWillAppear ( _ animated : Bool )
{
super . viewWillAppear ( animated )
self . updateDataSource ( )
2020-02-10 17:30:11 -08:00
self . fetchAppIDs ( )
2019-08-28 11:13:22 -07:00
}
2019-07-24 12:23:54 -07:00
override func prepare ( for segue : UIStoryboardSegue , sender : Any ? )
{
2019-09-12 13:51:03 -07:00
guard let identifier = segue . identifier else { return }
2019-07-24 12:23:54 -07:00
2019-09-12 13:51:03 -07:00
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
}
2019-07-24 12:23:54 -07:00
}
2019-07-28 15:51:36 -07:00
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
}
2020-02-10 17:30:11 -08:00
@IBAction func unwindToMyAppsViewController ( _ segue : UIStoryboardSegue )
{
}
2024-08-06 10:43:52 +09:00
var minimuxerStatus : Bool {
guard minimuxer . ready ( ) else {
ToastView ( error : ( OperationError . noWiFi as NSError ) . withLocalizedTitle ( " No WiFi or VPN! " ) ) . show ( in : self )
return false
}
return true
}
2019-05-20 21:26:01 +02:00
}
private extension MyAppsViewController
{
2019-07-19 16:42:40 -07:00
func makeDataSource ( ) -> RSTCompositeCollectionViewPrefetchingDataSource < InstalledApp , UIImage >
{
2020-03-11 14:43:19 -07:00
let dataSource = RSTCompositeCollectionViewPrefetchingDataSource < InstalledApp , UIImage > ( dataSources : [ self . noUpdatesDataSource , self . updatesDataSource , self . activeAppsDataSource , self . inactiveAppsDataSource ] )
2019-07-19 16:42:40 -07:00
dataSource . proxy = self
return dataSource
}
2019-07-24 15:01:33 -07: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 " }
dynamicDataSource . cellConfigurationHandler = { ( cell , _ , indexPath ) 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
cell . blurView . layer . cornerRadius = 20
cell . blurView . layer . masksToBounds = true
cell . blurView . backgroundColor = . altPrimary
2019-07-24 15:01:33 -07:00
}
return dynamicDataSource
}
2019-07-19 16:42:40 -07:00
func makeUpdatesDataSource ( ) -> RSTFetchedResultsCollectionViewPrefetchingDataSource < InstalledApp , UIImage >
2019-05-20 21:26:01 +02:00
{
2019-07-24 13:52:58 -07:00
let fetchRequest = InstalledApp . updatesFetchRequest ( )
2024-08-06 10:43:52 +09:00
fetchRequest . sortDescriptors = [ NSSortDescriptor ( keyPath : \ InstalledApp . storeApp ? . latestSupportedVersion ? . date , ascending : false ) ,
2019-07-28 15:08:13 -07:00
NSSortDescriptor ( keyPath : \ InstalledApp . name , ascending : true ) ]
2019-05-20 21:26:01 +02:00
fetchRequest . returnsObjectsAsFaults = false
2019-07-19 16:42:40 -07:00
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource < InstalledApp , UIImage > ( fetchRequest : fetchRequest , managedObjectContext : DatabaseManager . shared . viewContext )
dataSource . liveFetchLimit = maximumCollapsedUpdatesCount
dataSource . cellIdentifierHandler = { _ in " UpdateCell " }
2019-09-12 13:51:03 -07:00
dataSource . cellConfigurationHandler = { [ weak self ] ( cell , installedApp , indexPath ) in
guard let self = self else { return }
2024-08-06 10:43:52 +09:00
guard let app = installedApp . storeApp , let latestSupportedVersion = app . latestSupportedVersion else { return }
2019-05-20 21:26:01 +02:00
2019-07-19 16:42:40 -07: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
2019-09-19 11:29:10 -07:00
cell . tintColor = app . tintColor ? ? . altPrimary
2024-08-06 10:43:52 +09:00
cell . versionDescriptionTextView . text = latestSupportedVersion . localizedDescription
2019-05-20 21:26:01 +02:00
2019-10-23 13:32:17 -07:00
cell . bannerView . iconImageView . image = nil
cell . bannerView . iconImageView . isIndicatingActivity = true
2020-08-27 15:23:21 -07:00
cell . bannerView . configure ( for : app )
2024-08-06 10:43:52 +09:00
let versionDate = Date ( ) . relativeDateString ( since : latestSupportedVersion . date , dateFormatter : self . dateFormatter )
2020-08-27 15:23:21 -07:00
cell . bannerView . subtitleLabel . text = versionDate
let appName : String
if app . isBeta
{
appName = String ( format : NSLocalizedString ( " %@ beta " , comment : " " ) , app . name )
}
else
{
appName = app . name
}
2024-08-06 10:43:52 +09:00
cell . bannerView . accessibilityLabel = String ( format : NSLocalizedString ( " %@ %@ update. Released on %@. " , comment : " " ) , appName , latestSupportedVersion . version , versionDate )
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 )
2019-06-06 12:56:13 -07:00
2019-07-28 15:08:13 -07:00
if self . expandedAppUpdates . contains ( app . bundleIdentifier )
2019-06-06 12:56:13 -07:00
{
2019-07-19 16:42:40 -07:00
cell . mode = . expanded
2019-06-06 12:56:13 -07:00
}
else
{
2019-07-19 16:42:40 -07:00
cell . mode = . collapsed
2019-06-06 12:56:13 -07:00
}
2019-07-19 16:42:40 -07:00
2019-07-24 12:23:54 -07:00
cell . versionDescriptionTextView . moreButton . addTarget ( self , action : #selector ( MyAppsViewController . toggleUpdateCellMode ( _ : ) ) , for : . primaryActionTriggered )
2019-07-19 16:42:40 -07:00
let progress = AppManager . shared . installationProgress ( for : app )
2019-10-23 13:32:17 -07:00
cell . bannerView . button . progress = progress
2019-07-19 16:42:40 -07:00
cell . setNeedsLayout ( )
2019-05-20 21:26:01 +02:00
}
2019-08-20 19:06:03 -05:00
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
2019-08-27 16:00:59 -07:00
guard ! operation . isCancelled else { return operation . finish ( ) }
2019-08-20 19:06:03 -05:00
if let image = response ? . image
{
completionHandler ( image , nil )
}
else
{
completionHandler ( nil , error )
}
} )
}
}
dataSource . prefetchCompletionHandler = { ( cell , image , indexPath , 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
2019-08-20 19:06:03 -05:00
if let error = error
{
print ( " Error loading image: " , error )
}
}
2019-05-20 21:26:01 +02:00
return dataSource
}
2019-06-06 12:56:13 -07:00
2020-03-11 14:43:19 -07:00
func makeActiveAppsDataSource ( ) -> RSTFetchedResultsCollectionViewPrefetchingDataSource < InstalledApp , UIImage >
2019-06-06 12:56:13 -07:00
{
2020-03-11 14:43:19 -07:00
let fetchRequest = InstalledApp . activeAppsFetchRequest ( )
2019-07-28 15:08:13 -07:00
fetchRequest . relationshipKeyPathsForPrefetching = [ # keyPath ( InstalledApp . storeApp ) ]
2019-07-19 16:42:40 -07:00
fetchRequest . sortDescriptors = [ NSSortDescriptor ( keyPath : \ InstalledApp . expirationDate , ascending : true ) ,
NSSortDescriptor ( keyPath : \ InstalledApp . refreshedDate , ascending : false ) ,
2019-07-28 15:08:13 -07:00
NSSortDescriptor ( keyPath : \ InstalledApp . name , ascending : true ) ]
2019-07-19 16:42:40 -07:00
fetchRequest . returnsObjectsAsFaults = false
2019-06-06 12:56:13 -07:00
2019-07-19 16:42:40 -07:00
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource < InstalledApp , UIImage > ( fetchRequest : fetchRequest , managedObjectContext : DatabaseManager . shared . viewContext )
dataSource . cellIdentifierHandler = { _ in " AppCell " }
dataSource . cellConfigurationHandler = { ( cell , installedApp , indexPath ) in
2019-09-19 11:29:10 -07:00
let tintColor = installedApp . storeApp ? . tintColor ? ? . altPrimary
2019-07-19 16:42:40 -07: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
2019-07-19 16:42:40 -07:00
cell . tintColor = tintColor
2019-07-28 15:51:36 -07:00
2020-03-20 16:32:31 -07:00
cell . deactivateBadge ? . isHidden = false
if let dropIndexPath = self . dropDestinationIndexPath , dropIndexPath . section = = Section . activeApps . rawValue && dropIndexPath . item = = indexPath . item
{
cell . bannerView . alpha = 0.4
cell . deactivateBadge ? . alpha = 1.0
cell . deactivateBadge ? . transform = . identity
}
else
{
cell . bannerView . alpha = 1.0
cell . deactivateBadge ? . alpha = 0.0
cell . deactivateBadge ? . transform = CGAffineTransform . identity . scaledBy ( x : 0.33 , y : 0.33 )
}
2020-08-27 15:23:21 -07:00
cell . bannerView . configure ( for : installedApp )
2019-10-23 13:32:17 -07:00
cell . bannerView . iconImageView . isIndicatingActivity = true
2020-03-11 14:43:19 -07:00
cell . bannerView . buttonLabel . isHidden = false
cell . bannerView . buttonLabel . text = NSLocalizedString ( " Expires in " , comment : " " )
2019-10-23 13:32:17 -07:00
cell . bannerView . button . isIndicatingActivity = false
2020-03-20 15:52:11 -07:00
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 )
2019-07-19 16:42:40 -07:00
let currentDate = Date ( )
let numberOfDays = installedApp . expirationDate . numberOfCalendarDays ( since : currentDate )
2023-11-25 12:00:29 +09:00
let formatter = DateComponentsFormatter ( )
formatter . unitsStyle = . full
formatter . includesApproximationPhrase = false
formatter . includesTimeRemainingPhrase = false
2023-11-25 15:09:03 +09:00
formatter . allowedUnits = [ . day , . hour , . minute ]
2023-11-25 15:33:49 +09:00
2023-11-25 15:09:03 +09:00
formatter . maximumUnitCount = 1
2023-11-25 13:46:14 +09:00
2023-11-25 13:34:23 +09:00
cell . bannerView . button . setTitle ( formatter . string ( from : currentDate , to : installedApp . expirationDate ) ? . uppercased ( ) , for : . normal )
2020-08-27 15:23:21 -07:00
cell . bannerView . button . accessibilityLabel = String ( format : NSLocalizedString ( " Refresh %@ " , comment : " " ) , installedApp . name )
2023-11-25 12:00:29 +09:00
formatter . includesTimeRemainingPhrase = true
2023-11-25 13:34:23 +09:00
cell . bannerView . accessibilityLabel ? += " . " + ( formatter . string ( from : currentDate , to : installedApp . expirationDate ) ? ? NSLocalizedString ( " Unknown " , comment : " " ) ) + " "
2019-07-19 16:42:40 -07:00
// M a k e s u r e r e f r e s h b u t t o n i s c o r r e c t s i z e .
cell . layoutIfNeeded ( )
switch numberOfDays
{
2019-10-23 13:32:17 -07:00
case 2. . . 3 : cell . bannerView . button . tintColor = . refreshOrange
case 4. . . 5 : cell . bannerView . button . tintColor = . refreshYellow
case 6. . . : cell . bannerView . button . tintColor = . refreshGreen
default : cell . bannerView . button . tintColor = . refreshRed
2019-07-19 16:42:40 -07:00
}
2020-03-06 17:08:35 -08:00
if let progress = AppManager . shared . refreshProgress ( for : installedApp ) , progress . fractionCompleted < 1.0
2019-07-19 16:42:40 -07:00
{
2019-10-23 13:32:17 -07:00
cell . bannerView . button . progress = progress
2019-07-19 16:42:40 -07:00
}
else
{
2019-10-23 13:32:17 -07:00
cell . bannerView . button . progress = nil
2019-07-19 16:42:40 -07:00
}
}
2019-07-28 15:51:36 -07:00
dataSource . prefetchHandler = { ( item , indexPath , completion ) in
2020-10-01 14:09:45 -07:00
RSTAsyncBlockOperation { ( operation ) in
item . managedObjectContext ? . perform {
item . loadIcon { ( result ) in
switch result
{
case . failure ( let error ) : completion ( nil , error )
case . success ( let image ) : completion ( image , nil )
}
}
2019-07-28 15:51:36 -07:00
}
}
}
dataSource . prefetchCompletionHandler = { ( cell , image , indexPath , error ) in
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
}
2019-07-19 16:42:40 -07:00
return dataSource
2019-06-06 12:56:13 -07:00
}
2019-08-28 11:13:22 -07:00
2020-03-11 14:43:19 -07: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
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 ? ? . altPrimary
let cell = cell as ! InstalledAppCollectionViewCell
cell . layoutMargins . left = self . view . layoutMargins . left
cell . layoutMargins . right = self . view . layoutMargins . right
cell . tintColor = UIColor . gray
cell . bannerView . iconImageView . isIndicatingActivity = true
cell . bannerView . buttonLabel . isHidden = true
2020-03-20 16:32:31 -07:00
cell . bannerView . alpha = 1.0
cell . deactivateBadge ? . isHidden = true
cell . deactivateBadge ? . alpha = 0.0
cell . deactivateBadge ? . transform = CGAffineTransform . identity . scaledBy ( x : 0.5 , y : 0.5 )
2020-03-11 14:43:19 -07:00
2020-08-27 15:23:21 -07:00
cell . bannerView . configure ( for : installedApp )
2020-03-11 14:43:19 -07:00
cell . bannerView . button . isIndicatingActivity = false
cell . bannerView . button . tintColor = tintColor
cell . bannerView . button . setTitle ( NSLocalizedString ( " ACTIVATE " , comment : " " ) , for : . normal )
2020-03-20 15:52:11 -07:00
cell . bannerView . button . removeTarget ( self , action : nil , for : . primaryActionTriggered )
2020-03-11 14:43:19 -07:00
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 )
2020-03-11 14:43:19 -07:00
// M a k e s u r e r e f r e s h b u t t o n i s c o r r e c t s i z e .
cell . layoutIfNeeded ( )
// E n s u r e n o l e f t o v e r p r o g r e s s f r o m a c t i v e a p p s c e l l r e u s e .
cell . bannerView . button . progress = nil
2020-05-16 16:17:18 -07:00
if let progress = AppManager . shared . refreshProgress ( for : installedApp ) , progress . fractionCompleted < 1.0
{
cell . bannerView . button . progress = progress
}
else
{
cell . bannerView . button . progress = nil
}
2020-03-11 14:43:19 -07:00
}
dataSource . prefetchHandler = { ( item , indexPath , completion ) in
2020-10-01 14:09:45 -07:00
RSTAsyncBlockOperation { ( operation ) in
item . managedObjectContext ? . perform {
item . loadIcon { ( result ) in
switch result
{
case . failure ( let error ) : completion ( nil , error )
case . success ( let image ) : completion ( image , nil )
}
}
2020-03-11 14:43:19 -07:00
}
}
}
dataSource . prefetchCompletionHandler = { ( cell , image , indexPath , error ) in
let cell = cell as ! InstalledAppCollectionViewCell
cell . bannerView . iconImageView . image = image
cell . bannerView . iconImageView . isIndicatingActivity = false
}
return dataSource
}
2019-08-28 11:13:22 -07:00
func updateDataSource ( )
{
2022-12-08 19:24:28 -06:00
2019-08-28 11:13:22 -07:00
self . dataSource . predicate = nil
2022-12-08 19:24:28 -06:00
2019-08-28 11:13:22 -07:00
}
2019-06-06 12:56:13 -07:00
}
private extension MyAppsViewController
{
2019-07-19 16:42:40 -07:00
func update ( )
2019-06-06 12:56:13 -07:00
{
2019-07-19 16:42:40 -07:00
if self . updatesDataSource . itemCount > 0
{
self . navigationController ? . tabBarItem . badgeValue = String ( describing : self . updatesDataSource . itemCount )
2019-07-24 13:52:58 -07:00
UIApplication . shared . applicationIconBadgeNumber = Int ( self . updatesDataSource . itemCount )
2019-07-19 16:42:40 -07:00
}
else
{
self . navigationController ? . tabBarItem . badgeValue = nil
2019-07-24 13:52:58 -07:00
UIApplication . shared . applicationIconBadgeNumber = 0
2019-06-21 11:20:03 -07:00
}
2019-07-24 15:01:33 -07:00
2019-09-12 12:49:19 -07:00
if self . isViewLoaded
{
UIView . performWithoutAnimation {
self . collectionView . reloadSections ( IndexSet ( integer : Section . updates . rawValue ) )
}
}
2019-06-21 11:20:03 -07:00
}
2020-02-10 17:30:11 -08:00
func fetchAppIDs ( )
{
AppManager . shared . fetchAppIDs { ( result ) in
do
{
let ( _ , context ) = try result . get ( )
try context . save ( )
}
catch
{
print ( " Failed to fetch App IDs. " , error )
}
}
}
2020-03-06 17:08:35 -08:00
func refresh ( _ installedApps : [ InstalledApp ] , completionHandler : @ escaping ( [ String : Result < InstalledApp , Error > ] ) -> Void )
2019-06-21 11:20:03 -07:00
{
2020-03-06 17:08:35 -08:00
let group = AppManager . shared . refresh ( installedApps , presentingViewController : self , group : self . refreshGroup )
group . completionHandler = { ( results ) in
DispatchQueue . main . async {
let failures = results . compactMapValues { ( result ) -> Error ? in
2019-06-25 14:35:00 -07:00
switch result
2019-06-06 12:56:13 -07:00
{
2020-03-06 17:08:35 -08:00
case . failure ( OperationError . cancelled ) : return nil
case . failure ( let error ) : return error
case . success : return nil
}
}
guard ! failures . isEmpty else { return }
if let failure = failures . first , results . count = = 1
{
2024-08-06 10:43:52 +09:00
ToastView ( error : failure . value ) . show ( in : self )
2020-03-06 17:08:35 -08:00
}
else
{
let localizedText : String
if failures . count = = 1
{
localizedText = NSLocalizedString ( " Failed to refresh 1 app. " , comment : " " )
}
else
{
localizedText = String ( format : NSLocalizedString ( " Failed to refresh %@ apps. " , comment : " " ) , NSNumber ( value : failures . count ) )
2019-06-06 12:56:13 -07:00
}
2020-03-11 14:43:19 -07:00
let error = failures . first ? . value as NSError ?
2020-03-19 15:02:35 -07:00
let detailText = error ? . localizedFailure ? ? error ? . localizedFailureReason ? ? error ? . localizedDescription
2020-03-06 17:08:35 -08:00
2024-08-06 10:43:52 +09:00
let toastView = ToastView ( text : localizedText , detailText : detailText , opensLog : true )
2020-03-11 14:43:19 -07:00
toastView . preferredDuration = 4.0
2024-08-06 10:43:52 +09:00
toastView . show ( in : self )
2019-06-06 12:56:13 -07:00
}
2019-06-10 15:03:47 -07:00
}
2019-06-25 14:35:00 -07:00
2020-03-06 17:08:35 -08:00
self . refreshGroup = nil
completionHandler ( results )
2019-06-10 15:03:47 -07:00
}
2020-03-06 17:08:35 -08:00
self . refreshGroup = group
UIView . performWithoutAnimation {
2020-03-11 14:43:19 -07:00
self . collectionView . reloadSections ( [ Section . activeApps . rawValue , Section . inactiveApps . rawValue ] )
2019-06-25 14:35:00 -07:00
}
2019-06-06 12:56:13 -07:00
}
2019-05-20 21:26:01 +02:00
}
2019-05-20 21:36:39 +02:00
2019-07-19 16:42:40 -07:00
private extension MyAppsViewController
2019-05-20 21:36:39 +02:00
{
2019-07-19 16:42:40 -07:00
@IBAction func toggleAppUpdates ( _ sender : UIButton )
2019-05-20 21:36:39 +02:00
{
2019-07-19 16:42:40 -07:00
let visibleCells = self . collectionView . visibleCells
self . collectionView . performBatchUpdates ( {
2019-05-20 21:36:39 +02:00
2019-07-19 16:42:40 -07:00
self . isUpdateSectionCollapsed . toggle ( )
UIView . animate ( withDuration : 0.3 , animations : {
if self . isUpdateSectionCollapsed
2019-06-05 11:03:49 -07:00
{
2019-07-19 16:42:40 -07:00
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
2019-06-05 11:03:49 -07:00
}
2019-07-19 16:42:40 -07:00
else
2019-06-05 11:03:49 -07:00
{
2019-07-19 16:42:40 -07:00
self . updatesDataSource . liveFetchLimit = 0
sender . titleLabel ? . transform = CGAffineTransform . identity . rotated ( by : . pi )
2019-06-05 11:03:49 -07:00
}
2019-07-19 16:42:40 -07:00
} )
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
2019-07-28 15:08:13 -07:00
if self . expandedAppUpdates . contains ( installedApp . bundleIdentifier )
2019-07-19 16:42:40 -07:00
{
2019-07-28 15:08:13 -07:00
self . expandedAppUpdates . remove ( installedApp . bundleIdentifier )
2019-07-19 16:42:40 -07:00
cell ? . mode = . collapsed
}
else
{
2019-07-28 15:08:13 -07:00
self . expandedAppUpdates . insert ( installedApp . bundleIdentifier )
2019-07-19 16:42:40 -07:00
cell ? . mode = . expanded
2019-06-05 11:03:49 -07:00
}
2019-07-28 15:08:13 -07:00
self . cachedUpdateSizes [ installedApp . bundleIdentifier ] = nil
2019-07-19 16:42:40 -07:00
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 )
2020-03-11 14:43:19 -07:00
self . refresh ( installedApp )
2019-06-05 11:03:49 -07:00
}
2019-07-19 16:42:40 -07:00
@IBAction func refreshAllApps ( _ sender : UIBarButtonItem )
2019-06-05 11:03:49 -07:00
{
2024-08-06 10:43:52 +09:00
guard minimuxerStatus else { return }
2023-10-20 21:43:51 -04:00
2019-07-19 16:42:40 -07:00
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
2020-03-11 14:43:19 -07:00
self . collectionView . reloadSections ( [ Section . activeApps . rawValue , Section . inactiveApps . rawValue ] )
2019-07-19 16:42:40 -07:00
}
}
2020-09-08 12:29:44 -07:00
if #available ( iOS 14 , * )
{
let interaction = INInteraction . refreshAllApps ( )
interaction . donate { ( error ) in
guard let error = error else { return }
print ( " Failed to donate intent \( interaction . intent ) . " , error )
}
}
2019-05-20 21:36:39 +02:00
}
2019-06-06 12:56:13 -07:00
2019-07-19 16:42:40 -07:00
@IBAction func updateApp ( _ sender : UIButton )
2019-06-06 12:56:13 -07:00
{
2019-07-19 16:42:40 -07:00
let point = self . collectionView . convert ( sender . center , from : sender . superview )
guard let indexPath = self . collectionView . indexPathForItem ( at : point ) else { return }
2019-06-06 12:56:13 -07:00
2020-03-31 14:31:34 -07:00
let installedApp = self . dataSource . item ( at : indexPath )
2019-06-06 12:56:13 -07:00
2020-03-31 14:31:34 -07:00
let previousProgress = AppManager . shared . installationProgress ( for : installedApp )
2019-07-19 16:42:40 -07:00
guard previousProgress = = nil else {
previousProgress ? . cancel ( )
return
}
2020-03-31 14:31:34 -07:00
_ = AppManager . shared . update ( installedApp , presentingViewController : self ) { ( result ) in
2019-07-19 16:42:40 -07:00
DispatchQueue . main . async {
switch result
{
case . failure ( OperationError . cancelled ) :
self . collectionView . reloadItems ( at : [ indexPath ] )
case . failure ( let error ) :
2024-08-06 10:43:52 +09:00
ToastView ( error : error , opensLog : true ) . show ( in : self )
2019-07-19 16:42:40 -07:00
self . collectionView . reloadItems ( at : [ indexPath ] )
case . success :
2020-03-31 14:31:34 -07:00
print ( " Updated app: " , installedApp . bundleIdentifier )
2019-07-19 16:42:40 -07:00
// N o n e e d t o r e l o a d , s i n c e t h e t h e u p d a t e c e l l i s g o n e n o w .
}
2019-07-24 15:01:33 -07:00
self . update ( )
2019-07-19 16:42:40 -07:00
}
}
self . collectionView . reloadItems ( at : [ indexPath ] )
}
2019-07-28 15:51:36 -07:00
@IBAction func sideloadApp ( _ sender : UIBarButtonItem )
{
2024-08-06 10:43:52 +09:00
guard minimuxerStatus else { return }
2023-10-20 21:43:51 -04:00
2023-03-02 15:27:31 -06:00
let supportedTypes = UTType . types ( tag : " ipa " , tagClass : . filenameExtension , conformingTo : nil )
2020-03-30 13:25:14 -07:00
2023-03-02 15:27:31 -06:00
let documentPickerViewController = UIDocumentPickerViewController ( forOpeningContentTypes : supportedTypes , asCopy : true )
2020-03-30 13:25:14 -07:00
documentPickerViewController . delegate = self
self . present ( documentPickerViewController , animated : true , completion : nil )
2019-07-28 15:51:36 -07:00
}
2020-05-17 23:44:36 -07:00
func sideloadApp ( at url : URL , completion : @ escaping ( Result < Void , Error > ) -> Void )
2019-09-27 17:39:36 -07:00
{
2020-05-17 23:44:36 -07:00
let progress = Progress . discreteProgress ( totalUnitCount : 100 )
2020-03-20 15:56:10 -07:00
2019-09-27 17:39:36 -07:00
self . navigationItem . leftBarButtonItem ? . isIndicatingActivity = true
2020-05-17 23:44:36 -07:00
class Context
2020-03-20 15:56:10 -07:00
{
2020-05-17 23:44:36 -07:00
var fileURL : URL ?
var application : ALTApplication ?
var installedApp : InstalledApp ? {
didSet {
self . installedAppContext = self . installedApp ? . managedObjectContext
}
}
private var installedAppContext : NSManagedObjectContext ?
2019-09-27 17:39:36 -07:00
2020-05-17 23:44:36 -07:00
var error : Error ?
}
let temporaryDirectory = FileManager . default . uniqueTemporaryURL ( )
let unzippedAppDirectory = temporaryDirectory . appendingPathComponent ( " App " )
let context = Context ( )
let downloadOperation : RSTAsyncBlockOperation ?
if url . isFileURL
{
downloadOperation = nil
context . fileURL = url
progress . totalUnitCount -= 20
}
else
{
let downloadProgress = Progress . discreteProgress ( totalUnitCount : 100 )
downloadOperation = RSTAsyncBlockOperation { ( operation ) in
let downloadTask = URLSession . shared . downloadTask ( with : url ) { ( fileURL , response , error ) in
do
{
let ( fileURL , _ ) = try Result ( ( fileURL , response ) , error ) . get ( )
try FileManager . default . createDirectory ( at : temporaryDirectory , withIntermediateDirectories : true , attributes : nil )
let destinationURL = temporaryDirectory . appendingPathComponent ( " App.ipa " )
try FileManager . default . moveItem ( at : fileURL , to : destinationURL )
context . fileURL = destinationURL
}
catch
{
context . error = error
}
operation . finish ( )
2020-03-20 15:56:10 -07:00
}
2020-05-17 23:44:36 -07:00
downloadProgress . addChild ( downloadTask . progress , withPendingUnitCount : 100 )
downloadTask . resume ( )
2020-03-20 15:56:10 -07:00
}
2020-05-17 23:44:36 -07:00
progress . addChild ( downloadProgress , withPendingUnitCount : 20 )
2020-03-20 15:56:10 -07:00
}
2020-05-17 23:44:36 -07:00
let unzipProgress = Progress . discreteProgress ( totalUnitCount : 1 )
2024-08-16 12:57:42 +09:00
let unzipAppOperation = BlockOperation {
2020-03-20 15:56:10 -07:00
do
{
2020-05-17 23:44:36 -07:00
if let error = context . error
{
throw error
}
2020-03-20 15:56:10 -07:00
2020-05-17 23:44:36 -07:00
guard let fileURL = context . fileURL else { throw OperationError . invalidParameters }
2021-10-25 21:46:19 -07:00
defer {
try ? FileManager . default . removeItem ( at : fileURL )
}
2020-05-17 23:44:36 -07:00
try FileManager . default . createDirectory ( at : unzippedAppDirectory , withIntermediateDirectories : true , attributes : nil )
let unzippedApplicationURL = try FileManager . default . unzipAppBundle ( at : fileURL , toDirectory : unzippedAppDirectory )
2020-03-20 15:56:10 -07:00
guard let application = ALTApplication ( fileURL : unzippedApplicationURL ) else { throw OperationError . invalidApp }
2020-05-17 23:44:36 -07:00
context . application = application
2020-03-20 15:56:10 -07:00
2020-05-17 23:44:36 -07:00
unzipProgress . completedUnitCount = 1
}
catch
{
context . error = error
}
}
progress . addChild ( unzipProgress , withPendingUnitCount : 10 )
if let downloadOperation = downloadOperation
{
unzipAppOperation . addDependency ( downloadOperation )
}
let installProgress = Progress . discreteProgress ( totalUnitCount : 100 )
let installAppOperation = RSTAsyncBlockOperation { ( operation ) in
do
{
if let error = context . error
2020-03-20 15:56:10 -07:00
{
2020-05-17 23:44:36 -07:00
throw error
2020-03-20 15:56:10 -07:00
}
2020-05-17 23:44:36 -07:00
guard let application = context . application else { throw OperationError . invalidParameters }
2021-10-25 22:27:30 -07:00
let group = AppManager . shared . install ( application , presentingViewController : self ) { ( result ) in
2020-05-17 23:44:36 -07:00
switch result
{
case . success ( let installedApp ) : context . installedApp = installedApp
case . failure ( let error ) : context . error = error
}
operation . finish ( )
}
2021-10-25 22:27:30 -07:00
installProgress . addChild ( group . progress , withPendingUnitCount : 100 )
2020-03-20 15:56:10 -07:00
}
catch
{
2020-05-17 23:44:36 -07:00
context . error = error
operation . finish ( )
}
}
installAppOperation . completionBlock = {
try ? FileManager . default . removeItem ( at : temporaryDirectory )
DispatchQueue . main . async {
self . navigationItem . leftBarButtonItem ? . isIndicatingActivity = false
self . sideloadingProgressView . observedProgress = nil
self . sideloadingProgressView . setHidden ( true , animated : true )
switch Result ( context . installedApp , context . error )
{
case . success ( let app ) :
completion ( . success ( ( ) ) )
app . managedObjectContext ? . perform {
print ( " Successfully installed app: " , app . bundleIdentifier )
}
case . failure ( OperationError . cancelled ) :
completion ( . failure ( ( OperationError . cancelled ) ) )
case . failure ( let error ) :
2024-08-06 10:43:52 +09:00
ToastView ( error : error , opensLog : true ) . show ( in : self )
2020-05-17 23:44:36 -07:00
completion ( . failure ( error ) )
}
2019-09-27 17:39:36 -07:00
}
}
2024-08-16 12:57:42 +09:00
installAppOperation . addDependency ( unzipAppOperation )
2020-05-17 23:44:36 -07:00
progress . addChild ( installProgress , withPendingUnitCount : 65 )
self . sideloadingProgress = progress
self . sideloadingProgressView . progress = 0
self . sideloadingProgressView . isHidden = false
self . sideloadingProgressView . observedProgress = self . sideloadingProgress
2024-08-16 12:57:42 +09:00
let operations = [ downloadOperation , unzipAppOperation , installAppOperation ] . compactMap { $0 }
2020-05-17 23:44:36 -07:00
self . operationQueue . addOperations ( operations , waitUntilFinished : false )
2019-09-27 17:39:36 -07:00
}
2020-03-11 14:43:19 -07:00
@IBAction func activateApp ( _ 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 )
self . activate ( installedApp )
}
@IBAction func deactivateApp ( _ 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 )
self . deactivate ( installedApp )
}
@objc func presentInactiveAppsAlert ( )
{
2020-05-17 23:36:30 -07:00
let message : String
if UserDefaults . standard . activeAppLimitIncludesExtensions
{
2021-10-04 15:29:10 -07:00
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 : " " )
2020-05-17 23:36:30 -07:00
}
else
{
2021-10-04 15:29:10 -07:00
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 : " " )
2020-05-17 23:36:30 -07:00
}
let alertController = UIAlertController ( title : NSLocalizedString ( " What are inactive apps? " , comment : " " ) , message : message , preferredStyle : . alert )
2020-03-11 14:43:19 -07:00
alertController . addAction ( . ok )
self . present ( alertController , animated : true , completion : nil )
}
2020-03-20 16:32:31 -07:00
func updateCell ( at indexPath : IndexPath )
2020-03-11 14:43:19 -07:00
{
2020-03-20 16:32:31 -07:00
guard let cell = collectionView . cellForItem ( at : indexPath ) as ? InstalledAppCollectionViewCell else { return }
2020-03-11 14:43:19 -07:00
2020-03-20 16:32:31 -07:00
let installedApp = self . dataSource . item ( at : indexPath )
self . dataSource . cellConfigurationHandler ( cell , installedApp , indexPath )
2020-03-11 14:43:19 -07:00
2020-03-20 16:32:31 -07:00
cell . bannerView . iconImageView . isIndicatingActivity = false
2020-03-11 14:43:19 -07:00
}
}
private extension MyAppsViewController
{
2021-09-02 15:52:59 -05:00
func open ( _ installedApp : InstalledApp )
{
UIApplication . shared . open ( installedApp . openAppURL ) { success in
guard ! success else { return }
2024-08-06 10:43:52 +09:00
ToastView ( error : OperationError . openAppFailed ( name : installedApp . name ) , opensLog : true ) . show ( in : self )
2021-09-02 15:52:59 -05:00
}
}
2020-03-11 14:43:19 -07:00
func refresh ( _ installedApp : InstalledApp )
{
2024-08-06 10:43:52 +09:00
guard minimuxerStatus else { return }
2023-10-20 21:43:51 -04:00
2020-03-11 14:43:19 -07:00
let previousProgress = AppManager . shared . refreshProgress ( for : installedApp )
guard previousProgress = = nil else {
previousProgress ? . cancel ( )
return
}
self . refresh ( [ installedApp ] ) { ( results ) in
// I f a n e r r o r o c c u r e d , r e l o a d t h e s e c t i o n s o t h e p r o g r e s s b a r i s n o l o n g e r v i s i b l e .
if results . values . contains ( where : { $0 . error != nil } )
{
DispatchQueue . main . async {
self . collectionView . reloadSections ( [ Section . activeApps . rawValue , Section . inactiveApps . rawValue ] )
}
}
print ( " Finished refreshing with results: " , results . map { ( $0 , $1 . error ? . localizedDescription ? ? " success " ) } )
}
}
func activate ( _ installedApp : InstalledApp )
{
2024-08-06 10:43:52 +09:00
guard minimuxerStatus else { return }
2023-10-20 21:43:51 -04:00
2021-09-15 14:27:16 -07:00
func finish ( _ result : Result < InstalledApp , Error > )
2020-03-11 14:43:19 -07:00
{
2021-09-15 14:27:16 -07:00
do
{
let app = try result . get ( )
app . managedObjectContext ? . perform {
2020-03-20 16:32:31 -07:00
try ? app . managedObjectContext ? . save ( )
}
2021-09-15 14:27:16 -07:00
}
catch OperationError . cancelled
{
// I g n o r e
}
catch
{
print ( " Failed to activate app: " , error )
DispatchQueue . main . async {
installedApp . isActive = false
2020-03-11 14:43:19 -07:00
2024-08-06 10:43:52 +09:00
ToastView ( error : error , opensLog : true ) . show ( in : self )
2020-03-11 14:43:19 -07:00
}
}
}
2021-09-15 14:27:16 -07:00
if UserDefaults . standard . activeAppsLimit != nil , #available ( iOS 13 , * )
2020-03-20 16:32:31 -07:00
{
2021-09-15 14:27:16 -07:00
// U s e r D e f a u l t s . s t a n d a r d . a c t i v e A p p s L i m i t i s o n l y n o n - n i l o n i O S 1 3 . 3 . 1 o r l a t e r , s o t h e # a v a i l a b l e c h e c k i s j u s t s o w e c a n u s e C o m b i n e .
guard let app = ALTApplication ( fileURL : installedApp . fileURL ) else { return finish ( . failure ( OperationError . invalidApp ) ) }
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 )
2020-03-20 16:32:31 -07:00
}
2021-09-15 14:27:16 -07:00
. first { isActive in ! isActive }
. sink { _ in
// A p r e v i o u s l y a c t i v e a p p i s n o w i n a c t i v e ,
// w h i c h m e a n s t h e r e a r e n o w e n o u g h s l o t s t o a c t i v a t e t h e a p p ,
// s o p r e - e m p t i v e l y m a r k i t a s a c t i v e t o p r o v i d e v i s u a l f e e d b a c k s o o n e r .
installedApp . isActive = true
cancellable ? . cancel ( )
}
AppManager . shared . deactivateApps ( for : app , presentingViewController : self ) { result in
cancellable ? . cancel ( )
installedApp . managedObjectContext ? . perform {
switch result
{
case . failure ( let error ) :
installedApp . isActive = false
finish ( . failure ( error ) )
case . success :
installedApp . isActive = true
AppManager . shared . activate ( installedApp , presentingViewController : self , completionHandler : finish ( _ : ) )
}
2020-03-11 14:43:19 -07:00
}
}
}
2020-03-20 16:32:31 -07:00
else
{
2021-09-15 14:27:16 -07:00
installedApp . isActive = true
AppManager . shared . activate ( installedApp , presentingViewController : self , completionHandler : finish ( _ : ) )
2020-03-20 16:32:31 -07:00
}
2020-03-11 14:43:19 -07:00
}
func deactivate ( _ installedApp : InstalledApp , completionHandler : ( ( Result < InstalledApp , Error > ) -> Void ) ? = nil )
{
2024-08-06 10:43:52 +09:00
guard installedApp . isActive , minimuxerStatus else { return }
2020-03-11 14:43:19 -07:00
installedApp . isActive = false
2020-05-16 16:17:18 -07:00
AppManager . shared . deactivate ( installedApp , presentingViewController : self ) { ( result ) in
2020-03-11 14:43:19 -07:00
do
{
let app = try result . get ( )
try ? app . managedObjectContext ? . save ( )
print ( " Finished deactivating app: " , app . bundleIdentifier )
}
catch
{
2024-08-06 10:43:52 +09:00
print ( " Failed to deactivate app: " , error )
2020-03-11 14:43:19 -07:00
DispatchQueue . main . async {
installedApp . isActive = true
2024-08-06 10:43:52 +09:00
ToastView ( error : error , opensLog : true ) . show ( in : self )
2020-03-11 14:43:19 -07:00
}
}
completionHandler ? ( result )
}
}
func remove ( _ installedApp : InstalledApp )
2019-07-28 15:51:36 -07:00
{
2022-11-05 23:50:07 -07:00
let title = String ( format : NSLocalizedString ( " Remove “%@” from SideStore? " , comment : " " ) , installedApp . name )
2020-05-16 15:34:10 -07:00
let message : String
if UserDefaults . standard . isLegacyDeactivationSupported
{
message = NSLocalizedString ( " You must also delete it from the home screen to fully uninstall the app. " , comment : " " )
}
else
{
message = NSLocalizedString ( " This will also erase all backup data for this app. " , comment : " " )
}
2024-08-17 10:28:42 +09:00
let alertController = UIAlertController ( title : title , message : message , preferredStyle : . alert )
2019-07-28 15:51:36 -07:00
alertController . addAction ( . cancel )
alertController . addAction ( UIAlertAction ( title : NSLocalizedString ( " Remove " , comment : " " ) , style : . destructive , handler : { ( action ) in
2020-05-16 15:34:10 -07:00
AppManager . shared . remove ( installedApp ) { ( result ) in
switch result
{
case . success : break
case . failure ( let error ) :
DispatchQueue . main . async {
2024-08-06 10:43:52 +09:00
ToastView ( error : error , opensLog : true ) . show ( in : self )
2020-05-16 15:34:10 -07:00
}
}
2019-07-28 15:51:36 -07:00
}
} ) )
self . present ( alertController , animated : true , completion : nil )
}
2020-05-16 16:34:50 -07:00
2020-05-19 11:47:43 -07:00
func backup ( _ installedApp : InstalledApp )
{
2024-08-06 10:43:52 +09:00
guard minimuxerStatus else { return }
2020-05-19 11:47:43 -07:00
let title = NSLocalizedString ( " Start Backup? " , comment : " " )
2022-11-05 23:50:07 -07:00
let message = NSLocalizedString ( " This will replace any previous backups. Please leave SideStore open until the backup is complete. " , comment : " " )
2020-05-19 11:47:43 -07:00
let alertController = UIAlertController ( title : title , message : message , preferredStyle : . actionSheet )
alertController . addAction ( . cancel )
let actionTitle = String ( format : NSLocalizedString ( " Back Up %@ " , comment : " " ) , installedApp . name )
alertController . addAction ( UIAlertAction ( title : actionTitle , style : . default , handler : { ( action ) in
AppManager . shared . backup ( installedApp , presentingViewController : self ) { ( result ) in
do
{
let app = try result . get ( )
try ? app . managedObjectContext ? . save ( )
print ( " Finished backing up app: " , app . bundleIdentifier )
}
catch
{
print ( " Failed to back up app: " , error )
DispatchQueue . main . async {
2024-08-06 10:43:52 +09:00
ToastView ( error : error , opensLog : true ) . show ( in : self )
2020-05-19 11:47:43 -07:00
self . collectionView . reloadSections ( [ Section . activeApps . rawValue , Section . inactiveApps . rawValue ] )
}
}
}
DispatchQueue . main . async {
self . collectionView . reloadSections ( [ Section . activeApps . rawValue , Section . inactiveApps . rawValue ] )
}
} ) )
self . present ( alertController , animated : true , completion : nil )
}
2020-05-16 16:39:02 -07:00
func restore ( _ installedApp : InstalledApp )
{
2024-08-06 10:43:52 +09:00
guard minimuxerStatus else { return }
2020-05-16 16:39:02 -07:00
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 )
alertController . addAction ( UIAlertAction ( title : NSLocalizedString ( " Restore Backup " , comment : " " ) , style : . destructive , handler : { ( action ) in
AppManager . shared . restore ( installedApp , presentingViewController : self ) { ( result ) in
do
{
let app = try result . get ( )
try ? app . managedObjectContext ? . save ( )
print ( " Finished restoring app: " , app . bundleIdentifier )
}
catch
{
print ( " Failed to restore app: " , error )
DispatchQueue . main . async {
2024-08-06 10:43:52 +09:00
ToastView ( error : error , opensLog : true ) . show ( in : self )
2020-05-16 16:39:02 -07:00
}
}
}
DispatchQueue . main . async {
self . collectionView . reloadSections ( [ Section . activeApps . rawValue ] )
}
} ) )
self . present ( alertController , animated : true , completion : nil )
}
2020-05-16 16:34:50 -07:00
func exportBackup ( for installedApp : InstalledApp )
{
guard let backupURL = FileManager . default . backupDirectoryURL ( for : installedApp ) else { return }
2023-03-02 15:27:31 -06:00
let documentPicker = UIDocumentPickerViewController ( forExporting : [ backupURL ] , asCopy : true )
// D o n ' t s e t d e l e g a t e t o a v o i d c o n f l i c t i n g w i t h i m p o r t c a l l b a c k s .
// d o c u m e n t P i c k e r . d e l e g a t e = s e l f
2020-05-16 16:34:50 -07:00
self . present ( documentPicker , animated : true , completion : nil )
}
2020-10-01 14:09:45 -07:00
func chooseIcon ( for installedApp : InstalledApp )
{
self . _imagePickerInstalledApp = installedApp
let imagePicker = UIImagePickerController ( )
imagePicker . delegate = self
imagePicker . allowsEditing = true
self . present ( imagePicker , animated : true , completion : nil )
}
func changeIcon ( for installedApp : InstalledApp , to image : UIImage ? )
{
// R e m o v e p r e v i o u s i c o n f r o m c a c h e .
self . activeAppsDataSource . prefetchItemCache . removeObject ( forKey : installedApp )
self . 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 )
if let image = image
{
guard let icon = image . resizing ( toFill : CGSize ( width : 256 , height : 256 ) ) ,
let iconData = icon . pngData ( )
else { return }
try iconData . write ( to : tempApp . alternateIconURL , options : . atomic )
}
else
{
try FileManager . default . removeItem ( at : tempApp . alternateIconURL )
}
try context . save ( )
if tempApp . isActive
{
DispatchQueue . main . async {
self . refresh ( installedApp )
}
}
}
catch
{
print ( " Failed to change app icon. " , error )
DispatchQueue . main . async {
2024-08-06 10:43:52 +09:00
ToastView ( error : error , opensLog : true ) . show ( in : self )
2020-10-01 14:09:45 -07:00
}
}
}
}
2021-09-03 13:57:15 -05:00
@ available ( iOS 14 , * )
func enableJIT ( for installedApp : InstalledApp )
{
2024-08-15 09:58:26 +10:00
let sidejitenabled = UserDefaults . standard . sidejitenable
if # unavailable ( iOS 17 ) {
guard minimuxerStatus else { return }
}
2024-08-06 10:43:52 +09:00
2024-08-15 09:58:26 +10:00
if #available ( iOS 17 , * ) , ! sidejitenabled {
2024-08-06 10:43:52 +09:00
ToastView ( error : ( OperationError . tooNewError as NSError ) . withLocalizedTitle ( " No iOS 17 On Device JIT! " ) , opensLog : true ) . show ( in : self )
AppManager . shared . log ( OperationError . tooNewError , operation : . enableJIT , app : installedApp )
2023-10-20 21:43:51 -04:00
return
}
2024-06-17 09:43:25 +10:00
2021-09-03 13:57:15 -05:00
AppManager . shared . enableJIT ( for : installedApp ) { result in
DispatchQueue . main . async {
switch result
{
case . success : break
case . failure ( let error ) :
2024-08-06 10:43:52 +09:00
ToastView ( error : error , opensLog : true ) . show ( in : self )
AppManager . shared . log ( error , operation : . enableJIT , app : installedApp )
2021-09-03 13:57:15 -05:00
}
}
}
}
2019-07-28 15:51:36 -07:00
}
private extension MyAppsViewController
{
2019-07-30 17:00:04 -07:00
@objc func didFetchSource ( _ notification : Notification )
2019-07-19 16:42:40 -07:00
{
DispatchQueue . main . async {
if self . updatesDataSource . fetchedResultsController . fetchedObjects = = nil
{
do { try self . updatesDataSource . fetchedResultsController . performFetch ( ) }
catch { print ( " Error fetching: " , error ) }
}
self . update ( )
}
}
2019-07-28 15:51:36 -07:00
2019-09-27 17:39:36 -07:00
@objc func importApp ( _ notification : Notification )
{
2020-02-11 13:29:28 -08:00
// M a k e s u r e l e f t U I B a r B u t t o n I t e m h a s b e e n s e t .
self . loadViewIfNeeded ( )
2019-09-27 17:39:36 -07:00
2020-05-17 23:44:36 -07:00
guard let url = notification . userInfo ? [ AppDelegate . importAppDeepLinkURLKey ] as ? URL else { return }
2019-09-27 17:39:36 -07:00
2020-05-17 23:44:36 -07:00
self . sideloadApp ( at : url ) { ( result ) in
guard url . isFileURL else { return }
2019-09-27 17:39:36 -07:00
do
{
2020-05-17 23:44:36 -07:00
try FileManager . default . removeItem ( at : url )
2019-09-27 17:39:36 -07:00
}
catch
{
print ( " Unable to remove imported .ipa. " , error )
}
}
}
2019-07-19 16:42:40 -07:00
}
extension MyAppsViewController
{
override func collectionView ( _ collectionView : UICollectionView , viewForSupplementaryElementOfKind kind : String , at indexPath : IndexPath ) -> UICollectionReusableView
{
2019-07-31 13:37:51 -07:00
let section = Section ( rawValue : indexPath . section ) !
switch section
2019-07-19 16:42:40 -07:00
{
2019-07-31 13:37:51 -07:00
case . noUpdates : return UICollectionReusableView ( )
case . updates :
2019-07-19 16:42:40 -07:00
let headerView = collectionView . dequeueReusableSupplementaryView ( ofKind : UICollectionView . elementKindSectionHeader , withReuseIdentifier : " UpdatesHeader " , for : indexPath ) as ! UpdatesCollectionHeaderView
UIView . performWithoutAnimation {
2019-09-19 11:29:10 -07:00
headerView . button . backgroundColor = UIColor . altPrimary . withAlphaComponent ( 0.15 )
2019-07-19 16:42:40 -07:00
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 )
2019-07-19 16:42:40 -07:00
headerView . button . addTarget ( self , action : #selector ( MyAppsViewController . toggleAppUpdates ) , for : . primaryActionTriggered )
2019-07-24 15:01:33 -07:00
if self . isUpdateSectionCollapsed
{
headerView . button . titleLabel ? . transform = . identity
}
else
{
headerView . button . titleLabel ? . transform = CGAffineTransform . identity . rotated ( by : . pi )
}
headerView . isHidden = ( self . updatesDataSource . itemCount <= 2 )
2019-07-19 16:42:40 -07:00
headerView . button . layoutIfNeeded ( )
}
return headerView
2019-07-31 13:37:51 -07:00
2020-03-11 14:43:19 -07:00
case . activeApps where kind = = UICollectionView . elementKindSectionHeader :
let headerView = collectionView . dequeueReusableSupplementaryView ( ofKind : UICollectionView . elementKindSectionHeader , withReuseIdentifier : " ActiveAppsHeader " , for : indexPath ) as ! InstalledAppsCollectionHeaderView
2019-07-19 16:42:40 -07:00
UIView . performWithoutAnimation {
2020-03-11 14:43:19 -07:00
headerView . layoutMargins . left = self . view . layoutMargins . left
headerView . layoutMargins . right = self . view . layoutMargins . right
if UserDefaults . standard . activeAppsLimit = = nil
{
headerView . textLabel . text = NSLocalizedString ( " Installed " , comment : " " )
}
else
{
headerView . textLabel . text = NSLocalizedString ( " Active " , comment : " " )
}
2019-07-19 16:42:40 -07:00
headerView . button . isIndicatingActivity = false
2019-09-19 11:29:10 -07:00
headerView . button . activityIndicatorView . color = . altPrimary
2019-07-19 16:42:40 -07:00
headerView . button . setTitle ( NSLocalizedString ( " Refresh All " , comment : " " ) , for : . normal )
headerView . button . addTarget ( self , action : #selector ( MyAppsViewController . refreshAllApps ( _ : ) ) , for : . primaryActionTriggered )
headerView . button . layoutIfNeeded ( )
2020-08-27 15:25:52 -07:00
if self . isRefreshingAllApps
{
headerView . button . isIndicatingActivity = true
headerView . button . accessibilityLabel = NSLocalizedString ( " Refreshing " , comment : " " )
headerView . button . accessibilityTraits . remove ( . notEnabled )
}
else
{
headerView . button . isIndicatingActivity = false
headerView . button . accessibilityLabel = nil
}
2020-03-11 14:43:19 -07:00
}
return headerView
case . inactiveApps where kind = = UICollectionView . elementKindSectionHeader :
let headerView = collectionView . dequeueReusableSupplementaryView ( ofKind : UICollectionView . elementKindSectionHeader , withReuseIdentifier : " InactiveAppsHeader " , for : indexPath ) as ! InstalledAppsCollectionHeaderView
UIView . performWithoutAnimation {
headerView . layoutMargins . left = self . view . layoutMargins . left
headerView . layoutMargins . right = self . view . layoutMargins . right
headerView . textLabel . text = NSLocalizedString ( " Inactive " , comment : " " )
headerView . button . setTitle ( nil , for : . normal )
if #available ( iOS 13.0 , * )
{
headerView . button . setImage ( UIImage ( systemName : " questionmark.circle " ) , for : . normal )
}
headerView . button . addTarget ( self , action : #selector ( MyAppsViewController . presentInactiveAppsAlert ) , for : . primaryActionTriggered )
2019-07-19 16:42:40 -07:00
}
return headerView
2020-01-24 14:54:52 -08:00
2020-03-11 14:43:19 -07: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
2020-02-10 17:30:11 -08:00
guard let team = DatabaseManager . shared . activeTeam ( ) else { return footerView }
switch team . type
2020-01-24 14:54:52 -08:00
{
2020-02-10 17:30:11 -08:00
case . free :
let registeredAppIDs = team . appIDs . count
let maximumAppIDCount = 10
2023-10-17 17:34:12 +03:00
let remainingAppIDs = maximumAppIDCount - registeredAppIDs
2020-02-10 17:30:11 -08:00
if remainingAppIDs = = 1
{
footerView . textLabel . text = String ( format : NSLocalizedString ( " 1 App ID Remaining " , comment : " " ) )
}
else
{
footerView . textLabel . text = String ( format : NSLocalizedString ( " %@ App IDs Remaining " , comment : " " ) , NSNumber ( value : remainingAppIDs ) )
}
2023-10-17 17:34:12 +03:00
footerView . textLabel . isHidden = remainingAppIDs < 0
2020-02-10 17:30:11 -08:00
case . individual , . organization , . unknown : footerView . textLabel . isHidden = true
@ unknown default : break
2020-01-24 14:54:52 -08:00
}
return footerView
2019-07-19 16:42:40 -07:00
}
}
2019-09-12 13:51:03 -07:00
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
}
}
2019-07-19 16:42:40 -07:00
}
2020-03-11 14:43:19 -07:00
@ available ( iOS 13.0 , * )
extension MyAppsViewController
{
2020-10-01 14:09:45 -07:00
private func actions ( for installedApp : InstalledApp ) -> [ UIMenuElement ]
2020-03-11 14:43:19 -07:00
{
2020-10-01 14:09:45 -07:00
var actions = [ UIMenuElement ] ( )
2020-03-11 14:43:19 -07:00
2021-09-02 15:52:59 -05:00
let openAction = UIAction ( title : NSLocalizedString ( " Open " , comment : " " ) , image : UIImage ( systemName : " arrow.up.forward.app " ) ) { ( action ) in
self . open ( installedApp )
}
let openMenu = UIMenu ( title : " " , options : . displayInline , children : [ openAction ] )
2020-03-11 14:43:19 -07:00
let refreshAction = UIAction ( title : NSLocalizedString ( " Refresh " , comment : " " ) , image : UIImage ( systemName : " arrow.clockwise " ) ) { ( action ) in
self . refresh ( installedApp )
}
let activateAction = UIAction ( title : NSLocalizedString ( " Activate " , comment : " " ) , image : UIImage ( systemName : " checkmark.circle " ) ) { ( action ) in
self . activate ( installedApp )
}
let deactivateAction = UIAction ( title : NSLocalizedString ( " Deactivate " , comment : " " ) , image : UIImage ( systemName : " xmark.circle " ) , attributes : . destructive ) { ( action ) in
self . deactivate ( installedApp )
}
let removeAction = UIAction ( title : NSLocalizedString ( " Remove " , comment : " " ) , image : UIImage ( systemName : " trash " ) , attributes : . destructive ) { ( action ) in
self . remove ( installedApp )
}
2021-09-03 13:57:15 -05:00
let jitAction = UIAction ( title : NSLocalizedString ( " Enable JIT " , comment : " " ) , image : UIImage ( systemName : " bolt " ) ) { ( action ) in
guard #available ( iOS 14 , * ) else { return }
self . enableJIT ( for : installedApp )
}
2020-05-19 11:47:43 -07:00
let backupAction = UIAction ( title : NSLocalizedString ( " Back Up " , comment : " " ) , image : UIImage ( systemName : " doc.on.doc " ) ) { ( action ) in
self . backup ( installedApp )
}
2020-05-16 16:34:50 -07:00
let exportBackupAction = UIAction ( title : NSLocalizedString ( " Export Backup " , comment : " " ) , image : UIImage ( systemName : " arrow.up.doc " ) ) { ( action ) in
self . exportBackup ( for : installedApp )
}
2020-05-16 16:39:02 -07:00
let restoreBackupAction = UIAction ( title : NSLocalizedString ( " Restore Backup " , comment : " " ) , image : UIImage ( systemName : " arrow.down.doc " ) ) { ( action ) in
self . restore ( installedApp )
}
2020-10-01 14:09:45 -07:00
let chooseIconAction = UIAction ( title : NSLocalizedString ( " Photos " , comment : " " ) , image : UIImage ( systemName : " photo " ) ) { ( action ) in
self . chooseIcon ( for : installedApp )
}
let removeIconAction = UIAction ( title : NSLocalizedString ( " Remove Custom Icon " , comment : " " ) , image : UIImage ( systemName : " trash " ) , attributes : [ . destructive ] ) { ( action ) in
self . changeIcon ( for : installedApp , to : nil )
}
var changeIconActions = [ chooseIconAction ]
if installedApp . hasAlternateIcon
{
changeIconActions . append ( removeIconAction )
}
let changeIconMenu = UIMenu ( title : NSLocalizedString ( " Change Icon " , comment : " " ) , image : UIImage ( systemName : " photo " ) , children : changeIconActions )
2020-05-16 15:34:10 -07:00
guard installedApp . bundleIdentifier != StoreApp . altstoreAppID else {
2020-10-05 13:59:44 -07:00
#if BETA
2020-10-01 14:09:45 -07:00
return [ refreshAction , changeIconMenu ]
2020-10-05 13:59:44 -07:00
#else
return [ refreshAction ]
#endif
2020-05-16 15:34:10 -07:00
}
if installedApp . isActive
2020-03-11 14:43:19 -07:00
{
2021-09-02 15:52:59 -05:00
actions . append ( openMenu )
2020-05-16 15:34:10 -07:00
actions . append ( refreshAction )
2020-03-11 14:43:19 -07:00
}
else
{
2020-05-16 15:34:10 -07:00
actions . append ( activateAction )
}
2020-05-19 11:47:43 -07:00
2021-09-03 13:57:15 -05:00
if installedApp . isActive , #available ( iOS 14 , * )
{
actions . append ( jitAction )
}
2020-10-05 13:59:44 -07:00
#if BETA
2020-10-01 14:09:45 -07:00
actions . append ( changeIconMenu )
2020-10-05 13:59:44 -07:00
#endif
2020-10-01 14:09:45 -07:00
2020-05-19 11:47:43 -07:00
if installedApp . isActive
{
actions . append ( backupAction )
}
else if let _ = UTTypeCopyDeclaration ( installedApp . installedAppUTI as CFString ) ? . takeRetainedValue ( ) as NSDictionary ? , ! UserDefaults . standard . isLegacyDeactivationSupported
{
// A l l o w b a c k i n g u p i n a c t i v e a p p s i f t h e y a r e s t i l l i n s t a l l e d ,
// b u t o n a n i O S v e r s i o n t h a t n o l o n g e r s u p p o r t s l e g a c y d e a c t i v a t i o n .
// T h i s h a n d l e s e d g e c a s e w h e r e y o u c a n ' t i n s t a l l m o r e a p p s u n t i l y o u
// d e l e t e s o m e , b u t c a n ' t a c t i v a t e i n a c t i v e a p p s a g a i n t o b a c k t h e m u p f i r s t .
actions . append ( backupAction )
}
2020-05-16 16:34:50 -07:00
2020-05-19 11:47:43 -07:00
if let backupDirectoryURL = FileManager . default . backupDirectoryURL ( for : installedApp )
2020-05-16 16:34:50 -07:00
{
var backupExists = false
var outError : NSError ? = nil
self . coordinator . coordinate ( readingItemAt : backupDirectoryURL , options : [ . withoutChanges ] , error : & outError ) { ( backupDirectoryURL ) in
#if DEBUG
backupExists = true
#else
backupExists = FileManager . default . fileExists ( atPath : backupDirectoryURL . path )
#endif
}
if backupExists
{
actions . append ( exportBackupAction )
2020-05-16 16:39:02 -07:00
if installedApp . isActive
{
actions . append ( restoreBackupAction )
}
2020-05-16 16:34:50 -07:00
}
else if let error = outError
{
print ( " Unable to check if backup exists: " , error )
}
}
2020-05-16 15:34:10 -07:00
2020-05-19 11:47:43 -07:00
if installedApp . isActive
{
actions . append ( deactivateAction )
}
2020-05-16 15:34:10 -07:00
#if DEBUG
if installedApp . bundleIdentifier != StoreApp . altstoreAppID
{
2020-03-11 14:43:19 -07:00
actions . append ( removeAction )
}
2020-05-16 15:34:10 -07:00
#else
if ( UserDefaults . standard . legacySideloadedApps ? ? [ ] ) . contains ( installedApp . bundleIdentifier )
{
// L e g a c y s i d e l o a d e d a p p , s o c a n ' t d e t e c t i f i t ' s d e l e t e d .
actions . append ( removeAction )
}
else if ! UserDefaults . standard . isLegacyDeactivationSupported && ! installedApp . isActive
{
// I n a c t i v e a p p s a r e a c t u a l l y d e l e t e d , s o w e n e e d a n o t h e r w a y
// f o r u s e r t o r e m o v e t h e m f r o m A l t S t o r e .
actions . append ( removeAction )
}
#endif
2020-03-11 14:43:19 -07:00
return actions
}
override func collectionView ( _ collectionView : UICollectionView , contextMenuConfigurationForItemAt indexPath : IndexPath , point : CGPoint ) -> UIContextMenuConfiguration ?
{
let section = Section ( rawValue : indexPath . section ) !
switch section
{
case . updates , . noUpdates : return nil
case . activeApps , . inactiveApps :
let installedApp = self . dataSource . item ( at : indexPath )
return UIContextMenuConfiguration ( identifier : indexPath as NSIndexPath , previewProvider : nil ) { ( suggestedActions ) -> UIMenu ? in
let actions = self . actions ( for : installedApp )
let menu = UIMenu ( title : " " , children : actions )
return menu
}
}
}
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 }
let parameters = UIPreviewParameters ( )
parameters . backgroundColor = . clear
parameters . visiblePath = UIBezierPath ( roundedRect : cell . bannerView . bounds , cornerRadius : cell . bannerView . layer . cornerRadius )
let preview = UITargetedPreview ( view : cell . bannerView , parameters : parameters )
return preview
}
override func collectionView ( _ collectionView : UICollectionView , previewForDismissingContextMenuWithConfiguration configuration : UIContextMenuConfiguration ) -> UITargetedPreview ?
{
return self . collectionView ( collectionView , previewForHighlightingContextMenuWithConfiguration : configuration )
}
}
2019-07-19 16:42:40 -07:00
extension MyAppsViewController : UICollectionViewDelegateFlowLayout
{
func collectionView ( _ collectionView : UICollectionView , layout collectionViewLayout : UICollectionViewLayout , sizeForItemAt indexPath : IndexPath ) -> CGSize
{
let section = Section . allCases [ indexPath . section ]
switch section
{
2019-07-24 15:01:33 -07:00
case . noUpdates :
2019-10-23 13:32:17 -07:00
let size = CGSize ( width : collectionView . bounds . width , height : 44 )
2019-07-24 15:01:33 -07:00
return size
2019-07-19 16:42:40 -07:00
case . updates :
let item = self . dataSource . item ( at : indexPath )
2019-07-28 15:08:13 -07:00
if let previousHeight = self . cachedUpdateSizes [ item . bundleIdentifier ]
2019-07-19 16:42:40 -07:00
{
return previousHeight
}
2019-10-23 13:32:17 -07:00
// M a n u a l l y c h a n g e c e l l ' s w i d t h t o p r e v e n t c o n f l i c t i n g w i t h U I V i e w - E n c a p s u l a t e d - L a y o u t - W i d t h c o n s t r a i n t s .
self . prototypeUpdateCell . frame . size . width = collectionView . bounds . width
let widthConstraint = self . prototypeUpdateCell . contentView . widthAnchor . constraint ( equalToConstant : collectionView . bounds . width )
2019-07-19 16:42:40 -07:00
NSLayoutConstraint . activate ( [ widthConstraint ] )
defer { NSLayoutConstraint . deactivate ( [ widthConstraint ] ) }
self . dataSource . cellConfigurationHandler ( self . prototypeUpdateCell , item , indexPath )
let size = self . prototypeUpdateCell . contentView . systemLayoutSizeFitting ( UIView . layoutFittingCompressedSize )
2019-07-28 15:08:13 -07:00
self . cachedUpdateSizes [ item . bundleIdentifier ] = size
2019-07-19 16:42:40 -07:00
return size
2020-03-11 14:43:19 -07:00
case . activeApps , . inactiveApps :
2019-10-23 13:32:17 -07:00
return CGSize ( width : collectionView . bounds . width , height : 88 )
2019-07-19 16:42:40 -07:00
}
}
func collectionView ( _ collectionView : UICollectionView , layout collectionViewLayout : UICollectionViewLayout , referenceSizeForHeaderInSection section : Int ) -> CGSize
{
let section = Section . allCases [ section ]
switch section
{
2019-07-24 15:01:33 -07:00
case . noUpdates : return . zero
case . updates :
let height : CGFloat = self . updatesDataSource . itemCount > maximumCollapsedUpdatesCount ? 26 : 0
return CGSize ( width : collectionView . bounds . width , height : height )
2020-03-11 14:43:19 -07:00
case . activeApps : return CGSize ( width : collectionView . bounds . width , height : 29 )
case . inactiveApps where self . inactiveAppsDataSource . itemCount = = 0 : return . zero
case . inactiveApps : return CGSize ( width : collectionView . bounds . width , height : 29 )
2019-07-19 16:42:40 -07:00
}
}
2020-01-24 14:54:52 -08:00
func collectionView ( _ collectionView : UICollectionView , layout collectionViewLayout : UICollectionViewLayout , referenceSizeForFooterInSection section : Int ) -> CGSize
{
let section = Section . allCases [ section ]
2020-03-11 14:43:19 -07:00
func appIDsFooterSize ( ) -> CGSize
2020-01-24 14:54:52 -08:00
{
2020-02-10 17:30:11 -08:00
guard let _ = DatabaseManager . shared . activeTeam ( ) else { return . zero }
let indexPath = IndexPath ( row : 0 , section : section . rawValue )
let footerView = self . collectionView ( collectionView , viewForSupplementaryElementOfKind : UICollectionView . elementKindSectionFooter , at : indexPath ) as ! InstalledAppsCollectionFooterView
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
}
2020-03-11 14:43:19 -07:00
switch section
{
case . noUpdates : return . zero
case . updates : return . zero
case . activeApps where self . inactiveAppsDataSource . itemCount = = 0 : return appIDsFooterSize ( )
case . activeApps : return . zero
case . inactiveApps where self . inactiveAppsDataSource . itemCount = = 0 : return . zero
case . inactiveApps : return appIDsFooterSize ( )
}
2020-01-24 14:54:52 -08:00
}
2019-10-23 13:32:17 -07:00
func collectionView ( _ myCV : UICollectionView , layout collectionViewLayout : UICollectionViewLayout , insetForSectionAt section : Int ) -> UIEdgeInsets
2019-07-19 16:42:40 -07:00
{
let section = Section . allCases [ section ]
switch section
{
2019-10-23 13:32:17 -07:00
case . noUpdates where self . updatesDataSource . itemCount != 0 : return . zero
case . updates where self . updatesDataSource . itemCount = = 0 : return . zero
default : return UIEdgeInsets ( top : 12 , left : 0 , bottom : 20 , right : 0 )
2019-07-24 15:01:33 -07:00
}
}
}
2020-03-20 16:32:31 -07:00
extension MyAppsViewController : UICollectionViewDragDelegate
{
func collectionView ( _ collectionView : UICollectionView , itemsForBeginning session : UIDragSession , at indexPath : IndexPath ) -> [ UIDragItem ]
{
switch Section ( rawValue : indexPath . section ) !
{
case . updates , . noUpdates :
return [ ]
case . activeApps , . inactiveApps :
guard UserDefaults . standard . activeAppsLimit != nil else { return [ ] }
guard let cell = collectionView . cellForItem ( at : indexPath as IndexPath ) as ? InstalledAppCollectionViewCell else { return [ ] }
let item = self . dataSource . item ( at : indexPath )
guard item . bundleIdentifier != StoreApp . altstoreAppID else { return [ ] }
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 )
let preview = UIDragPreview ( view : cell . bannerView . iconImageView , parameters : parameters )
return preview
}
return [ dragItem ]
}
}
func collectionView ( _ collectionView : UICollectionView , dragPreviewParametersForItemAt indexPath : IndexPath ) -> UIDragPreviewParameters ?
{
guard let cell = collectionView . cellForItem ( at : indexPath as IndexPath ) as ? InstalledAppCollectionViewCell else { return nil }
let parameters = UIDragPreviewParameters ( )
parameters . backgroundColor = . clear
parameters . visiblePath = UIBezierPath ( roundedRect : cell . bannerView . frame , cornerRadius : cell . bannerView . layer . cornerRadius )
return parameters
}
func collectionView ( _ collectionView : UICollectionView , dragSessionDidEnd session : UIDragSession )
{
let previousDestinationIndexPath = self . dropDestinationIndexPath
self . dropDestinationIndexPath = nil
if let indexPath = previousDestinationIndexPath
{
// A c c e s s c e l l d i r e c t l y t o p r e v e n t U I g l i t c h e s d u e t o r a c e c o n d i t i o n s w h e n r e f r e s h i n g
self . updateCell ( at : indexPath )
}
}
}
extension MyAppsViewController : UICollectionViewDropDelegate
{
func collectionView ( _ collectionView : UICollectionView , canHandle session : UIDropSession ) -> Bool
{
return session . localDragSession != nil
}
func collectionView ( _ collectionView : UICollectionView , dropSessionDidUpdate session : UIDropSession , withDestinationIndexPath destinationIndexPath : IndexPath ? ) -> UICollectionViewDropProposal
{
guard
let activeAppsLimit = UserDefaults . standard . activeAppsLimit ,
let installedApp = session . items . first ? . localObject as ? InstalledApp
else { return UICollectionViewDropProposal ( operation : . cancel ) }
// R e t r i e v e h e a d e r a t t r i b u t e s f o r l o c a t i o n c a l c u l a t i o n s .
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 ) }
var dropDestinationIndexPath : IndexPath ? = nil
defer
{
// A n i m a t e s e l e c t i o n c h a n g e s .
if dropDestinationIndexPath != self . dropDestinationIndexPath
{
let previousIndexPath = self . dropDestinationIndexPath
self . dropDestinationIndexPath = dropDestinationIndexPath
let indexPaths = [ previousIndexPath , dropDestinationIndexPath ] . compactMap { $0 }
let propertyAnimator = UIViewPropertyAnimator ( springTimingParameters : UISpringTimingParameters ( ) ) {
for indexPath in indexPaths
{
// A c c e s s c e l l d i r e c t l y s o w e c a n a n i m a t e i t c o r r e c t l y .
self . updateCell ( at : indexPath )
}
}
propertyAnimator . startAnimation ( )
}
}
let point = session . location ( in : collectionView )
if installedApp . isActive
{
// D e a c t i v a t i n g
if point . y > inactiveAppsHeaderAttributes . frame . minY
{
// I n a c t i v e a p p s s e c t i o n .
return UICollectionViewDropProposal ( operation : . copy , intent : . insertAtDestinationIndexPath )
}
else if point . y > activeAppsHeaderAttributes . frame . minY
{
// A c t i v e a p p s s e c t i o n .
return UICollectionViewDropProposal ( operation : . move , intent : . insertAtDestinationIndexPath )
}
else
{
return UICollectionViewDropProposal ( operation : . cancel )
}
}
else
{
// A c t i v a t i n g
guard point . y > activeAppsHeaderAttributes . frame . minY else {
// A b o v e a c t i v e a p p s s e c t i o n .
return UICollectionViewDropProposal ( operation : . cancel )
}
guard point . y < inactiveAppsHeaderAttributes . frame . minY else {
// I n a c t i v e a p p s s e c t i o n .
return UICollectionViewDropProposal ( operation : . move , intent : . insertAtDestinationIndexPath )
}
2020-05-17 23:36:30 -07:00
let activeAppsCount = ( self . activeAppsDataSource . fetchedResultsController . fetchedObjects ? ? [ ] ) . map { $0 . requiredActiveSlots } . reduce ( 0 , + )
2020-03-20 16:32:31 -07:00
let availableActiveApps = max ( activeAppsLimit - activeAppsCount , 0 )
2020-05-17 23:36:30 -07:00
if installedApp . requiredActiveSlots <= availableActiveApps
2020-03-20 16:32:31 -07:00
{
// E n o u g h a c t i v e a p p s l o t s , s o n o n e e d t o d e a c t i v a t e a p p f i r s t .
return UICollectionViewDropProposal ( operation : . copy , intent : . insertAtDestinationIndexPath )
}
else
{
// N o t e n o u g h a c t i v e a p p s l o t s , s o w e n e e d t o d e a c t i v a t e a n a p p .
// P r o v i d e d d e s t i n a t i o n I n d e x P a t h i s i n a c c u r a t e .
guard let indexPath = collectionView . indexPathForItem ( at : point ) , indexPath . section = = Section . activeApps . rawValue else {
// I n v a l i d d e s t i n a t i o n i n d e x p a t h .
return UICollectionViewDropProposal ( operation : . cancel )
}
let installedApp = self . dataSource . item ( at : indexPath )
guard installedApp . bundleIdentifier != StoreApp . altstoreAppID else {
// C a n ' t d e a c t i v a t e A l t S t o r e .
return UICollectionViewDropProposal ( operation : . forbidden , intent : . insertIntoDestinationIndexPath )
}
// T h i s a p p c a n b e d e a c t i v a t e d !
dropDestinationIndexPath = indexPath
return UICollectionViewDropProposal ( operation : . move , intent : . insertIntoDestinationIndexPath )
}
}
}
func collectionView ( _ collectionView : UICollectionView , performDropWith coordinator : UICollectionViewDropCoordinator )
{
guard let installedApp = coordinator . session . items . first ? . localObject as ? InstalledApp else { return }
guard let destinationIndexPath = coordinator . destinationIndexPath else { return }
if installedApp . isActive
{
guard destinationIndexPath . section = = Section . inactiveApps . rawValue else { return }
self . deactivate ( installedApp )
}
else
{
guard destinationIndexPath . section = = Section . activeApps . rawValue else { return }
switch coordinator . proposal . intent
{
case . insertIntoDestinationIndexPath :
installedApp . isActive = true
let previousInstalledApp = self . dataSource . item ( at : destinationIndexPath )
self . deactivate ( previousInstalledApp ) { ( result ) in
installedApp . managedObjectContext ? . perform {
switch result
{
case . failure : installedApp . isActive = false
case . success : self . activate ( installedApp )
}
}
}
case . insertAtDestinationIndexPath :
self . activate ( installedApp )
case . unspecified : break
@ unknown default : break
}
}
}
}
2019-07-24 15:01:33 -07:00
extension MyAppsViewController : NSFetchedResultsControllerDelegate
{
func controllerWillChangeContent ( _ controller : NSFetchedResultsController < NSFetchRequestResult > )
{
2019-07-31 11:24:18 -07:00
// R e s p o n d i n g t o N S F e t c h e d R e s u l t s C o n t r o l l e r u p d a t e s b e f o r e t h e c o l l e c t i o n v i e w h a s
// b e e n s h o w n m a y t h r o w e x c e p t i o n s b e c a u s e t h e c o l l e c t i o n v i e w c a n n o t a c c u r a t e l y
// c o u n t t h e n u m b e r o f i t e m s b e f o r e t h e u p d a t e . H o w e v e r , i f w e m a n u a l l y c a l l
// p e r f o r m B a t c h U p d a t e s _ b e f o r e _ r e s p o n d i n g t o u p d a t e s , t h e c o l l e c t i o n v i e w c a n g e t
// a n a c c u r a t e p r e - u p d a t e i t e m c o u n t .
self . collectionView . performBatchUpdates ( nil , completion : nil )
2019-07-24 15:01:33 -07:00
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
{
// R e m o v e " N o U p d a t e s A v a i l a b l e " c e l l .
let change = RSTCellContentChange ( type : . delete , currentIndexPath : IndexPath ( item : 0 , section : Section . noUpdates . rawValue ) , destinationIndexPath : nil )
self . collectionView . add ( change )
2019-07-19 16:42:40 -07:00
}
2019-07-24 15:01:33 -07:00
else if previousUpdateCount > 0 && updateCount = = 0
{
// I n s e r t " N o U p d a t e s A v a i l a b l e " c e l l .
let change = RSTCellContentChange ( type : . insert , currentIndexPath : nil , destinationIndexPath : IndexPath ( item : 0 , section : Section . noUpdates . rawValue ) )
self . collectionView . add ( change )
}
self . updatesDataSource . controllerDidChangeContent ( controller )
2019-06-06 12:56:13 -07:00
}
2019-05-20 21:36:39 +02:00
}
2019-07-28 15:51:36 -07:00
extension MyAppsViewController : UIDocumentPickerDelegate
{
func documentPicker ( _ controller : UIDocumentPickerViewController , didPickDocumentsAt urls : [ URL ] )
{
guard let fileURL = urls . first else { return }
2023-03-02 15:27:31 -06:00
self . sideloadApp ( at : fileURL ) { ( result ) in
print ( " Sideloaded app at \( fileURL ) with result: " , result )
2019-07-28 15:51:36 -07:00
}
}
}
2019-09-12 13:51:03 -07:00
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 )
}
}
2020-10-01 14:09:45 -07:00
extension MyAppsViewController : UIImagePickerControllerDelegate , UINavigationControllerDelegate
{
func imagePickerController ( _ picker : UIImagePickerController , didFinishPickingMediaWithInfo info : [ UIImagePickerController . InfoKey : Any ] )
{
defer {
picker . dismiss ( animated : true , completion : nil )
self . _imagePickerInstalledApp = nil
}
guard let image = info [ . editedImage ] as ? UIImage , let installedApp = self . _imagePickerInstalledApp else { return }
self . changeIcon ( for : installedApp , to : image )
}
func imagePickerControllerDidCancel ( _ picker : UIImagePickerController )
{
picker . dismiss ( animated : true , completion : nil )
self . _imagePickerInstalledApp = nil
}
}