From caf424df915ddf0fed73ffbd08bb407907f8dce6 Mon Sep 17 00:00:00 2001 From: Riley Testut Date: Wed, 31 Jul 2019 13:35:12 -0700 Subject: [PATCH] [AltStore] Tracks background refresh attempts for debugging --- AltStore.xcodeproj/project.pbxproj | 8 + AltStore/AppDelegate.swift | 306 ++++++++++-------- AltStore/Base.lproj/Main.storyboard | 100 ++++++ .../Components/BackgroundTaskManager.swift | 2 +- .../AltStore.xcdatamodel/contents | 14 +- AltStore/Model/RefreshAttempt.swift | 51 +++ .../RefreshAttemptsViewController.swift | 73 +++++ 7 files changed, 418 insertions(+), 136 deletions(-) create mode 100644 AltStore/Model/RefreshAttempt.swift create mode 100644 AltStore/Settings/RefreshAttemptsViewController.swift diff --git a/AltStore.xcodeproj/project.pbxproj b/AltStore.xcodeproj/project.pbxproj index 6c1d41be..1418a595 100644 --- a/AltStore.xcodeproj/project.pbxproj +++ b/AltStore.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ BF0201BB22C2EFA3000B93E4 /* AltSign.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BF5AB3A72285FE6C00DC914B /* AltSign.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; BF0201BD22C2EFBC000B93E4 /* openssl.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF4713A422976CFC00784A2F /* openssl.framework */; }; BF0201BE22C2EFBC000B93E4 /* openssl.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = BF4713A422976CFC00784A2F /* openssl.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + BF02419422F2156E00129732 /* RefreshAttempt.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF02419322F2156E00129732 /* RefreshAttempt.swift */; }; + BF02419622F2199300129732 /* RefreshAttemptsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF02419522F2199300129732 /* RefreshAttemptsViewController.swift */; }; BF08858322DE795100DE9F1E /* MyAppsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF08858222DE795100DE9F1E /* MyAppsViewController.swift */; }; BF08858522DE7EC800DE9F1E /* UpdateCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF08858422DE7EC800DE9F1E /* UpdateCollectionViewCell.swift */; }; BF0C4EBD22A1BD8B009A2DD7 /* AppManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF0C4EBC22A1BD8B009A2DD7 /* AppManager.swift */; }; @@ -252,6 +254,8 @@ /* Begin PBXFileReference section */ 1039C07E517311FC499A0B64 /* Pods_AltStore.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AltStore.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A136EE677716B80768E9F0A2 /* Pods-AltStore.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AltStore.release.xcconfig"; path = "Target Support Files/Pods-AltStore/Pods-AltStore.release.xcconfig"; sourceTree = ""; }; + BF02419322F2156E00129732 /* RefreshAttempt.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshAttempt.swift; sourceTree = ""; }; + BF02419522F2199300129732 /* RefreshAttemptsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshAttemptsViewController.swift; sourceTree = ""; }; BF08858222DE795100DE9F1E /* MyAppsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyAppsViewController.swift; sourceTree = ""; }; BF08858422DE7EC800DE9F1E /* UpdateCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateCollectionViewCell.swift; sourceTree = ""; }; BF0C4EBC22A1BD8B009A2DD7 /* AppManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppManager.swift; sourceTree = ""; }; @@ -853,6 +857,7 @@ BFBBE2DE22931F73002097FA /* App.swift */, BF3D648722E79A3700E9056B /* AppPermission.swift */, BFBBE2E022931F81002097FA /* InstalledApp.swift */, + BF02419322F2156E00129732 /* RefreshAttempt.swift */, BFE338DC22F0E7F3002E24B9 /* Source.swift */, BFE6326522A857C100F30809 /* Team.swift */, ); @@ -883,6 +888,7 @@ isa = PBXGroup; children = ( BFDB69FC22A9A7B7007EA6D6 /* SettingsViewController.swift */, + BF02419522F2199300129732 /* RefreshAttemptsViewController.swift */, ); path = Settings; sourceTree = ""; @@ -1300,6 +1306,7 @@ BF770E6722BD57C4002A40FE /* BackgroundTaskManager.swift in Sources */, BF3D64B022E8D4B800E9056B /* AppContentViewControllerCells.swift in Sources */, BFBBE2DD22931B20002097FA /* AltStore.xcdatamodeld in Sources */, + BF02419422F2156E00129732 /* RefreshAttempt.swift in Sources */, BFE338E822F10E56002E24B9 /* LaunchViewController.swift in Sources */, BFE6325C22A83C0100F30809 /* AuthenticationViewController.swift in Sources */, BFB1169B2293274D00BB457C /* JSONDecoder+ManagedObjectContext.swift in Sources */, @@ -1312,6 +1319,7 @@ BF770E5822BC3D0F002A40FE /* OperationGroup.swift in Sources */, BF18B0F122E25DF9005C4CF5 /* ToastView.swift in Sources */, BF3D649F22E7B24C00E9056B /* CollapsingTextView.swift in Sources */, + BF02419622F2199300129732 /* RefreshAttemptsViewController.swift in Sources */, BF08858322DE795100DE9F1E /* MyAppsViewController.swift in Sources */, BF9ABA4F22DD41A9008935CF /* UIColor+Hex.swift in Sources */, BFD52BD422A0800A000B7ED1 /* ServerManager.swift in Sources */, diff --git a/AltStore/AppDelegate.swift b/AltStore/AppDelegate.swift index 4252ef1f..46e32484 100644 --- a/AltStore/AppDelegate.swift +++ b/AltStore/AppDelegate.swift @@ -13,6 +13,18 @@ import AVFoundation import AltSign import Roxas +private enum RefreshError: LocalizedError +{ + case noInstalledApps + + var errorDescription: String? { + switch self + { + case .noInstalledApps: return NSLocalizedString("No installed apps to refresh.", comment: "") + } + } +} + private extension CFNotificationName { static let requestAppState = CFNotificationName("com.altstore.RequestAppState" as CFString) @@ -43,12 +55,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? private var runningApplications: Set? - private var isLaunching = false func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - self.isLaunching = true - self.setTintColor() ServerManager.shared.startDiscovering() @@ -61,10 +70,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { self.prepareForBackgroundFetch() - DispatchQueue.main.async { - self.isLaunching = false - } - return true } @@ -118,36 +123,12 @@ extension AppDelegate self.application(application, performFetchWithCompletionHandler: completionHandler) } - func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) + func application(_ application: UIApplication, performFetchWithCompletionHandler backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - let isLaunching = self.isLaunching + ServerManager.shared.startDiscovering() + let refreshIdentifier = UUID().uuidString - let installedApps = InstalledApp.fetchAppsForBackgroundRefresh(in: DatabaseManager.shared.viewContext) - guard !installedApps.isEmpty else { - ServerManager.shared.stopDiscovering() - completionHandler(.noData) - return - } - - self.runningApplications = [] - - let identifiers = installedApps.compactMap { $0.bundleIdentifier } - print("Apps to refresh:", identifiers) - - DispatchQueue.global().async { - let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter() - - for identifier in identifiers - { - let appIsRunningNotification = CFNotificationName.appIsRunning(for: identifier) - CFNotificationCenterAddObserver(notificationCenter, nil, ReceivedApplicationState, appIsRunningNotification.rawValue, nil, .deliverImmediately) - - let requestAppStateNotification = CFNotificationName.requestAppState(for: identifier) - CFNotificationCenterPostNotification(notificationCenter, requestAppStateNotification, nil, nil, true) - } - } - BackgroundTaskManager.shared.performExtendedBackgroundTask { (taskResult, taskCompletionHandler) in func finish(_ result: Result<[String: Result], Error>) @@ -156,26 +137,74 @@ extension AppDelegate ServerManager.shared.stopDiscovering() - self.scheduleFinishedRefreshingNotification(for: result, identifier: refreshIdentifier, isLaunching: isLaunching, delay: 0) + self.scheduleFinishedRefreshingNotification(for: result, identifier: refreshIdentifier, delay: 0) taskCompletionHandler() - - DispatchQueue.main.async { - guard UIApplication.shared.applicationState == .background else { return } - - // Exit so that if background fetch occurs again soon we're not suspended. - exit(0) - } } if let error = taskResult.error { print("Error starting extended background task. Aborting.", error) - completionHandler(.failed) + backgroundFetchCompletionHandler(.failed) finish(.failure(error)) return } + if !DatabaseManager.shared.isStarted + { + DatabaseManager.shared.start() { (error) in + if let error = error + { + backgroundFetchCompletionHandler(.failed) + finish(.failure(error)) + } + else + { + self.refreshApps(identifier: refreshIdentifier, backgroundFetchCompletionHandler: backgroundFetchCompletionHandler, completionHandler: finish(_:)) + } + } + } + else + { + self.refreshApps(identifier: refreshIdentifier, backgroundFetchCompletionHandler: backgroundFetchCompletionHandler, completionHandler: finish(_:)) + } + } + } +} + +private extension AppDelegate +{ + func refreshApps(identifier: String, + backgroundFetchCompletionHandler: @escaping (UIBackgroundFetchResult) -> Void, + completionHandler: @escaping (Result<[String: Result], Error>) -> Void) + { + DatabaseManager.shared.persistentContainer.performBackgroundTask { (context) in + + let installedApps = InstalledApp.fetchAppsForBackgroundRefresh(in: context) + guard !installedApps.isEmpty else { + backgroundFetchCompletionHandler(.noData) + completionHandler(.failure(RefreshError.noInstalledApps)) + return + } + + self.runningApplications = [] + + let identifiers = installedApps.compactMap { $0.bundleIdentifier } + print("Apps to refresh:", identifiers) + + DispatchQueue.global().async { + let notificationCenter = CFNotificationCenterGetDarwinNotifyCenter() + + for identifier in identifiers + { + let appIsRunningNotification = CFNotificationName.appIsRunning(for: identifier) + CFNotificationCenterAddObserver(notificationCenter, nil, ReceivedApplicationState, appIsRunningNotification.rawValue, nil, .deliverImmediately) + + let requestAppStateNotification = CFNotificationName.requestAppState(for: identifier) + CFNotificationCenterPostNotification(notificationCenter, requestAppStateNotification, nil, nil, true) + } + } + var fetchSourceResult: Result? var serversResult: Result? @@ -231,16 +260,16 @@ extension AppDelegate dispatchGroup.notify(queue: .main) { guard let fetchSourceResult = fetchSourceResult, let serversResult = serversResult else { - completionHandler(.failed) + backgroundFetchCompletionHandler(.failed) return } // Call completionHandler early to improve chances of refreshing in the background again. switch (fetchSourceResult, serversResult) { - case (.success, .success): completionHandler(.newData) - case (.success, .failure(ConnectionError.serverNotFound)): completionHandler(.newData) - case (.failure, _), (_, .failure): completionHandler(.failed) + case (.success, .success): backgroundFetchCompletionHandler(.newData) + case (.success, .failure(ConnectionError.serverNotFound)): backgroundFetchCompletionHandler(.newData) + case (.failure, _), (_, .failure): backgroundFetchCompletionHandler(.failed) } } @@ -248,45 +277,47 @@ extension AppDelegate // a) give us time to discover AltServers // b) give other processes a chance to respond to requestAppState notification DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { - if ServerManager.shared.discoveredServers.isEmpty - { - serversResult = .failure(ConnectionError.serverNotFound) - } - else - { - serversResult = .success(()) - } - - dispatchGroup.leave() - - let filteredApps = installedApps.filter { !(self.runningApplications?.contains($0.bundleIdentifier) ?? false) } - print("Filtered Apps to Refresh:", filteredApps.map { $0.bundleIdentifier }) - - let group = AppManager.shared.refresh(filteredApps, presentingViewController: nil) - group.beginInstallationHandler = { (installedApp) in - guard installedApp.bundleIdentifier == App.altstoreAppID else { return } - - // We're starting to install AltStore, which means the app is about to quit. - // So, we schedule a "refresh successful" local notification to be displayed after a delay, - // but if the app is still running, we cancel the notification. - // Then, we schedule another notification and repeat the process. - - // Also since AltServer has already received the app, it can finish installing even if we're no longer running in background. - - if let error = group.error + context.perform { + if ServerManager.shared.discoveredServers.isEmpty { - self.scheduleFinishedRefreshingNotification(for: .failure(error), identifier: refreshIdentifier, isLaunching: isLaunching) + serversResult = .failure(ConnectionError.serverNotFound) } else { - var results = group.results - results[installedApp.bundleIdentifier] = .success(installedApp) - - self.scheduleFinishedRefreshingNotification(for: .success(results), identifier: refreshIdentifier, isLaunching: isLaunching) + serversResult = .success(()) + } + + dispatchGroup.leave() + + let filteredApps = installedApps.filter { !(self.runningApplications?.contains($0.bundleIdentifier) ?? false) } + print("Filtered Apps to Refresh:", filteredApps.map { $0.bundleIdentifier }) + + let group = AppManager.shared.refresh(filteredApps, presentingViewController: nil) + group.beginInstallationHandler = { (installedApp) in + guard installedApp.bundleIdentifier == App.altstoreAppID else { return } + + // We're starting to install AltStore, which means the app is about to quit. + // So, we schedule a "refresh successful" local notification to be displayed after a delay, + // but if the app is still running, we cancel the notification. + // Then, we schedule another notification and repeat the process. + + // Also since AltServer has already received the app, it can finish installing even if we're no longer running in background. + + if let error = group.error + { + self.scheduleFinishedRefreshingNotification(for: .failure(error), identifier: identifier) + } + else + { + var results = group.results + results[installedApp.bundleIdentifier] = .success(installedApp) + + self.scheduleFinishedRefreshingNotification(for: .success(results), identifier: identifier) + } + } + group.completionHandler = { (result) in + completionHandler(result) } - } - group.completionHandler = { (result) in - finish(result) } } } @@ -300,69 +331,76 @@ extension AppDelegate self.runningApplications?.insert(appID) } - func scheduleFinishedRefreshingNotification(for result: Result<[String: Result], Error>, identifier: String, isLaunching: Bool, delay: TimeInterval = 5) + func scheduleFinishedRefreshingNotification(for result: Result<[String: Result], Error>, identifier: String, delay: TimeInterval = 5) { - self.cancelFinishedRefreshingNotification(identifier: identifier) - - let content = UNMutableNotificationContent() - - var shouldPresentAlert = true - - do + func scheduleFinishedRefreshingNotification() { - let results = try result.get() - shouldPresentAlert = !results.isEmpty + self.cancelFinishedRefreshingNotification(identifier: identifier) - for (_, result) in results + let content = UNMutableNotificationContent() + + var shouldPresentAlert = true + + do { - guard case let .failure(error) = result else { continue } - throw error + let results = try result.get() + shouldPresentAlert = !results.isEmpty + + for (_, result) in results + { + guard case let .failure(error) = result else { continue } + throw error + } + + content.title = NSLocalizedString("Refreshed Apps", comment: "") + content.body = NSLocalizedString("All apps have been refreshed.", comment: "") + } + catch ConnectionError.serverNotFound + { + shouldPresentAlert = false + } + catch RefreshError.noInstalledApps + { + shouldPresentAlert = false + } + catch + { + print("Failed to refresh apps in background.", error) + + content.title = NSLocalizedString("Failed to Refresh Apps", comment: "") + content.body = error.localizedDescription + + shouldPresentAlert = true } - content.title = NSLocalizedString("Refreshed Apps", comment: "") - content.body = NSLocalizedString("All apps have been refreshed.", comment: "") - } - catch let error as NSError where - (error.domain == NSOSStatusErrorDomain || error.domain == AVFoundationErrorDomain) && - error.code == AVAudioSession.ErrorCode.cannotStartPlaying.rawValue && - !isLaunching - { - // We can only start background audio when the app is being launched, - // and _not_ if it's already suspended in background. - // Since we are currently suspended in background and not launching, we'll just ignore the error. - - shouldPresentAlert = false - } - catch ConnectionError.serverNotFound - { - shouldPresentAlert = false - } - catch - { - print("Failed to refresh apps in background.", error) - - content.title = NSLocalizedString("Failed to Refresh Apps", comment: "") - content.body = error.localizedDescription - - shouldPresentAlert = true - } - - if shouldPresentAlert - { - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay + 1, repeats: false) - - let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) - UNUserNotificationCenter.current().add(request) - - if delay > 0 + if shouldPresentAlert { - DispatchQueue.global().asyncAfter(deadline: .now() + delay) { - // If app is still running at this point, we schedule another notification with same identifier. - // This prevents the currently scheduled notification from displaying, and starts another countdown timer. - self.scheduleFinishedRefreshingNotification(for: result, identifier: identifier, isLaunching: isLaunching) + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: delay + 1, repeats: false) + + let request = UNNotificationRequest(identifier: identifier, content: content, trigger: trigger) + UNUserNotificationCenter.current().add(request) + + if delay > 0 + { + DispatchQueue.global().asyncAfter(deadline: .now() + delay) { + // If app is still running at this point, we schedule another notification with same identifier. + // This prevents the currently scheduled notification from displaying, and starts another countdown timer. + scheduleFinishedRefreshingNotification() + } } } } + + scheduleFinishedRefreshingNotification() + + // Perform synchronously to ensure app doesn't quit before we've finishing saving to disk. + let context = DatabaseManager.shared.persistentContainer.newBackgroundContext() + context.performAndWait { + _ = RefreshAttempt(identifier: identifier, result: result, context: context) + + do { try context.save() } + catch { print("Failed to save refresh attempt.", error) } + } } func cancelFinishedRefreshingNotification(identifier: String) diff --git a/AltStore/Base.lproj/Main.storyboard b/AltStore/Base.lproj/Main.storyboard index af0754bc..6816f389 100644 --- a/AltStore/Base.lproj/Main.storyboard +++ b/AltStore/Base.lproj/Main.storyboard @@ -693,6 +693,30 @@ World + + + + + + + + + + + + + + + + + + @@ -721,6 +745,82 @@ World + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AltStore/Components/BackgroundTaskManager.swift b/AltStore/Components/BackgroundTaskManager.swift index a275d598..db44eb0a 100644 --- a/AltStore/Components/BackgroundTaskManager.swift +++ b/AltStore/Components/BackgroundTaskManager.swift @@ -56,7 +56,7 @@ extension BackgroundTaskManager self.isPlaying = false } - self.audioEngineQueue.async { + self.audioEngineQueue.sync { do { try AVAudioSession.sharedInstance().setCategory(.playback, options: .mixWithOthers) diff --git a/AltStore/Model/AltStore.xcdatamodeld/AltStore.xcdatamodel/contents b/AltStore/Model/AltStore.xcdatamodeld/AltStore.xcdatamodel/contents index 2f6a06e6..8785dc96 100644 --- a/AltStore/Model/AltStore.xcdatamodeld/AltStore.xcdatamodel/contents +++ b/AltStore/Model/AltStore.xcdatamodeld/AltStore.xcdatamodel/contents @@ -56,6 +56,17 @@ + + + + + + + + + + + @@ -84,7 +95,8 @@ - + + \ No newline at end of file diff --git a/AltStore/Model/RefreshAttempt.swift b/AltStore/Model/RefreshAttempt.swift new file mode 100644 index 00000000..dbd77cd7 --- /dev/null +++ b/AltStore/Model/RefreshAttempt.swift @@ -0,0 +1,51 @@ +// +// RefreshAttempt.swift +// AltStore +// +// Created by Riley Testut on 7/31/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import CoreData + +@objc(RefreshAttempt) +class RefreshAttempt: NSManagedObject, Fetchable +{ + @NSManaged var identifier: String + @NSManaged var date: Date + + @NSManaged var isSuccess: Bool + @NSManaged var errorDescription: String? + + private override init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) + { + super.init(entity: entity, insertInto: context) + } + + init(identifier: String, result: Result, context: NSManagedObjectContext) + { + super.init(entity: RefreshAttempt.entity(), insertInto: context) + + self.identifier = identifier + self.date = Date() + + switch result + { + case .success: + self.isSuccess = true + self.errorDescription = nil + + case .failure(let error): + self.isSuccess = false + self.errorDescription = error.localizedDescription + } + } +} + +extension RefreshAttempt +{ + @nonobjc class func fetchRequest() -> NSFetchRequest + { + return NSFetchRequest(entityName: "RefreshAttempt") + } +} diff --git a/AltStore/Settings/RefreshAttemptsViewController.swift b/AltStore/Settings/RefreshAttemptsViewController.swift new file mode 100644 index 00000000..81290290 --- /dev/null +++ b/AltStore/Settings/RefreshAttemptsViewController.swift @@ -0,0 +1,73 @@ +// +// RefreshAttemptsViewController.swift +// AltStore +// +// Created by Riley Testut on 7/31/19. +// Copyright © 2019 Riley Testut. All rights reserved. +// + +import UIKit + +import Roxas + +@objc(RefreshAttemptTableViewCell) +private class RefreshAttemptTableViewCell: UITableViewCell +{ + @IBOutlet var successLabel: UILabel! + @IBOutlet var dateLabel: UILabel! + @IBOutlet var errorDescriptionLabel: UILabel! +} + +class RefreshAttemptsViewController: UITableViewController +{ + private lazy var dataSource = self.makeDataSource() + + private lazy var dateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .short + dateFormatter.timeStyle = .short + return dateFormatter + }() + + override func viewDidLoad() + { + super.viewDidLoad() + + self.tableView.dataSource = self.dataSource + } +} + +private extension RefreshAttemptsViewController +{ + func makeDataSource() -> RSTFetchedResultsTableViewDataSource + { + let fetchRequest = RefreshAttempt.fetchRequest() as NSFetchRequest + fetchRequest.sortDescriptors = [NSSortDescriptor(keyPath: \RefreshAttempt.date, ascending: false)] + fetchRequest.returnsObjectsAsFaults = false + + let dataSource = RSTFetchedResultsTableViewDataSource(fetchRequest: fetchRequest, managedObjectContext: DatabaseManager.shared.viewContext) + dataSource.cellConfigurationHandler = { [weak self] (cell, attempt, indexPath) in + let cell = cell as! RefreshAttemptTableViewCell + cell.dateLabel.text = self?.dateFormatter.string(from: attempt.date) + cell.errorDescriptionLabel.text = attempt.errorDescription + + if attempt.isSuccess + { + cell.successLabel.text = NSLocalizedString("Success", comment: "") + cell.successLabel.textColor = .altGreen + } + else + { + cell.successLabel.text = NSLocalizedString("Failure", comment: "") + cell.successLabel.textColor = .refreshRed + } + } + + let placeholderView = RSTPlaceholderView() + placeholderView.textLabel.text = NSLocalizedString("No Refresh Attempts", comment: "") + placeholderView.detailTextLabel.text = NSLocalizedString("The more you use AltStore, the more often iOS will allow it to refresh apps in the background.", comment: "") + dataSource.placeholderView = placeholderView + + return dataSource + } +}