Caches Friend Zone patrons to offset slow loading time

The Patreon API doesn’t have a way to fetch just the patrons belonging to our Friend Zone tier. Instead, we need to fetch ALL patrons (including inactive ones) and filter out those not in the tier. This is very inefficient, and takes over a minute to complete as of April 14, 2022, due to the number of patrons we have.

We can’t do much to change this, but AltStore will now at least cache the fetched patrons with Core Data. Additionally, AltStore will only perform this long fetch whenever the Friend Zone list actually changes, rather than every time the Patreon screen appears.
This commit is contained in:
Riley Testut
2022-04-14 17:39:43 -07:00
parent 8ddeb7f9fb
commit 07daff261a
8 changed files with 191 additions and 39 deletions

View File

@@ -358,6 +358,7 @@
D58D5F2E26DFE68E00E55E38 /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = D58D5F2D26DFE68E00E55E38 /* LaunchAtLogin */; };
D593F1942717749A006E82DE /* PatchAppOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D593F1932717749A006E82DE /* PatchAppOperation.swift */; };
D5DAE0942804B0B80034D8D4 /* ScreenshotProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DAE0932804B0B80034D8D4 /* ScreenshotProcessor.swift */; };
D5DAE0962804DF430034D8D4 /* UpdatePatronsOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */; };
D5E1E7C128077DE90016FC96 /* FetchTrustedSourcesOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5E1E7C028077DE90016FC96 /* FetchTrustedSourcesOperation.swift */; };
D5F2F6A92720B7C20081CCF5 /* PatchViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F2F6A82720B7C20081CCF5 /* PatchViewController.swift */; };
/* End PBXBuildFile section */
@@ -819,6 +820,7 @@
D57F2C9326E01BC700B9FA39 /* UIDevice+Vibration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIDevice+Vibration.swift"; sourceTree = "<group>"; };
D593F1932717749A006E82DE /* PatchAppOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchAppOperation.swift; sourceTree = "<group>"; };
D5DAE0932804B0B80034D8D4 /* ScreenshotProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotProcessor.swift; sourceTree = "<group>"; };
D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdatePatronsOperation.swift; sourceTree = "<group>"; };
D5E1E7C028077DE90016FC96 /* FetchTrustedSourcesOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FetchTrustedSourcesOperation.swift; sourceTree = "<group>"; };
D5F2F6A82720B7C20081CCF5 /* PatchViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PatchViewController.swift; sourceTree = "<group>"; };
EA79A60285C6AF5848AA16E9 /* Pods-AltStore.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AltStore.debug.xcconfig"; path = "Target Support Files/Pods-AltStore/Pods-AltStore.debug.xcconfig"; sourceTree = "<group>"; };
@@ -1677,6 +1679,7 @@
BFDBBD7F246CB84F004ED2F3 /* RemoveAppBackupOperation.swift */,
BFF00D312501BDA100746320 /* BackgroundRefreshAppsOperation.swift */,
D57F2C9026E0070200B9FA39 /* EnableJITOperation.swift */,
D5DAE0952804DF430034D8D4 /* UpdatePatronsOperation.swift */,
D5E1E7C028077DE90016FC96 /* FetchTrustedSourcesOperation.swift */,
BF7B44062725A4B8005288A4 /* Patch App */,
);
@@ -2612,6 +2615,7 @@
BF3432FB246B894F0052F4A1 /* BackupAppOperation.swift in Sources */,
BF9ABA4922DD0742008935CF /* ScreenshotCollectionViewCell.swift in Sources */,
BF9ABA4D22DD16DE008935CF /* PillButton.swift in Sources */,
D5DAE0962804DF430034D8D4 /* UpdatePatronsOperation.swift in Sources */,
BFE6326C22A86FF300F30809 /* AuthenticationOperation.swift in Sources */,
BFF435D8255CBDAB00DD724F /* ALTApplication+AltStoreApp.swift in Sources */,
BF4B78FE24B3D1DB008AB4AC /* SceneDelegate.swift in Sources */,

View File

@@ -69,6 +69,7 @@ extension LaunchViewController
guard !self.didFinishLaunching else { return }
AppManager.shared.update()
AppManager.shared.updatePatronsIfNeeded()
PatreonAPI.shared.refreshPatreonAccount()
// Add view controller as child (rather than presenting modally)

View File

@@ -20,7 +20,8 @@ import Roxas
extension AppManager
{
static let didFetchSourceNotification = Notification.Name("com.altstore.AppManager.didFetchSource")
static let didFetchSourceNotification = Notification.Name("io.altstore.AppManager.didFetchSource")
static let didUpdatePatronsNotification = Notification.Name("io.altstore.AppManager.didUpdatePatrons")
static let expirationWarningNotificationID = "altstore-expiration-warning"
static let enableJITResultNotificationID = "altstore-enable-jit"
@@ -48,6 +49,8 @@ class AppManager
@available(iOS 13, *)
private(set) lazy var publisher: AppManagerPublisher = AppManagerPublisher()
private(set) var updatePatronsResult: Result<Void, Error>?
private let operationQueue = OperationQueue()
private let serialOperationQueue = OperationQueue()
@@ -412,6 +415,34 @@ extension AppManager
return fetchTrustedSourcesOperation
}
func updatePatronsIfNeeded()
{
guard self.operationQueue.operations.allSatisfy({ !($0 is UpdatePatronsOperation) }) else {
// There's already an UpdatePatronsOperation running.
return
}
self.updatePatronsResult = nil
let updatePatronsOperation = UpdatePatronsOperation()
updatePatronsOperation.resultHandler = { (result) in
do
{
try result.get()
self.updatePatronsResult = .success(())
}
catch
{
print("Error updating Friend Zone Patrons:", error)
self.updatePatronsResult = .failure(error)
}
NotificationCenter.default.post(name: AppManager.didUpdatePatronsNotification, object: self)
}
self.run([updatePatronsOperation], context: nil)
}
@discardableResult
func install<T: AppProtocol>(_ app: T, presentingViewController: UIViewController?, context: AuthenticatedOperationContext = AuthenticatedOperationContext(), completionHandler: @escaping (Result<InstalledApp, Error>) -> Void) -> RefreshGroup
{

View File

@@ -0,0 +1,107 @@
//
// UpdatePatronsOperation.swift
// AltStore
//
// Created by Riley Testut on 4/11/22.
// Copyright © 2022 Riley Testut. All rights reserved.
//
import Foundation
import CoreData
import AltStoreCore
private extension URL
{
#if STAGING
static let patreonInfo = URL(string: "https://f000.backblazeb2.com/file/altstore-staging/altstore/patreon.json")!
#else
static let patreonInfo = URL(string: "https://cdn.altstore.io/file/altstore/altstore/patreon.json")!
#endif
}
extension UpdatePatronsOperation
{
private struct Response: Decodable
{
var version: Int
var accessToken: String
var refreshID: String
}
}
class UpdatePatronsOperation: ResultOperation<Void>
{
let context: NSManagedObjectContext
init(context: NSManagedObjectContext = DatabaseManager.shared.persistentContainer.newBackgroundContext())
{
self.context = context
}
override func main()
{
super.main()
let dataTask = URLSession.shared.dataTask(with: .patreonInfo) { (data, response, error) in
do
{
guard let data = data else { throw error! }
let response = try AltStoreCore.JSONDecoder().decode(Response.self, from: data)
let previousRefreshID = UserDefaults.shared.patronsRefreshID
guard response.refreshID != previousRefreshID else {
self.finish(.success(()))
return
}
PatreonAPI.shared.fetchPatrons { (result) in
self.context.perform {
do
{
let patrons = try result.get()
let managedPatrons = patrons.map { (patron) -> PatreonAccount in
let account = PatreonAccount(patron: patron, context: self.context)
account.isFriendZonePatron = true
return account
}
var patronIDs = Set(managedPatrons.map { $0.identifier })
if let userAccountID = Keychain.shared.patreonAccountID
{
// Insert userAccountID into patronIDs to prevent it from being deleted.
patronIDs.insert(userAccountID)
}
let removedPredicate = NSPredicate(format: "NOT (%K IN %@)", #keyPath(PatreonAccount.identifier), patronIDs)
let removedPatrons = PatreonAccount.all(satisfying: removedPredicate, in: self.context)
for patreonAccount in removedPatrons
{
self.context.delete(patreonAccount)
}
try self.context.save()
UserDefaults.shared.patronsRefreshID = response.refreshID
self.finish(.success(()))
print("Updated Friend Zone Patrons!")
}
catch
{
self.finish(.failure(error))
}
}
}
}
catch
{
self.finish(.failure(error))
}
}
dataTask.resume()
}
}

View File

@@ -29,8 +29,6 @@ class PatreonViewController: UICollectionViewController
private var prototypeAboutHeader: AboutPatreonHeaderView!
private var patronsResult: Result<[Patron], Error>?
override var preferredStatusBarStyle: UIStatusBarStyle {
return .lightContent
}
@@ -48,6 +46,8 @@ class PatreonViewController: UICollectionViewController
self.collectionView.register(PatronsHeaderView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: "PatronsHeader")
self.collectionView.register(PatronsFooterView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: "PatronsFooter")
NotificationCenter.default.addObserver(self, selector: #selector(PatreonViewController.didUpdatePatrons(_:)), name: AppManager.didUpdatePatronsNotification, object: nil)
self.update()
}
@@ -75,20 +75,24 @@ class PatreonViewController: UICollectionViewController
private extension PatreonViewController
{
func makeDataSource() -> RSTCompositeCollectionViewDataSource<Patron>
func makeDataSource() -> RSTCompositeCollectionViewDataSource<PatreonAccount>
{
let aboutDataSource = RSTDynamicCollectionViewDataSource<Patron>()
let aboutDataSource = RSTDynamicCollectionViewDataSource<PatreonAccount>()
aboutDataSource.numberOfSectionsHandler = { 1 }
aboutDataSource.numberOfItemsHandler = { _ in 0 }
let dataSource = RSTCompositeCollectionViewDataSource<Patron>(dataSources: [aboutDataSource, self.patronsDataSource])
let dataSource = RSTCompositeCollectionViewDataSource<PatreonAccount>(dataSources: [aboutDataSource, self.patronsDataSource])
dataSource.proxy = self
return dataSource
}
func makePatronsDataSource() -> RSTArrayCollectionViewDataSource<Patron>
func makePatronsDataSource() -> RSTFetchedResultsCollectionViewDataSource<PatreonAccount>
{
let patronsDataSource = RSTArrayCollectionViewDataSource<Patron>(items: [])
let fetchRequest: NSFetchRequest<PatreonAccount> = PatreonAccount.fetchRequest()
fetchRequest.predicate = NSPredicate(format: "%K == YES", #keyPath(PatreonAccount.isFriendZonePatron))
fetchRequest.sortDescriptors = [NSSortDescriptor(key: #keyPath(PatreonAccount.name), ascending: true, selector: #selector(NSString.caseInsensitiveCompare(_:)))]
let patronsDataSource = RSTFetchedResultsCollectionViewDataSource<PatreonAccount>(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext)
patronsDataSource.cellConfigurationHandler = { (cell, patron, indexPath) in
let cell = cell as! PatronCollectionViewCell
cell.textLabel.text = patron.name
@@ -173,31 +177,8 @@ private extension PatreonViewController
{
@objc func fetchPatrons()
{
if let result = self.patronsResult, case .failure = result
{
self.patronsResult = nil
self.collectionView.reloadData()
}
PatreonAPI.shared.fetchPatrons { (result) in
self.patronsResult = result
do
{
let patrons = try result.get()
let sortedPatrons = patrons.sorted { $0.name < $1.name }
self.patronsDataSource.items = sortedPatrons
}
catch
{
print("Failed to fetch patrons:", error)
DispatchQueue.main.async {
self.collectionView.reloadData()
}
}
}
AppManager.shared.updatePatronsIfNeeded()
self.update()
}
@objc func openPatreonURL(_ sender: UIButton)
@@ -263,6 +244,13 @@ private extension PatreonViewController
alertController.addAction(.cancel)
self.present(alertController, animated: true, completion: nil)
}
@objc func didUpdatePatrons(_ notification: Notification)
{
DispatchQueue.main.async {
self.collectionView.reloadData()
}
}
}
extension PatreonViewController
@@ -291,11 +279,18 @@ extension PatreonViewController
footerView.button.isHidden = false
footerView.button.addTarget(self, action: #selector(PatreonViewController.fetchPatrons), for: .primaryActionTriggered)
switch self.patronsResult
if self.patronsDataSource.itemCount > 0
{
case .none: footerView.button.isIndicatingActivity = true
case .success?: footerView.button.isHidden = true
case .failure?: footerView.button.setTitle(NSLocalizedString("Error Loading Patrons", comment: ""), for: .normal)
footerView.button.isHidden = true
}
else
{
switch AppManager.shared.updatePatronsResult
{
case .none: footerView.button.isIndicatingActivity = true
case .success?: footerView.button.isHidden = true
case .failure?: footerView.button.setTitle(NSLocalizedString("Error Loading Patrons", comment: ""), for: .normal)
}
}
return footerView

View File

@@ -37,6 +37,8 @@ public extension UserDefaults
@NSManaged var patchedApps: [String]?
@NSManaged var patronsRefreshID: String?
@NSManaged var trustedSourceIDs: [String]?
var activeAppsLimit: Int? {

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="17505" systemVersion="19G2021" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="20086" systemVersion="21E230" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
<entity name="Account" representedClassName="Account" syncable="YES">
<attribute name="appleID" attributeType="String"/>
<attribute name="firstName" attributeType="String"/>
@@ -87,6 +87,7 @@
<entity name="PatreonAccount" representedClassName="PatreonAccount" syncable="YES">
<attribute name="firstName" optional="YES" attributeType="String"/>
<attribute name="identifier" attributeType="String"/>
<attribute name="isFriendZonePatron" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="isPatron" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="name" attributeType="String"/>
<uniquenessConstraints>
@@ -168,7 +169,7 @@
<element name="InstalledApp" positionX="-63" positionY="0" width="128" height="268"/>
<element name="InstalledExtension" positionX="-45" positionY="135" width="128" height="163"/>
<element name="NewsItem" positionX="-45" positionY="126" width="128" height="238"/>
<element name="PatreonAccount" positionX="-45" positionY="117" width="128" height="105"/>
<element name="PatreonAccount" positionX="-45" positionY="117" width="128" height="104"/>
<element name="RefreshAttempt" positionX="-45" positionY="117" width="128" height="105"/>
<element name="Source" positionX="-45" positionY="99" width="128" height="133"/>
<element name="StoreApp" positionX="-63" positionY="-18" width="128" height="343"/>

View File

@@ -38,6 +38,7 @@ public class PatreonAccount: NSManagedObject, Fetchable
@NSManaged public var firstName: String?
@NSManaged public var isPatron: Bool
@NSManaged public var isFriendZonePatron: NSNumber?
private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?)
{
@@ -62,6 +63,16 @@ public class PatreonAccount: NSManagedObject, Fetchable
self.isPatron = false
}
}
public init(patron: Patron, context: NSManagedObjectContext)
{
super.init(entity: PatreonAccount.entity(), insertInto: context)
self.identifier = patron.identifier
self.name = patron.name
self.firstName = nil
self.isPatron = (patron.status == .active)
}
}
public extension PatreonAccount