Files
SideStore/SideStoreApp/Sources/SideStoreUIKit/News/NewsViewController.swift

457 lines
18 KiB
Swift
Raw Normal View History

2019-09-03 21:58:07 -07:00
//
// NewsViewController.swift
// AltStore
//
// Created by Riley Testut on 8/29/19.
// Copyright © 2019 Riley Testut. All rights reserved.
//
import SafariServices
2023-03-01 00:48:36 -05:00
import UIKit
2019-09-03 21:58:07 -07:00
2023-03-01 00:48:36 -05:00
import SideStoreCore
2023-03-01 14:36:52 -05:00
import RoxasUIKit
2019-09-03 21:58:07 -07:00
import Nuke
import OSLog
#if canImport(Logging)
import Logging
#endif
2019-09-03 21:58:07 -07:00
2023-03-01 00:48:36 -05:00
private final class AppBannerFooterView: UICollectionReusableView {
2019-09-03 21:58:07 -07:00
let bannerView = AppBannerView(frame: .zero)
let tapGestureRecognizer = UITapGestureRecognizer(target: nil, action: nil)
2023-03-01 00:48:36 -05:00
override init(frame: CGRect) {
2019-09-03 21:58:07 -07:00
super.init(frame: frame)
2023-03-01 00:48:36 -05:00
addGestureRecognizer(tapGestureRecognizer)
bannerView.translatesAutoresizingMaskIntoConstraints = false
addSubview(bannerView)
NSLayoutConstraint.activate([
2023-03-01 00:48:36 -05:00
bannerView.topAnchor.constraint(equalTo: topAnchor),
bannerView.bottomAnchor.constraint(equalTo: bottomAnchor),
bannerView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
bannerView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor)
])
2019-09-03 21:58:07 -07:00
}
2023-03-01 00:48:36 -05:00
@available(*, unavailable)
required init?(coder _: NSCoder) {
2019-09-03 21:58:07 -07:00
fatalError("init(coder:) has not been implemented")
}
}
2023-03-01 00:48:36 -05:00
final class NewsViewController: UICollectionViewController {
2019-09-03 21:58:07 -07:00
private lazy var dataSource = self.makeDataSource()
private lazy var placeholderView = RSTPlaceholderView(frame: .zero)
2023-03-01 00:48:36 -05:00
2019-09-03 21:58:07 -07:00
private var prototypeCell: NewsCollectionViewCell!
2023-03-01 00:48:36 -05:00
private var loadingState: LoadingState = .loading {
didSet {
2023-03-01 00:48:36 -05:00
update()
}
}
2023-03-01 00:48:36 -05:00
2019-09-03 21:58:07 -07:00
// Cache
private var cachedCellSizes = [String: CGSize]()
2023-03-01 00:48:36 -05:00
required init?(coder: NSCoder) {
super.init(coder: coder)
2023-03-01 00:48:36 -05:00
2023-03-01 19:09:33 -05:00
NotificationCenter.default.addObserver(self, selector: #selector(NewsViewController.importApp(_:)), name: SideStoreAppDelegate.importAppDeepLinkNotification, object: nil)
}
2023-03-01 00:48:36 -05:00
override func viewDidLoad() {
2019-09-03 21:58:07 -07:00
super.viewDidLoad()
2023-03-01 00:48:36 -05:00
prototypeCell = NewsCollectionViewCell.instantiate(with: NewsCollectionViewCell.nib!)
prototypeCell.contentView.translatesAutoresizingMaskIntoConstraints = false
collectionView.dataSource = dataSource
collectionView.prefetchDataSource = dataSource
collectionView.register(NewsCollectionViewCell.nib, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier)
collectionView.register(AppBannerFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "AppBanner")
registerForPreviewing(with: self, sourceView: collectionView)
update()
2019-09-03 21:58:07 -07:00
}
2023-03-01 00:48:36 -05:00
override func viewWillAppear(_ animated: Bool) {
2019-09-03 21:58:07 -07:00
super.viewWillAppear(animated)
2023-03-01 00:48:36 -05:00
fetchSource()
2019-09-03 21:58:07 -07:00
}
2023-03-01 00:48:36 -05:00
override func viewWillLayoutSubviews() {
2019-10-03 12:30:53 -07:00
super.viewWillLayoutSubviews()
2023-03-01 00:48:36 -05:00
if collectionView.contentInset.bottom != 20 {
2019-10-03 12:30:53 -07:00
// Triggers collection view update in iOS 13, which crashes if we do it in viewDidLoad()
// since the database might not be loaded yet.
2023-03-01 00:48:36 -05:00
collectionView.contentInset.bottom = 20
2019-10-03 12:30:53 -07:00
}
}
2023-03-01 00:48:36 -05:00
@IBAction private func unwindFromSourcesViewController(_: UIStoryboardSegue) {
fetchSource()
}
2019-09-03 21:58:07 -07:00
}
2023-03-01 00:48:36 -05:00
private extension NewsViewController {
func makeDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<NewsItem, UIImage> {
2019-09-03 21:58:07 -07:00
let fetchRequest = NewsItem.fetchRequest() as NSFetchRequest<NewsItem>
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \NewsItem.date, ascending: false),
NSSortDescriptor(keyPath: \NewsItem.sortIndex, ascending: true),
NSSortDescriptor(keyPath: \NewsItem.sourceIdentifier, ascending: true)]
2023-03-01 00:48:36 -05:00
let fetchedResultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext, sectionNameKeyPath: #keyPath(NewsItem.objectID), cacheName: nil)
2023-03-01 00:48:36 -05:00
2019-09-03 21:58:07 -07:00
let dataSource = RSTFetchedResultsCollectionViewPrefetchingDataSource<NewsItem, UIImage>(fetchedResultsController: fetchedResultsController)
dataSource.proxy = self
2023-03-01 00:48:36 -05:00
dataSource.cellConfigurationHandler = { cell, newsItem, _ in
2019-09-03 21:58:07 -07:00
let cell = cell as! NewsCollectionViewCell
cell.layoutMargins.left = self.view.layoutMargins.left
cell.layoutMargins.right = self.view.layoutMargins.right
2023-03-01 00:48:36 -05:00
2019-09-03 21:58:07 -07:00
cell.titleLabel.text = newsItem.title
cell.captionLabel.text = newsItem.caption
cell.contentBackgroundView.backgroundColor = newsItem.tintColor
2023-03-01 00:48:36 -05:00
2019-09-03 21:58:07 -07:00
cell.imageView.image = nil
2023-03-01 00:48:36 -05:00
if newsItem.imageURL != nil {
2019-09-03 21:58:07 -07:00
cell.imageView.isIndicatingActivity = true
cell.imageView.isHidden = false
2023-03-01 00:48:36 -05:00
} else {
2019-09-03 21:58:07 -07:00
cell.imageView.isIndicatingActivity = false
cell.imageView.isHidden = true
}
2023-03-01 00:48:36 -05:00
cell.isAccessibilityElement = true
cell.accessibilityLabel = (cell.titleLabel.text ?? "") + ". " + (cell.captionLabel.text ?? "")
2023-03-01 00:48:36 -05:00
if newsItem.storeApp != nil || newsItem.externalURL != nil {
cell.accessibilityTraits.insert(.button)
2023-03-01 00:48:36 -05:00
} else {
cell.accessibilityTraits.remove(.button)
}
2019-09-03 21:58:07 -07:00
}
2023-03-01 00:48:36 -05:00
dataSource.prefetchHandler = { newsItem, _, completionHandler in
2019-09-03 21:58:07 -07:00
guard let imageURL = newsItem.imageURL else { return nil }
2023-03-01 00:48:36 -05:00
return RSTAsyncBlockOperation { operation in
ImagePipeline.shared.loadImage(with: imageURL, progress: nil, completion: { response, error in
2019-09-03 21:58:07 -07:00
guard !operation.isCancelled else { return operation.finish() }
2023-03-01 00:48:36 -05:00
if let image = response?.image {
2019-09-03 21:58:07 -07:00
completionHandler(image, nil)
2023-03-01 00:48:36 -05:00
} else {
2019-09-03 21:58:07 -07:00
completionHandler(nil, error)
}
})
}
}
2023-03-01 00:48:36 -05:00
dataSource.prefetchCompletionHandler = { cell, image, _, error in
2019-09-03 21:58:07 -07:00
let cell = cell as! NewsCollectionViewCell
cell.imageView.isIndicatingActivity = false
cell.imageView.image = image
2023-03-01 00:48:36 -05:00
if let error = error {
2023-03-02 00:40:11 -05:00
os_log("Error loading image: %@", type: .error , error.localizedDescription)
2019-09-03 21:58:07 -07:00
}
}
2023-03-01 00:48:36 -05:00
dataSource.placeholderView = placeholderView
2019-09-03 21:58:07 -07:00
return dataSource
}
2023-03-01 00:48:36 -05:00
func fetchSource() {
loadingState = .loading
AppManager.shared.fetchSources { result in
do {
do {
let (_, context) = try result.get()
try context.save()
2023-03-01 00:48:36 -05:00
DispatchQueue.main.async {
self.loadingState = .finished(.success(()))
}
2023-03-01 00:48:36 -05:00
} catch let error as AppManager.FetchSourcesError {
try error.managedObjectContext?.save()
throw error
}
2023-03-01 00:48:36 -05:00
} catch {
2019-09-03 21:58:07 -07:00
DispatchQueue.main.async {
2023-03-01 00:48:36 -05:00
if self.dataSource.itemCount > 0 {
let toastView = ToastView(error: error)
toastView.addTarget(nil, action: #selector(TabBarController.presentSources), for: .touchUpInside)
toastView.show(in: self)
}
2023-03-01 00:48:36 -05:00
self.loadingState = .finished(.failure(error))
2019-09-03 21:58:07 -07:00
}
}
}
}
2023-03-01 00:48:36 -05:00
func update() {
switch loadingState {
case .loading:
2023-03-01 00:48:36 -05:00
placeholderView.textLabel.isHidden = true
placeholderView.detailTextLabel.isHidden = false
placeholderView.detailTextLabel.text = NSLocalizedString("Loading...", comment: "")
placeholderView.activityIndicatorView.startAnimating()
case let .finished(.failure(error)):
placeholderView.textLabel.isHidden = false
placeholderView.detailTextLabel.isHidden = false
placeholderView.textLabel.text = NSLocalizedString("Unable to Fetch News", comment: "")
placeholderView.detailTextLabel.text = error.localizedDescription
placeholderView.activityIndicatorView.stopAnimating()
case .finished(.success):
2023-03-01 00:48:36 -05:00
placeholderView.textLabel.isHidden = true
placeholderView.detailTextLabel.isHidden = true
placeholderView.activityIndicatorView.stopAnimating()
}
}
2019-09-03 21:58:07 -07:00
}
2023-03-01 00:48:36 -05:00
private extension NewsViewController {
@objc func handleTapGesture(_ gestureRecognizer: UITapGestureRecognizer) {
2019-09-03 21:58:07 -07:00
guard let footerView = gestureRecognizer.view as? UICollectionReusableView else { return }
2023-03-01 00:48:36 -05:00
let indexPaths = collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter)
guard let indexPath = indexPaths.first(where: { indexPath -> Bool in
2019-09-03 21:58:07 -07:00
let supplementaryView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath)
return supplementaryView == footerView
}) else { return }
2023-03-01 00:48:36 -05:00
let item = dataSource.item(at: indexPath)
2019-09-03 21:58:07 -07:00
guard let storeApp = item.storeApp else { return }
2023-03-01 00:48:36 -05:00
2019-09-03 21:58:07 -07:00
let appViewController = AppViewController.makeAppViewController(app: storeApp)
2023-03-01 00:48:36 -05:00
navigationController?.pushViewController(appViewController, animated: true)
2019-09-03 21:58:07 -07:00
}
2023-03-01 00:48:36 -05:00
@objc func performAppAction(_ sender: PillButton) {
let point = collectionView.convert(sender.center, from: sender.superview)
let indexPaths = collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter)
guard let indexPath = indexPaths.first(where: { indexPath -> Bool in
2019-09-03 21:58:07 -07:00
let supplementaryView = self.collectionView.supplementaryView(forElementKind: UICollectionView.elementKindSectionFooter, at: indexPath)
return supplementaryView?.frame.contains(point) ?? false
}) else { return }
2023-03-01 00:48:36 -05:00
let app = dataSource.item(at: indexPath)
2019-09-03 21:58:07 -07:00
guard let storeApp = app.storeApp else { return }
2023-03-01 00:48:36 -05:00
if let installedApp = app.storeApp?.installedApp {
open(installedApp)
} else {
install(storeApp, at: indexPath)
2019-09-03 21:58:07 -07:00
}
}
2023-03-01 00:48:36 -05:00
@objc func install(_ storeApp: StoreApp, at indexPath: IndexPath) {
2019-09-03 21:58:07 -07:00
let previousProgress = AppManager.shared.installationProgress(for: storeApp)
guard previousProgress == nil else {
previousProgress?.cancel()
return
}
2023-03-01 00:48:36 -05:00
_ = AppManager.shared.install(storeApp, presentingViewController: self) { result in
2019-09-03 21:58:07 -07:00
DispatchQueue.main.async {
2023-03-01 00:48:36 -05:00
switch result {
2019-09-03 21:58:07 -07:00
case .failure(OperationError.cancelled): break // Ignore
2023-03-01 00:48:36 -05:00
case let .failure(error):
let toastView = ToastView(error: error)
toastView.show(in: self)
2023-03-01 00:48:36 -05:00
2023-03-02 00:40:11 -05:00
case .success: os_log("Installed app: %@", type: .info, storeApp.bundleIdentifier)
2019-09-03 21:58:07 -07:00
}
2023-03-01 00:48:36 -05:00
2019-09-03 21:58:07 -07:00
UIView.performWithoutAnimation {
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
}
}
}
2023-03-01 00:48:36 -05:00
2019-09-03 21:58:07 -07:00
UIView.performWithoutAnimation {
self.collectionView.reloadSections(IndexSet(integer: indexPath.section))
}
}
2023-03-01 00:48:36 -05:00
func open(_ installedApp: InstalledApp) {
2019-09-03 21:58:07 -07:00
UIApplication.shared.open(installedApp.openAppURL)
}
}
2023-03-01 00:48:36 -05:00
private extension NewsViewController {
@objc func importApp(_: Notification) {
presentedViewController?.dismiss(animated: true, completion: nil)
}
}
2023-03-01 00:48:36 -05:00
extension NewsViewController {
override func collectionView(_: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let newsItem = dataSource.item(at: indexPath)
if let externalURL = newsItem.externalURL {
2019-09-03 21:58:07 -07:00
let safariViewController = SFSafariViewController(url: externalURL)
safariViewController.preferredControlTintColor = newsItem.tintColor
2023-03-01 00:48:36 -05:00
present(safariViewController, animated: true, completion: nil)
} else if let storeApp = newsItem.storeApp {
2019-09-03 21:58:07 -07:00
let appViewController = AppViewController.makeAppViewController(app: storeApp)
2023-03-01 00:48:36 -05:00
navigationController?.pushViewController(appViewController, animated: true)
2019-09-03 21:58:07 -07:00
}
}
2023-03-01 00:48:36 -05:00
override func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind _: String, at indexPath: IndexPath) -> UICollectionReusableView {
let item = dataSource.item(at: indexPath)
2019-09-03 21:58:07 -07:00
let footerView = collectionView.dequeueReusableSupplementaryView(ofKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "AppBanner", for: indexPath) as! AppBannerFooterView
guard let storeApp = item.storeApp else { return footerView }
2023-03-01 00:48:36 -05:00
footerView.layoutMargins.left = view.layoutMargins.left
footerView.layoutMargins.right = view.layoutMargins.right
2020-08-27 15:23:21 -07:00
footerView.bannerView.configure(for: storeApp)
2023-03-01 00:48:36 -05:00
2019-09-03 21:58:07 -07:00
footerView.bannerView.tintColor = storeApp.tintColor
footerView.bannerView.button.addTarget(self, action: #selector(NewsViewController.performAppAction(_:)), for: .primaryActionTriggered)
footerView.tapGestureRecognizer.addTarget(self, action: #selector(NewsViewController.handleTapGesture(_:)))
2023-03-01 00:48:36 -05:00
2019-09-03 21:58:07 -07:00
footerView.bannerView.button.isIndicatingActivity = false
2023-03-01 00:48:36 -05:00
if storeApp.installedApp == nil {
2020-08-27 15:23:21 -07:00
let buttonTitle = NSLocalizedString("Free", comment: "")
footerView.bannerView.button.setTitle(buttonTitle.uppercased(), for: .normal)
footerView.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Download %@", comment: ""), storeApp.name)
footerView.bannerView.button.accessibilityValue = buttonTitle
2023-03-01 00:48:36 -05:00
2019-09-03 21:58:07 -07:00
let progress = AppManager.shared.installationProgress(for: storeApp)
footerView.bannerView.button.progress = progress
2023-03-01 00:48:36 -05:00
if let versionDate = storeApp.latestVersion?.date, versionDate > Date() {
2019-09-07 15:37:08 -07:00
footerView.bannerView.button.countdownDate = storeApp.versionDate
2023-03-01 00:48:36 -05:00
} else {
2019-09-07 15:37:08 -07:00
footerView.bannerView.button.countdownDate = nil
}
2023-03-01 00:48:36 -05:00
} else {
2019-09-03 21:58:07 -07:00
footerView.bannerView.button.setTitle(NSLocalizedString("OPEN", comment: ""), for: .normal)
2020-08-27 15:23:21 -07:00
footerView.bannerView.button.accessibilityLabel = String(format: NSLocalizedString("Open %@", comment: ""), storeApp.name)
footerView.bannerView.button.accessibilityValue = nil
2019-09-03 21:58:07 -07:00
footerView.bannerView.button.progress = nil
2019-09-07 15:37:08 -07:00
footerView.bannerView.button.countdownDate = nil
2019-09-03 21:58:07 -07:00
}
2023-03-01 00:48:36 -05:00
2019-09-03 21:58:07 -07:00
Nuke.loadImage(with: storeApp.iconURL, into: footerView.bannerView.iconImageView)
2023-03-01 00:48:36 -05:00
2019-09-03 21:58:07 -07:00
return footerView
}
}
2023-03-01 00:48:36 -05:00
extension NewsViewController: UICollectionViewDelegateFlowLayout {
func collectionView(_ collectionView: UICollectionView, layout _: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
let item = dataSource.item(at: indexPath)
if let previousSize = cachedCellSizes[item.identifier] {
2019-09-03 21:58:07 -07:00
return previousSize
}
2023-03-01 00:48:36 -05:00
let widthConstraint = prototypeCell.contentView.widthAnchor.constraint(equalToConstant: collectionView.bounds.width)
2019-09-03 21:58:07 -07:00
NSLayoutConstraint.activate([widthConstraint])
defer { NSLayoutConstraint.deactivate([widthConstraint]) }
2023-03-01 00:48:36 -05:00
dataSource.cellConfigurationHandler(prototypeCell, item, indexPath)
let size = prototypeCell.contentView.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
cachedCellSizes[item.identifier] = size
2019-09-03 21:58:07 -07:00
return size
}
2023-03-01 00:48:36 -05:00
func collectionView(_: UICollectionView, layout _: UICollectionViewLayout, referenceSizeForFooterInSection section: Int) -> CGSize {
let item = dataSource.item(at: IndexPath(row: 0, section: section))
if item.storeApp != nil {
2019-09-03 21:58:07 -07:00
return CGSize(width: 88, height: 88)
2023-03-01 00:48:36 -05:00
} else {
2019-09-03 21:58:07 -07:00
return .zero
}
}
2023-03-01 00:48:36 -05:00
func collectionView(_: UICollectionView, layout _: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets {
var insets = UIEdgeInsets(top: 30, left: 0, bottom: 13, right: 0)
2023-03-01 00:48:36 -05:00
if section == 0 {
2019-09-03 21:58:07 -07:00
insets.top = 10
}
2023-03-01 00:48:36 -05:00
2019-09-03 21:58:07 -07:00
return insets
}
}
2023-03-01 00:48:36 -05:00
extension NewsViewController: UIViewControllerPreviewingDelegate {
func previewingContext(_ previewingContext: UIViewControllerPreviewing, viewControllerForLocation location: CGPoint) -> UIViewController? {
if let indexPath = collectionView.indexPathForItem(at: location), let cell = collectionView.cellForItem(at: indexPath) {
2019-09-03 21:58:07 -07:00
// Previewing news item.
2023-03-01 00:48:36 -05:00
2019-09-03 21:58:07 -07:00
previewingContext.sourceRect = cell.frame
2023-03-01 00:48:36 -05:00
let newsItem = dataSource.item(at: indexPath)
if let externalURL = newsItem.externalURL {
2019-09-03 21:58:07 -07:00
let safariViewController = SFSafariViewController(url: externalURL)
safariViewController.preferredControlTintColor = newsItem.tintColor
return safariViewController
2023-03-01 00:48:36 -05:00
} else if let storeApp = newsItem.storeApp {
2019-09-03 21:58:07 -07:00
let appViewController = AppViewController.makeAppViewController(app: storeApp)
return appViewController
}
2023-03-01 00:48:36 -05:00
2019-09-03 21:58:07 -07:00
return nil
2023-03-01 00:48:36 -05:00
} else {
2019-09-03 21:58:07 -07:00
// Previewing app banner (or nothing).
2023-03-01 00:48:36 -05:00
let indexPaths = collectionView.indexPathsForVisibleSupplementaryElements(ofKind: UICollectionView.elementKindSectionFooter)
guard let indexPath = indexPaths.first(where: { indexPath -> Bool in
2019-09-03 21:58:07 -07:00
let layoutAttributes = self.collectionView.layoutAttributesForSupplementaryElement(ofKind: UICollectionView.elementKindSectionFooter, at: indexPath)
return layoutAttributes?.frame.contains(location) ?? false
}) else { return nil }
2023-03-01 00:48:36 -05:00
guard let layoutAttributes = collectionView.layoutAttributesForSupplementaryElement(ofKind: UICollectionView.elementKindSectionFooter, at: indexPath) else { return nil }
2019-09-03 21:58:07 -07:00
previewingContext.sourceRect = layoutAttributes.frame
2023-03-01 00:48:36 -05:00
let item = dataSource.item(at: indexPath)
2019-09-03 21:58:07 -07:00
guard let storeApp = item.storeApp else { return nil }
2023-03-01 00:48:36 -05:00
2019-09-03 21:58:07 -07:00
let appViewController = AppViewController.makeAppViewController(app: storeApp)
return appViewController
}
}
2023-03-01 00:48:36 -05:00
func previewingContext(_: UIViewControllerPreviewing, commit viewControllerToCommit: UIViewController) {
if let safariViewController = viewControllerToCommit as? SFSafariViewController {
present(safariViewController, animated: true, completion: nil)
} else {
navigationController?.pushViewController(viewControllerToCommit, animated: true)
2019-09-03 21:58:07 -07:00
}
}
}