Supports new “versions” key in source JSON

Allows sources to list multiple versions of an app.

Preserves backwards compatibility by assigning legacy version values when assigning AppVersions.
This commit is contained in:
Riley Testut
2022-09-12 17:05:55 -07:00
committed by Joseph Mattello
parent 5765cb8330
commit fa160124d2
11 changed files with 198 additions and 52 deletions

View File

@@ -80,10 +80,21 @@ class AppContentViewController: UITableViewController
self.subtitleLabel.text = self.app.subtitle self.subtitleLabel.text = self.app.subtitle
self.descriptionTextView.text = self.app.localizedDescription self.descriptionTextView.text = self.app.localizedDescription
self.versionDescriptionTextView.text = self.app.versionDescription
self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), self.app.version) if let version = self.app.latestVersion
self.versionDateLabel.text = Date().relativeDateString(since: self.app.versionDate, dateFormatter: self.dateFormatter) {
self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: Int64(self.app.size)) self.versionDescriptionTextView.text = version.localizedDescription
self.versionLabel.text = String(format: NSLocalizedString("Version %@", comment: ""), version.version)
self.versionDateLabel.text = Date().relativeDateString(since: version.date, dateFormatter: self.dateFormatter)
self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: version.size)
}
else
{
self.versionDescriptionTextView.text = nil
self.versionLabel.text = nil
self.versionDateLabel.text = nil
self.sizeLabel.text = self.byteCountFormatter.string(fromByteCount: 0)
}
self.descriptionTextView.maximumNumberOfLines = 5 self.descriptionTextView.maximumNumberOfLines = 5
self.descriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered) self.descriptionTextView.moreButton.addTarget(self, action: #selector(AppContentViewController.toggleCollapsingSection(_:)), for: .primaryActionTriggered)

View File

@@ -384,10 +384,10 @@ private extension AppViewController
button.progress = progress button.progress = progress
} }
if Date() < self.app.versionDate if let versionDate = self.app.latestVersion?.date, versionDate > Date()
{ {
self.bannerView.button.countdownDate = self.app.versionDate self.bannerView.button.countdownDate = versionDate
self.navigationBarDownloadButton.countdownDate = self.app.versionDate self.navigationBarDownloadButton.countdownDate = versionDate
} }
else else
{ {

View File

@@ -380,11 +380,11 @@ private extension AppDelegate
for update in updates for update in updates
{ {
guard !previousUpdates.contains(where: { $0[#keyPath(InstalledApp.bundleIdentifier)] == update.bundleIdentifier }) else { continue } guard !previousUpdates.contains(where: { $0[#keyPath(InstalledApp.bundleIdentifier)] == update.bundleIdentifier }) else { continue }
guard let storeApp = update.storeApp else { continue } guard let storeApp = update.storeApp, let version = storeApp.version else { continue }
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = NSLocalizedString("New Update Available", comment: "") content.title = NSLocalizedString("New Update Available", comment: "")
content.body = String(format: NSLocalizedString("%@ %@ is now available for download.", comment: ""), update.name, storeApp.version) content.body = String(format: NSLocalizedString("%@ %@ is now available for download.", comment: ""), update.name, version)
content.sound = .default content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil) let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)

View File

@@ -113,7 +113,7 @@ private extension BrowseViewController
let progress = AppManager.shared.installationProgress(for: app) let progress = AppManager.shared.installationProgress(for: app)
cell.bannerView.button.progress = progress cell.bannerView.button.progress = progress
if Date() < app.versionDate if let versionDate = app.latestVersion?.date, versionDate > Date()
{ {
cell.bannerView.button.countdownDate = app.versionDate cell.bannerView.button.countdownDate = app.versionDate
} }

View File

@@ -186,7 +186,7 @@ private extension MyAppsViewController
func makeUpdatesDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage> func makeUpdatesDataSource() -> RSTFetchedResultsCollectionViewPrefetchingDataSource<InstalledApp, UIImage>
{ {
let fetchRequest = InstalledApp.updatesFetchRequest() let fetchRequest = InstalledApp.updatesFetchRequest()
fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \InstalledApp.storeApp?.versionDate, ascending: true), fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \InstalledApp.storeApp?.latestVersion?.date, ascending: true),
NSSortDescriptor(keyPath: \InstalledApp.name, ascending: true)] NSSortDescriptor(keyPath: \InstalledApp.name, ascending: true)]
fetchRequest.returnsObjectsAsFaults = false fetchRequest.returnsObjectsAsFaults = false
@@ -195,7 +195,7 @@ private extension MyAppsViewController
dataSource.cellIdentifierHandler = { _ in "UpdateCell" } dataSource.cellIdentifierHandler = { _ in "UpdateCell" }
dataSource.cellConfigurationHandler = { [weak self] (cell, installedApp, indexPath) in dataSource.cellConfigurationHandler = { [weak self] (cell, installedApp, indexPath) in
guard let self = self else { return } guard let self = self else { return }
guard let app = installedApp.storeApp else { return } guard let app = installedApp.storeApp, let latestVersion = app.latestVersion else { return }
let cell = cell as! UpdateCollectionViewCell let cell = cell as! UpdateCollectionViewCell
cell.layoutMargins.left = self.view.layoutMargins.left cell.layoutMargins.left = self.view.layoutMargins.left
@@ -209,7 +209,7 @@ private extension MyAppsViewController
cell.bannerView.configure(for: app) cell.bannerView.configure(for: app)
let versionDate = Date().relativeDateString(since: app.versionDate, dateFormatter: self.dateFormatter) let versionDate = Date().relativeDateString(since: latestVersion.date, dateFormatter: self.dateFormatter)
cell.bannerView.subtitleLabel.text = versionDate cell.bannerView.subtitleLabel.text = versionDate
let appName: String let appName: String
@@ -223,7 +223,7 @@ private extension MyAppsViewController
appName = app.name appName = app.name
} }
cell.bannerView.accessibilityLabel = String(format: NSLocalizedString("%@ %@ update. Released on %@.", comment: ""), appName, app.version, versionDate) cell.bannerView.accessibilityLabel = String(format: NSLocalizedString("%@ %@ update. Released on %@.", comment: ""), appName, latestVersion.version, versionDate)
cell.bannerView.button.isIndicatingActivity = false cell.bannerView.button.isIndicatingActivity = false
cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.updateApp(_:)), for: .primaryActionTriggered) cell.bannerView.button.addTarget(self, action: #selector(MyAppsViewController.updateApp(_:)), for: .primaryActionTriggered)

View File

@@ -391,7 +391,7 @@ extension NewsViewController
let progress = AppManager.shared.installationProgress(for: storeApp) let progress = AppManager.shared.installationProgress(for: storeApp)
footerView.bannerView.button.progress = progress footerView.bannerView.button.progress = progress
if Date() < storeApp.versionDate if let versionDate = storeApp.latestVersion?.date, versionDate > Date()
{ {
footerView.bannerView.button.countdownDate = storeApp.versionDate footerView.bannerView.button.countdownDate = storeApp.versionDate
} }

View File

@@ -47,7 +47,6 @@ public class AppVersion: NSManagedObject, Decodable, Fetchable
super.init(entity: entity, insertInto: context) super.init(entity: entity, insertInto: context)
} }
private enum CodingKeys: String, CodingKey private enum CodingKeys: String, CodingKey
{ {
case version case version
@@ -55,31 +54,34 @@ public class AppVersion: NSManagedObject, Decodable, Fetchable
case localizedDescription case localizedDescription
case downloadURL case downloadURL
case size case size
case _minOSVersion
case _maxOSVersion
case appBundleID
case sourceID
} }
public required init(from decoder: Decoder) throws public required init(from decoder: Decoder) throws
{ {
guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") } guard let context = decoder.managedObjectContext else { preconditionFailure("Decoder must have non-nil NSManagedObjectContext.") }
super.init(entity: NewsItem.entity(), insertInto: context) super.init(entity: AppVersion.entity(), insertInto: context)
let container = try decoder.container(keyedBy: CodingKeys.self) do
self.version = try container.decode(String.self, forKey: .version) {
self.date = try container.decode(Date.self, forKey: .date) let container = try decoder.container(keyedBy: CodingKeys.self)
self.localizedDescription = try container.decodeIfPresent(String.self, forKey: .localizedDescription) self.version = try container.decode(String.self, forKey: .version)
self.downloadURL = try container.decode(URL.self, forKey: .downloadURL) self.date = try container.decode(Date.self, forKey: .date)
self.size = try container.decode(Int64.self, forKey: .size) self.localizedDescription = try container.decodeIfPresent(String.self, forKey: .localizedDescription)
self._minOSVersion = try container.decodeIfPresent(String.self, forKey: ._minOSVersion) self.downloadURL = try container.decode(URL.self, forKey: .downloadURL)
self._maxOSVersion = try container.decodeIfPresent(String.self, forKey: ._maxOSVersion) self.size = try container.decode(Int64.self, forKey: .size)
}
self.appBundleID = try container.decode(String.self, forKey: .appBundleID) catch
self.sourceID = try container.decodeIfPresent(String.self, forKey: .sourceID) {
if let context = self.managedObjectContext
{
context.delete(self)
}
throw error
}
} }
} }
@@ -89,4 +91,26 @@ public extension AppVersion
{ {
return NSFetchRequest<AppVersion>(entityName: "AppVersion") return NSFetchRequest<AppVersion>(entityName: "AppVersion")
} }
class func makeAppVersion(
version: String,
date: Date,
localizedDescription: String? = nil,
downloadURL: URL,
size: Int64,
appBundleID: String,
sourceID: String? = nil,
in context: NSManagedObjectContext) -> AppVersion
{
let appVersion = AppVersion(context: context)
appVersion.version = version
appVersion.date = date
appVersion.localizedDescription = localizedDescription
appVersion.downloadURL = downloadURL
appVersion.size = size
appVersion.appBundleID = appBundleID
appVersion.sourceID = sourceID
return appVersion
}
} }

View File

@@ -211,7 +211,7 @@ private extension DatabaseManager
else else
{ {
storeApp = StoreApp.makeAltStoreApp(in: context) storeApp = StoreApp.makeAltStoreApp(in: context)
storeApp.version = localApp.version storeApp.latestVersion?.version = localApp.version
storeApp.source = altStoreSource storeApp.source = altStoreSource
} }

View File

@@ -146,7 +146,7 @@ public extension InstalledApp
{ {
let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest<InstalledApp> let fetchRequest = InstalledApp.fetchRequest() as NSFetchRequest<InstalledApp>
fetchRequest.predicate = NSPredicate(format: "%K == YES AND %K != nil AND %K != %K", fetchRequest.predicate = NSPredicate(format: "%K == YES AND %K != nil AND %K != %K",
#keyPath(InstalledApp.isActive), #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.version), #keyPath(InstalledApp.storeApp.version)) #keyPath(InstalledApp.isActive), #keyPath(InstalledApp.storeApp), #keyPath(InstalledApp.version), #keyPath(InstalledApp.storeApp.latestVersion.version))
return fetchRequest return fetchRequest
} }

View File

@@ -31,6 +31,12 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
{ {
permission.managedObjectContext?.delete(permission) permission.managedObjectContext?.delete(permission)
} }
// Delete previous versions (different than below).
for case let appVersion as AppVersion in previousApp._versions where appVersion.app == nil
{
appVersion.managedObjectContext?.delete(appVersion)
}
} }
default: default:
@@ -55,6 +61,16 @@ open class MergePolicy: RSTRelationshipPreservingMergePolicy
permission.managedObjectContext?.delete(permission) permission.managedObjectContext?.delete(permission)
} }
if let contextApp = conflict.conflictingObjects.first as? StoreApp
{
let contextVersions = Set(contextApp._versions.lazy.compactMap { $0 as? AppVersion }.map { $0.version })
for case let appVersion as AppVersion in databaseObject._versions where !contextVersions.contains(appVersion.version)
{
print("[ALTLog] Deleting cached app version: \(appVersion.appBundleID + "_" + appVersion.version), not in:", contextApp.versions.map { $0.appBundleID + "_" + $0.version })
appVersion.managedObjectContext?.delete(appVersion)
}
}
case let databaseObject as Source: case let databaseObject as Source:
guard let conflictedObject = conflict.conflictingObjects.first as? Source else { break } guard let conflictedObject = conflict.conflictingObjects.first as? Source else { break }

View File

@@ -103,22 +103,41 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
@NSManaged public private(set) var developerName: String @NSManaged public private(set) var developerName: String
@NSManaged public private(set) var localizedDescription: String @NSManaged public private(set) var localizedDescription: String
@NSManaged public private(set) var size: Int32 @NSManaged @objc(size) private var _size: Int32
@NSManaged public private(set) var iconURL: URL @NSManaged public private(set) var iconURL: URL
@NSManaged public private(set) var screenshotURLs: [URL] @NSManaged public private(set) var screenshotURLs: [URL]
@NSManaged public var version: String @NSManaged @objc(version) private var _version: String
@NSManaged public private(set) var versionDate: Date @NSManaged @objc(versionDate) private var _versionDate: Date
@NSManaged public private(set) var versionDescription: String? @NSManaged @objc(versionDescription) private var _versionDescription: String?
@NSManaged public private(set) var downloadURL: URL @NSManaged @objc(downloadURL) private var _downloadURL: URL
@NSManaged public private(set) var platformURLs: PlatformURLs? @NSManaged public private(set) var platformURLs: PlatformURLs?
@NSManaged public private(set) var tintColor: UIColor? @NSManaged public private(set) var tintColor: UIColor?
@NSManaged public private(set) var isBeta: Bool @NSManaged public private(set) var isBeta: Bool
@NSManaged public var sourceIdentifier: String? @objc public internal(set) var sourceIdentifier: String? {
get {
self.willAccessValue(forKey: #keyPath(sourceIdentifier))
defer { self.didAccessValue(forKey: #keyPath(sourceIdentifier)) }
let sourceIdentifier = self.primitiveSourceIdentifier
return sourceIdentifier
}
set {
self.willChangeValue(forKey: #keyPath(sourceIdentifier))
self.primitiveSourceIdentifier = newValue
self.didChangeValue(forKey: #keyPath(sourceIdentifier))
for version in self.versions
{
version.sourceID = newValue
}
}
}
@NSManaged private var primitiveSourceIdentifier: String?
@NSManaged public var sortIndex: Int32 @NSManaged public var sortIndex: Int32
@@ -152,6 +171,31 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
return self._versions.array as! [AppVersion] return self._versions.array as! [AppVersion]
} }
@nonobjc public var size: Int64? {
guard let version = self.latestVersion else { return nil }
return version.size
}
@nonobjc public var version: String? {
guard let version = self.latestVersion else { return nil }
return version.version
}
@nonobjc public var versionDescription: String? {
guard let version = self.latestVersion else { return nil }
return version.localizedDescription
}
@nonobjc public var versionDate: Date? {
guard let version = self.latestVersion else { return nil }
return version.date
}
@nonobjc public var downloadURL: URL? {
guard let version = self.latestVersion else { return nil }
return version.downloadURL
}
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
{ {
super.init(entity: entity, insertInto: context) super.init(entity: entity, insertInto: context)
@@ -175,6 +219,7 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
case permissions case permissions
case size case size
case isBeta = "beta" case isBeta = "beta"
case versions
} }
public required init(from decoder: Decoder) throws public required init(from decoder: Decoder) throws
@@ -194,10 +239,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.version = try container.decode(String.self, forKey: .version)
self.versionDate = try container.decode(Date.self, forKey: .versionDate)
self.versionDescription = try container.decodeIfPresent(String.self, forKey: .versionDescription)
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) ?? [] self.screenshotURLs = try container.decodeIfPresent([URL].self, forKey: .screenshotURLs) ?? []
@@ -207,14 +248,14 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
self.platformURLs = platformURLs self.platformURLs = platformURLs
// Backwards compatibility, use the fiirst (iOS will be first since sorted that way) // Backwards compatibility, use the fiirst (iOS will be first since sorted that way)
if let first = platformURLs.sorted().first { if let first = platformURLs.sorted().first {
self.downloadURL = first.downloadURL self._downloadURL = first.downloadURL
} else { } else {
throw DecodingError.dataCorruptedError(forKey: .platformURLs, in: container, debugDescription: "platformURLs has no entries") throw DecodingError.dataCorruptedError(forKey: .platformURLs, in: container, debugDescription: "platformURLs has no entries")
} }
} else if let downloadURL = downloadURL { } else if let downloadURL = downloadURL {
self.downloadURL = downloadURL self._downloadURL = downloadURL
} else { } else {
throw DecodingError.dataCorruptedError(forKey: .downloadURL, in: container, debugDescription: "E downloadURL:String or downloadURLs:[[Platform:URL]] key required.") throw DecodingError.dataCorruptedError(forKey: .downloadURL, in: container, debugDescription: "E downloadURL:String or downloadURLs:[[Platform:URL]] key required.")
} }
@@ -228,11 +269,40 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
self.tintColor = tintColor self.tintColor = tintColor
} }
self.size = try container.decode(Int32.self, forKey: .size)
self.isBeta = try container.decodeIfPresent(Bool.self, forKey: .isBeta) ?? false self.isBeta = try container.decodeIfPresent(Bool.self, forKey: .isBeta) ?? false
let permissions = try container.decodeIfPresent([AppPermission].self, forKey: .permissions) ?? [] let permissions = try container.decodeIfPresent([AppPermission].self, forKey: .permissions) ?? []
self._permissions = NSOrderedSet(array: permissions) self._permissions = NSOrderedSet(array: permissions)
if let versions = try container.decodeIfPresent([AppVersion].self, forKey: .versions)
{
//TODO: Throw error if there isn't at least one version.
for version in versions
{
version.appBundleID = self.bundleIdentifier
}
self.setVersions(versions)
}
else
{
let version = try container.decode(String.self, forKey: .version)
let versionDate = try container.decode(Date.self, forKey: .versionDate)
let versionDescription = try container.decodeIfPresent(String.self, forKey: .versionDescription)
let downloadURL = try container.decode(URL.self, forKey: .downloadURL)
let size = try container.decode(Int32.self, forKey: .size)
let appVersion = AppVersion.makeAppVersion(version: version,
date: versionDate,
localizedDescription: versionDescription,
downloadURL: downloadURL,
size: Int64(size),
appBundleID: self.bundleIdentifier,
in: context)
self.setVersions([appVersion])
}
} }
catch catch
{ {
@@ -246,6 +316,24 @@ public class StoreApp: NSManagedObject, Decodable, Fetchable
} }
} }
private extension StoreApp
{
func setVersions(_ versions: [AppVersion])
{
guard let latestVersion = versions.first else { preconditionFailure("StoreApp must have at least one AppVersion.") }
self.latestVersion = latestVersion
self._versions = NSOrderedSet(array: versions)
// Preserve backwards compatibility by assigning legacy property values.
self._version = latestVersion.version
self._versionDate = latestVersion.date
self._versionDescription = latestVersion.localizedDescription
self._downloadURL = latestVersion.downloadURL
self._size = Int32(latestVersion.size)
}
}
public extension StoreApp public extension StoreApp
{ {
@nonobjc class func fetchRequest() -> NSFetchRequest<StoreApp> @nonobjc class func fetchRequest() -> NSFetchRequest<StoreApp>
@@ -262,9 +350,16 @@ public extension StoreApp
app.localizedDescription = "SideStore is an alternative App Store." app.localizedDescription = "SideStore is an alternative App Store."
app.iconURL = URL(string: "https://user-images.githubusercontent.com/705880/63392210-540c5980-c37b-11e9-968c-8742fc68ab2e.png")! app.iconURL = URL(string: "https://user-images.githubusercontent.com/705880/63392210-540c5980-c37b-11e9-968c-8742fc68ab2e.png")!
app.screenshotURLs = [] app.screenshotURLs = []
app.version = "1.0" app.sourceIdentifier = Source.altStoreIdentifier
app.versionDate = Date()
app.downloadURL = URL(string: "http://rileytestut.com")! let appVersion = AppVersion.makeAppVersion(version: "1.0",
date: Date(),
downloadURL: URL(string: "http://rileytestut.com")!,
size: 0,
appBundleID: app.bundleIdentifier,
sourceID: Source.altStoreIdentifier,
in: context)
app.setVersions([appVersion])
#if BETA #if BETA
app.isBeta = true app.isBeta = true