diff --git a/Account/Sources/Account/Account.swift b/Account/Sources/Account/Account.swift index 13584afe7..c54941a04 100644 --- a/Account/Sources/Account/Account.swift +++ b/Account/Sources/Account/Account.swift @@ -711,6 +711,10 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, database.fetchStarredAndUnreadCount(for: flattenedWebFeeds().webFeedIDs(), completion: completion) } + public func fetchCountForStarredArticles() throws -> Int { + return try database.fetchStarredArticlesCount(flattenedWebFeeds().webFeedIDs()) + } + public func fetchUnreadArticleIDs(_ completion: @escaping ArticleIDsCompletionBlock) { database.fetchUnreadArticleIDsAsync(completion: completion) } diff --git a/Account/Sources/Account/AccountManager.swift b/Account/Sources/Account/AccountManager.swift index bc1911947..07bd893d9 100644 --- a/Account/Sources/Account/AccountManager.swift +++ b/Account/Sources/Account/AccountManager.swift @@ -393,6 +393,17 @@ public final class AccountManager: UnreadCountProvider { } } } + + // MARK: - Fetching Article Counts + + public func fetchCountForStarredArticles() throws -> Int { + precondition(Thread.isMainThread) + var count = 0 + for account in activeAccounts { + count += try account.fetchCountForStarredArticles() + } + return count + } // MARK: - Caches diff --git a/Account/Sources/Account/CloudKit/CloudKitWebDocumentation.swift b/Account/Sources/Account/CloudKit/CloudKitWebDocumentation.swift new file mode 100644 index 000000000..a55a494da --- /dev/null +++ b/Account/Sources/Account/CloudKit/CloudKitWebDocumentation.swift @@ -0,0 +1,12 @@ +// +// File.swift +// +// +// Created by Maurice Parker on 9/22/22. +// + +import Foundation + +public struct CloudKitWebDocumentation { + public static let limitationsAndSolutions = "https://netnewswire.com/help/iCloud" +} diff --git a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift index 5a7355201..fae1df9b2 100644 --- a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift +++ b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift @@ -114,6 +114,10 @@ public final class ArticlesDatabase { return try articlesTable.fetchStarredArticles(webFeedIDs, limit) } + public func fetchStarredArticlesCount(_ webFeedIDs: Set) throws -> Int { + return try articlesTable.fetchStarredArticlesCount(webFeedIDs) + } + public func fetchArticlesMatching(_ searchString: String, _ webFeedIDs: Set) throws -> Set
{ return try articlesTable.fetchArticlesMatching(searchString, webFeedIDs) } diff --git a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift index ab7655474..a64be83dc 100644 --- a/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift +++ b/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift @@ -32,7 +32,8 @@ final class ArticlesTable: DatabaseTable { let articleCutoffDate = Date().bySubtracting(days: 90) private typealias ArticlesFetchMethod = (FMDatabase) -> Set
- + private typealias ArticlesCountFetchMethod = (FMDatabase) -> Int + init(name: String, accountID: String, queue: DatabaseQueue, retentionStyle: ArticlesDatabase.RetentionStyle) { self.name = name @@ -103,6 +104,10 @@ final class ArticlesTable: DatabaseTable { fetchArticlesAsync({ self.fetchStarredArticles(webFeedIDs, limit, $0) }, completion) } + func fetchStarredArticlesCount(_ webFeedIDs: Set) throws -> Int { + return try fetchArticlesCount{ self.fetchStarredArticlesCount(webFeedIDs, $0) } + } + // MARK: - Fetching Search Articles func fetchArticlesMatching(_ searchString: String) throws -> Set
{ @@ -671,6 +676,23 @@ private extension ArticlesTable { return articles } + private func fetchArticlesCount(_ fetchMethod: @escaping ArticlesCountFetchMethod) throws -> Int { + var articlesCount = 0 + var error: DatabaseError? = nil + queue.runInDatabaseSync { databaseResult in + switch databaseResult { + case .success(let database): + articlesCount = fetchMethod(database) + case .failure(let databaseError): + error = databaseError + } + } + if let error = error { + throw(error) + } + return articlesCount + } + private func fetchArticlesAsync(_ fetchMethod: @escaping ArticlesFetchMethod, _ completion: @escaping ArticleSetResultBlock) { queue.runInDatabase { databaseResult in @@ -745,6 +767,19 @@ private extension ArticlesTable { return articlesWithSQL(sql, parameters, database) } + func fetchArticleCountsWithWhereClause(_ database: FMDatabase, whereClause: String, parameters: [AnyObject]) -> Int { + let sql = "select count(*) from articles natural join statuses where \(whereClause);" + guard let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) else { + return 0 + } + var articlesCount = 0 + if resultSet.next() { + articlesCount = resultSet.long(forColumnIndex: 0) + } + resultSet.close() + return articlesCount + } + func fetchArticlesMatching(_ searchString: String, _ database: FMDatabase) -> Set
{ let sql = "select rowid from search where search match ?;" let sqlSearchString = sqliteSearchString(with: searchString) @@ -840,20 +875,31 @@ private extension ArticlesTable { return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters) } - func fetchStarredArticles(_ webFeedIDs: Set, _ limit: Int?, _ database: FMDatabase) -> Set
{ - // select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and starred=1; - if webFeedIDs.isEmpty { - return Set
() - } - let parameters = webFeedIDs.map { $0 as AnyObject } - let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(webFeedIDs.count))! - var whereClause = "feedID in \(placeholders) and starred=1" - if let limit = limit { - whereClause.append(" order by coalesce(datePublished, dateModified, dateArrived) desc limit \(limit)") - } - return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters) - } - + func fetchStarredArticles(_ webFeedIDs: Set, _ limit: Int?, _ database: FMDatabase) -> Set
{ + // select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and starred=1; + if webFeedIDs.isEmpty { + return Set
() + } + let parameters = webFeedIDs.map { $0 as AnyObject } + let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(webFeedIDs.count))! + var whereClause = "feedID in \(placeholders) and starred=1" + if let limit = limit { + whereClause.append(" order by coalesce(datePublished, dateModified, dateArrived) desc limit \(limit)") + } + return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters) + } + + func fetchStarredArticlesCount(_ webFeedIDs: Set, _ database: FMDatabase) -> Int { + // select count from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and starred=1; + if webFeedIDs.isEmpty { + return 0 + } + let parameters = webFeedIDs.map { $0 as AnyObject } + let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(webFeedIDs.count))! + let whereClause = "feedID in \(placeholders) and starred=1" + return fetchArticleCountsWithWhereClause(database, whereClause: whereClause, parameters: parameters) + } + func fetchArticlesMatching(_ searchString: String, _ webFeedIDs: Set, _ database: FMDatabase) -> Set
{ let articles = fetchArticlesMatching(searchString, database) // TODO: include the feedIDs in the SQL rather than filtering here. diff --git a/Shared/Widget/WidgetDataEncoder.swift b/Shared/Widget/WidgetDataEncoder.swift index b23525f90..64c210934 100644 --- a/Shared/Widget/WidgetDataEncoder.swift +++ b/Shared/Widget/WidgetDataEncoder.swift @@ -19,23 +19,50 @@ public final class WidgetDataEncoder: Logging { private let fetchLimit = 7 - private var backgroundTaskID: UIBackgroundTaskIdentifier! private lazy var appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as! String private lazy var containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) private lazy var imageContainer = containerURL?.appendingPathComponent("widgetImages", isDirectory: true) private lazy var dataURL = containerURL?.appendingPathComponent("widget-data.json") - static let shared = WidgetDataEncoder() - private init () { + private let encodeWidgetDataQueue = CoalescingQueue(name: "Encode the Widget Data", interval: 5.0) + + init () { if imageContainer != nil { try? FileManager.default.createDirectory(at: imageContainer!, withIntermediateDirectories: true, attributes: nil) } + if #available(iOS 14, *) { + NotificationCenter.default.addObserver(self, selector: #selector(statusesDidChange(_:)), name: .StatusesDidChange, object: nil) + } + } + + func encodeIfNecessary() { + encodeWidgetDataQueue.performCallsImmediately() + } + + @available(iOS 14, *) + @objc func statusesDidChange(_ note: Notification) { + encodeWidgetDataQueue.add(self, #selector(performEncodeWidgetData)) + } + + @available(iOS 14, *) + @objc private func performEncodeWidgetData() { + // We will be on the Main Thread when the encodeIfNecessary function is called. We want + // block the main thread in that case so that the widget data is encoded. If it is on + // a background Thread, it was called by the CoalescingQueue. In that case we need to + // move it to the Main Thread and want to execute it async. + if Thread.isMainThread { + encodeWidgetData() + } else { + DispatchQueue.main.async { + self.encodeWidgetData() + } + } } @available(iOS 14, *) - func encodeWidgetData() throws { + private func encodeWidgetData() { flushSharedContainer() - logger.debug("Started encoding widget data.") + logger.debug("Starting encoding widget data.") do { let unreadArticles = Array(try AccountManager.shared.fetchArticles(.unread(fetchLimit))).sortedByDate(.orderedDescending) @@ -78,7 +105,7 @@ public final class WidgetDataEncoder: Logging { let latestData = WidgetData(currentUnreadCount: SmartFeedsController.shared.unreadFeed.unreadCount, currentTodayCount: SmartFeedsController.shared.todayFeed.unreadCount, - currentStarredCount: try! SmartFeedsController.shared.starredFeed.fetchArticles().count, + currentStarredCount: try AccountManager.shared.fetchCountForStarredArticles(), unreadArticles: unread, starredArticles: starred, todayArticles:today, @@ -88,10 +115,6 @@ public final class WidgetDataEncoder: Logging { DispatchQueue.global().async { [weak self] in guard let self = self else { return } - self.backgroundTaskID = UIApplication.shared.beginBackgroundTask (withName: "com.ranchero.NetNewsWire.Encode") { - UIApplication.shared.endBackgroundTask(self.backgroundTaskID!) - self.backgroundTaskID = .invalid - } let encodedData = try? JSONEncoder().encode(latestData) self.logger.debug("Finished encoding widget data.") @@ -103,14 +126,11 @@ public final class WidgetDataEncoder: Logging { if FileManager.default.createFile(atPath: self.dataURL!.path, contents: encodedData, attributes: nil) { self.logger.debug("Wrote widget data to container.") WidgetCenter.shared.reloadAllTimelines() - UIApplication.shared.endBackgroundTask(self.backgroundTaskID!) - self.backgroundTaskID = .invalid - } else { - UIApplication.shared.endBackgroundTask(self.backgroundTaskID!) - self.backgroundTaskID = .invalid } } + } catch { + logger.error("WidgetDataEncoder failed to write the widget data.") } } diff --git a/Technotes/ReleaseNotes-iOS.markdown b/Technotes/ReleaseNotes-iOS.markdown index 1fb706585..a35e4b84e 100644 --- a/Technotes/ReleaseNotes-iOS.markdown +++ b/Technotes/ReleaseNotes-iOS.markdown @@ -1,5 +1,18 @@ # iOS Release Notes +### 6.1 TestFlight build 6105 = 6 July 2022 + +Write widget icons to the shared container +Make crashes slightly less likely when building up widget data + +### 6.1 TestFlight build 6104 - 6 April 2022 + +Building on a new machine and making sure all’s well +Moved built-in themes to the app bundle so they’re always up to date +Fixed a crash in the Feeds list related to updating the feed image +Fixed a layout bug that could happen on returning to the Feeds list +Fixed a bug where go-to-feed might not properly expand disclosure triangles + ### 6.1 TestFlight build 6103 - 25 Jan 2022 * Fixed regression with keyboard shortcuts. diff --git a/Widget/Shared Views/ArticleItemView.swift b/Widget/Shared Views/ArticleItemView.swift index 917d1ac50..87c06bea9 100644 --- a/Widget/Shared Views/ArticleItemView.swift +++ b/Widget/Shared Views/ArticleItemView.swift @@ -15,14 +15,14 @@ struct ArticleItemView: View { var article: LatestArticle var deepLink: URL - @State private var iconImage: UIImage? + @State private var iconImage: Image? var body: some View { Link(destination: deepLink, label: { HStack(alignment: .top, spacing: nil, content: { // Feed Icon if iconImage != nil { - Image(uiImage: iconImage!) + iconImage! .resizable() .frame(width: 30, height: 30) .cornerRadius(4) @@ -51,22 +51,23 @@ struct ArticleItemView: View { } }) }).onAppear { - guard let feedIconPath = article.feedIconPath else { - iconImage = thumbnail(nil) - return - } - let path = URL(fileURLWithPath: feedIconPath) - let data = try? Data(contentsOf: path) - iconImage = thumbnail(data) + iconImage = thumbnail(from: article.feedIconPath) } } - func thumbnail(_ data: Data?) -> UIImage { - if data == nil { - return UIImage(systemName: "globe")! - } else { - return UIImage(data: data!)! - } + func thumbnail(from path: String?) -> Image? { + guard let imagePath = path else { + return Image(uiImage: UIImage(systemName: "globe")!) + } + + let url = URL(fileURLWithPath: imagePath) + + guard let data = try? Data(contentsOf: url), + let uiImage = UIImage(data: data) else { + return Image(uiImage: UIImage(systemName: "globe")!) + } + + return Image(uiImage: uiImage) } func pubDate(_ dateString: String) -> String { diff --git a/iOS/Account/Account.storyboard b/iOS/Account/Account.storyboard index 13c32d2ed..f1fb29edf 100644 --- a/iOS/Account/Account.storyboard +++ b/iOS/Account/Account.storyboard @@ -13,7 +13,7 @@ - + @@ -29,7 +29,7 @@ - + @@ -314,7 +314,7 @@ Don’t have a Feedbin account? - + @@ -678,7 +678,7 @@ Don’t have a Reader account? - + @@ -706,10 +706,21 @@ Don’t have a Reader account? + + + @@ -773,7 +784,7 @@ Don’t have a Reader account? - + diff --git a/iOS/Account/CloudKitAccountViewController.swift b/iOS/Account/CloudKitAccountViewController.swift index b70927aff..7fa11b313 100644 --- a/iOS/Account/CloudKitAccountViewController.swift +++ b/iOS/Account/CloudKitAccountViewController.swift @@ -7,6 +7,7 @@ // import UIKit +import SafariServices import Account enum CloudKitAccountViewControllerError: LocalizedError { @@ -30,7 +31,7 @@ class CloudKitAccountViewController: UITableViewController { } private func setupFooter() { - footerLabel.text = NSLocalizedString("Feeds in your iCloud account will be synced across your Mac and iOS devices.\n\nImportant note: while NetNewsWire itself is very fast, iCloud syncing is sometimes very slow. This can happen after adding a number of feeds and when setting it up on a new device.\n\nIf that happens to you, it may appear stuck. But don’t worry — it’s not. Just let it run.", comment: "iCloud") + footerLabel.text = NSLocalizedString("NetNewsWire will use your iCloud account to sync your subscriptions across your Mac and iOS devices.", comment: "iCloud") } @IBAction func cancel(_ sender: Any) { @@ -63,4 +64,10 @@ class CloudKitAccountViewController: UITableViewController { } } + @IBAction func openLimitationsAndSolutions(_ sender: Any) { + let vc = SFSafariViewController(url: URL(string: CloudKitWebDocumentation.limitationsAndSolutions)!) + vc.modalPresentationStyle = .pageSheet + present(vc, animated: true) + } + } diff --git a/iOS/AppDelegate.swift b/iOS/AppDelegate.swift index 20e89fe8c..80048543c 100644 --- a/iOS/AppDelegate.swift +++ b/iOS/AppDelegate.swift @@ -42,6 +42,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD var webFeedIconDownloader: WebFeedIconDownloader! var extensionContainersFile: ExtensionContainersFile! var extensionFeedAddRequestFile: ExtensionFeedAddRequestFile! + var widgetDataEncoder: WidgetDataEncoder! var unreadCount = 0 { didSet { @@ -111,6 +112,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD extensionContainersFile = ExtensionContainersFile() extensionFeedAddRequestFile = ExtensionFeedAddRequestFile() + widgetDataEncoder = WidgetDataEncoder() + syncTimer = ArticleStatusSyncTimer() #if DEBUG @@ -169,6 +172,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD func prepareAccountsForBackground() { extensionFeedAddRequestFile.suspend() + widgetDataEncoder.encodeIfNecessary() syncTimer?.invalidate() scheduleBackgroundFeedRefresh() syncArticleStatus() @@ -395,7 +399,6 @@ private extension AppDelegate { } AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log) { [unowned self] in if !AccountManager.shared.isSuspended { - try? WidgetDataEncoder.shared.encodeWidgetData() self.suspendApplication() self.logger.info("Account refresh operation completed.") task.setTaskCompleted(success: true) @@ -440,7 +443,6 @@ private extension AppDelegate { self.prepareAccountsForBackground() account!.syncArticleStatus(completion: { [weak self] _ in if !AccountManager.shared.isSuspended { - try? WidgetDataEncoder.shared.encodeWidgetData() self?.prepareAccountsForBackground() self?.suspendApplication() } @@ -467,7 +469,6 @@ private extension AppDelegate { account!.markArticles(article!, statusKey: .starred, flag: true) { _ in } account!.syncArticleStatus(completion: { [weak self] _ in if !AccountManager.shared.isSuspended { - try? WidgetDataEncoder.shared.encodeWidgetData() self?.prepareAccountsForBackground() self?.suspendApplication() } diff --git a/iOS/Inspector/AccountInspectorViewController.swift b/iOS/Inspector/AccountInspectorViewController.swift index 681852eb5..74ae3d034 100644 --- a/iOS/Inspector/AccountInspectorViewController.swift +++ b/iOS/Inspector/AccountInspectorViewController.swift @@ -7,6 +7,7 @@ // import UIKit +import SafariServices import Account class AccountInspectorViewController: UITableViewController { @@ -16,6 +17,7 @@ class AccountInspectorViewController: UITableViewController { @IBOutlet weak var nameTextField: UITextField! @IBOutlet weak var activeSwitch: UISwitch! @IBOutlet weak var deleteAccountButton: VibrantButton! + @IBOutlet weak var limitationsAndSolutionsButton: UIButton! var isModal = false weak var account: Account? @@ -36,6 +38,10 @@ class AccountInspectorViewController: UITableViewController { deleteAccountButton.setTitle(NSLocalizedString("Remove Account", comment: "Remove Account"), for: .normal) } + if account.type != .cloudKit { + limitationsAndSolutionsButton.isHidden = true + } + if isModal { let doneBarButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(done)) navigationItem.leftBarButtonItem = doneBarButtonItem @@ -115,6 +121,13 @@ class AccountInspectorViewController: UITableViewController { present(alertController, animated: true) } + + @IBAction func openLimitationsAndSolutions(_ sender: Any) { + let vc = SFSafariViewController(url: URL(string: CloudKitWebDocumentation.limitationsAndSolutions)!) + vc.modalPresentationStyle = .pageSheet + present(vc, animated: true) + } + } // MARK: Table View diff --git a/iOS/Inspector/Inspector.storyboard b/iOS/Inspector/Inspector.storyboard index 45fcc87f2..7dd8ddd8f 100644 --- a/iOS/Inspector/Inspector.storyboard +++ b/iOS/Inspector/Inspector.storyboard @@ -1,9 +1,9 @@ - + - + @@ -16,21 +16,41 @@ + + + + + + + + + + + + - + - + - + - + @@ -45,14 +65,14 @@ - + - +