Merge branch 'ios-release'
This commit is contained in:
commit
95f6f80583
@ -24,7 +24,6 @@ final class AccountMetadata: Codable {
|
|||||||
case lastArticleFetchEndTime
|
case lastArticleFetchEndTime
|
||||||
case endpointURL
|
case endpointURL
|
||||||
case externalID
|
case externalID
|
||||||
case lastCredentialRenewTime = "lastCredentialRenewTime"
|
|
||||||
case performedApril2020RetentionPolicyChange
|
case performedApril2020RetentionPolicyChange
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -83,16 +82,6 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var performedApril2020RetentionPolicyChange: Bool? {
|
var performedApril2020RetentionPolicyChange: Bool? {
|
||||||
didSet {
|
didSet {
|
||||||
|
@ -10,6 +10,12 @@ import Foundation
|
|||||||
import RSWeb
|
import RSWeb
|
||||||
import Secrets
|
import Secrets
|
||||||
|
|
||||||
|
protocol FeedlyAPICallerDelegate: class {
|
||||||
|
/// Implemented by the `FeedlyAccountDelegate` reauthorize the client with a fresh OAuth token so the client can retry the unauthorized request.
|
||||||
|
/// Pass `true` to the completion handler if the failing request should be retried with a fresh token or `false` if the unauthorized request should complete with the original failure error.
|
||||||
|
func reauthorizeFeedlyAPICaller(_ caller: FeedlyAPICaller, completionHandler: @escaping (Bool) -> ())
|
||||||
|
}
|
||||||
|
|
||||||
final class FeedlyAPICaller {
|
final class FeedlyAPICaller {
|
||||||
|
|
||||||
enum API {
|
enum API {
|
||||||
@ -48,6 +54,8 @@ final class FeedlyAPICaller {
|
|||||||
self.baseUrlComponents = api.baseUrlComponents
|
self.baseUrlComponents = api.baseUrlComponents
|
||||||
}
|
}
|
||||||
|
|
||||||
|
weak var delegate: FeedlyAPICallerDelegate?
|
||||||
|
|
||||||
var credentials: Credentials?
|
var credentials: Credentials?
|
||||||
|
|
||||||
var server: String? {
|
var server: String? {
|
||||||
@ -70,6 +78,54 @@ final class FeedlyAPICaller {
|
|||||||
isSuspended = false
|
isSuspended = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func send<R: Decodable>(request: URLRequest, resultType: R.Type, dateDecoding: JSONDecoder.DateDecodingStrategy = .iso8601, keyDecoding: JSONDecoder.KeyDecodingStrategy = .useDefaultKeys, completion: @escaping (Result<(HTTPURLResponse, R?), Error>) -> Void) {
|
||||||
|
transport.send(request: request, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding) { [weak self] result in
|
||||||
|
assert(Thread.isMainThread)
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
completion(result)
|
||||||
|
case .failure(let error):
|
||||||
|
switch error {
|
||||||
|
case TransportError.httpError(let statusCode) where statusCode == 401:
|
||||||
|
|
||||||
|
assert(self == nil ? true : self?.delegate != nil, "Check the delegate is set to \(FeedlyAccountDelegate.self).")
|
||||||
|
|
||||||
|
guard let self = self, let delegate = self.delegate else {
|
||||||
|
completion(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Capture the credentials before the reauthorization to check for a change.
|
||||||
|
let credentialsBefore = self.credentials
|
||||||
|
|
||||||
|
delegate.reauthorizeFeedlyAPICaller(self) { [weak self] isReauthorizedAndShouldRetry in
|
||||||
|
assert(Thread.isMainThread)
|
||||||
|
|
||||||
|
guard isReauthorizedAndShouldRetry, let self = self else {
|
||||||
|
completion(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for a change. Not only would it help debugging, but it'll also catch an infinitely recursive attempt to refresh.
|
||||||
|
guard let accessToken = self.credentials?.secret, accessToken != credentialsBefore?.secret else {
|
||||||
|
assertionFailure("Could not update the request with a new OAuth token. Did \(String(describing: self.delegate)) set them on \(self)?")
|
||||||
|
completion(result)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var reauthorizedRequest = request
|
||||||
|
reauthorizedRequest.setValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||||
|
|
||||||
|
self.send(request: reauthorizedRequest, resultType: resultType, dateDecoding: dateDecoding, keyDecoding: keyDecoding, completion: completion)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
completion(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func importOpml(_ opmlData: Data, completion: @escaping (Result<Void, Error>) -> ()) {
|
func importOpml(_ opmlData: Data, completion: @escaping (Result<Void, Error>) -> ()) {
|
||||||
guard !isSuspended else {
|
guard !isSuspended else {
|
||||||
return DispatchQueue.main.async {
|
return DispatchQueue.main.async {
|
||||||
@ -96,7 +152,7 @@ final class FeedlyAPICaller {
|
|||||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||||
request.httpBody = opmlData
|
request.httpBody = opmlData
|
||||||
|
|
||||||
transport.send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let (httpResponse, _)):
|
case .success(let (httpResponse, _)):
|
||||||
if httpResponse.statusCode == 200 {
|
if httpResponse.statusCode == 200 {
|
||||||
@ -148,7 +204,7 @@ final class FeedlyAPICaller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
transport.send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let (httpResponse, collections)):
|
case .success(let (httpResponse, collections)):
|
||||||
if httpResponse.statusCode == 200, let collection = collections?.first {
|
if httpResponse.statusCode == 200, let collection = collections?.first {
|
||||||
@ -201,7 +257,7 @@ final class FeedlyAPICaller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
transport.send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let (httpResponse, collections)):
|
case .success(let (httpResponse, collections)):
|
||||||
if httpResponse.statusCode == 200, let collection = collections?.first {
|
if httpResponse.statusCode == 200, let collection = collections?.first {
|
||||||
@ -249,7 +305,7 @@ final class FeedlyAPICaller {
|
|||||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||||
|
|
||||||
transport.send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let (httpResponse, _)):
|
case .success(let (httpResponse, _)):
|
||||||
if httpResponse.statusCode == 200 {
|
if httpResponse.statusCode == 200 {
|
||||||
@ -282,14 +338,8 @@ final class FeedlyAPICaller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let encodedFeedId = encodeForURLPath(feedId) else {
|
|
||||||
return DispatchQueue.main.async {
|
|
||||||
completion(.failure(FeedlyAccountDelegateError.unexpectedResourceId(feedId)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var components = baseUrlComponents
|
var components = baseUrlComponents
|
||||||
components.percentEncodedPath = "/v3/collections/\(encodedCollectionId)/feeds/\(encodedFeedId)"
|
components.percentEncodedPath = "/v3/collections/\(encodedCollectionId)/feeds/.mdelete"
|
||||||
|
|
||||||
guard let url = components.url else {
|
guard let url = components.url else {
|
||||||
fatalError("\(components) does not produce a valid URL.")
|
fatalError("\(components) does not produce a valid URL.")
|
||||||
@ -301,7 +351,20 @@ final class FeedlyAPICaller {
|
|||||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||||
|
|
||||||
transport.send(request: request, resultType: [FeedlyFeed].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
do {
|
||||||
|
struct RemovableFeed: Encodable {
|
||||||
|
let id: String
|
||||||
|
}
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
let data = try encoder.encode([RemovableFeed(id: feedId)])
|
||||||
|
request.httpBody = data
|
||||||
|
} catch {
|
||||||
|
return DispatchQueue.main.async {
|
||||||
|
completion(.failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send(request: request, resultType: [FeedlyFeed].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success((let httpResponse, _)):
|
case .success((let httpResponse, _)):
|
||||||
if httpResponse.statusCode == 200 {
|
if httpResponse.statusCode == 200 {
|
||||||
@ -363,7 +426,7 @@ extension FeedlyAPICaller: FeedlyAddFeedToCollectionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
transport.send(request: request, resultType: [FeedlyFeed].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
send(request: request, resultType: [FeedlyFeed].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success((_, let collectionFeeds)):
|
case .success((_, let collectionFeeds)):
|
||||||
if let feeds = collectionFeeds {
|
if let feeds = collectionFeeds {
|
||||||
@ -429,7 +492,7 @@ extension FeedlyAPICaller: OAuthAuthorizationCodeGrantRequesting {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
transport.send(request: request, resultType: AccessTokenResponse.self, keyDecoding: .convertFromSnakeCase) { result in
|
send(request: request, resultType: AccessTokenResponse.self, keyDecoding: .convertFromSnakeCase) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let (_, tokenResponse)):
|
case .success(let (_, tokenResponse)):
|
||||||
if let response = tokenResponse {
|
if let response = tokenResponse {
|
||||||
@ -476,7 +539,7 @@ extension FeedlyAPICaller: OAuthAcessTokenRefreshRequesting {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
transport.send(request: request, resultType: AccessTokenResponse.self, keyDecoding: .convertFromSnakeCase) { result in
|
send(request: request, resultType: AccessTokenResponse.self, keyDecoding: .convertFromSnakeCase) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let (_, tokenResponse)):
|
case .success(let (_, tokenResponse)):
|
||||||
if let response = tokenResponse {
|
if let response = tokenResponse {
|
||||||
@ -517,7 +580,7 @@ extension FeedlyAPICaller: FeedlyGetCollectionsService {
|
|||||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||||
|
|
||||||
transport.send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
send(request: request, resultType: [FeedlyCollection].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let (_, collections)):
|
case .success(let (_, collections)):
|
||||||
if let response = collections {
|
if let response = collections {
|
||||||
@ -585,7 +648,7 @@ extension FeedlyAPICaller: FeedlyGetStreamContentsService {
|
|||||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||||
|
|
||||||
transport.send(request: request, resultType: FeedlyStream.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
send(request: request, resultType: FeedlyStream.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let (_, collections)):
|
case .success(let (_, collections)):
|
||||||
if let response = collections {
|
if let response = collections {
|
||||||
@ -653,7 +716,7 @@ extension FeedlyAPICaller: FeedlyGetStreamIdsService {
|
|||||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||||
|
|
||||||
transport.send(request: request, resultType: FeedlyStreamIds.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
send(request: request, resultType: FeedlyStreamIds.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let (_, collections)):
|
case .success(let (_, collections)):
|
||||||
if let response = collections {
|
if let response = collections {
|
||||||
@ -708,7 +771,7 @@ extension FeedlyAPICaller: FeedlyGetEntriesService {
|
|||||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||||
|
|
||||||
transport.send(request: request, resultType: [FeedlyEntry].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
send(request: request, resultType: [FeedlyEntry].self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let (_, entries)):
|
case .success(let (_, entries)):
|
||||||
if let response = entries {
|
if let response = entries {
|
||||||
@ -767,7 +830,7 @@ extension FeedlyAPICaller: FeedlyMarkArticlesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
transport.send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let (httpResponse, _)):
|
case .success(let (httpResponse, _)):
|
||||||
if httpResponse.statusCode == 200 {
|
if httpResponse.statusCode == 200 {
|
||||||
@ -811,7 +874,7 @@ extension FeedlyAPICaller: FeedlySearchService {
|
|||||||
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
request.addValue("application/json", forHTTPHeaderField: HTTPRequestHeader.contentType)
|
||||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||||
|
|
||||||
transport.send(request: request, resultType: FeedlyFeedsSearchResponse.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
send(request: request, resultType: FeedlyFeedsSearchResponse.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let (_, searchResponse)):
|
case .success(let (_, searchResponse)):
|
||||||
if let response = searchResponse {
|
if let response = searchResponse {
|
||||||
@ -853,7 +916,7 @@ extension FeedlyAPICaller: FeedlyLogoutService {
|
|||||||
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
request.addValue("application/json", forHTTPHeaderField: "Accept-Type")
|
||||||
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
request.addValue("OAuth \(accessToken)", forHTTPHeaderField: HTTPRequestHeader.authorization)
|
||||||
|
|
||||||
transport.send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
send(request: request, resultType: String.self, dateDecoding: .millisecondsSince1970, keyDecoding: .convertFromSnakeCase) { result in
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let (httpResponse, _)):
|
case .success(let (httpResponse, _)):
|
||||||
if httpResponse.statusCode == 200 {
|
if httpResponse.statusCode == 200 {
|
||||||
|
@ -38,12 +38,14 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
|||||||
|
|
||||||
var credentials: Credentials? {
|
var credentials: Credentials? {
|
||||||
didSet {
|
didSet {
|
||||||
|
#if DEBUG
|
||||||
// https://developer.feedly.com/v3/developer/
|
// https://developer.feedly.com/v3/developer/
|
||||||
if let devToken = ProcessInfo.processInfo.environment["FEEDLY_DEV_ACCESS_TOKEN"], !devToken.isEmpty {
|
if let devToken = ProcessInfo.processInfo.environment["FEEDLY_DEV_ACCESS_TOKEN"], !devToken.isEmpty {
|
||||||
caller.credentials = Credentials(type: .oauthAccessToken, username: "Developer", secret: devToken)
|
caller.credentials = Credentials(type: .oauthAccessToken, username: "Developer", secret: devToken)
|
||||||
} else {
|
return
|
||||||
caller.credentials = credentials
|
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
caller.credentials = credentials
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,6 +55,10 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
|||||||
|
|
||||||
var refreshProgress = DownloadProgress(numberOfTasks: 0)
|
var refreshProgress = DownloadProgress(numberOfTasks: 0)
|
||||||
|
|
||||||
|
/// Set on `accountDidInitialize` for the purposes of refreshing OAuth tokens when they expire.
|
||||||
|
/// See the implementation for `FeedlyAPICallerDelegate`.
|
||||||
|
private weak var initializedAccount: Account?
|
||||||
|
|
||||||
internal let caller: FeedlyAPICaller
|
internal let caller: FeedlyAPICaller
|
||||||
|
|
||||||
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Feedly")
|
private let log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "Feedly")
|
||||||
@ -92,6 +98,8 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
|||||||
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3")
|
let databaseFilePath = (dataFolder as NSString).appendingPathComponent("Sync.sqlite3")
|
||||||
self.database = SyncDatabase(databaseFilePath: databaseFilePath)
|
self.database = SyncDatabase(databaseFilePath: databaseFilePath)
|
||||||
self.oauthAuthorizationClient = api.oauthAuthorizationClient
|
self.oauthAuthorizationClient = api.oauthAuthorizationClient
|
||||||
|
|
||||||
|
self.caller.delegate = self
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Account API
|
// MARK: Account API
|
||||||
@ -117,17 +125,10 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
|||||||
|
|
||||||
let log = self.log
|
let log = self.log
|
||||||
|
|
||||||
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)
|
let syncAllOperation = FeedlySyncAllOperation(account: account, feedlyUserId: credentials.username, caller: caller, database: database, lastSuccessfulFetchStartDate: accountMetadata?.lastArticleFetchStartTime, downloadProgress: refreshProgress, log: log)
|
||||||
|
|
||||||
syncAllOperation.downloadProgress = refreshProgress
|
syncAllOperation.downloadProgress = refreshProgress
|
||||||
|
|
||||||
// Ensure the sync uses the latest credential.
|
|
||||||
syncAllOperation.addDependency(refreshAccessToken)
|
|
||||||
|
|
||||||
let date = Date()
|
let date = Date()
|
||||||
syncAllOperation.syncCompletionHandler = { [weak self] result in
|
syncAllOperation.syncCompletionHandler = { [weak self] result in
|
||||||
if case .success = result {
|
if case .success = result {
|
||||||
@ -505,6 +506,7 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func accountDidInitialize(_ account: Account) {
|
func accountDidInitialize(_ account: Account) {
|
||||||
|
initializedAccount = account
|
||||||
credentials = try? account.retrieveCredentials(type: .oauthAccessToken)
|
credentials = try? account.retrieveCredentials(type: .oauthAccessToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -538,3 +540,37 @@ final class FeedlyAccountDelegate: AccountDelegate {
|
|||||||
caller.resume()
|
caller.resume()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension FeedlyAccountDelegate: FeedlyAPICallerDelegate {
|
||||||
|
|
||||||
|
func reauthorizeFeedlyAPICaller(_ caller: FeedlyAPICaller, completionHandler: @escaping (Bool) -> ()) {
|
||||||
|
guard let account = initializedAccount else {
|
||||||
|
completionHandler(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Captures a failure to refresh a token, assuming that it was refreshed unless told otherwise.
|
||||||
|
final class RefreshAccessTokenOperationDelegate: FeedlyOperationDelegate {
|
||||||
|
|
||||||
|
private(set) var didReauthorize = true
|
||||||
|
|
||||||
|
func feedlyOperation(_ operation: FeedlyOperation, didFailWith error: Error) {
|
||||||
|
didReauthorize = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshAccessToken = FeedlyRefreshAccessTokenOperation(account: account, service: self, oauthClient: oauthAuthorizationClient, log: log)
|
||||||
|
refreshAccessToken.downloadProgress = refreshProgress
|
||||||
|
|
||||||
|
/// This must be strongly referenced by the completionBlock of the `FeedlyRefreshAccessTokenOperation`.
|
||||||
|
let refreshAccessTokenDelegate = RefreshAccessTokenOperationDelegate()
|
||||||
|
refreshAccessToken.delegate = refreshAccessTokenDelegate
|
||||||
|
|
||||||
|
refreshAccessToken.completionBlock = { operation in
|
||||||
|
assert(Thread.isMainThread)
|
||||||
|
completionHandler(refreshAccessTokenDelegate.didReauthorize && !operation.isCanceled)
|
||||||
|
}
|
||||||
|
|
||||||
|
MainThreadOperationQueue.shared.add(refreshAccessToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -26,11 +26,11 @@ extension OAuthAuthorizationClient {
|
|||||||
/// See https://developer.feedly.com/v3/sandbox/ for more information.
|
/// See https://developer.feedly.com/v3/sandbox/ for more information.
|
||||||
/// The return value models public sandbox API values found at:
|
/// The return value models public sandbox API values found at:
|
||||||
/// https://groups.google.com/forum/#!topic/feedly-cloud/WwQWMgDmOuw
|
/// https://groups.google.com/forum/#!topic/feedly-cloud/WwQWMgDmOuw
|
||||||
/// They are due to expire on January 31 2020.
|
/// They are due to expire on May 31st 2020.
|
||||||
/// Verify the sandbox URL host in the FeedlyAPICaller.API.baseUrlComponents method, too.
|
/// Verify the sandbox URL host in the FeedlyAPICaller.API.baseUrlComponents method, too.
|
||||||
return OAuthAuthorizationClient(id: "sandbox",
|
return OAuthAuthorizationClient(id: "sandbox",
|
||||||
redirectUri: "urn:ietf:wg:oauth:2.0:oob",
|
redirectUri: "urn:ietf:wg:oauth:2.0:oob",
|
||||||
state: nil,
|
state: nil,
|
||||||
secret: "nZmS4bqxgRQkdPks")
|
secret: "4ZfZ5DvqmJ8vKgMj")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,32 +18,14 @@ final class FeedlyRefreshAccessTokenOperation: FeedlyOperation {
|
|||||||
let account: Account
|
let account: Account
|
||||||
let log: OSLog
|
let 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.
|
init(account: Account, service: OAuthAccessTokenRefreshing, oauthClient: OAuthAuthorizationClient, log: OSLog) {
|
||||||
let refreshDate: Date?
|
|
||||||
|
|
||||||
init(account: Account, service: OAuthAccessTokenRefreshing, oauthClient: OAuthAuthorizationClient, refreshDate: Date?, log: OSLog) {
|
|
||||||
self.oauthClient = oauthClient
|
self.oauthClient = oauthClient
|
||||||
self.service = service
|
self.service = service
|
||||||
self.account = account
|
self.account = account
|
||||||
self.refreshDate = refreshDate
|
|
||||||
self.log = log
|
self.log = log
|
||||||
}
|
}
|
||||||
|
|
||||||
override func run() {
|
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 {
|
|
||||||
os_log(.debug, log: log, "Skipping access token renewal.")
|
|
||||||
didFinish()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let refreshToken: Credentials
|
let refreshToken: Credentials
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@ -83,8 +65,6 @@ final class FeedlyRefreshAccessTokenOperation: FeedlyOperation {
|
|||||||
// Now store the access token because we want the account delegate to use it.
|
// Now store the access token because we want the account delegate to use it.
|
||||||
try account.storeCredentials(grant.accessToken)
|
try account.storeCredentials(grant.accessToken)
|
||||||
|
|
||||||
account.metadata.lastCredentialRenewTime = Date()
|
|
||||||
|
|
||||||
didFinish()
|
didFinish()
|
||||||
} catch {
|
} catch {
|
||||||
didFinish(with: error)
|
didFinish(with: error)
|
||||||
|
@ -129,8 +129,9 @@ class WebViewController: UIViewController {
|
|||||||
func scrollPageDown() {
|
func scrollPageDown() {
|
||||||
guard let webView = webView else { return }
|
guard let webView = webView else { return }
|
||||||
|
|
||||||
|
let overlap = 2 * UIFont.systemFont(ofSize: UIFont.systemFontSize).lineHeight * UIScreen.main.scale
|
||||||
let scrollToY: CGFloat = {
|
let scrollToY: CGFloat = {
|
||||||
let fullScroll = webView.scrollView.contentOffset.y + webView.scrollView.layoutMarginsGuide.layoutFrame.height
|
let fullScroll = webView.scrollView.contentOffset.y + webView.scrollView.layoutMarginsGuide.layoutFrame.height - overlap
|
||||||
let final = finalScrollPosition()
|
let final = finalScrollPosition()
|
||||||
return fullScroll < final ? fullScroll : final
|
return fullScroll < final ? fullScroll : final
|
||||||
}()
|
}()
|
||||||
|
@ -167,6 +167,9 @@ private extension KeyboardManager {
|
|||||||
|
|
||||||
let nextDownTitle = NSLocalizedString("Select Next Down", comment: "Select Next Down")
|
let nextDownTitle = NSLocalizedString("Select Next Down", comment: "Select Next Down")
|
||||||
keys.append(KeyboardManager.createKeyCommand(title: nextDownTitle, action: "selectNextDown:", input: UIKeyCommand.inputDownArrow, modifiers: []))
|
keys.append(KeyboardManager.createKeyCommand(title: nextDownTitle, action: "selectNextDown:", input: UIKeyCommand.inputDownArrow, modifiers: []))
|
||||||
|
|
||||||
|
let getFeedInfo = NSLocalizedString("Get Feed Info", comment: "Get Feed Info")
|
||||||
|
keys.append(KeyboardManager.createKeyCommand(title: getFeedInfo, action: "showFeedInspector:", input: "i", modifiers: .command))
|
||||||
|
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
@ -192,6 +195,9 @@ private extension KeyboardManager {
|
|||||||
let findInArticleTitle = NSLocalizedString("Find in Article", comment: "Find in Article")
|
let findInArticleTitle = NSLocalizedString("Find in Article", comment: "Find in Article")
|
||||||
keys.append(KeyboardManager.createKeyCommand(title: findInArticleTitle, action: "beginFind:", input: "f", modifiers: [.command]))
|
keys.append(KeyboardManager.createKeyCommand(title: findInArticleTitle, action: "beginFind:", input: "f", modifiers: [.command]))
|
||||||
|
|
||||||
|
let getFeedInfo = NSLocalizedString("Get Feed Info", comment: "Get Feed Info")
|
||||||
|
keys.append(KeyboardManager.createKeyCommand(title: getFeedInfo, action: "showFeedInspector:", input: "i", modifiers: .command))
|
||||||
|
|
||||||
let toggleSidebar = NSLocalizedString("Toggle Sidebar", comment: "Toggle Sidebar")
|
let toggleSidebar = NSLocalizedString("Toggle Sidebar", comment: "Toggle Sidebar")
|
||||||
keys.append(KeyboardManager.createKeyCommand(title: toggleSidebar, action: "toggleSidebar:", input: "s", modifiers: [.command, .control]))
|
keys.append(KeyboardManager.createKeyCommand(title: toggleSidebar, action: "toggleSidebar:", input: "s", modifiers: [.command, .control]))
|
||||||
|
|
||||||
|
@ -470,6 +470,10 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
|
|||||||
self?.coordinator.markAllAsReadInTimeline()
|
self?.coordinator.markAllAsReadInTimeline()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@objc func showFeedInspector(_ sender: Any?) {
|
||||||
|
coordinator.showFeedInspector()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: API
|
// MARK: API
|
||||||
|
|
||||||
@ -636,12 +640,23 @@ private extension MasterFeedViewController {
|
|||||||
|
|
||||||
func queueApply(snapshot: NSDiffableDataSourceSnapshot<Node, Node>, animatingDifferences: Bool = true, completion: (() -> Void)? = nil) {
|
func queueApply(snapshot: NSDiffableDataSourceSnapshot<Node, Node>, animatingDifferences: Bool = true, completion: (() -> Void)? = nil) {
|
||||||
let operation = MasterFeedDataSourceOperation(dataSource: dataSource, snapshot: snapshot, animating: animatingDifferences)
|
let operation = MasterFeedDataSourceOperation(dataSource: dataSource, snapshot: snapshot, animating: animatingDifferences)
|
||||||
operation.completionBlock = { _ in
|
operation.completionBlock = { [weak self] _ in
|
||||||
|
self?.enableTableViewSelection()
|
||||||
completion?()
|
completion?()
|
||||||
}
|
}
|
||||||
|
disableTableViewSelectionIfNecessary()
|
||||||
operationQueue.add(operation)
|
operationQueue.add(operation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func disableTableViewSelectionIfNecessary() {
|
||||||
|
// We only need to disable tableView selection if the feeds are filtered by unread
|
||||||
|
guard coordinator.isReadFeedsFiltered else { return }
|
||||||
|
tableView.allowsSelection = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enableTableViewSelection() {
|
||||||
|
tableView.allowsSelection = true
|
||||||
|
}
|
||||||
|
|
||||||
func makeDataSource() -> MasterFeedDataSource {
|
func makeDataSource() -> MasterFeedDataSource {
|
||||||
let dataSource = MasterFeedDataSource(tableView: tableView, cellProvider: { [weak self] tableView, indexPath, node in
|
let dataSource = MasterFeedDataSource(tableView: tableView, cellProvider: { [weak self] tableView, indexPath, node in
|
||||||
|
@ -89,6 +89,14 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disable swipe back on iPad Mice
|
||||||
|
if #available(iOS 13.4, *) {
|
||||||
|
guard let gesture = self.navigationController?.interactivePopGestureRecognizer as? UIPanGestureRecognizer else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gesture.allowedScrollTypesMask = []
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
@ -166,7 +174,7 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
|
|||||||
coordinator.navigateToDetail()
|
coordinator.navigateToDetail()
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func showFeedInspector(_ sender: UITapGestureRecognizer) {
|
@objc func showFeedInspector(_ sender: Any?) {
|
||||||
coordinator.showFeedInspector()
|
coordinator.showFeedInspector()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1110,7 +1110,9 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func showFeedInspector() {
|
func showFeedInspector() {
|
||||||
guard let feed = timelineFeed as? WebFeed else {
|
let timelineWebFeed = timelineFeed as? WebFeed
|
||||||
|
let articleFeed = currentArticle?.webFeed
|
||||||
|
guard let feed = timelineWebFeed ?? articleFeed else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
showFeedInspector(for: feed)
|
showFeedInspector(for: feed)
|
||||||
|
@ -28,7 +28,9 @@ class TitleActivityItemSource: NSObject, UIActivityItemSource {
|
|||||||
|
|
||||||
switch activityType.rawValue {
|
switch activityType.rawValue {
|
||||||
case "com.omnigroup.OmniFocus3.iOS.QuickEntry",
|
case "com.omnigroup.OmniFocus3.iOS.QuickEntry",
|
||||||
"com.culturedcode.ThingsiPhone.ShareExtension":
|
"com.culturedcode.ThingsiPhone.ShareExtension",
|
||||||
|
"com.tapbots.Tweetbot4.shareextension",
|
||||||
|
"com.buffer.buffer.Buffer":
|
||||||
return title
|
return title
|
||||||
default:
|
default:
|
||||||
return NSNull()
|
return NSNull()
|
||||||
|
@ -1 +1 @@
|
|||||||
Subproject commit e1524fcb2a42ccc5db765599adf3f7addc59fc6f
|
Subproject commit 851b3b672763dc5d402c54caa5c10adb9f1a8eef
|
Loading…
x
Reference in New Issue
Block a user