[AltStoreCore] Adds AppScreenshot to support dynamically-sized screenshots

Preserves StoreApp.imageURL field in database model for backwards compatibility.
This commit is contained in:
Riley Testut
2023-10-11 15:05:27 -05:00
committed by Magesh K
parent 59a72ad096
commit 932e66deca
5 changed files with 211 additions and 5 deletions

View File

@@ -435,6 +435,7 @@
D5F48B4C29CD0C48002B52A4 /* AsyncManaged.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F48B4929CD0B67002B52A4 /* AsyncManaged.swift */; };
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 */; };
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 */; };
@@ -1096,6 +1097,7 @@
D5F48B4929CD0B67002B52A4 /* AsyncManaged.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncManaged.swift; sourceTree = "<group>"; };
D5F5AF2D28FDD2EC00C938F5 /* TestErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestErrors.swift; sourceTree = "<group>"; };
D5F5AF7C28ECEA990067C736 /* ErrorDetailsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorDetailsViewController.swift; sourceTree = "<group>"; };
D5F9821C2AB900060045751F /* AppScreenshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppScreenshot.swift; sourceTree = "<group>"; };
D5F99A1728D11DB500476A16 /* AltStore10ToAltStore11.xcmappingmodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcmappingmodel; path = AltStore10ToAltStore11.xcmappingmodel; sourceTree = "<group>"; };
D5F99A1928D12B1400476A16 /* StoreApp10ToStoreApp11Policy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreApp10ToStoreApp11Policy.swift; sourceTree = "<group>"; };
D5FB28EB2ADDF68D00A1C337 /* UIFontDescriptor+Bold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIFontDescriptor+Bold.swift"; sourceTree = "<group>"; };
@@ -1666,6 +1668,7 @@
BF66EEC92501AECA007EE018 /* Account.swift */,
BF66EEC72501AECA007EE018 /* AppID.swift */,
BF66EEC62501AECA007EE018 /* AppPermission.swift */,
D5F9821C2AB900060045751F /* AppScreenshot.swift */,
D52C08ED28AEC37A006C4AE5 /* AppVersion.swift */,
BF66EECA2501AECA007EE018 /* DatabaseManager.swift */,
D5FD4ECA2A9532960097BEE8 /* DatabaseManager+Async.swift */,
@@ -3056,6 +3059,7 @@
BF66EEE82501AED0007EE018 /* UserDefaults+AltStore.swift in Sources */,
BF340E9A250AD39500A192CB /* ViewApp.intentdefinition in Sources */,
BFAECC522501B0A400528F27 /* CodableError.swift in Sources */,
D5F9821D2AB900060045751F /* AppScreenshot.swift in Sources */,
BF66EE9E2501AEC1007EE018 /* Fetchable.swift in Sources */,
BF66EEDF2501AECA007EE018 /* PatreonAccount.swift in Sources */,
BFAECC532501B0A400528F27 /* ServerProtocol.swift in Sources */,

View File

@@ -42,6 +42,21 @@
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="AppScreenshot" representedClassName="AppScreenshot" syncable="YES">
<attribute name="appBundleID" attributeType="String"/>
<attribute name="height" optional="YES" attributeType="Integer 16" usesScalarValueType="NO"/>
<attribute name="imageURL" attributeType="URI"/>
<attribute name="sourceID" attributeType="String"/>
<attribute name="width" optional="YES" attributeType="Integer 16" usesScalarValueType="NO"/>
<relationship name="app" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="StoreApp" inverseName="screenshots" inverseEntity="StoreApp"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="imageURL"/>
<constraint value="appBundleID"/>
<constraint value="sourceID"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
<entity name="AppVersion" representedClassName="AppVersion" syncable="YES">
<attribute name="appBundleID" attributeType="String"/>
<attribute name="buildVersion" attributeType="String"/>
@@ -207,6 +222,7 @@
<relationship name="loggedErrors" toMany="YES" deletionRule="Nullify" destinationEntity="LoggedError" inverseName="storeApp" inverseEntity="LoggedError"/>
<relationship name="newsItems" toMany="YES" deletionRule="Nullify" destinationEntity="NewsItem" inverseName="storeApp" inverseEntity="NewsItem"/>
<relationship name="permissions" toMany="YES" deletionRule="Cascade" destinationEntity="AppPermission" inverseName="app" inverseEntity="AppPermission"/>
<relationship name="screenshots" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="AppScreenshot" inverseName="app" inverseEntity="AppScreenshot"/>
<relationship name="source" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Source" inverseName="apps" inverseEntity="Source"/>
<relationship name="versions" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="AppVersion" inverseName="app" inverseEntity="AppVersion"/>
<uniquenessConstraints>

View File

@@ -0,0 +1,85 @@
//
// AppScreenshot.swift
// AltStoreCore
//
// Created by Riley Testut on 9/18/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import CoreData
@objc(AppScreenshot)
public class AppScreenshot: NSManagedObject, Fetchable, Decodable
{
/* Properties */
@NSManaged public private(set) var imageURL: URL
public private(set) var size: CGSize? {
get {
guard let width = self.width?.doubleValue, let height = self.height?.doubleValue else { return nil }
return CGSize(width: width, height: height)
}
set {
if let newValue
{
self.width = NSNumber(value: newValue.width)
self.height = NSNumber(value: newValue.height)
}
else
{
self.width = nil
self.height = nil
}
}
}
@NSManaged private var width: NSNumber?
@NSManaged private var height: NSNumber?
@NSManaged public internal(set) var appBundleID: String
@NSManaged public internal(set) var sourceID: String
/* Relationships */
@NSManaged public internal(set) var app: StoreApp?
private enum CodingKeys: String, CodingKey
{
case imageURL
case width
case height
}
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
{
super.init(entity: entity, insertInto: context)
}
internal init(imageURL: URL, size: CGSize?, context: NSManagedObjectContext)
{
super.init(entity: AppScreenshot.entity(), insertInto: context)
self.imageURL = imageURL
self.size = size
}
public required init(from decoder: Decoder) throws
{
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
super.init(entity: AppScreenshot.entity(), insertInto: context)
let container = try decoder.container(keyedBy: CodingKeys.self)
self.imageURL = try container.decode(URL.self, forKey: .imageURL)
self.width = try container.decodeIfPresent(Int16.self, forKey: .width).map { NSNumber(value: $0) }
self.height = try container.decodeIfPresent(Int16.self, forKey: .height).map { NSNumber(value: $0) }
}
}
public extension AppScreenshot
{
@nonobjc class func fetchRequest() -> NSFetchRequest<AppScreenshot>
{
return NSFetchRequest<AppScreenshot>(entityName: "AppScreenshot")
}
}

View File

@@ -156,6 +156,12 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
{
appVersion.managedObjectContext?.delete(appVersion)
}
// Delete previous screenshots (different than below).
for case let appScreenshot as AppScreenshot in previousApp._screenshots where appScreenshot.app == nil
{
appScreenshot.managedObjectContext?.delete(appScreenshot)
}
}
case is AppVersion where conflict.conflictingObjects.count == 2:
@@ -181,8 +187,9 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
return
}
var sortedVersionIDsByGlobalAppID = [String: NSOrderedSet]()
var permissionsByGlobalAppID = [String: Set<AnyHashable>]()
var sortedVersionIDsByGlobalAppID = [String: NSOrderedSet]()
var sortedScreenshotURLsByGlobalAppID = [String: NSOrderedSet]()
var featuredAppIDsBySourceID = [String: [String]]()
@@ -212,10 +219,19 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
databaseVersion.managedObjectContext?.delete(databaseVersion)
}
// Screenshots
let contextScreenshotURLs = NSOrderedSet(array: contextApp._screenshots.lazy.compactMap { $0 as? AppScreenshot }.map { $0.imageURL })
for case let databaseScreenshot as AppScreenshot in databaseObject._screenshots where !contextScreenshotURLs.contains(databaseScreenshot.imageURL)
{
// Screenshot's imageURL does NOT exist in context, so delete existing databaseScreenshot.
databaseScreenshot.managedObjectContext?.delete(databaseScreenshot)
}
if let globallyUniqueID = contextApp.globallyUniqueID
{
permissionsByGlobalAppID[globallyUniqueID] = contextPermissions
sortedVersionIDsByGlobalAppID[globallyUniqueID] = contextVersionIDs
sortedScreenshotURLsByGlobalAppID[globallyUniqueID] = contextScreenshotURLs
}
case let databaseObject as Source:
@@ -295,6 +311,31 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
appVersions = fixedAppVersions
}
// Screenshots
if let sortedScreenshotURLs = sortedScreenshotURLsByGlobalAppID[globallyUniqueID],
let sortedScreenshotURLsArray = sortedScreenshotURLs.array as? [URL],
case let databaseScreenshotURLs = databaseObject.screenshots.map({ $0.imageURL }),
databaseScreenshotURLs != sortedScreenshotURLsArray
{
// Screenshot order is incorrect, so attempt to fix by re-sorting.
let fixedScreenshots = databaseObject.screenshots.sorted { (screenshotA, screenshotB) in
let indexA = sortedScreenshotURLs.index(of: screenshotA.imageURL)
let indexB = sortedScreenshotURLs.index(of: screenshotB.imageURL)
return indexA < indexB
}
let appScreenshotURLs = fixedScreenshots.map { $0.imageURL }
if appScreenshotURLs == sortedScreenshotURLsArray
{
databaseObject.setScreenshots(fixedScreenshots)
}
else
{
// Screenshots are still not in correct order, but not worth throwing error so ignore.
print("Failed to re-sort screenshots into correct order. Expected:", sortedScreenshotURLsArray)
}
}
}
// Always update versions post-merging to make sure latestSupportedVersion is correct.

View File

@@ -136,6 +136,11 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
{
permission.sourceID = self.sourceIdentifier ?? ""
}
for screenshot in self.screenshots
{
screenshot.sourceID = self.sourceIdentifier ?? ""
}
}
}
@NSManaged private var primitiveSourceIdentifier: String?
@@ -200,6 +205,10 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
guard let version = self.latestSupportedVersion else { return nil }
return version.downloadURL
}
@nonobjc public var screenshots: [AppScreenshot] {
return self._screenshots.array as! [AppScreenshot]
}
@NSManaged @objc(screenshots) private(set) var _screenshots: NSOrderedSet
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
{
@@ -212,19 +221,24 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
case bundleIdentifier
case developerName
case localizedDescription
case version
case versionDescription
case versionDate
case iconURL
case screenshotURLs
case downloadURL
case platformURLs
case screenshots
case tintColor
case subtitle
case permissions = "appPermissions"
case size
case isBeta = "beta"
case versions
// Legacy
case version
case versionDescription
case versionDate
case downloadURL
case screenshotURLs
}
public required init(from decoder: Decoder) throws
@@ -245,7 +259,6 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
self.subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle)
self.iconURL = try container.decode(URL.self, forKey: .iconURL)
self.screenshotURLs = try container.decodeIfPresent([URL].self, forKey: .screenshotURLs) ?? []
var downloadURL = try container.decodeIfPresent(URL.self, forKey: .downloadURL)
let platformURLs = try container.decodeIfPresent(PlatformURLs.self.self, forKey: .platformURLs)
@@ -291,6 +304,33 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
self.isBeta = try container.decodeIfPresent(Bool.self, forKey: .isBeta) ?? false
if let screenshots = try container.decodeIfPresent([AppScreenshot].self, forKey: .screenshots)
{
for screenshot in screenshots
{
screenshot.appBundleID = self.bundleIdentifier
}
self.setScreenshots(screenshots)
}
else if let screenshotURLs = try container.decodeIfPresent([URL].self, forKey: .screenshotURLs)
{
// Assume 9:16 iPhone 8 screen dimensions for legacy screenshotURLs.
let legacyAspectRatio = CGSize(width: 750, height: 1334)
let screenshots = screenshotURLs.map { imageURL in
let screenshot = AppScreenshot(imageURL: imageURL, size: legacyAspectRatio, context: context)
screenshot.appBundleID = self.bundleIdentifier
return screenshot
}
self.setScreenshots(screenshots)
}
else
{
self.setScreenshots([])
}
if let appPermissions = try container.decodeIfPresent(AppPermissions.self, forKey: .permissions)
{
let allPermissions = appPermissions.entitlements + appPermissions.privacy
@@ -402,6 +442,26 @@ internal extension StoreApp
self._permissions = permissions as NSSet
}
func setScreenshots(_ screenshots: [AppScreenshot])
{
for case let screenshot as AppScreenshot in self._screenshots
{
if screenshots.contains(screenshot)
{
screenshot.app = self
}
else
{
screenshot.app = nil
}
}
self._screenshots = NSOrderedSet(array: screenshots)
// Backwards compatibility
self.screenshotURLs = screenshots.map { $0.imageURL }
}
}
public extension StoreApp