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

View File

@@ -42,6 +42,21 @@
</uniquenessConstraint> </uniquenessConstraint>
</uniquenessConstraints> </uniquenessConstraints>
</entity> </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"> <entity name="AppVersion" representedClassName="AppVersion" syncable="YES">
<attribute name="appBundleID" attributeType="String"/> <attribute name="appBundleID" attributeType="String"/>
<attribute name="buildVersion" 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="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="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="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="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"/> <relationship name="versions" toMany="YES" deletionRule="Cascade" ordered="YES" destinationEntity="AppVersion" inverseName="app" inverseEntity="AppVersion"/>
<uniquenessConstraints> <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) 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: case is AppVersion where conflict.conflictingObjects.count == 2:
@@ -181,8 +187,9 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
return return
} }
var sortedVersionIDsByGlobalAppID = [String: NSOrderedSet]()
var permissionsByGlobalAppID = [String: Set<AnyHashable>]() var permissionsByGlobalAppID = [String: Set<AnyHashable>]()
var sortedVersionIDsByGlobalAppID = [String: NSOrderedSet]()
var sortedScreenshotURLsByGlobalAppID = [String: NSOrderedSet]()
var featuredAppIDsBySourceID = [String: [String]]() var featuredAppIDsBySourceID = [String: [String]]()
@@ -212,10 +219,19 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
databaseVersion.managedObjectContext?.delete(databaseVersion) 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 if let globallyUniqueID = contextApp.globallyUniqueID
{ {
permissionsByGlobalAppID[globallyUniqueID] = contextPermissions permissionsByGlobalAppID[globallyUniqueID] = contextPermissions
sortedVersionIDsByGlobalAppID[globallyUniqueID] = contextVersionIDs sortedVersionIDsByGlobalAppID[globallyUniqueID] = contextVersionIDs
sortedScreenshotURLsByGlobalAppID[globallyUniqueID] = contextScreenshotURLs
} }
case let databaseObject as Source: case let databaseObject as Source:
@@ -295,6 +311,31 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
appVersions = fixedAppVersions 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. // 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 ?? "" permission.sourceID = self.sourceIdentifier ?? ""
} }
for screenshot in self.screenshots
{
screenshot.sourceID = self.sourceIdentifier ?? ""
}
} }
} }
@NSManaged private var primitiveSourceIdentifier: String? @NSManaged private var primitiveSourceIdentifier: String?
@@ -200,6 +205,10 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
guard let version = self.latestSupportedVersion else { return nil } guard let version = self.latestSupportedVersion else { return nil }
return version.downloadURL 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?) private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
{ {
@@ -212,19 +221,24 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
case bundleIdentifier case bundleIdentifier
case developerName case developerName
case localizedDescription case localizedDescription
case version
case versionDescription
case versionDate
case iconURL case iconURL
case screenshotURLs case screenshotURLs
case downloadURL case downloadURL
case platformURLs case platformURLs
case screenshots
case tintColor case tintColor
case subtitle case subtitle
case permissions = "appPermissions" case permissions = "appPermissions"
case size case size
case isBeta = "beta" case isBeta = "beta"
case versions case versions
// Legacy
case version
case versionDescription
case versionDate
case downloadURL
case screenshotURLs
} }
public required init(from decoder: Decoder) throws 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.subtitle = try container.decodeIfPresent(String.self, forKey: .subtitle)
self.iconURL = try container.decode(URL.self, forKey: .iconURL) 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) var downloadURL = try container.decodeIfPresent(URL.self, forKey: .downloadURL)
let platformURLs = try container.decodeIfPresent(PlatformURLs.self.self, forKey: .platformURLs) 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 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) if let appPermissions = try container.decodeIfPresent(AppPermissions.self, forKey: .permissions)
{ {
let allPermissions = appPermissions.entitlements + appPermissions.privacy let allPermissions = appPermissions.entitlements + appPermissions.privacy
@@ -402,6 +442,26 @@ internal extension StoreApp
self._permissions = permissions as NSSet 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 public extension StoreApp