Merge pull request #4 from Ranchero-Software/ios-release

PR
This commit is contained in:
Stuart Breckenridge 2020-05-20 21:29:02 +08:00 committed by GitHub
commit c76a7aff40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 200 additions and 69 deletions

View File

@ -23,7 +23,6 @@ final class AccountMetadata: Codable {
case lastArticleFetchStartTime = "lastArticleFetch" case lastArticleFetchStartTime = "lastArticleFetch"
case lastArticleFetchEndTime case lastArticleFetchEndTime
case endpointURL case endpointURL
case lastCredentialRenewTime = "lastCredentialRenewTime"
case performedApril2020RetentionPolicyChange case performedApril2020RetentionPolicyChange
} }
@ -82,16 +81,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 {

View File

@ -9,6 +9,12 @@
import Foundation import Foundation
import RSWeb import RSWeb
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 {
@ -47,6 +53,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? {
@ -69,6 +77,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 {
@ -95,7 +151,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 {
@ -147,7 +203,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 {
@ -200,7 +256,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 {
@ -248,7 +304,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 {
@ -281,14 +337,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.")
@ -300,7 +350,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 {
@ -362,7 +425,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 {
@ -428,7 +491,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 {
@ -475,7 +538,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 {
@ -516,7 +579,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 {
@ -584,7 +647,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 {
@ -652,7 +715,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 {
@ -707,7 +770,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 {
@ -766,7 +829,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 {
@ -810,7 +873,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 {
@ -852,7 +915,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 {

View File

@ -37,12 +37,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
} }
} }
@ -52,6 +54,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")
@ -91,6 +97,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
@ -112,17 +120,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 {
@ -500,6 +501,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)
} }
@ -533,3 +535,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)
}
}

View File

@ -25,11 +25,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")
} }
} }

View File

@ -17,32 +17,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 {
@ -82,8 +64,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)

View File

@ -90,9 +90,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
DispatchQueue.main.async { DispatchQueue.main.async {
self.unreadCount = AccountManager.shared.unreadCount self.unreadCount = AccountManager.shared.unreadCount
} }
UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .sound, .alert]) { (granted, error) in UNUserNotificationCenter.current().getNotificationSettings { (settings) in
if granted { if settings.authorizationStatus == .authorized {
DispatchQueue.main.async { DispatchQueue.main.async {
UIApplication.shared.registerForRemoteNotifications() UIApplication.shared.registerForRemoteNotifications()
} }

View File

@ -9,6 +9,7 @@
import UIKit import UIKit
import Account import Account
import SafariServices import SafariServices
import UserNotifications
class WebFeedInspectorViewController: UITableViewController { class WebFeedInspectorViewController: UITableViewController {
@ -38,6 +39,8 @@ class WebFeedInspectorViewController: UITableViewController {
return webFeed.homePageURL == nil return webFeed.homePageURL == nil
} }
private var userNotificationSettings: UNNotificationSettings?
override func viewDidLoad() { override func viewDidLoad() {
tableView.register(InspectorIconHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader") tableView.register(InspectorIconHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader")
@ -51,6 +54,13 @@ class WebFeedInspectorViewController: UITableViewController {
feedURLLabel.text = webFeed.url feedURLLabel.text = webFeed.url
NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(updateNotificationSettings), name: UIApplication.willEnterForegroundNotification, object: nil)
}
override func viewDidAppear(_ animated: Bool) {
updateNotificationSettings()
} }
override func viewDidDisappear(_ animated: Bool) { override func viewDidDisappear(_ animated: Bool) {
@ -67,7 +77,30 @@ class WebFeedInspectorViewController: UITableViewController {
} }
@IBAction func notifyAboutNewArticlesChanged(_ sender: Any) { @IBAction func notifyAboutNewArticlesChanged(_ sender: Any) {
webFeed.isNotifyAboutNewArticles = notifyAboutNewArticlesSwitch.isOn guard let settings = userNotificationSettings else {
notifyAboutNewArticlesSwitch.isOn = !notifyAboutNewArticlesSwitch.isOn
return
}
if settings.authorizationStatus == .denied {
notifyAboutNewArticlesSwitch.isOn = !notifyAboutNewArticlesSwitch.isOn
present(notificationUpdateErrorAlert(), animated: true, completion: nil)
} else if settings.authorizationStatus == .authorized {
webFeed.isNotifyAboutNewArticles = notifyAboutNewArticlesSwitch.isOn
} else {
UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .sound, .alert]) { (granted, error) in
self.updateNotificationSettings()
if granted {
DispatchQueue.main.async {
self.webFeed.isNotifyAboutNewArticles = self.notifyAboutNewArticlesSwitch.isOn
UIApplication.shared.registerForRemoteNotifications()
}
} else {
DispatchQueue.main.async {
self.notifyAboutNewArticlesSwitch.isOn = !self.notifyAboutNewArticlesSwitch.isOn
}
}
}
}
} }
@IBAction func alwaysShowReaderViewChanged(_ sender: Any) { @IBAction func alwaysShowReaderViewChanged(_ sender: Any) {
@ -158,3 +191,33 @@ extension WebFeedInspectorViewController: UITextFieldDelegate {
} }
} }
// MARK: UNUserNotificationCenter
extension WebFeedInspectorViewController {
@objc
func updateNotificationSettings() {
UNUserNotificationCenter.current().getNotificationSettings { (settings) in
DispatchQueue.main.async {
self.userNotificationSettings = settings
if settings.authorizationStatus == .authorized {
UIApplication.shared.registerForRemoteNotifications()
}
}
}
}
func notificationUpdateErrorAlert() -> UIAlertController {
let alert = UIAlertController(title: NSLocalizedString("Enable Notifications", comment: "Notifications"),
message: NSLocalizedString("Notifications need to be enabled in the Settings app.", comment: "Notifications need to be enabled in the Settings app."), preferredStyle: .alert)
let openSettings = UIAlertAction(title: NSLocalizedString("Open Settings", comment: "Open Settings"), style: .default) { (action) in
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [UIApplication.OpenExternalURLOptionsKey.universalLinksOnly : false], completionHandler: nil)
}
let dismiss = UIAlertAction(title: NSLocalizedString("Dismiss", comment: "Dismiss"), style: .cancel, handler: nil)
alert.addAction(openSettings)
alert.addAction(dismiss)
return alert
}
}