Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
1600d21
Make settings service/network reactive
koke Jan 18, 2016
8a26666
Added notices UI for the connection errors
koke Jan 19, 2016
d50c207
Added link to WWDC video in the documentation
koke Jan 19, 2016
72479f2
Merge branch 'issue/fix-imports-7.3' into feature/my-profile-error-ha…
koke Jan 19, 2016
9336aed
Remove PaddedLabel accessors
koke Jan 19, 2016
2cbf62c
Adds reachability to refresh logic
koke Jan 19, 2016
caa2cf6
Merge branch 'develop' into feature/my-profile-error-handling
koke Jan 21, 2016
07e5e60
Add Rx forwardIf operator
koke Jan 21, 2016
3abc714
Don't emit an error for canceled requests
koke Jan 21, 2016
f653973
Stop refreshing when VC disappears
koke Jan 21, 2016
ea137d8
Improved refreshing logic
koke Jan 21, 2016
2c31f90
Refactor settings remote to multicast
koke Jan 21, 2016
e5b7a41
Refactor service to favor stored properties
koke Jan 21, 2016
68ed00e
Merge branch 'develop' into feature/my-profile-error-handling
koke Jan 22, 2016
74d2937
Documentation and comments for settings remote
koke Jan 22, 2016
eba03b9
Unit Tests for settings remote
koke Jan 22, 2016
bbaf0e9
Remove unused method
koke Jan 22, 2016
e81a341
Unit tests for AccountSettingsService
koke Jan 22, 2016
391d36d
Rename forwardIf as pausable
koke Jan 25, 2016
5f5a7af
Make remoteSettings emit .Refreshing initially
koke Jan 25, 2016
c408f95
Unit Tests for Rx pausable operator
koke Jan 25, 2016
8be6b28
Adds retryIf Rx operator
koke Jan 25, 2016
c150eac
Fix Rx pausable test
koke Jan 25, 2016
f5e028a
Make remoteSettings emit errors instead of .Failing
koke Jan 25, 2016
fe46587
Unit test for .Stalled
koke Jan 25, 2016
4626660
Updated documentation for request
koke Jan 25, 2016
5eae3de
Unit tests for settings reachability
koke Jan 25, 2016
b9af55c
Refactor unit tests for readability
koke Jan 25, 2016
4f10c4a
Renamed AccountSettingsRemoteTests
koke Jan 25, 2016
63b76c1
Don't share remote's settings observable
koke Jan 25, 2016
0ab268e
Merge branch 'develop' into feature/my-profile-error-handling
koke Jan 25, 2016
1586132
Fixed stuck error message
koke Jan 25, 2016
fba681c
Merge branch 'feature/my-profile-rxswift' into feature/my-profile-err…
koke Jan 25, 2016
e6ea38a
Merge branch 'feature/my-profile-rxswift' into feature/my-profile-err…
koke Jan 26, 2016
f918d93
Don't log settings errors twice
koke Jan 26, 2016
0a69fdf
End file in new line
koke Jan 26, 2016
ee71629
Turn second initializer into a convenience initializer
koke Jan 26, 2016
5d20736
Fixed Animator example
koke Jan 26, 2016
8f2bcfb
Merge branch 'develop' into feature/my-profile-error-handling
koke Jan 26, 2016
e59301d
Add ImmuTable.Empty helper
koke Jan 27, 2016
4ac0147
Refactor My Profile to improve memory management
koke Jan 27, 2016
47d92db
Add some more safeguards against a nil presenter
koke Jan 27, 2016
3f20e45
Fix presenter assertion
koke Jan 27, 2016
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ target :WordPressTest, :exclusive => true do
pod 'Expecta', '0.3.2'
pod 'Nimble', '~> 3.0.0'
pod 'RxSwift', '~> 2.1.0'
pod 'RxTests', '~> 2.1.0'
end

target 'UITests', :exclusive => true do
Expand Down
4 changes: 4 additions & 0 deletions Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ PODS:
- RxCocoa (2.1.0):
- RxSwift (~> 2.0)
- RxSwift (2.1.0)
- RxTests (2.1.0):
- RxSwift (~> 2.0)
- Simperium (0.8.10):
- Simperium/DiffMatchPach (= 0.8.10)
- Simperium/JRSwizzle (= 0.8.10)
Expand Down Expand Up @@ -210,6 +212,7 @@ DEPENDENCIES:
- ReactiveCocoa (~> 2.4.7)
- RxCocoa (~> 2.1.0)
- RxSwift (~> 2.1.0)
- RxTests (~> 2.1.0)
- Simperium (= 0.8.10)
- Specta (= 1.0.5)
- SVProgressHUD (~> 1.1.3)
Expand Down Expand Up @@ -282,6 +285,7 @@ SPEC CHECKSUMS:
ReactiveCocoa: eb38dee0a0e698f73a9b25e5c1faea2bb4c79240
RxCocoa: 79b5feb8378545336e756a0a33fcf5e95050b71c
RxSwift: 110fb07f81c17c2c3b3254d168363057b1880d18
RxTests: 94c67ffc37c36bd8c7aec90a84601a3db142be94
Simperium: f507d9b400c499048a98fe728a0b2b9956fd14c1
Specta: ac94d110b865115fe60ff2c6d7281053c6f8e8a2
SVProgressHUD: 748080e4f36e603f6c02aec292664239df5279c1
Expand Down
83 changes: 83 additions & 0 deletions WordPress/Classes/Extensions/Rx.swift
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 {

Copy link
Copy Markdown
Contributor

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?

Copy link
Copy Markdown
Member Author

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome!

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)
}
}
}
}
}
72 changes: 69 additions & 3 deletions WordPress/Classes/Networking/AccountSettingsRemote.swift
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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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 AccountSettingsService initialiser, which is called on the main thread.

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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 hashValue is part of Hashable, which is a Swift thing, and WordPressComApi is Obj-C. So I think I did this instead of having to create a WordPressComApi swift extension just to implement Hashable. And also, because Hashable is Equatable which brings me to this:

in theory, two instances of WordPressComApi that are logically equivalent (same auth token, same username, etc) are interchangeable.

That would be nice, but WordPressComApi is a subclass of AFHTTPRequestOperationManager, so it's not just the auth token and username, but its own operation queue with in-progress operations, and all the other inherited AFNetworking state.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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
Expand Down Expand Up @@ -85,4 +151,4 @@ class AccountSettingsRemote: ServiceRemoteREST {
enum Error: ErrorType {
case DecodeError
}
}
}
146 changes: 130 additions & 16 deletions WordPress/Classes/Services/AccountSettingsService.swift
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 {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was considering renaming Defaults to Config, since "Defaults" implies a value that can be overridden.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By default, it uses Reachability.internetConnection which is a class constant, so it should be just one instance

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 {
Expand Down Expand Up @@ -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
}
}
}
}
Loading