-
Notifications
You must be signed in to change notification settings - Fork 1.2k
My Profile Network logic #4732
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
My Profile Network logic #4732
Changes from all commits
1600d21
8a26666
d50c207
72479f2
9336aed
2cbf62c
caa2cf6
07e5e60
3abc714
f653973
ea137d8
2c31f90
e5b7a41
68ed00e
74d2937
eba03b9
bbaf0e9
e81a341
391d36d
5f5a7af
c408f95
8be6b28
c150eac
f5e028a
fe46587
4626660
5eae3de
b9af55c
4f10c4a
63b76c1
0ab268e
1586132
fba681c
e6ea38a
f918d93
0a69fdf
ee71629
5d20736
8f2bcfb
e59301d
4ac0147
47d92db
3f20e45
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| import RxSwift | ||
|
|
||
| // MARK: - pausable | ||
|
|
||
| extension ObservableType { | ||
|
|
||
| /** | ||
| Pauses the underlying observable sequence based upon the observable sequence which yields true/false. | ||
|
|
||
| - parameter pauser: The observable sequence used to pause the underlying sequence. | ||
| - returns: An observable sequence that subscribes and emits the values of the source observable as long as the last emitted value of the condition observable is true. | ||
| */ | ||
| public func pausable<ConditionO: ObservableConvertibleType where ConditionO.E == Bool>(pauser: ConditionO) -> Observable<E> { | ||
| return Pausable(source: self, pauser: pauser.asObservable()).asObservable() | ||
| } | ||
| } | ||
|
|
||
| class Pausable<S: ObservableType>: ObservableType { | ||
| typealias E = S.E | ||
| typealias DisposeKey = CompositeDisposable.DisposeKey | ||
|
|
||
| private let _lock = NSRecursiveLock() | ||
|
|
||
| private let _source: S | ||
| private let _pauser: Observable<Bool> | ||
| private let _group = CompositeDisposable() | ||
|
|
||
| private var _connectionKey: DisposeKey? = nil | ||
|
|
||
| init(source: S, pauser: Observable<Bool>) { | ||
| _source = source | ||
| _pauser = pauser | ||
| } | ||
|
|
||
| func subscribe<O: ObserverType where O.E == E>(observer: O) -> Disposable { | ||
| let conn = _source.publish() | ||
| let connection = conn.subscribe(observer) | ||
| _group.addDisposable(connection) | ||
|
|
||
| let subscription = _pauser | ||
| .distinctUntilChanged() | ||
| .subscribeNext { active in | ||
| self._lock.lock(); defer { self._lock.unlock() } // lock { | ||
| if active { | ||
| self._connectionKey = self._group.addDisposable(conn.connect()) | ||
| } else { | ||
| if let connectionKey = self._connectionKey { | ||
| self._group.removeDisposable(connectionKey) | ||
| self._connectionKey = nil | ||
| } | ||
| } | ||
| // } | ||
| } | ||
| _group.addDisposable(subscription) | ||
|
|
||
| return _group | ||
| } | ||
| } | ||
|
|
||
| // MARK: - retryIf | ||
|
|
||
| extension ObservableType { | ||
| /** | ||
| Repeats the source observable sequence on error if the given condition evaluates true. | ||
|
|
||
| - parameter condition: A closure to be evaluated on error to decide if the source sequence should be retried. It takes two parameters: an incrementing `count` integer, and a `lastError` containing the latest error emitted. | ||
| - returns: An observable sequence producing the elements of the given sequence repeatedly until it terminates successfully or the condition evaluates false. | ||
| */ | ||
| public func retryIf(condition: (count: Int, lastError: NSError) -> Bool) -> Observable<E> { | ||
| return retryWhen { (errors: Observable<NSError>) in | ||
| errors.scan((0, nil)) { (accumulator: (Int, NSError!), error) in | ||
| (accumulator.0 + 1, error) | ||
| } | ||
| .flatMap { (count, lastError) -> Observable<Int> in | ||
| if condition(count: count, lastError: lastError) { | ||
| return Observable.just(count) | ||
| } else { | ||
| return Observable.error(lastError) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,12 +1,78 @@ | ||
| import AFNetworking | ||
| import Foundation | ||
| import RxSwift | ||
|
|
||
| class AccountSettingsRemote: ServiceRemoteREST { | ||
| func getSettings(success success: AccountSettings -> Void, failure: ErrorType -> Void) { | ||
| static let remotes = NSMapTable(keyOptions: .StrongMemory, valueOptions: .WeakMemory) | ||
|
|
||
| /// Returns an AccountSettingsRemote with the given api, reusing a previous | ||
| /// remote if it exists. | ||
| static func remoteWithApi(api: WordPressComApi) -> AccountSettingsRemote { | ||
| // We're hashing on the authToken because we don't want duplicate api | ||
| // objects for the same account. | ||
| // | ||
| // In theory this would be taken care of by the fact that the api comes | ||
| // from a WPAccount, and since WPAccount is a managed object Core Data | ||
| // guarantees there's only one of it. | ||
| // | ||
| // However it might be possible that the account gets deallocated and | ||
| // when it's fetched again it would create a different api object. | ||
| let key = api.authToken.hashValue | ||
| // FIXME: not thread safe | ||
| // @koke 2016-01-21 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure what to do about this. I could just document the restriction, add a precondition, and make sure we only call this on the main thread. Right now this method is only called from the Other than it being easier, there's no good reason to restrict this to the main thread, but making this thread safe adds complexity for no immediate gain. Thoughts?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is one of those cases why services have been kept ephemeral to prevent issues like this. I'd rather use the uniqueness of WordPressComApi which should come up with its own hashValue that is based upon a set of business logic conditions. This makes AccountSettingsRemote know too much about the api class I think. Then, in theory, two instances of WordPressComApi that are logically equivalent (same auth token, same username, etc) are interchangeable.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can encapsulate the hashing inside the api object, IIRC this was mostly a convenience since
That would be nice, but Which, by the way, was the reason why we wanted to reuse the same API object per account: so it's all the same operation queue, and we don't send too many concurrent requests to the same host.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That makes complete sense! |
||
| if let remote = remotes.objectForKey(key) { | ||
| return remote as! AccountSettingsRemote | ||
| } else { | ||
| let remote = AccountSettingsRemote(api: api) | ||
| remotes.setObject(remote, forKey: key) | ||
| return remote | ||
| } | ||
| } | ||
|
|
||
| let settings: Observable<AccountSettings> | ||
|
|
||
| /// Creates a new AccountSettingsRemote. It is recommended that you use AccountSettingsRemote.remoteWithApi(_) | ||
| /// instead. | ||
| override init(api: WordPressComApi) { | ||
| settings = AccountSettingsRemote.settingsWithApi(api) | ||
| super.init(api: api) | ||
| } | ||
|
|
||
| private static func settingsWithApi(api: WordPressComApi) -> Observable<AccountSettings> { | ||
| let settings = Observable<AccountSettings>.create { observer in | ||
| let remote = AccountSettingsRemote(api: api) | ||
| let operation = remote.getSettings( | ||
| success: { settings in | ||
| observer.onNext(settings) | ||
| observer.onCompleted() | ||
| }, failure: { error in | ||
| let nserror = error as NSError | ||
| if nserror.domain == NSURLErrorDomain && nserror.code == NSURLErrorCancelled { | ||
| // If we canceled the operation, don't propagate the error | ||
| // This probably means the observable is being disposed | ||
| DDLogSwift.logError("Canceled refreshing settings") | ||
| } else { | ||
| observer.onError(error) | ||
| } | ||
| }) | ||
| return AnonymousDisposable() { | ||
| if let operation = operation { | ||
| if !operation.finished { | ||
| operation.cancel() | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return settings | ||
| } | ||
|
|
||
| func getSettings(success success: AccountSettings -> Void, failure: ErrorType -> Void) -> AFHTTPRequestOperation? { | ||
| let endpoint = "me/settings" | ||
| let parameters = ["context": "edit"] | ||
| let path = pathForEndpoint(endpoint, withVersion: ServiceRemoteRESTApiVersion_1_1) | ||
|
|
||
| api.GET(path, | ||
| return api.GET(path, | ||
| parameters: parameters, | ||
| success: { | ||
| operation, responseObject in | ||
|
|
@@ -85,4 +151,4 @@ class AccountSettingsRemote: ServiceRemoteREST { | |
| enum Error: ErrorType { | ||
| case DecodeError | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,34 +1,127 @@ | ||
| import Foundation | ||
| import Reachability | ||
| import RxCocoa | ||
| import RxSwift | ||
|
|
||
| let AccountSettingsServiceChangeSaveFailedNotification = "AccountSettingsServiceChangeSaveFailed" | ||
|
|
||
| struct AccountSettingsService { | ||
| let remote: AccountSettingsRemote | ||
| protocol AccountSettingsRemoteInterface { | ||
| var settings: Observable<AccountSettings> { get } | ||
| func updateSetting(change: AccountSettingsChange, success: () -> Void, failure: ErrorType -> Void) | ||
| } | ||
|
|
||
| extension AccountSettingsRemote: AccountSettingsRemoteInterface {} | ||
|
|
||
| class AccountSettingsService { | ||
| struct Defaults { | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I was considering renaming
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually I feel like Defaults are the default value and if they're overridden they'd be Config. 😄 NSUserDefaults would call these registered defaults. Eventually as we adopt this pattern elsewhere I can see these ending up somewhere centralized. |
||
| static let stallTimeout = 4.0 | ||
| static let maxRetries = 3 | ||
| static let pollingInterval = 60.0 | ||
| } | ||
|
|
||
| let remote: AccountSettingsRemoteInterface | ||
| let userID: Int | ||
|
|
||
| private let context = ContextManager.sharedInstance().mainContext | ||
|
|
||
| init(userID: Int, api: WordPressComApi) { | ||
| self.remote = AccountSettingsRemote(api: api) | ||
| self.userID = userID | ||
| var testScheduler: SchedulerType? = nil | ||
| private var scheduler: SchedulerType { | ||
| return testScheduler ?? MainScheduler.instance | ||
| } | ||
|
|
||
| func refreshSettings(completion: (Bool) -> Void) { | ||
| remote.getSettings( | ||
| success: { | ||
| (settings) -> Void in | ||
| convenience init(userID: Int, api: WordPressComApi) { | ||
| let remote = AccountSettingsRemote.remoteWithApi(api) | ||
| self.init(userID: userID, remote: remote) | ||
| } | ||
|
|
||
| self.updateSettings(settings) | ||
| completion(true) | ||
| }, failure: { | ||
| (error) -> Void in | ||
| init(userID: Int, remote: AccountSettingsRemoteInterface) { | ||
| self.userID = userID | ||
| self.remote = remote | ||
| } | ||
|
|
||
| DDLogSwift.logError(String(error)) | ||
| completion(false) | ||
| var testReachability: Observable<Bool>? = nil | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this mean we'll have Reachability instances for each instance of the AccountSettingsService? No big deal if so, just something to consider the more services we have hanging around with this new approach.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. By default, it uses
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah yes, now I remember why we got away with doing host-based reachability instances. |
||
| /// Emits a boolean value each time reachability changes for the internet connection. | ||
| private lazy var reachable: Observable<Bool> = { | ||
| return self.testReachability ?? Reachability.internetConnection | ||
| }() | ||
|
|
||
| /// Performs a network refresh of settings and emits values with the refresh status. | ||
| /// | ||
| /// - When it's subscribed, it requests a refresh from the server | ||
| /// - If a networking error happens it doesn't emit a new value and will retry the request. | ||
| /// - If it reaches the maximum permitted number of retries it will emit an Error. | ||
| /// - If an error not related to networking happens, it will emit an Error. | ||
| /// - When the data is refreshed, it will emit an `.Idle` value and complete. | ||
| private lazy var remoteSettings: Observable<RefreshStatus> = { | ||
| return self.remote.settings | ||
| .map({ settings -> RefreshStatus in | ||
| self.updateSettings(settings) | ||
| return .Idle | ||
| }) | ||
| .retryIf({ (count, error) in | ||
| if error.domain == NSURLErrorDomain { | ||
| DDLogSwift.logError("Error refreshing settings (attempt \(count)): \(error)") | ||
| } else { | ||
| DDLogSwift.logError("Error refreshing settings (unrecoverable): \(error)") | ||
| } | ||
|
|
||
| return error.domain == NSURLErrorDomain && count < Defaults.maxRetries | ||
| }) | ||
| }() | ||
|
|
||
| /// Emits one `.Stalled` value after a timeout and then completes | ||
| private lazy var stalled: Observable<RefreshStatus> = { | ||
| return Observable<RefreshStatus> | ||
| .just(.Stalled) | ||
| .delaySubscription(Defaults.stallTimeout, scheduler: self.scheduler) | ||
| }() | ||
|
|
||
| /// Performs a network refresh of settings and emits values with the refresh status. | ||
| /// | ||
| /// - When it's subscribed, it requests a refresh from the server | ||
| /// - If it takes more than `stallTimeout` to complete, it will emit a `.Stalled` value and continue waiting for the request to finish. | ||
| /// - If a networking error happens it doesn't emit a new value and will retry the request. | ||
| /// - If it reaches the maximum permitted number of retries it will emit an Error. | ||
| /// - If an error not related to networking happens, it will emit an Error. | ||
| /// - When the data is refreshed, it will emit an `.Idle` value and complete. | ||
| lazy private(set) var request: Observable<RefreshStatus> = { | ||
| let remoteSettings = self.remoteSettings.shareReplayLatestWhileConnected() | ||
| let stalledSettings = Observable.of(self.stalled, remoteSettings) | ||
| .merge() | ||
|
|
||
| return remoteSettings | ||
| .amb(stalledSettings) | ||
| .startWith(.Refreshing) | ||
| }() | ||
|
|
||
| /// Emits values when the refresh status changes. | ||
| /// | ||
| /// On subscription, this will start refreshing settings, polling each minute, while there's an internet connection. | ||
| /// Possible values: | ||
| /// - `.Refreshing` when it starts getting remote data. | ||
| /// - `.Stalled` when it's getting remote data and hasn't succeeded before `stallTimeout`. | ||
| /// - `.Offline` when there is no internet connection. | ||
| /// - `.Idle` when the request was successful and it's waiting for the polling interval. | ||
| /// - An error when the request couldn't complete. It will stop retrying. | ||
| lazy var refresh: Observable<RefreshStatus> = { | ||
| // Copy request to avoid capture of self in closure | ||
| let request = self.request | ||
|
|
||
| // Convert to a polling request | ||
| let polling = Observable<Int> | ||
| .interval(Defaults.pollingInterval, scheduler: self.scheduler) | ||
| .startWith(0) | ||
| .flatMapLatest({ _ in request }) | ||
|
|
||
| // Enable only when reachable, otherwise emit .Offline | ||
| return self.reachable.flatMapLatest({ reachable -> Observable<RefreshStatus> in | ||
| if reachable { | ||
| return polling | ||
| } else { | ||
| return Observable.just(.Offline) | ||
| } | ||
| }) | ||
| } | ||
| }() | ||
|
|
||
| func saveChange(change: AccountSettingsChange) { | ||
| guard let reverse = try? applyChange(change) else { | ||
|
|
@@ -113,4 +206,25 @@ struct AccountSettingsService { | |
| enum Errors: ErrorType { | ||
| case NotFound | ||
| } | ||
|
|
||
| enum RefreshStatus { | ||
| case Idle | ||
| case Refreshing | ||
| case Stalled | ||
| case Failed | ||
| case Offline | ||
|
|
||
| var errorMessage: String? { | ||
| switch self { | ||
| case Stalled: | ||
| return NSLocalizedString("We are having trouble loading data", comment: "Error message displayed when a refresh is taking longer than usual. The refresh hasn't failed and it might still succeed") | ||
| case Failed: | ||
| return NSLocalizedString("We had trouble loading data", comment: "Error message displayed when a refresh failed") | ||
| case Offline: | ||
| return NSLocalizedString("You are currently offline", comment: "Error message displayed when the app can't connect to the API servers") | ||
| case Idle, Refreshing: | ||
| return nil | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just wondering if these class extension methods are something you came up with or if they're from a third party?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My own code, although pausable is inspired by the RxJS implementation. It is written in this style because I originally submitted it as a PR to RxSwift and followed their style.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome!