From 8973adbdbdc9cb85cf9c3f1b0a1e3a6a3816f899 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kiel=20Gillard=20=F0=9F=A4=AA?= <kgillard@deputy.com>
Date: Sun, 19 Apr 2020 08:31:20 +1000
Subject: [PATCH 1/9] Add lastCredentialRenewTime and honour it in
 FeedlyRefreshAccessTokenOperation

---
 Frameworks/Account/AccountMetadata.swift      | 11 ++++++++++
 .../FeedlyRefreshAccessTokenOperation.swift   | 21 ++++++++++++++++++-
 2 files changed, 31 insertions(+), 1 deletion(-)

diff --git a/Frameworks/Account/AccountMetadata.swift b/Frameworks/Account/AccountMetadata.swift
index 7c5f378f9..f4c50ab44 100644
--- a/Frameworks/Account/AccountMetadata.swift
+++ b/Frameworks/Account/AccountMetadata.swift
@@ -23,6 +23,7 @@ final class AccountMetadata: Codable {
 		case lastArticleFetchStartTime = "lastArticleFetch"
 		case lastArticleFetchEndTime
 		case endpointURL
+		case lastCredentialRenewTime = "lastCredentialRenewTime"
 	}
 
 	var name: String? {
@@ -80,6 +81,16 @@ final class AccountMetadata: Codable {
 			}
 		}
 	}
+	
+	/// The last moment an account successfully renewed its credentials, or `nil` if no such moment exists.
+	/// An account delegate can use this value to decide when to next ask the service provider to renew credentials.
+	var lastCredentialRenewTime: Date? {
+		didSet {
+			if lastCredentialRenewTime != oldValue {
+				valueDidChange(.lastCredentialRenewTime)
+			}
+		}
+	}
 
 	weak var delegate: AccountMetadataDelegate?
 	
diff --git a/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift
index d1c68d970..d6412f202 100644
--- a/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift
+++ b/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift
@@ -17,14 +17,31 @@ final class FeedlyRefreshAccessTokenOperation: FeedlyOperation {
 	let account: Account
 	let log: OSLog
 	
-	init(account: Account, service: OAuthAccessTokenRefreshing, oauthClient: OAuthAuthorizationClient, log: OSLog) {
+	/// The moment the refresh is being requested. The token will refresh only if the account's `lastCredentialRenewTime` is not on the same day as this moment. When nil, the operation will always refresh the token.
+	let refreshDate: Date?
+	
+	init(account: Account, service: OAuthAccessTokenRefreshing, oauthClient: OAuthAuthorizationClient, refreshDate: Date?, log: OSLog) {
 		self.oauthClient = oauthClient
 		self.service = service
 		self.account = account
+		self.refreshDate = refreshDate
 		self.log = log
 	}
 	
 	override func run() {
+		// Only refresh the token if these dates are not on the same day.
+		let shouldRefresh: Bool = {
+			guard let date = refreshDate, let lastRenewDate = account.metadata.lastCredentialRenewTime else {
+				return true
+			}
+			return !Calendar.current.isDate(lastRenewDate, equalTo: date, toGranularity: .day)
+		}()
+		
+		guard shouldRefresh else {
+			didFinish()
+			return
+		}
+		
 		let refreshToken: Credentials
 		
 		do {
@@ -64,6 +81,8 @@ final class FeedlyRefreshAccessTokenOperation: FeedlyOperation {
 				// Now store the access token because we want the account delegate to use it.
 				try account.storeCredentials(grant.accessToken)
 				
+				account.metadata.lastCredentialRenewTime = Date()
+				
 				didFinish()
 			} catch {
 				didFinish(with: error)

From 5ab13ae705ba75540bf3947411ee0a7c9e44d2fc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kiel=20Gillard=20=F0=9F=A4=AA?= <kgillard@deputy.com>
Date: Sun, 19 Apr 2020 08:45:51 +1000
Subject: [PATCH 2/9] Ensure token is refreshed at least once a day before
 syncing.

---
 .../Feedly/FeedlyAccountDelegate.swift        | 26 ++++++++++++-------
 .../FeedlyAddNewFeedOperation.swift           |  2 +-
 .../FeedlyGetUpdatedArticleIdsOperation.swift |  4 +--
 ...edlyIngestStarredArticleIdsOperation.swift |  4 +--
 ...eedlyIngestStreamArticleIdsOperation.swift |  4 +--
 ...eedlyIngestUnreadArticleIdsOperation.swift |  4 +--
 .../FeedlyRefreshAccessTokenOperation.swift   |  1 +
 .../Operations/FeedlySyncAllOperation.swift   | 16 ++++++------
 8 files changed, 34 insertions(+), 27 deletions(-)

diff --git a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift
index 369e237f8..184ea576b 100644
--- a/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift
+++ b/Frameworks/Account/Feedly/FeedlyAccountDelegate.swift
@@ -111,11 +111,20 @@ final class FeedlyAccountDelegate: AccountDelegate {
 		}
 		
 		let log = self.log
-		let operation = FeedlySyncAllOperation(account: account, credentials: credentials, caller: caller, database: database, lastSuccessfulFetchStartDate: accountMetadata?.lastArticleFetchStartTime, downloadProgress: refreshProgress, log: log)
 		
-		operation.downloadProgress = refreshProgress
+		let refreshAccessToken = FeedlyRefreshAccessTokenOperation(account: account, service: self, oauthClient: oauthAuthorizationClient, refreshDate: Date(), log: log)
+		refreshAccessToken.downloadProgress = refreshProgress
+		operationQueue.add(refreshAccessToken)
+		
+		let syncAllOperation = FeedlySyncAllOperation(account: account, feedlyUserId: credentials.username, caller: caller, database: database, lastSuccessfulFetchStartDate: accountMetadata?.lastArticleFetchStartTime, downloadProgress: refreshProgress, log: log)
+		
+		syncAllOperation.downloadProgress = refreshProgress
+		
+		// Ensure the sync uses the latest credential.
+		syncAllOperation.addDependency(refreshAccessToken)
+		
 		let date = Date()
-		operation.syncCompletionHandler = { [weak self] result in
+		syncAllOperation.syncCompletionHandler = { [weak self] result in
 			if case .success = result {
 				self?.accountMetadata?.lastArticleFetchStartTime = date
 				self?.accountMetadata?.lastArticleFetchEndTime = Date()
@@ -125,9 +134,9 @@ final class FeedlyAccountDelegate: AccountDelegate {
 			completion(result)
 		}
 		
-		currentSyncAllOperation = operation
+		currentSyncAllOperation = syncAllOperation
 		
-		operationQueue.add(operation)
+		operationQueue.add(syncAllOperation)
 	}
 	
 	func sendArticleStatus(for account: Account, completion: @escaping ((Result<Void, Error>) -> Void)) {
@@ -155,7 +164,7 @@ final class FeedlyAccountDelegate: AccountDelegate {
 		
 		let group = DispatchGroup()
 		
-		let ingestUnread = FeedlyIngestUnreadArticleIdsOperation(account: account, credentials: credentials, service: caller, database: database, newerThan: nil, log: log)
+		let ingestUnread = FeedlyIngestUnreadArticleIdsOperation(account: account, userId: credentials.username, service: caller, database: database, newerThan: nil, log: log)
 		
 		group.enter()
 		ingestUnread.completionBlock = { _ in
@@ -163,7 +172,7 @@ final class FeedlyAccountDelegate: AccountDelegate {
 			
 		}
 		
-		let ingestStarred = FeedlyIngestStarredArticleIdsOperation(account: account, credentials: credentials, service: caller, database: database, newerThan: nil, log: log)
+		let ingestStarred = FeedlyIngestStarredArticleIdsOperation(account: account, userId: credentials.username, service: caller, database: database, newerThan: nil, log: log)
 		
 		group.enter()
 		ingestStarred.completionBlock = { _ in
@@ -492,9 +501,6 @@ final class FeedlyAccountDelegate: AccountDelegate {
 	
 	func accountDidInitialize(_ account: Account) {
 		credentials = try? account.retrieveCredentials(type: .oauthAccessToken)
-		
-		let refreshAccessToken = FeedlyRefreshAccessTokenOperation(account: account, service: self, oauthClient: oauthAuthorizationClient, log: log)
-		operationQueue.add(refreshAccessToken)
 	}
 	
 	func accountWillBeDeleted(_ account: Account) {
diff --git a/Frameworks/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift
index e3641511e..d4852c2f4 100644
--- a/Frameworks/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift
+++ b/Frameworks/Account/Feedly/Operations/FeedlyAddNewFeedOperation.swift
@@ -89,7 +89,7 @@ class FeedlyAddNewFeedOperation: FeedlyOperation, FeedlyOperationDelegate, Feedl
 		createFeeds.downloadProgress = downloadProgress
 		operationQueue.add(createFeeds)
 		
-		let syncUnread = FeedlyIngestUnreadArticleIdsOperation(account: account, credentials: credentials, service: syncUnreadIdsService, database: database, newerThan: nil, log: log)
+		let syncUnread = FeedlyIngestUnreadArticleIdsOperation(account: account, userId: credentials.username, service: syncUnreadIdsService, database: database, newerThan: nil, log: log)
 		syncUnread.addDependency(createFeeds)
 		syncUnread.downloadProgress = downloadProgress
 		syncUnread.delegate = self
diff --git a/Frameworks/Account/Feedly/Operations/FeedlyGetUpdatedArticleIdsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyGetUpdatedArticleIdsOperation.swift
index 1ed7a2c10..43ce44643 100644
--- a/Frameworks/Account/Feedly/Operations/FeedlyGetUpdatedArticleIdsOperation.swift
+++ b/Frameworks/Account/Feedly/Operations/FeedlyGetUpdatedArticleIdsOperation.swift
@@ -29,8 +29,8 @@ class FeedlyGetUpdatedArticleIdsOperation: FeedlyOperation, FeedlyEntryIdentifie
 		self.log = log
 	}
 	
-	convenience init(account: Account, credentials: Credentials, service: FeedlyGetStreamIdsService, newerThan: Date?, log: OSLog) {
-		let all = FeedlyCategoryResourceId.Global.all(for: credentials.username)
+	convenience init(account: Account, userId: String, service: FeedlyGetStreamIdsService, newerThan: Date?, log: OSLog) {
+		let all = FeedlyCategoryResourceId.Global.all(for: userId)
 		self.init(account: account, resource: all, service: service, newerThan: newerThan, log: log)
 	}
 	
diff --git a/Frameworks/Account/Feedly/Operations/FeedlyIngestStarredArticleIdsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyIngestStarredArticleIdsOperation.swift
index 02dc42043..7793315e0 100644
--- a/Frameworks/Account/Feedly/Operations/FeedlyIngestStarredArticleIdsOperation.swift
+++ b/Frameworks/Account/Feedly/Operations/FeedlyIngestStarredArticleIdsOperation.swift
@@ -25,8 +25,8 @@ final class FeedlyIngestStarredArticleIdsOperation: FeedlyOperation {
 	private var remoteEntryIds = Set<String>()
 	private let log: OSLog
 	
-	convenience init(account: Account, credentials: Credentials, service: FeedlyGetStreamIdsService, database: SyncDatabase, newerThan: Date?, log: OSLog) {
-		let resource = FeedlyTagResourceId.Global.saved(for: credentials.username)
+	convenience init(account: Account, userId: String, service: FeedlyGetStreamIdsService, database: SyncDatabase, newerThan: Date?, log: OSLog) {
+		let resource = FeedlyTagResourceId.Global.saved(for: userId)
 		self.init(account: account, resource: resource, service: service, database: database, newerThan: newerThan, log: log)
 	}
 	
diff --git a/Frameworks/Account/Feedly/Operations/FeedlyIngestStreamArticleIdsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyIngestStreamArticleIdsOperation.swift
index 12b906d12..8e7716159 100644
--- a/Frameworks/Account/Feedly/Operations/FeedlyIngestStreamArticleIdsOperation.swift
+++ b/Frameworks/Account/Feedly/Operations/FeedlyIngestStreamArticleIdsOperation.swift
@@ -28,8 +28,8 @@ class FeedlyIngestStreamArticleIdsOperation: FeedlyOperation {
 		self.log = log
 	}
 	
-	convenience init(account: Account, credentials: Credentials, service: FeedlyGetStreamIdsService, log: OSLog) {
-		let all = FeedlyCategoryResourceId.Global.all(for: credentials.username)
+	convenience init(account: Account, userId: String, service: FeedlyGetStreamIdsService, log: OSLog) {
+		let all = FeedlyCategoryResourceId.Global.all(for: userId)
 		self.init(account: account, resource: all, service: service, log: log)
 	}
 	
diff --git a/Frameworks/Account/Feedly/Operations/FeedlyIngestUnreadArticleIdsOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyIngestUnreadArticleIdsOperation.swift
index 669a9672d..271caec65 100644
--- a/Frameworks/Account/Feedly/Operations/FeedlyIngestUnreadArticleIdsOperation.swift
+++ b/Frameworks/Account/Feedly/Operations/FeedlyIngestUnreadArticleIdsOperation.swift
@@ -26,8 +26,8 @@ final class FeedlyIngestUnreadArticleIdsOperation: FeedlyOperation {
 	private var remoteEntryIds = Set<String>()
 	private let log: OSLog
 	
-	convenience init(account: Account, credentials: Credentials, service: FeedlyGetStreamIdsService, database: SyncDatabase, newerThan: Date?, log: OSLog) {
-		let resource = FeedlyCategoryResourceId.Global.all(for: credentials.username)
+	convenience init(account: Account, userId: String, service: FeedlyGetStreamIdsService, database: SyncDatabase, newerThan: Date?, log: OSLog) {
+		let resource = FeedlyCategoryResourceId.Global.all(for: userId)
 		self.init(account: account, resource: resource, service: service, database: database, newerThan: newerThan, log: log)
 	}
 	
diff --git a/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift
index d6412f202..b4878dc49 100644
--- a/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift
+++ b/Frameworks/Account/Feedly/Operations/FeedlyRefreshAccessTokenOperation.swift
@@ -38,6 +38,7 @@ final class FeedlyRefreshAccessTokenOperation: FeedlyOperation {
 		}()
 		
 		guard shouldRefresh else {
+			os_log(.debug, log: log, "Skipping access token renewal.")
 			didFinish()
 			return
 		}
diff --git a/Frameworks/Account/Feedly/Operations/FeedlySyncAllOperation.swift b/Frameworks/Account/Feedly/Operations/FeedlySyncAllOperation.swift
index b0c87e027..6a2e36a76 100644
--- a/Frameworks/Account/Feedly/Operations/FeedlySyncAllOperation.swift
+++ b/Frameworks/Account/Feedly/Operations/FeedlySyncAllOperation.swift
@@ -33,7 +33,7 @@ final class FeedlySyncAllOperation: FeedlyOperation {
 	///
 	/// Download articles for statuses at the union of those statuses without its corresponding article and those included in 3 (changed since last successful sync).
 	///
-	init(account: Account, credentials: Credentials, lastSuccessfulFetchStartDate: Date?, markArticlesService: FeedlyMarkArticlesService, getUnreadService: FeedlyGetStreamIdsService, getCollectionsService: FeedlyGetCollectionsService, getStreamContentsService: FeedlyGetStreamContentsService, getStarredService: FeedlyGetStreamIdsService, getStreamIdsService: FeedlyGetStreamIdsService, getEntriesService: FeedlyGetEntriesService, database: SyncDatabase, downloadProgress: DownloadProgress, log: OSLog) {
+	init(account: Account, feedlyUserId: String, lastSuccessfulFetchStartDate: Date?, markArticlesService: FeedlyMarkArticlesService, getUnreadService: FeedlyGetStreamIdsService, getCollectionsService: FeedlyGetCollectionsService, getStreamContentsService: FeedlyGetStreamContentsService, getStarredService: FeedlyGetStreamIdsService, getStreamIdsService: FeedlyGetStreamIdsService, getEntriesService: FeedlyGetEntriesService, database: SyncDatabase, downloadProgress: DownloadProgress, log: OSLog) {
 		self.syncUUID = UUID()
 		self.log = log
 		self.operationQueue.suspend()
@@ -53,7 +53,7 @@ final class FeedlySyncAllOperation: FeedlyOperation {
 		getCollections.delegate = self
 		getCollections.downloadProgress = downloadProgress
 		getCollections.addDependency(sendArticleStatuses)
-			self.operationQueue.add(getCollections)
+		self.operationQueue.add(getCollections)
 		
 		// Ensure a folder exists for each Collection, removing Folders without a corresponding Collection.
 		let mirrorCollectionsAsFolders = FeedlyMirrorCollectionsAsFoldersOperation(account: account, collectionsProvider: getCollections, log: log)
@@ -67,14 +67,14 @@ final class FeedlySyncAllOperation: FeedlyOperation {
 		createFeedsOperation.addDependency(mirrorCollectionsAsFolders)
 		self.operationQueue.add(createFeedsOperation)
 		
-		let getAllArticleIds = FeedlyIngestStreamArticleIdsOperation(account: account, credentials: credentials, service: getStreamIdsService, log: log)
+		let getAllArticleIds = FeedlyIngestStreamArticleIdsOperation(account: account, userId: feedlyUserId, service: getStreamIdsService, log: log)
 		getAllArticleIds.delegate = self
 		getAllArticleIds.downloadProgress = downloadProgress
 		getAllArticleIds.addDependency(createFeedsOperation)
 		self.operationQueue.add(getAllArticleIds)
 		
 		// Get each page of unread article ids in the global.all stream for the last 31 days (nil = Feedly API default).
-		let getUnread = FeedlyIngestUnreadArticleIdsOperation(account: account, credentials: credentials, service: getUnreadService, database: database, newerThan: nil, log: log)
+		let getUnread = FeedlyIngestUnreadArticleIdsOperation(account: account, userId: feedlyUserId, service: getUnreadService, database: database, newerThan: nil, log: log)
 		getUnread.delegate = self
 		getUnread.addDependency(getAllArticleIds)
 		getUnread.downloadProgress = downloadProgress
@@ -82,14 +82,14 @@ final class FeedlySyncAllOperation: FeedlyOperation {
 		
 		// Get each page of the article ids which have been update since the last successful fetch start date.
 		// If the date is nil, this operation provides an empty set (everything is new, nothing is updated).
-		let getUpdated = FeedlyGetUpdatedArticleIdsOperation(account: account, credentials: credentials, service: getStreamIdsService, newerThan: lastSuccessfulFetchStartDate, log: log)
+		let getUpdated = FeedlyGetUpdatedArticleIdsOperation(account: account, userId: feedlyUserId, service: getStreamIdsService, newerThan: lastSuccessfulFetchStartDate, log: log)
 		getUpdated.delegate = self
 		getUpdated.downloadProgress = downloadProgress
 		getUpdated.addDependency(createFeedsOperation)
 		self.operationQueue.add(getUpdated)
 		
 		// Get each page of the article ids for starred articles.
-		let getStarred = FeedlyIngestStarredArticleIdsOperation(account: account, credentials: credentials, service: getStarredService, database: database, newerThan: nil, log: log)
+		let getStarred = FeedlyIngestStarredArticleIdsOperation(account: account, userId: feedlyUserId, service: getStarredService, database: database, newerThan: nil, log: log)
 		getStarred.delegate = self
 		getStarred.downloadProgress = downloadProgress
 		getStarred.addDependency(createFeedsOperation)
@@ -125,8 +125,8 @@ final class FeedlySyncAllOperation: FeedlyOperation {
 		self.operationQueue.add(finishOperation)
 	}
 	
-	convenience init(account: Account, credentials: Credentials, caller: FeedlyAPICaller, database: SyncDatabase, lastSuccessfulFetchStartDate: Date?, downloadProgress: DownloadProgress, log: OSLog) {
-		self.init(account: account, credentials: credentials, lastSuccessfulFetchStartDate: lastSuccessfulFetchStartDate, markArticlesService: caller, getUnreadService: caller, getCollectionsService: caller, getStreamContentsService: caller, getStarredService: caller, getStreamIdsService: caller, getEntriesService: caller, database: database, downloadProgress: downloadProgress, log: log)
+	convenience init(account: Account, feedlyUserId: String, caller: FeedlyAPICaller, database: SyncDatabase, lastSuccessfulFetchStartDate: Date?, downloadProgress: DownloadProgress, log: OSLog) {
+		self.init(account: account, feedlyUserId: feedlyUserId, lastSuccessfulFetchStartDate: lastSuccessfulFetchStartDate, markArticlesService: caller, getUnreadService: caller, getCollectionsService: caller, getStreamContentsService: caller, getStarredService: caller, getStreamIdsService: caller, getEntriesService: caller, database: database, downloadProgress: downloadProgress, log: log)
 	}
 	
 	override func run() {

From d2812fa1321defde8febd8098b41ff2a67cca82a Mon Sep 17 00:00:00 2001
From: Brent Simmons <brent@ranchero.com>
Date: Sat, 18 Apr 2020 16:59:13 -0700
Subject: [PATCH 3/9] Delete old statuses at startup.

---
 .../ArticlesDatabase/ArticlesDatabase.swift   |  1 +
 .../ArticlesDatabase/ArticlesTable.swift      | 26 ++++++++++++++++++-
 2 files changed, 26 insertions(+), 1 deletion(-)

diff --git a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift
index aac8852db..4b16049d8 100644
--- a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift
+++ b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift
@@ -273,6 +273,7 @@ public final class ArticlesDatabase {
 			articlesTable.deleteOldArticles()
 		}
 		articlesTable.deleteArticlesNotInSubscribedToFeedIDs(subscribedToWebFeedIDs)
+		articlesTable.deleteOldStatuses()
 	}
 }
 
diff --git a/Frameworks/ArticlesDatabase/ArticlesTable.swift b/Frameworks/ArticlesDatabase/ArticlesTable.swift
index e87f699e8..68a31189f 100644
--- a/Frameworks/ArticlesDatabase/ArticlesTable.swift
+++ b/Frameworks/ArticlesDatabase/ArticlesTable.swift
@@ -493,7 +493,7 @@ final class ArticlesTable: DatabaseTable {
 	/// Because deleting articles might block the database for too long,
 	/// we do this in a careful way: delete articles older than a year,
 	/// check to see how much time has passed, then decide whether or not to continue.
-	/// Repeat for successively shorter time intervals.
+	/// Repeat for successively more-recent dates.
 	func deleteOldArticles() {
 		precondition(retentionStyle == .syncSystem)
 
@@ -525,6 +525,30 @@ final class ArticlesTable: DatabaseTable {
 		}
 	}
 
+	/// Delete old statuses.
+	func deleteOldStatuses() {
+		queue.runInTransaction { databaseResult in
+			guard let database = databaseResult.database else {
+				return
+			}
+
+			let sql: String
+			let cutoffDate: Date
+			
+			switch self.retentionStyle {
+			case .syncSystem:
+				sql = "select articleID from statuses where dateArrived<? and read=1 and starred=0 and articleID not in (select articleID from articles);"
+				cutoffDate = Date().bySubtracting(days: 180)
+			case .feedBased:
+				sql = "select articleID from statuses where dateArrived<? and starred=0 and articleID not in (select articleID from articles);"
+				cutoffDate = Date().bySubtracting(days: 30)
+			}
+
+			let parameters = [cutoffDate] as [Any]
+			database.executeUpdate(sql, withArgumentsIn: parameters)
+		}
+	}
+
 	/// Delete articles from feeds that are no longer in the current set of subscribed-to feeds.
 	/// This deletes from the articles and articleStatuses tables,
 	/// and, via a trigger, it also deletes from the search index.

From b2b000dd2e581b6417be23c811c297987e562cc9 Mon Sep 17 00:00:00 2001
From: Brent Simmons <brent@ranchero.com>
Date: Sat, 18 Apr 2020 20:18:32 -0700
Subject: [PATCH 4/9] Implement updated retention policy.

---
 .../ArticlesDatabase/ArticlesDatabase.swift   | 12 +++--
 .../ArticlesDatabase/ArticlesTable.swift      | 48 ++++++-------------
 .../FetchAllUnreadCountsOperation.swift       |  8 ++--
 .../FetchFeedUnreadCountOperation.swift       |  4 +-
 .../FetchUnreadCountsForFeedsOperation.swift  | 10 ++--
 5 files changed, 30 insertions(+), 52 deletions(-)

diff --git a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift
index 4b16049d8..d0f3c424c 100644
--- a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift
+++ b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift
@@ -146,7 +146,7 @@ public final class ArticlesDatabase {
 
 	/// Fetch all non-zero unread counts.
 	public func fetchAllUnreadCounts(_ completion: @escaping UnreadCountDictionaryCompletionBlock) {
-		let operation = FetchAllUnreadCountsOperation(databaseQueue: queue, cutoffDate: articlesTable.articleCutoffDate)
+		let operation = FetchAllUnreadCountsOperation(databaseQueue: queue)
 		operationQueue.cancelOperations(named: operation.name!)
 		operation.completionBlock = { operation in
 			let fetchOperation = operation as! FetchAllUnreadCountsOperation
@@ -167,7 +167,7 @@ public final class ArticlesDatabase {
 
 	/// Fetch non-zero unread counts for given webFeedIDs.
 	public func fetchUnreadCounts(for webFeedIDs: Set<String>, _ completion: @escaping UnreadCountDictionaryCompletionBlock) {
-		let operation = FetchUnreadCountsForFeedsOperation(webFeedIDs: webFeedIDs, databaseQueue: queue, cutoffDate: articlesTable.articleCutoffDate)
+		let operation = FetchUnreadCountsForFeedsOperation(webFeedIDs: webFeedIDs, databaseQueue: queue)
 		operation.completionBlock = { operation in
 			let fetchOperation = operation as! FetchUnreadCountsForFeedsOperation
 			completion(fetchOperation.result)
@@ -265,9 +265,11 @@ public final class ArticlesDatabase {
 
 	// MARK: - Cleanup
 
-	// These are to be used only at startup. These are to prevent the database from growing forever.
-
-	/// Calls the various clean-up functions.
+	/// Calls the various clean-up functions. To be used only at startup.
+	///
+	/// This prevents the database from growing forever. If we didn’t do this:
+	/// 1) The database would grow to an inordinate size, and
+	/// 2) the app would become very slow.
 	public func cleanupDatabaseAtStartup(subscribedToWebFeedIDs: Set<String>) {
 		if retentionStyle == .syncSystem {
 			articlesTable.deleteOldArticles()
diff --git a/Frameworks/ArticlesDatabase/ArticlesTable.swift b/Frameworks/ArticlesDatabase/ArticlesTable.swift
index 68a31189f..635d1d9b3 100644
--- a/Frameworks/ArticlesDatabase/ArticlesTable.swift
+++ b/Frameworks/ArticlesDatabase/ArticlesTable.swift
@@ -181,7 +181,7 @@ final class ArticlesTable: DatabaseTable {
 
 		// 1. Ensure statuses for all the incoming articles.
 		// 2. Create incoming articles with parsedItems.
-		// 3. [Deleted - no longer needed]
+		// 3. [Deleted - this step is no longer needed]
 		// 4. Fetch all articles for the feed.
 		// 5. Create array of Articles not in database and save them.
 		// 6. Create array of updated Articles and save what’s changed.
@@ -494,6 +494,8 @@ final class ArticlesTable: DatabaseTable {
 	/// we do this in a careful way: delete articles older than a year,
 	/// check to see how much time has passed, then decide whether or not to continue.
 	/// Repeat for successively more-recent dates.
+	///
+	/// Returns `true` if it deleted old articles all the way up to the 90 day cutoff date.
 	func deleteOldArticles() {
 		precondition(retentionStyle == .syncSystem)
 
@@ -537,10 +539,10 @@ final class ArticlesTable: DatabaseTable {
 			
 			switch self.retentionStyle {
 			case .syncSystem:
-				sql = "select articleID from statuses where dateArrived<? and read=1 and starred=0 and articleID not in (select articleID from articles);"
+				sql = "delete from statuses where dateArrived<? and read=1 and starred=0 and articleID not in (select articleID from articles);"
 				cutoffDate = Date().bySubtracting(days: 180)
 			case .feedBased:
-				sql = "select articleID from statuses where dateArrived<? and starred=0 and articleID not in (select articleID from articles);"
+				sql = "delete from statuses where dateArrived<? and starred=0 and articleID not in (select articleID from articles);"
 				cutoffDate = Date().bySubtracting(days: 30)
 			}
 
@@ -672,31 +674,11 @@ private extension ArticlesTable {
 		return cachedArticles.union(articlesWithFetchedAuthors)
 	}
 
-	func fetchArticlesWithWhereClause(_ database: FMDatabase, whereClause: String, parameters: [AnyObject], withLimits: Bool) -> Set<Article> {
-		// Don’t fetch articles that shouldn’t appear in the UI. The rules:
-		// * Must not be deleted.
-		// * Must be either 1) starred or 2) dateArrived must be newer than cutoff date.
-
-		if withLimits {
-			let sql = "select * from articles natural join statuses where \(whereClause) and (starred=1 or dateArrived>?);"
-			return articlesWithSQL(sql, parameters + [articleCutoffDate as AnyObject], database)
-		}
-		else {
-			let sql = "select * from articles natural join statuses where \(whereClause);"
-			return articlesWithSQL(sql, parameters, database)
-		}
+	func fetchArticlesWithWhereClause(_ database: FMDatabase, whereClause: String, parameters: [AnyObject]) -> Set<Article> {
+		let sql = "select * from articles natural join statuses where \(whereClause);"
+		return articlesWithSQL(sql, parameters, database)
 	}
 
-//	func fetchUnreadCount(_ webFeedID: String, _ database: FMDatabase) -> Int {
-//		// Count only the articles that would appear in the UI.
-//		// * Must be unread.
-//		// * Must not be deleted.
-//		// * Must be either 1) starred or 2) dateArrived must be newer than cutoff date.
-//
-//		let sql = "select count(*) from articles natural join statuses where feedID=? and read=0 and (starred=1 or dateArrived>?);"
-//		return numberWithSQLAndParameters(sql, [webFeedID, articleCutoffDate], in: database)
-//	}
-	
 	func fetchArticlesMatching(_ searchString: String, _ database: FMDatabase) -> Set<Article> {
 		let sql = "select rowid from search where search match ?;"
 		let sqlSearchString = sqliteSearchString(with: searchString)
@@ -712,7 +694,7 @@ private extension ArticlesTable {
 		let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(searchRowIDs.count))!
 		let whereClause = "searchRowID in \(placeholders)"
 		let parameters: [AnyObject] = Array(searchRowIDs) as [AnyObject]
-		return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: true)
+		return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
 	}
 
 	func sqliteSearchString(with searchString: String) -> String {
@@ -785,7 +767,7 @@ private extension ArticlesTable {
 		let parameters = webFeedIDs.map { $0 as AnyObject }
 		let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(webFeedIDs.count))!
 		let whereClause = "feedID in \(placeholders)"
-		return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: true)
+		return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
 	}
 
 	func fetchUnreadArticles(_ webFeedIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
@@ -796,11 +778,11 @@ private extension ArticlesTable {
 		let parameters = webFeedIDs.map { $0 as AnyObject }
 		let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(webFeedIDs.count))!
 		let whereClause = "feedID in \(placeholders) and read=0"
-		return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: true)
+		return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
 	}
 
 	func fetchArticlesForFeedID(_ webFeedID: String, withLimits: Bool, _ database: FMDatabase) -> Set<Article> {
-		return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [webFeedID as AnyObject], withLimits: withLimits)
+		return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [webFeedID as AnyObject])
 	}
 
 	func fetchArticles(articleIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
@@ -810,7 +792,7 @@ private extension ArticlesTable {
 		let parameters = articleIDs.map { $0 as AnyObject }
 		let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(articleIDs.count))!
 		let whereClause = "articleID in \(placeholders)"
-		return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: false)
+		return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
 	}
 
 	func fetchArticlesSince(_ webFeedIDs: Set<String>, _ cutoffDate: Date, _ database: FMDatabase) -> Set<Article> {
@@ -823,7 +805,7 @@ private extension ArticlesTable {
 		let parameters = webFeedIDs.map { $0 as AnyObject } + [cutoffDate as AnyObject, cutoffDate as AnyObject]
 		let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(webFeedIDs.count))!
 		let whereClause = "feedID in \(placeholders) and (datePublished > ? or (datePublished is null and dateArrived > ?))"
-		return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: false)
+		return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
 	}
 
 	func fetchStarredArticles(_ webFeedIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
@@ -834,7 +816,7 @@ private extension ArticlesTable {
 		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 fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: false)
+		return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters)
 		}
 
 	func fetchArticlesMatching(_ searchString: String, _ webFeedIDs: Set<String>, _ database: FMDatabase) -> Set<Article> {
diff --git a/Frameworks/ArticlesDatabase/Operations/FetchAllUnreadCountsOperation.swift b/Frameworks/ArticlesDatabase/Operations/FetchAllUnreadCountsOperation.swift
index 2f82d2549..04c77d955 100644
--- a/Frameworks/ArticlesDatabase/Operations/FetchAllUnreadCountsOperation.swift
+++ b/Frameworks/ArticlesDatabase/Operations/FetchAllUnreadCountsOperation.swift
@@ -22,11 +22,9 @@ public final class FetchAllUnreadCountsOperation: MainThreadOperation {
 	public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
 
 	private let queue: DatabaseQueue
-	private let cutoffDate: Date
 
-	init(databaseQueue: DatabaseQueue, cutoffDate: Date) {
+	init(databaseQueue: DatabaseQueue) {
 		self.queue = databaseQueue
-		self.cutoffDate = cutoffDate
 	}
 	
 	public func run() {
@@ -49,9 +47,9 @@ public final class FetchAllUnreadCountsOperation: MainThreadOperation {
 private extension FetchAllUnreadCountsOperation {
 
 	func fetchUnreadCounts(_ database: FMDatabase) {
-		let sql = "select distinct feedID, count(*) from articles natural join statuses where read=0 and (starred=1 or dateArrived>?) group by feedID;"
+		let sql = "select distinct feedID, count(*) from articles natural join statuses where read=0 group by feedID;"
 
-		guard let resultSet = database.executeQuery(sql, withArgumentsIn: [cutoffDate]) else {
+		guard let resultSet = database.executeQuery(sql, withArgumentsIn: nil) else {
 			informOperationDelegateOfCompletion()
 			return
 		}
diff --git a/Frameworks/ArticlesDatabase/Operations/FetchFeedUnreadCountOperation.swift b/Frameworks/ArticlesDatabase/Operations/FetchFeedUnreadCountOperation.swift
index 4f8c99504..289bb73e6 100644
--- a/Frameworks/ArticlesDatabase/Operations/FetchFeedUnreadCountOperation.swift
+++ b/Frameworks/ArticlesDatabase/Operations/FetchFeedUnreadCountOperation.swift
@@ -52,9 +52,9 @@ public final class FetchFeedUnreadCountOperation: MainThreadOperation {
 private extension FetchFeedUnreadCountOperation {
 
 	func fetchUnreadCount(_ database: FMDatabase) {
-		let sql = "select count(*) from articles natural join statuses where feedID=? and read=0 and (starred=1 or dateArrived>?);"
+		let sql = "select count(*) from articles natural join statuses where feedID=? and read=0;"
 
-		guard let resultSet = database.executeQuery(sql, withArgumentsIn: [webFeedID, cutoffDate]) else {
+		guard let resultSet = database.executeQuery(sql, withArgumentsIn: [webFeedID]) else {
 			informOperationDelegateOfCompletion()
 			return
 		}
diff --git a/Frameworks/ArticlesDatabase/Operations/FetchUnreadCountsForFeedsOperation.swift b/Frameworks/ArticlesDatabase/Operations/FetchUnreadCountsForFeedsOperation.swift
index b323f4e4f..7f7533b77 100644
--- a/Frameworks/ArticlesDatabase/Operations/FetchUnreadCountsForFeedsOperation.swift
+++ b/Frameworks/ArticlesDatabase/Operations/FetchUnreadCountsForFeedsOperation.swift
@@ -23,13 +23,11 @@ public final class FetchUnreadCountsForFeedsOperation: MainThreadOperation {
 	public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
 
 	private let queue: DatabaseQueue
-	private let cutoffDate: Date
 	private let webFeedIDs: Set<String>
 
-	init(webFeedIDs: Set<String>, databaseQueue: DatabaseQueue, cutoffDate: Date) {
+	init(webFeedIDs: Set<String>, databaseQueue: DatabaseQueue) {
 		self.webFeedIDs = webFeedIDs
 		self.queue = databaseQueue
-		self.cutoffDate = cutoffDate
 	}
 
 	public func run() {
@@ -53,11 +51,9 @@ private extension FetchUnreadCountsForFeedsOperation {
 
 	func fetchUnreadCounts(_ database: FMDatabase) {
 		let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(webFeedIDs.count))!
-		let sql = "select distinct feedID, count(*) from articles natural join statuses where feedID in \(placeholders) and read=0 and (starred=1 or dateArrived>?) group by feedID;"
+		let sql = "select distinct feedID, count(*) from articles natural join statuses where feedID in \(placeholders) and read=0 group by feedID;"
 
-		var parameters = [Any]()
-		parameters += Array(webFeedIDs) as [Any]
-		parameters += [cutoffDate] as [Any]
+		let parameters = Array(webFeedIDs) as [Any]
 
 		guard let resultSet = database.executeQuery(sql, withArgumentsIn: parameters) else {
 			informOperationDelegateOfCompletion()

From 75d2158163a5eb6be74d109d9ef316c7fa53805a Mon Sep 17 00:00:00 2001
From: Brent Simmons <brent@ranchero.com>
Date: Sun, 19 Apr 2020 14:10:12 -0700
Subject: [PATCH 5/9] =?UTF-8?q?Perform=20a=20one-time=20(per=20local=20acc?=
 =?UTF-8?q?ount)=20cleanup=20made=20necessary=20by=20the=20retention=20pol?=
 =?UTF-8?q?icy=20change=20=E2=80=94=C2=A0mark=20articles=20older=20than=20?=
 =?UTF-8?q?the=2090-day=20window=20as=20read.=20This=20way=20users=20won?=
 =?UTF-8?q?=E2=80=99t=20get=20a=20flood=20of=20old,=20unread=20articles=20?=
 =?UTF-8?q?when=20they=20run=20this=20new=20version.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 Frameworks/Account/Account.swift               | 12 ++++++++++++
 Frameworks/Account/AccountMetadata.swift       |  9 +++++++++
 .../ArticlesDatabase/ArticlesDatabase.swift    | 18 ++++++++++++++++++
 .../ArticlesDatabase/ArticlesTable.swift       | 16 ++++++++++++++++
 4 files changed, 55 insertions(+)

diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift
index 812ad9693..9824386d4 100644
--- a/Frameworks/Account/Account.swift
+++ b/Frameworks/Account/Account.swift
@@ -279,7 +279,19 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container,
 		webFeedMetadataFile.load()
 		opmlFile.load()
 
+		var shouldHandleRetentionPolicyChange = false
+		if type == .onMyMac {
+			let didHandlePolicyChange = metadata.performedApril2020RetentionPolicyChange ?? false
+			shouldHandleRetentionPolicyChange = !didHandlePolicyChange
+		}
+
 		DispatchQueue.main.async {
+			if shouldHandleRetentionPolicyChange {
+				// Handle one-time database changes made necessary by April 2020 retention policy change.
+				self.database.performApril2020RetentionPolicyChange()
+				self.metadata.performedApril2020RetentionPolicyChange = true
+			}
+
 			self.database.cleanupDatabaseAtStartup(subscribedToWebFeedIDs: self.flattenedWebFeeds().webFeedIDs())
 			self.fetchAllUnreadCounts()
 		}
diff --git a/Frameworks/Account/AccountMetadata.swift b/Frameworks/Account/AccountMetadata.swift
index f4c50ab44..9f5845a64 100644
--- a/Frameworks/Account/AccountMetadata.swift
+++ b/Frameworks/Account/AccountMetadata.swift
@@ -24,6 +24,7 @@ final class AccountMetadata: Codable {
 		case lastArticleFetchEndTime
 		case endpointURL
 		case lastCredentialRenewTime = "lastCredentialRenewTime"
+		case performedApril2020RetentionPolicyChange
 	}
 
 	var name: String? {
@@ -92,6 +93,14 @@ final class AccountMetadata: Codable {
 		}
 	}
 
+	var performedApril2020RetentionPolicyChange: Bool? {
+		didSet {
+			if performedApril2020RetentionPolicyChange != oldValue {
+				valueDidChange(.performedApril2020RetentionPolicyChange)
+			}
+		}
+	}
+
 	weak var delegate: AccountMetadataDelegate?
 	
 	func valueDidChange(_ key: CodingKeys) {
diff --git a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift
index d0f3c424c..0ee215866 100644
--- a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift
+++ b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift
@@ -277,6 +277,24 @@ public final class ArticlesDatabase {
 		articlesTable.deleteArticlesNotInSubscribedToFeedIDs(subscribedToWebFeedIDs)
 		articlesTable.deleteOldStatuses()
 	}
+
+	/// Do database cleanups made necessary by the retention policy change in April 2020.
+	///
+	/// The retention policy for feed-based systems changed in April 2020:
+	/// we keep articles only for as long as they’re in the feed.
+	/// This change could result in a bunch of older articles suddenly
+	/// appearing as unread articles.
+	///
+	/// These are articles that were in the database,
+	/// but weren’t appearing in the UI because they were beyond the 90-day window.
+	/// (The previous retention policy used a 90-day window.)
+	///
+	/// This function marks everything as read that’s beyond that 90-day window.
+	/// It’s intended to be called only once on an account.
+	public func performApril2020RetentionPolicyChange() {
+		precondition(retentionStyle == .feedBased)
+		articlesTable.markOlderStatusesAsRead()
+	}
 }
 
 // MARK: - Private
diff --git a/Frameworks/ArticlesDatabase/ArticlesTable.swift b/Frameworks/ArticlesDatabase/ArticlesTable.swift
index 635d1d9b3..ff36a8f9b 100644
--- a/Frameworks/ArticlesDatabase/ArticlesTable.swift
+++ b/Frameworks/ArticlesDatabase/ArticlesTable.swift
@@ -580,6 +580,22 @@ final class ArticlesTable: DatabaseTable {
 			}
 		}
 	}
+
+	/// Mark statuses beyond the 90-day window as read.
+	///
+	/// This is not intended for wide use: this is part of implementing
+	/// the April 2020 retention policy change for feed-based accounts.
+	func markOlderStatusesAsRead() {
+		queue.runInDatabase { databaseResult in
+			guard let database = databaseResult.database else {
+				return
+			}
+
+			let sql = "update statuses set read = true where dateArrived<?;"
+			let parameters = [self.articleCutoffDate] as [Any]
+			database.executeUpdate(sql, withArgumentsIn: parameters)
+		}
+	}
 }
 
 // MARK: - Private

From 02ee0b25631f47207110a2d91c0680e2d3e4529c Mon Sep 17 00:00:00 2001
From: Brent Simmons <brent@ranchero.com>
Date: Sun, 19 Apr 2020 19:12:34 -0700
Subject: [PATCH 6/9] Bump version to 5.0.1 build 40.

---
 xcconfig/common/NetNewsWire_ios_target_common.xcconfig | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/xcconfig/common/NetNewsWire_ios_target_common.xcconfig b/xcconfig/common/NetNewsWire_ios_target_common.xcconfig
index f7abdaac9..4a153915d 100644
--- a/xcconfig/common/NetNewsWire_ios_target_common.xcconfig
+++ b/xcconfig/common/NetNewsWire_ios_target_common.xcconfig
@@ -1,7 +1,7 @@
 
 // High Level Settings common to both the iOS application and any extensions we bundle with it
-MARKETING_VERSION = 5.0
-CURRENT_PROJECT_VERSION = 39
+MARKETING_VERSION = 5.0.1
+CURRENT_PROJECT_VERSION = 40
 
 ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES
 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon

From 7afcf681fecc12a1bb2e4f5a987d58eed8962ab6 Mon Sep 17 00:00:00 2001
From: Maurice Parker <mo@vincode.io>
Date: Tue, 21 Apr 2020 01:52:39 -0500
Subject: [PATCH 7/9] Stablize swipe back gesture for timeline.  Issue #2002

---
 iOS/UIKit Extensions/PoppableGestureRecognizerDelegate.swift | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/iOS/UIKit Extensions/PoppableGestureRecognizerDelegate.swift b/iOS/UIKit Extensions/PoppableGestureRecognizerDelegate.swift
index fa9cda81e..8df7295bd 100644
--- a/iOS/UIKit Extensions/PoppableGestureRecognizerDelegate.swift	
+++ b/iOS/UIKit Extensions/PoppableGestureRecognizerDelegate.swift	
@@ -18,7 +18,7 @@ final class PoppableGestureRecognizerDelegate: NSObject, UIGestureRecognizerDele
     }
 
     func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
-        return true
+		return navigationController?.viewControllers.count ?? 0 > 2
     }
 	
 }

From f7bdf9d47cc9afa3115298043fbfb20b0c9167d1 Mon Sep 17 00:00:00 2001
From: Maurice Parker <mo@vincode.io>
Date: Tue, 21 Apr 2020 01:57:19 -0500
Subject: [PATCH 8/9] Correct text resizing issues in color palette settings. 
 Issue #2000

---
 iOS/Settings/Settings.storyboard | 34 ++++++++++++++++----------------
 1 file changed, 17 insertions(+), 17 deletions(-)

diff --git a/iOS/Settings/Settings.storyboard b/iOS/Settings/Settings.storyboard
index 1ecb90a8e..7983e3cb4 100644
--- a/iOS/Settings/Settings.storyboard
+++ b/iOS/Settings/Settings.storyboard
@@ -1,8 +1,8 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="9cW-lu-HoC">
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="16096" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="9cW-lu-HoC">
     <device id="retina6_1" orientation="portrait" appearance="light"/>
     <dependencies>
-        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15509"/>
+        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
         <capability name="Named colors" minToolsVersion="9.0"/>
         <capability name="Safe area layout guides" minToolsVersion="9.0"/>
         <capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@@ -320,21 +320,21 @@
                             <tableViewSection headerTitle="Appearance" id="TkH-4v-yhk">
                                 <cells>
                                     <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" id="EvG-yE-gDF" customClass="VibrantBasicTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
-                                        <rect key="frame" x="0.0" y="819.5" width="374" height="44"/>
+                                        <rect key="frame" x="20" y="819.5" width="374" height="44"/>
                                         <autoresizingMask key="autoresizingMask"/>
                                         <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="EvG-yE-gDF" id="wBN-zJ-6pN">
                                             <rect key="frame" x="0.0" y="0.0" width="343" height="44"/>
                                             <autoresizingMask key="autoresizingMask"/>
                                             <subviews>
-                                                <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Color Palette" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="2Fp-li-dGP">
+                                                <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Color Palette" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="2Fp-li-dGP">
                                                     <rect key="frame" x="20" y="11.5" width="99" height="21"/>
-                                                    <fontDescription key="fontDescription" type="system" pointSize="17"/>
+                                                    <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
                                                     <nil key="textColor"/>
                                                     <nil key="highlightedColor"/>
                                                 </label>
-                                                <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Automatic" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="16m-Ns-Y8V">
+                                                <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Automatic" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="16m-Ns-Y8V">
                                                     <rect key="frame" x="257" y="11.5" width="78" height="21"/>
-                                                    <fontDescription key="fontDescription" type="system" pointSize="17"/>
+                                                    <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
                                                     <nil key="textColor"/>
                                                     <nil key="highlightedColor"/>
                                                 </label>
@@ -356,7 +356,7 @@
                             <tableViewSection headerTitle="Help" id="CS8-fJ-ghn">
                                 <cells>
                                     <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="uGk-2d-oFc" style="IBUITableViewCellStyleDefault" id="Tle-IV-D40" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
-                                        <rect key="frame" x="0.0" y="919.5" width="374" height="44"/>
+                                        <rect key="frame" x="20" y="919.5" width="374" height="44"/>
                                         <autoresizingMask key="autoresizingMask"/>
                                         <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Tle-IV-D40" id="IJD-ZB-8Wm">
                                             <rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
@@ -373,7 +373,7 @@
                                         </tableViewCellContentView>
                                     </tableViewCell>
                                     <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="6G3-yV-Eyh" style="IBUITableViewCellStyleDefault" id="Tbf-fE-nfx" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
-                                        <rect key="frame" x="0.0" y="963.5" width="374" height="44"/>
+                                        <rect key="frame" x="20" y="963.5" width="374" height="44"/>
                                         <autoresizingMask key="autoresizingMask"/>
                                         <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Tbf-fE-nfx" id="beV-vI-g3r">
                                             <rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
@@ -390,7 +390,7 @@
                                         </tableViewCellContentView>
                                     </tableViewCell>
                                     <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="lfL-bQ-sOp" style="IBUITableViewCellStyleDefault" id="mFn-fE-zqa" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
-                                        <rect key="frame" x="0.0" y="1007.5" width="374" height="44"/>
+                                        <rect key="frame" x="20" y="1007.5" width="374" height="44"/>
                                         <autoresizingMask key="autoresizingMask"/>
                                         <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="mFn-fE-zqa" id="jTe-mf-MRj">
                                             <rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
@@ -407,7 +407,7 @@
                                         </tableViewCellContentView>
                                     </tableViewCell>
                                     <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="DDJ-8P-3YY" style="IBUITableViewCellStyleDefault" id="iGs-ze-4gQ" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
-                                        <rect key="frame" x="0.0" y="1051.5" width="374" height="44"/>
+                                        <rect key="frame" x="20" y="1051.5" width="374" height="44"/>
                                         <autoresizingMask key="autoresizingMask"/>
                                         <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="iGs-ze-4gQ" id="EqZ-rF-N0l">
                                             <rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
@@ -424,7 +424,7 @@
                                         </tableViewCellContentView>
                                     </tableViewCell>
                                     <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="DsV-Qv-X4K" style="IBUITableViewCellStyleDefault" id="taJ-sg-wnU" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
-                                        <rect key="frame" x="0.0" y="1095.5" width="374" height="44"/>
+                                        <rect key="frame" x="20" y="1095.5" width="374" height="44"/>
                                         <autoresizingMask key="autoresizingMask"/>
                                         <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="taJ-sg-wnU" id="axB-si-1KM">
                                             <rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
@@ -441,7 +441,7 @@
                                         </tableViewCellContentView>
                                     </tableViewCell>
                                     <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="zMz-hU-UYU" style="IBUITableViewCellStyleDefault" id="OXi-cg-ab9" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
-                                        <rect key="frame" x="0.0" y="1139.5" width="374" height="44"/>
+                                        <rect key="frame" x="20" y="1139.5" width="374" height="44"/>
                                         <autoresizingMask key="autoresizingMask"/>
                                         <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="OXi-cg-ab9" id="npR-a0-9wv">
                                             <rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
@@ -458,7 +458,7 @@
                                         </tableViewCellContentView>
                                     </tableViewCell>
                                     <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="T7x-zl-6Yf" style="IBUITableViewCellStyleDefault" id="VpI-0o-3Px" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
-                                        <rect key="frame" x="0.0" y="1183.5" width="374" height="44"/>
+                                        <rect key="frame" x="20" y="1183.5" width="374" height="44"/>
                                         <autoresizingMask key="autoresizingMask"/>
                                         <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="VpI-0o-3Px" id="xRH-i4-vne">
                                             <rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
@@ -475,7 +475,7 @@
                                         </tableViewCellContentView>
                                     </tableViewCell>
                                     <tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" textLabel="NeD-y8-KrM" style="IBUITableViewCellStyleDefault" id="TIX-yK-rC6" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
-                                        <rect key="frame" x="0.0" y="1227.5" width="374" height="44"/>
+                                        <rect key="frame" x="20" y="1227.5" width="374" height="44"/>
                                         <autoresizingMask key="autoresizingMask"/>
                                         <tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="TIX-yK-rC6" id="qr8-EN-Ofg">
                                             <rect key="frame" x="0.0" y="0.0" width="355" height="44"/>
@@ -1021,10 +1021,10 @@
                                     <rect key="frame" x="0.0" y="0.0" width="374" height="43.5"/>
                                     <autoresizingMask key="autoresizingMask"/>
                                     <subviews>
-                                        <label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="6tf-Xb-7b8">
+                                        <label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" id="6tf-Xb-7b8">
                                             <rect key="frame" x="20" y="0.0" width="334" height="43.5"/>
                                             <autoresizingMask key="autoresizingMask"/>
-                                            <fontDescription key="fontDescription" type="system" pointSize="17"/>
+                                            <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
                                             <nil key="textColor"/>
                                             <nil key="highlightedColor"/>
                                         </label>

From fc374727573ba978621c947a6b45666b07c0f02c Mon Sep 17 00:00:00 2001
From: Maurice Parker <mo@vincode.io>
Date: Tue, 21 Apr 2020 02:09:59 -0500
Subject: [PATCH 9/9] Fix source code warnings.

---
 Frameworks/Account/CloudKit/CloudKitArticlesZone.swift | 2 --
 Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift    | 4 ++--
 2 files changed, 2 insertions(+), 4 deletions(-)

diff --git a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift
index ae1ecab1d..1f71d3c46 100644
--- a/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift
+++ b/Frameworks/Account/CloudKit/CloudKitArticlesZone.swift
@@ -172,8 +172,6 @@ private extension CloudKitArticlesZone {
 				record![CloudKitArticleStatus.Fields.read] = status.flag ? "1" : "0"
 			case .starred:
 				record![CloudKitArticleStatus.Fields.starred] = status.flag ? "1" : "0"
-			default:
-				break
 			}
 		}
 		
diff --git a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift
index 9c974b2cd..0437bb386 100644
--- a/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift
+++ b/Frameworks/Account/NewsBlur/NewsBlurAPICaller.swift
@@ -37,7 +37,7 @@ final class NewsBlurAPICaller: NSObject {
 	func validateCredentials(completion: @escaping (Result<Credentials?, Error>) -> Void) {
 		requestData(endpoint: "api/login", resultType: NewsBlurLoginResponse.self) { result in
 			switch result {
-			case .success(let response, let payload):
+			case .success((let response, let payload)):
 				guard let url = response.url, let headerFields = response.allHeaderFields as? [String: String], payload?.code != -1 else {
 					let error = payload?.errors?.username ?? payload?.errors?.others
 					if let message = error?.first {
@@ -227,7 +227,7 @@ final class NewsBlurAPICaller: NSObject {
 				resultType: NewsBlurAddURLResponse.self
 		) { result in
 			switch result {
-			case .success(_, let payload):
+			case .success((_, let payload)):
 				completion(.success(payload?.feed))
 			case .failure(let error):
 				completion(.failure(error))