Files
SideStore/AltStore/Sources/SourceDetailContentViewController.swift
Riley Testut 389af4d5e6 Switches from StoreApp.isBeta to isPledged to determine whether app is visible
If StoreApp.isHiddenWithoutPledge == false (default), we’ll still show the app.
2024-12-26 21:15:29 +05:30

471 lines
21 KiB
Swift

//
// SourcesDetailContentViewController.swift
// AltStore
//
// Created by Riley Testut on 3/8/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import UIKit
import SafariServices
import AltStoreCore
import Roxas
import Nuke
private let sectionInset = 20.0
extension SourceDetailContentViewController
{
private enum Section: Int
{
case news
case featuredApps
case about
}
private enum ElementKind: String
{
case title
case button
}
}
class SourceDetailContentViewController: UICollectionViewController
{
let source: Source
private lazy var dataSource = self.makeDataSource()
private lazy var newsDataSource = self.makeNewsDataSource()
private lazy var appsDataSource = self.makeAppsDataSource()
private lazy var aboutDataSource = self.makeAboutDataSource()
override var collectionViewLayout: UICollectionViewCompositionalLayout {
return self.collectionView.collectionViewLayout as! UICollectionViewCompositionalLayout
}
init?(source: Source, coder: NSCoder)
{
self.source = source
super.init(coder: coder)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad()
{
super.viewDidLoad()
self.collectionView.backgroundColor = .altBackground
let collectionViewLayout = self.makeLayout(source: self.source)
self.collectionView.collectionViewLayout = collectionViewLayout
self.collectionView.register(NewsCollectionViewCell.nib, forCellWithReuseIdentifier: "NewsCell")
self.collectionView.register(TitleCollectionReusableView.self, forSupplementaryViewOfKind: ElementKind.title.rawValue, withReuseIdentifier: ElementKind.title.rawValue)
self.collectionView.register(ButtonCollectionReusableView.self, forSupplementaryViewOfKind: ElementKind.button.rawValue, withReuseIdentifier: ElementKind.button.rawValue)
self.dataSource.proxy = self
self.collectionView.dataSource = self.dataSource
self.collectionView.prefetchDataSource = self.dataSource
}
override func viewSafeAreaInsetsDidChange()
{
super.viewSafeAreaInsetsDidChange()
// Add sectionInset to safeAreaInsets.bottom.
self.collectionView.contentInset = UIEdgeInsets(top: sectionInset, left: 0, bottom: self.view.safeAreaInsets.bottom + sectionInset, right: 0)
}
}
private extension SourceDetailContentViewController
{
func makeLayout(source: Source) -> UICollectionViewCompositionalLayout
{
let layoutConfig = UICollectionViewCompositionalLayoutConfiguration()
layoutConfig.interSectionSpacing = 10
let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in
guard let section = Section(rawValue: sectionIndex) else { return nil }
switch section
{
case .news:
guard !source.newsItems.isEmpty else { return nil }
// Underestimate height to prevent jumping size abruptly.
let heightDimension: NSCollectionLayoutDimension = if #available(iOS 17, *) { .uniformAcrossSiblings(estimate: 50) } else { .estimated(50) }
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: heightDimension)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupWidth = layoutEnvironment.container.contentSize.width - sectionInset * 2
let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(groupWidth), heightDimension: heightDimension)
let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item])
let buttonSize = NSCollectionLayoutSize(widthDimension: .estimated(60), heightDimension: .estimated(20))
let sectionFooter = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: buttonSize, elementKind: ElementKind.button.rawValue, alignment: .bottomTrailing)
let layoutSection = NSCollectionLayoutSection(group: group)
layoutSection.interGroupSpacing = 10
layoutSection.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: sectionInset, bottom: 4, trailing: sectionInset)
layoutSection.orthogonalScrollingBehavior = .groupPagingCentered
layoutSection.boundarySupplementaryItems = [sectionFooter]
return layoutSection
case .featuredApps:
// Always show Featured Apps section, even if there are no apps.
// guard !source.effectiveFeaturedApps.isEmpty else { return nil }
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .absolute(88))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item])
let titleSize = NSCollectionLayoutSize(widthDimension: .estimated(75), heightDimension: .estimated(40))
let titleHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.title.rawValue, alignment: .topLeading)
let buttonSize = NSCollectionLayoutSize(widthDimension: .estimated(60), heightDimension: .estimated(20))
let buttonHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: buttonSize, elementKind: ElementKind.button.rawValue, alignment: .bottomTrailing)
let layoutSection = NSCollectionLayoutSection(group: group)
layoutSection.interGroupSpacing = 15
layoutSection.contentInsets = NSDirectionalEdgeInsets(top: 15 /* independent of sectionInset */, leading: sectionInset, bottom: 4, trailing: sectionInset)
layoutSection.orthogonalScrollingBehavior = .none
layoutSection.boundarySupplementaryItems = [titleHeader, buttonHeader]
return layoutSection
case .about:
guard source.localizedDescription != nil else { return nil }
let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(200))
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let group = NSCollectionLayoutGroup.vertical(layoutSize: itemSize, subitems: [item])
let titleSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .estimated(40))
let titleHeader = NSCollectionLayoutBoundarySupplementaryItem(layoutSize: titleSize, elementKind: ElementKind.title.rawValue, alignment: .topLeading)
let layoutSection = NSCollectionLayoutSection(group: group)
layoutSection.contentInsets = NSDirectionalEdgeInsets(top: 15 /* independent of sectionInset */, leading: sectionInset, bottom: 0, trailing: sectionInset)
layoutSection.orthogonalScrollingBehavior = .none
layoutSection.boundarySupplementaryItems = [titleHeader]
return layoutSection
}
}, configuration: layoutConfig)
return layout
}
func makeDataSource() -> RSTCompositeCollectionViewPrefetchingDataSource<NSManagedObject, UIImage>
{
let newsDataSource = self.newsDataSource as! RSTFetchedResultsCollectionViewDataSource<NSManagedObject>
let appsDataSource = self.appsDataSource as! RSTArrayCollectionViewPrefetchingDataSource<NSManagedObject, UIImage>
let dataSource = RSTCompositeCollectionViewPrefetchingDataSource<NSManagedObject, UIImage>(dataSources: [newsDataSource, appsDataSource, self.aboutDataSource])
return dataSource
}
func makeNewsDataSource() -> RSTFetchedResultsCollectionViewDataSource<NewsItem>
{
let fetchRequest = NewsItem.sortedFetchRequest(for: self.source)
let context = self.source.managedObjectContext ?? DatabaseManager.shared.viewContext
let dataSource = RSTFetchedResultsCollectionViewDataSource(fetchRequest: fetchRequest, managedObjectContext: context)
dataSource.liveFetchLimit = 5
dataSource.cellIdentifierHandler = { _ in "NewsCell" }
dataSource.cellConfigurationHandler = { (cell, newsItem, indexPath) in
let cell = cell as! NewsCollectionViewCell
// For some reason, setting cell.layoutMargins = .zero does not update cell.contentView.layoutMargins.
cell.layoutMargins = .zero
cell.contentView.layoutMargins = .zero
cell.titleLabel.text = newsItem.title
cell.captionLabel.text = newsItem.caption
cell.contentBackgroundView.backgroundColor = newsItem.tintColor
cell.imageView.image = nil
cell.imageView.isHidden = true
cell.isAccessibilityElement = true
cell.accessibilityLabel = (cell.titleLabel.text ?? "") + ". " + (cell.captionLabel.text ?? "")
if newsItem.storeApp != nil || newsItem.externalURL != nil
{
cell.accessibilityTraits.insert(.button)
}
else
{
cell.accessibilityTraits.remove(.button)
}
}
return dataSource
}
func makeAppsDataSource() -> RSTArrayCollectionViewPrefetchingDataSource<StoreApp, UIImage>
{
let featuredApps = self.source.effectiveFeaturedApps
let limitedFeaturedApps = Array(featuredApps.prefix(5))
let dataSource = RSTArrayCollectionViewPrefetchingDataSource<StoreApp, UIImage>(items: limitedFeaturedApps)
dataSource.cellIdentifierHandler = { _ in "AppCell" }
dataSource.predicate = StoreApp.visibleAppsPredicate
dataSource.cellConfigurationHandler = { [weak self] (cell, storeApp, indexPath) in
let cell = cell as! AppBannerCollectionViewCell
cell.tintColor = storeApp.tintColor
// For some reason, setting cell.layoutMargins = .zero does not update cell.contentView.layoutMargins.
cell.layoutMargins = .zero
cell.contentView.layoutMargins = .zero
cell.contentView.backgroundColor = .altBackground
cell.bannerView.configure(for: storeApp)
cell.bannerView.iconImageView.isIndicatingActivity = true
cell.bannerView.buttonLabel.isHidden = true
cell.bannerView.button.isIndicatingActivity = false
cell.bannerView.button.tintColor = storeApp.tintColor
let buttonTitle = NSLocalizedString("Free", comment: "")
cell.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal)
cell.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), storeApp.name)
cell.bannerView.button.accessibilityValue = buttonTitle
cell.bannerView.button.addTarget(self, action: #selector(SourceDetailContentViewController.addSourceThenDownloadApp(_:)), for: .primaryActionTriggered)
let progress = AppManager.shared.installationProgress(for: storeApp)
cell.bannerView.button.progress = progress
if let versionDate = storeApp.latestSupportedVersion?.date, versionDate > Date()
{
cell.bannerView.button.countdownDate = versionDate
}
else
{
cell.bannerView.button.countdownDate = nil
}
// Make sure refresh button is correct size.
cell.layoutIfNeeded()
if let progress = AppManager.shared.installationProgress(for: storeApp), progress.fractionCompleted < 1.0
{
cell.bannerView.button.progress = progress
}
else
{
cell.bannerView.button.progress = nil
}
}
dataSource.prefetchHandler = { (storeApp, indexPath, completion) -> Foundation.Operation? in
return RSTAsyncBlockOperation { (operation) in
storeApp.managedObjectContext?.perform {
ImagePipeline.shared.loadImage(with: storeApp.iconURL, progress: nil) { result in
guard !operation.isCancelled else { return operation.finish() }
switch result
{
case .success(let response): completion(response.image, nil)
case .failure(let error): completion(nil, error)
}
}
}
}
}
dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in
let cell = cell as! AppBannerCollectionViewCell
cell.bannerView.iconImageView.image = image
cell.bannerView.iconImageView.isIndicatingActivity = false
if let error
{
print("[ALTLog] Error loading source icon:", error)
}
}
return dataSource
}
func makeAboutDataSource() -> RSTDynamicCollectionViewDataSource<NSManagedObject>
{
let dataSource = RSTDynamicCollectionViewDataSource<NSManagedObject>()
dataSource.numberOfSectionsHandler = { 1 }
dataSource.numberOfItemsHandler = { [source] _ in source.localizedDescription == nil ? 0 : 1 }
dataSource.cellIdentifierHandler = { _ in "AboutCell" }
dataSource.cellConfigurationHandler = { [source] (cell, _, indexPath) in
let cell = cell as! TextViewCollectionViewCell
cell.contentView.layoutMargins = .zero // Fixes incorrect margins if not initially on screen.
cell.textView.text = source.localizedDescription
cell.textView.isCollapsed = false
cell.textView.backgroundColor = .altBackground
}
return dataSource
}
}
private extension SourceDetailContentViewController
{
@objc func viewAllNews()
{
self.performSegue(withIdentifier: "showAllNews", sender: nil)
}
@objc func viewAllApps()
{
self.performSegue(withIdentifier: "showAllApps", sender: nil)
}
@IBSegueAction
func makeNewsViewController(_ coder: NSCoder) -> UIViewController?
{
let newsViewController = NewsViewController(source: self.source, coder: coder)
return newsViewController
}
@IBSegueAction
func makeBrowseViewController(_ coder: NSCoder) -> UIViewController?
{
let browseViewController = BrowseViewController(source: self.source, coder: coder)
return browseViewController
}
}
extension SourceDetailContentViewController
{
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView
{
let supplementaryView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: kind, for: indexPath)
let section = Section(rawValue: indexPath.section)!
let kind = ElementKind(rawValue: kind)!
switch (section, kind)
{
case (.news, _):
let buttonView = supplementaryView as! ButtonCollectionReusableView
buttonView.button.setTitle(NSLocalizedString("View All", comment: ""), for: .normal)
buttonView.button.removeTarget(self, action: nil, for: .primaryActionTriggered)
buttonView.button.addTarget(self, action: #selector(SourceDetailContentViewController.viewAllNews), for: .primaryActionTriggered)
case (.featuredApps, .title):
let titleView = supplementaryView as! TitleCollectionReusableView
titleView.label.text = NSLocalizedString("Featured Apps", comment: "")
case (.featuredApps, .button):
let buttonView = supplementaryView as! ButtonCollectionReusableView
buttonView.button.setTitle(NSLocalizedString("View All Apps", comment: ""), for: .normal)
buttonView.button.removeTarget(self, action: nil, for: .primaryActionTriggered)
buttonView.button.addTarget(self, action: #selector(SourceDetailContentViewController.viewAllApps), for: .primaryActionTriggered)
case (.about, _):
let titleView = supplementaryView as! TitleCollectionReusableView
titleView.label.text = NSLocalizedString("About", comment: "")
}
return supplementaryView
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath)
{
let section = Section(rawValue: indexPath.section)!
let item = self.dataSource.item(at: indexPath)
switch (section, item)
{
case (.news, let newsItem as NewsItem):
if let externalURL = newsItem.externalURL
{
let safariViewController = SFSafariViewController(url: externalURL)
safariViewController.preferredControlTintColor = newsItem.tintColor
self.present(safariViewController, animated: true, completion: nil)
}
else if let storeApp = newsItem.storeApp
{
let appViewController = AppViewController.makeAppViewController(app: storeApp)
self.navigationController?.pushViewController(appViewController, animated: true)
}
case (.featuredApps, let storeApp as StoreApp):
let appViewController = AppViewController.makeAppViewController(app: storeApp)
self.navigationController?.pushViewController(appViewController, animated: true)
default: break
}
}
}
private extension SourceDetailContentViewController
{
@objc func addSourceThenDownloadApp(_ sender: UIButton)
{
let point = self.collectionView.convert(sender.center, from: sender.superview)
guard let indexPath = self.collectionView.indexPathForItem(at: point) else { return }
sender.isIndicatingActivity = true
let storeApp = self.dataSource.item(at: indexPath) as! StoreApp
Task<Void, Never> {
do
{
let isAdded = try await self.source.isAdded
if !isAdded
{
let message = String(format: NSLocalizedString("You must add this source before you can install apps from it.\n\n“%@” will begin downloading once it has been added.", comment: ""), storeApp.name)
try await AppManager.shared.add(self.source, message: message, presentingViewController: self)
}
do
{
try await self.downloadApp(storeApp)
}
catch OperationError.cancelled {}
catch
{
let toastView = ToastView(error: error)
toastView.opensErrorLog = true
toastView.show(in: self)
}
}
catch is CancellationError {}
catch
{
await self.presentAlert(title: NSLocalizedString("Unable to Add Source", comment: ""), message: error.localizedDescription)
}
sender.isIndicatingActivity = false
self.collectionView.reloadSections([Section.featuredApps.rawValue])
}
}
func downloadApp(_ storeApp: StoreApp) async throws
{
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
AppManager.shared.install(storeApp, presentingViewController: self) { result in
continuation.resume(with: result.map { _ in })
}
guard let index = self.appsDataSource.items.firstIndex(of: storeApp) else {
self.collectionView.reloadSections([Section.featuredApps.rawValue])
return
}
let indexPath = IndexPath(item: index, section: Section.featuredApps.rawValue)
self.collectionView.reloadItems(at: [indexPath])
}
}
}
extension SourceDetailContentViewController: ScrollableContentViewController
{
var scrollView: UIScrollView { self.collectionView }
}