diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index c8447165..39f08536 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -368,6 +368,7 @@ D54058B92A1D6269008CCC58 /* AppPermissionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54058B82A1D6269008CCC58 /* AppPermissionProtocol.swift */; }; D54058BB2A1D8FE3008CCC58 /* UIColor+AltStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */; }; D540E93828EE1BDE000F1B0F /* ErrorDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D540E93728EE1BDE000F1B0F /* ErrorDetailsViewController.swift */; }; + D5418F172AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5418F162AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift */; }; D54DED1428CBC44B008B27A0 /* ErrorLogTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */; }; D552B1D82A042A740066216F /* AppPermissionsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D552B1D72A042A740066216F /* AppPermissionsCard.swift */; }; D55467B82A8D5E2600F4CE90 /* AppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D55467B12A8D5E2600F4CE90 /* AppShortcuts.swift */; }; @@ -436,6 +437,7 @@ D5F5AF2E28FDD2EC00C938F5 /* TestErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F5AF2D28FDD2EC00C938F5 /* TestErrors.swift */; }; D5F5AF7D28ECEA990067C736 /* ErrorDetailsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F5AF7C28ECEA990067C736 /* ErrorDetailsViewController.swift */; }; D5F9821D2AB900060045751F /* AppScreenshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F9821C2AB900060045751F /* AppScreenshot.swift */; }; + D5F982212AB910180045751F /* AppScreenshotsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F982202AB910180045751F /* AppScreenshotsViewController.swift */; }; D5F99A1828D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = D5F99A1728D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel */; }; D5F99A1A28D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F99A1928D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift */; }; D5FB28EC2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */; }; @@ -1034,6 +1036,7 @@ D54058B82A1D6269008CCC58 /* AppPermissionProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissionProtocol.swift; sourceTree = ""; }; D54058BA2A1D8FE3008CCC58 /* UIColor+AltStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIColor+AltStore.swift"; sourceTree = ""; }; D540E93728EE1BDE000F1B0F /* ErrorDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDetailsViewController.swift; sourceTree = ""; }; + D5418F162AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppScreenshotCollectionViewCell.swift; sourceTree = ""; }; D54DED1328CBC44B008B27A0 /* ErrorLogTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorLogTableViewCell.swift; sourceTree = ""; }; D552B1D72A042A740066216F /* AppPermissionsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPermissionsCard.swift; sourceTree = ""; }; D55467B12A8D5E2600F4CE90 /* AppShortcuts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppShortcuts.swift; sourceTree = ""; }; @@ -1098,6 +1101,7 @@ D5F5AF2D28FDD2EC00C938F5 /* TestErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestErrors.swift; sourceTree = ""; }; D5F5AF7C28ECEA990067C736 /* ErrorDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDetailsViewController.swift; sourceTree = ""; }; D5F9821C2AB900060045751F /* AppScreenshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppScreenshot.swift; sourceTree = ""; }; + D5F982202AB910180045751F /* AppScreenshotsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppScreenshotsViewController.swift; sourceTree = ""; }; D5F99A1728D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = AltStore10ToAltStore11.xcmappingmodel; sourceTree = ""; }; D5F99A1928D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreApp10ToStoreApp11Policy.swift; sourceTree = ""; }; D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFontDescriptor+Bold.swift"; sourceTree = ""; }; @@ -1406,6 +1410,7 @@ BF3D64AF22E8D4B800E9056B /* AppContentViewControllerCells.swift */, D52EF2BD2A0594550096C377 /* AppDetailCollectionViewController.swift */, D552B1D72A042A740066216F /* AppPermissionsCard.swift */, + D5418F152AD740750014ABD6 /* Screenshots */, ); path = "App Detail"; sourceTree = ""; @@ -2210,6 +2215,15 @@ path = Previews; sourceTree = ""; }; + D5418F152AD740750014ABD6 /* Screenshots */ = { + isa = PBXGroup; + children = ( + D5F982202AB910180045751F /* AppScreenshotsViewController.swift */, + D5418F162AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift */, + ); + path = Screenshots; + sourceTree = ""; + }; D55467B02A8D5E2600F4CE90 /* App Intents */ = { isa = PBXGroup; children = ( @@ -3186,6 +3200,7 @@ BF3BEFC124086A1E00DE7D55 /* RefreshAppOperation.swift in Sources */, BFE60740231AFD2A002B0E8E /* InsetGroupTableViewCell.swift in Sources */, BF0DCA662433BDF500E3A595 /* AnalyticsManager.swift in Sources */, + D5418F172AD740890014ABD6 /* AppScreenshotCollectionViewCell.swift in Sources */, BFCCB51A245E3401001853EA /* VerifyAppOperation.swift in Sources */, BFF0B6982322CAB8007A79E1 /* InstructionsViewController.swift in Sources */, BF9ABA4522DCFF43008935CF /* BrowseViewController.swift in Sources */, @@ -3277,6 +3292,7 @@ BFB3645A2325985F00CD0EB1 /* FindServerOperation.swift in Sources */, D52EF2BE2A0594550096C377 /* AppDetailCollectionViewController.swift in Sources */, BF3BEFBF2408673400DE7D55 /* FetchProvisioningProfilesOperation.swift in Sources */, + D5F982212AB910180045751F /* AppScreenshotsViewController.swift in Sources */, BFF0B69023219C6D007A79E1 /* PatreonComponents.swift in Sources */, BFBE0004250ACFFB0080826E /* ViewApp.intentdefinition in Sources */, BF56D2AF23DF9E310006506D /* AppIDsViewController.swift in Sources */, diff --git a/AltStore/App Detail/AppContentViewController.swift b/AltStore/App Detail/AppContentViewController.swift index ac79a372..5eff1864 100644 --- a/AltStore/App Detail/AppContentViewController.swift +++ b/AltStore/App Detail/AppContentViewController.swift @@ -30,6 +30,12 @@ final class AppContentViewController: UITableViewController var app: StoreApp! private lazy var screenshotsDataSource = self.makeScreenshotsDataSource() + private lazy var dateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .none + return dateFormatter + }() private lazy var byteCountFormatter: ByteCountFormatter = { let formatter = ByteCountFormatter() @@ -43,32 +49,17 @@ final class AppContentViewController: UITableViewController @IBOutlet private var versionDateLabel: UILabel! @IBOutlet private var sizeLabel: UILabel! - @IBOutlet private var screenshotsCollectionView: UICollectionView! + @IBOutlet private(set) var appScreenshotsViewController: AppScreenshotsViewController! + @IBOutlet private var appScreenshotsHeightConstraint: NSLayoutConstraint! @IBOutlet private(set) var appDetailCollectionViewController: AppDetailCollectionViewController! @IBOutlet private var appDetailCollectionViewHeightConstraint: NSLayoutConstraint! - var preferredScreenshotSize: CGSize? { - let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout - - let aspectRatio: CGFloat = 16.0 / 9.0 // Hardcoded for now. - - let width = self.screenshotsCollectionView.bounds.width - (layout.minimumInteritemSpacing * 2) - - let itemWidth = width / 1.5 - let itemHeight = itemWidth * aspectRatio - - return CGSize(width: itemWidth, height: itemHeight) - } - override func viewDidLoad() { super.viewDidLoad() self.tableView.contentInset.bottom = 20 - - self.screenshotsCollectionView.dataSource = self.screenshotsDataSource - self.screenshotsCollectionView.prefetchDataSource = self.screenshotsDataSource self.subtitleLabel.text = self.app.subtitle self.descriptionTextView.text = self.app.localizedDescription @@ -99,17 +90,24 @@ final class AppContentViewController: UITableViewController { super.viewDidLayoutSubviews() - guard var size = self.preferredScreenshotSize else { return } - size.height = min(size.height, self.screenshotsCollectionView.bounds.height) // Silence temporary "item too tall" warning. + var needsTableViewUpdate = false - let layout = self.screenshotsCollectionView.collectionViewLayout as! UICollectionViewFlowLayout - layout.itemSize = size + let screenshotsHeight = self.appScreenshotsViewController.collectionView.contentSize.height + if self.appScreenshotsHeightConstraint.constant != screenshotsHeight && screenshotsHeight > 0 + { + self.appScreenshotsHeightConstraint.constant = screenshotsHeight + needsTableViewUpdate = true + } let permissionsHeight = self.appDetailCollectionViewController.collectionView.contentSize.height if self.appDetailCollectionViewHeightConstraint.constant != permissionsHeight && permissionsHeight > 0 { self.appDetailCollectionViewHeightConstraint.constant = permissionsHeight - + needsTableViewUpdate = true + } + + if needsTableViewUpdate + { UIView.performWithoutAnimation { // Update row height without animation. self.tableView.beginUpdates() @@ -121,40 +119,12 @@ final class AppContentViewController: UITableViewController private extension AppContentViewController { - func makeScreenshotsDataSource() -> RSTArrayCollectionViewPrefetchingDataSource + @IBSegueAction + func makeAppScreenshotsViewController(_ coder: NSCoder, sender: Any?) -> UIViewController? { - let dataSource = RSTArrayCollectionViewPrefetchingDataSource(items: self.app.screenshotURLs as [NSURL]) - dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in - let cell = cell as! ScreenshotCollectionViewCell - cell.imageView.image = nil - cell.imageView.isIndicatingActivity = true - } - dataSource.prefetchHandler = { (imageURL, indexPath, completionHandler) in - return RSTAsyncBlockOperation() { (operation) in - let request = ImageRequest(url: imageURL as URL, processors: [.screenshot]) - ImagePipeline.shared.loadImage(with: request, progress: nil) { result in - guard !operation.isCancelled else { return operation.finish() } - - switch result - { - case .success(let response): completionHandler(response.image, nil) - case .failure(let error): completionHandler(nil, error) - } - } - } - } - dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in - let cell = cell as! ScreenshotCollectionViewCell - cell.imageView.isIndicatingActivity = false - cell.imageView.image = image - - if let error = error - { - print("Error loading image:", error) - } - } - - return dataSource + let appScreenshotsViewController = AppScreenshotsViewController(app: self.app, coder: coder) + self.appScreenshotsViewController = appScreenshotsViewController + return appScreenshotsViewController } func makePermissionsDataSource() -> RSTArrayCollectionViewDataSource @@ -216,8 +186,8 @@ extension AppContentViewController switch Row.allCases[indexPath.row] { case .screenshots: - guard let size = self.preferredScreenshotSize else { return 0.0 } - return size.height + guard !self.app.screenshots.isEmpty else { return 0.0 } + return UITableView.automaticDimension case .permissions: guard !self.app.permissions.isEmpty else { return 0.0 } diff --git a/AltStore/App Detail/Screenshots/AppScreenshotCollectionViewCell.swift b/AltStore/App Detail/Screenshots/AppScreenshotCollectionViewCell.swift new file mode 100644 index 00000000..426595b7 --- /dev/null +++ b/AltStore/App Detail/Screenshots/AppScreenshotCollectionViewCell.swift @@ -0,0 +1,127 @@ +// +// AppScreenshotCollectionViewCell.swift +// AltStore +// +// Created by Riley Testut on 10/11/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit + +import AltStoreCore + +class AppScreenshotCollectionViewCell: UICollectionViewCell +{ + let imageView: UIImageView + + var aspectRatio: CGSize = AppScreenshot.defaultAspectRatio { + didSet { + self.updateAspectRatio() + } + } + + private var isRounded: Bool = false { + didSet { + self.setNeedsLayout() + self.layoutIfNeeded() + } + } + + private var aspectRatioConstraint: NSLayoutConstraint? + + override init(frame: CGRect) + { + self.imageView = UIImageView(frame: .zero) + self.imageView.clipsToBounds = true + self.imageView.layer.cornerCurve = .continuous + self.imageView.layer.borderColor = UIColor.tertiaryLabel.cgColor + + super.init(frame: frame) + + self.imageView.translatesAutoresizingMaskIntoConstraints = false + self.contentView.addSubview(self.imageView) + + let widthConstraint = self.imageView.widthAnchor.constraint(equalTo: self.contentView.widthAnchor) + widthConstraint.priority = UILayoutPriority(999) + + let heightConstraint = self.imageView.heightAnchor.constraint(equalTo: self.contentView.heightAnchor) + heightConstraint.priority = UILayoutPriority(999) + + NSLayoutConstraint.activate([ + widthConstraint, + heightConstraint, + self.imageView.widthAnchor.constraint(lessThanOrEqualTo: self.contentView.widthAnchor), + self.imageView.heightAnchor.constraint(lessThanOrEqualTo: self.contentView.heightAnchor), + self.imageView.centerXAnchor.constraint(equalTo: self.contentView.centerXAnchor), + self.imageView.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor) + ]) + + self.updateAspectRatio() + self.updateTraits() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) + { + super.traitCollectionDidChange(previousTraitCollection) + + self.updateTraits() + } + + override func layoutSubviews() + { + super.layoutSubviews() + + guard self.imageView.bounds.width != 0 else { + self.setNeedsLayout() + return + } + + if self.isRounded + { + let cornerRadius = self.imageView.bounds.width / 9.0 // Based on iPhone 15 + self.imageView.layer.cornerRadius = cornerRadius + } + else + { + let cornerRadius = self.imageView.bounds.width / 25.0 // Based on iPhone 8 + self.imageView.layer.cornerRadius = cornerRadius + } + } +} + +private extension AppScreenshotCollectionViewCell +{ + func updateAspectRatio() + { + self.aspectRatioConstraint?.isActive = false + + self.aspectRatioConstraint = self.imageView.widthAnchor.constraint(equalTo: self.imageView.heightAnchor, multiplier: self.aspectRatio.width / self.aspectRatio.height) + self.aspectRatioConstraint?.isActive = true + + let aspectRatio: Double + if self.aspectRatio.width > self.aspectRatio.height + { + aspectRatio = self.aspectRatio.height / self.aspectRatio.width + } + else + { + aspectRatio = self.aspectRatio.width / self.aspectRatio.height + } + + let tolerance = 0.001 as Double + let modernAspectRatio = AppScreenshot.defaultAspectRatio.width / AppScreenshot.defaultAspectRatio.height + + let isRounded = (aspectRatio >= modernAspectRatio - tolerance) && (aspectRatio <= modernAspectRatio + tolerance) + self.isRounded = isRounded + } + + func updateTraits() + { + let displayScale = (self.traitCollection.displayScale == 0.0) ? 1.0 : self.traitCollection.displayScale + self.imageView.layer.borderWidth = 1.0 / displayScale + } +} diff --git a/AltStore/App Detail/Screenshots/AppScreenshotsViewController.swift b/AltStore/App Detail/Screenshots/AppScreenshotsViewController.swift new file mode 100644 index 00000000..99c2c950 --- /dev/null +++ b/AltStore/App Detail/Screenshots/AppScreenshotsViewController.swift @@ -0,0 +1,145 @@ +// +// AppScreenshotsViewController.swift +// AltStore +// +// Created by Riley Testut on 9/18/23. +// Copyright © 2023 Riley Testut. All rights reserved. +// + +import UIKit + +import AltStoreCore +import Roxas + +import Nuke + +class AppScreenshotsViewController: UICollectionViewController +{ + let app: StoreApp + + private lazy var dataSource = self.makeDataSource() + + init?(app: StoreApp, coder: NSCoder) + { + self.app = app + + super.init(coder: coder) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() + { + super.viewDidLoad() + + self.collectionView.showsHorizontalScrollIndicator = false + + // Allow parent background color to show through. + self.collectionView.backgroundColor = nil + + // Match the parent table view margins. + self.collectionView.directionalLayoutMargins.top = 0 + self.collectionView.directionalLayoutMargins.bottom = 0 + self.collectionView.directionalLayoutMargins.leading = 20 + self.collectionView.directionalLayoutMargins.trailing = 20 + + let collectionViewLayout = self.makeLayout() + self.collectionView.collectionViewLayout = collectionViewLayout + + self.collectionView.register(AppScreenshotCollectionViewCell.self, forCellWithReuseIdentifier: RSTCellContentGenericCellIdentifier) + + self.collectionView.dataSource = self.dataSource + self.collectionView.prefetchDataSource = self.dataSource + } +} + +private extension AppScreenshotsViewController +{ + func makeLayout() -> UICollectionViewCompositionalLayout + { + let layoutConfig = UICollectionViewCompositionalLayoutConfiguration() + layoutConfig.contentInsetsReference = .layoutMargins + + let preferredHeight = 400.0 + let estimatedWidth = preferredHeight * (AppScreenshot.defaultAspectRatio.width / AppScreenshot.defaultAspectRatio.height) + + let layout = UICollectionViewCompositionalLayout(sectionProvider: { (sectionIndex, layoutEnvironment) -> NSCollectionLayoutSection? in + + let itemSize = NSCollectionLayoutSize(widthDimension: .estimated(estimatedWidth), heightDimension: .fractionalHeight(1.0)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: .estimated(estimatedWidth), heightDimension: .absolute(preferredHeight)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitems: [item]) + + let layoutSection = NSCollectionLayoutSection(group: group) + layoutSection.interGroupSpacing = 10 + layoutSection.orthogonalScrollingBehavior = .groupPaging + + return layoutSection + }, configuration: layoutConfig) + + return layout + } + + func makeDataSource() -> RSTArrayCollectionViewPrefetchingDataSource + { + let dataSource = RSTArrayCollectionViewPrefetchingDataSource(items: self.app.screenshots) + dataSource.cellConfigurationHandler = { (cell, screenshot, indexPath) in + let cell = cell as! AppScreenshotCollectionViewCell + cell.imageView.image = nil + cell.imageView.isIndicatingActivity = true + + var aspectRatio = screenshot.size ?? AppScreenshot.defaultAspectRatio + if aspectRatio.width > aspectRatio.height + { + aspectRatio = CGSize(width: aspectRatio.height, height: aspectRatio.width) + } + + cell.aspectRatio = aspectRatio + } + dataSource.prefetchHandler = { (screenshot, indexPath, completionHandler) in + let imageURL = screenshot.imageURL + return RSTAsyncBlockOperation() { (operation) in + let request = ImageRequest(url: imageURL, processors: [.screenshot]) + ImagePipeline.shared.loadImage(with: request, progress: nil) { result in + guard !operation.isCancelled else { return operation.finish() } + + switch result + { + case .success(let response): completionHandler(response.image, nil) + case .failure(let error): completionHandler(nil, error) + } + } + } + } + dataSource.prefetchCompletionHandler = { (cell, image, indexPath, error) in + let cell = cell as! AppScreenshotCollectionViewCell + cell.imageView.isIndicatingActivity = false + cell.imageView.image = image + + if let error = error + { + print("Error loading image:", error) + } + } + + return dataSource + } +} + +@available(iOS 17, *) +#Preview(traits: .portrait) { + DatabaseManager.shared.startForPreview() + + let fetchRequest = StoreApp.fetchRequest() + let storeApp = try! DatabaseManager.shared.viewContext.fetch(fetchRequest).first! + + let storyboard = UIStoryboard(name: "Main", bundle: .main) + let appViewConttroller = storyboard.instantiateViewController(withIdentifier: "appViewController") as! AppViewController + appViewConttroller.app = storeApp + + let navigationController = UINavigationController(rootViewController: appViewConttroller) + return navigationController +} diff --git a/AltStore/Base.lproj/Main.storyboard b/AltStore/Base.lproj/Main.storyboard index 94ac88e8..e21c508e 100644 --- a/AltStore/Base.lproj/Main.storyboard +++ b/AltStore/Base.lproj/Main.storyboard @@ -256,35 +256,24 @@ - + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + - - - - + + + + @@ -384,7 +373,7 @@ - + @@ -489,8 +478,8 @@ World + - @@ -502,6 +491,31 @@ World + + + + + + + + + + + + + + + + + + + + + + + + + @@ -520,7 +534,7 @@ World - + diff --git a/AltStoreCore/Model/AppScreenshot.swift b/AltStoreCore/Model/AppScreenshot.swift index 5f5ad149..f2a2be0b 100644 --- a/AltStoreCore/Model/AppScreenshot.swift +++ b/AltStoreCore/Model/AppScreenshot.swift @@ -8,6 +8,11 @@ import CoreData +public extension AppScreenshot +{ + static let defaultAspectRatio = CGSize(width: 9, height: 19.5) +} + @objc(AppScreenshot) public class AppScreenshot: NSManagedObject, Fetchable, Decodable {