Skip to content
Snippets Groups Projects
Commit 4c6ca969 authored by Ahmed Shehata's avatar Ahmed Shehata
Browse files

Merge branch 'feature/example-network-health-monitor' into 'main'

[Example App] Monitor network health

See merge request elixxir/elixxir-dapps-sdk-swift!11
parents b5ffe08c 721e81ea
No related branches found
No related tags found
1 merge request!11[Example App] Monitor network health
Showing
with 434 additions and 27 deletions
...@@ -135,10 +135,19 @@ let package = Package( ...@@ -135,10 +135,19 @@ let package = Package(
.target( .target(
name: "SessionFeature", name: "SessionFeature",
dependencies: [ dependencies: [
.target(name: "ErrorFeature"),
.product( .product(
name: "ComposableArchitecture", name: "ComposableArchitecture",
package: "swift-composable-architecture" package: "swift-composable-architecture"
), ),
.product(
name: "ComposablePresentation",
package: "swift-composable-presentation"
),
.product(
name: "ElixxirDAppsSDK",
package: "elixxir-dapps-sdk-swift"
),
], ],
swiftSettings: swiftSettings swiftSettings: swiftSettings
), ),
......
...@@ -29,6 +29,7 @@ extension AppEnvironment { ...@@ -29,6 +29,7 @@ extension AppEnvironment {
).eraseToAnyScheduler() ).eraseToAnyScheduler()
return AppEnvironment( return AppEnvironment(
makeId: UUID.init,
hasClient: clientSubject.map { $0 != nil }.eraseToAnyPublisher(), hasClient: clientSubject.map { $0 != nil }.eraseToAnyPublisher(),
mainScheduler: mainScheduler, mainScheduler: mainScheduler,
landing: LandingEnvironment( landing: LandingEnvironment(
...@@ -40,7 +41,11 @@ extension AppEnvironment { ...@@ -40,7 +41,11 @@ extension AppEnvironment {
mainScheduler: mainScheduler, mainScheduler: mainScheduler,
error: ErrorEnvironment() error: ErrorEnvironment()
), ),
session: SessionEnvironment() session: SessionEnvironment(
getClient: { clientSubject.value },
bgScheduler: bgScheduler,
mainScheduler: mainScheduler
)
) )
} }
} }
...@@ -10,7 +10,8 @@ struct AppState: Equatable { ...@@ -10,7 +10,8 @@ struct AppState: Equatable {
case session(SessionState) case session(SessionState)
} }
var scene: Scene = .landing(LandingState()) var id: UUID = UUID()
var scene: Scene = .landing(LandingState(id: UUID()))
} }
extension AppState.Scene { extension AppState.Scene {
...@@ -45,6 +46,7 @@ enum AppAction: Equatable { ...@@ -45,6 +46,7 @@ enum AppAction: Equatable {
} }
struct AppEnvironment { struct AppEnvironment {
var makeId: () -> UUID
var hasClient: AnyPublisher<Bool, Never> var hasClient: AnyPublisher<Bool, Never>
var mainScheduler: AnySchedulerOf<DispatchQueue> var mainScheduler: AnySchedulerOf<DispatchQueue>
var landing: LandingEnvironment var landing: LandingEnvironment
...@@ -55,20 +57,22 @@ let appReducer = Reducer<AppState, AppAction, AppEnvironment> ...@@ -55,20 +57,22 @@ let appReducer = Reducer<AppState, AppAction, AppEnvironment>
{ state, action, env in { state, action, env in
switch action { switch action {
case .viewDidLoad: case .viewDidLoad:
struct HasClientEffectId: Hashable {} struct HasClientEffectId: Hashable {
var id: UUID
}
return env.hasClient return env.hasClient
.removeDuplicates() .removeDuplicates()
.map(AppAction.clientDidChange(hasClient:)) .map(AppAction.clientDidChange(hasClient:))
.receive(on: env.mainScheduler) .receive(on: env.mainScheduler)
.eraseToEffect() .eraseToEffect()
.cancellable(id: HasClientEffectId(), cancelInFlight: true) .cancellable(id: HasClientEffectId(id: state.id), cancelInFlight: true)
case .clientDidChange(let hasClient): case .clientDidChange(let hasClient):
if hasClient { if hasClient {
let sessionState = state.scene.asSession ?? SessionState() let sessionState = state.scene.asSession ?? SessionState(id: env.makeId())
state.scene = .session(sessionState) state.scene = .session(sessionState)
} else { } else {
let landingState = state.scene.asLanding ?? LandingState() let landingState = state.scene.asLanding ?? LandingState(id: env.makeId())
state.scene = .landing(landingState) state.scene = .landing(landingState)
} }
return .none return .none
...@@ -95,6 +99,7 @@ let appReducer = Reducer<AppState, AppAction, AppEnvironment> ...@@ -95,6 +99,7 @@ let appReducer = Reducer<AppState, AppAction, AppEnvironment>
#if DEBUG #if DEBUG
extension AppEnvironment { extension AppEnvironment {
static let failing = AppEnvironment( static let failing = AppEnvironment(
makeId: { fatalError() },
hasClient: Empty().eraseToAnyPublisher(), hasClient: Empty().eraseToAnyPublisher(),
mainScheduler: .failing, mainScheduler: .failing,
landing: .failing, landing: .failing,
......
...@@ -5,17 +5,20 @@ import ErrorFeature ...@@ -5,17 +5,20 @@ import ErrorFeature
public struct LandingState: Equatable { public struct LandingState: Equatable {
public init( public init(
id: UUID,
hasStoredClient: Bool = false, hasStoredClient: Bool = false,
isMakingClient: Bool = false, isMakingClient: Bool = false,
isRemovingClient: Bool = false, isRemovingClient: Bool = false,
error: ErrorState? = nil error: ErrorState? = nil
) { ) {
self.id = id
self.hasStoredClient = hasStoredClient self.hasStoredClient = hasStoredClient
self.isMakingClient = isMakingClient self.isMakingClient = isMakingClient
self.isRemovingClient = isRemovingClient self.isRemovingClient = isRemovingClient
self.error = error self.error = error
} }
var id: UUID
var hasStoredClient: Bool var hasStoredClient: Bool
var isMakingClient: Bool var isMakingClient: Bool
var isRemovingClient: Bool var isRemovingClient: Bool
......
...@@ -80,7 +80,7 @@ public struct LandingView_Previews: PreviewProvider { ...@@ -80,7 +80,7 @@ public struct LandingView_Previews: PreviewProvider {
public static var previews: some View { public static var previews: some View {
NavigationView { NavigationView {
LandingView(store: .init( LandingView(store: .init(
initialState: .init(), initialState: .init(id: UUID()),
reducer: .empty, reducer: .empty,
environment: () environment: ()
)) ))
......
import ElixxirDAppsSDK
import SwiftUI
struct NetworkFollowerStatusView: View {
var status: NetworkFollowerStatus?
var body: some View {
switch status {
case .stopped:
Label("Stopped", systemImage: "stop.fill")
case .starting:
Label("Starting...", systemImage: "play")
case .running:
Label("Running", systemImage: "play.fill")
case .stopping:
Label("Stopping...", systemImage: "stop")
case .unknown(let code):
Label("Status \(code)", systemImage: "questionmark")
case .none:
Label("Unknown", systemImage: "questionmark")
}
}
}
#if DEBUG
struct NetworkFollowerStatusView_Previews: PreviewProvider {
static var previews: some View {
Group {
NetworkFollowerStatusView(status: .stopped)
NetworkFollowerStatusView(status: .starting)
NetworkFollowerStatusView(status: .running)
NetworkFollowerStatusView(status: .stopping)
NetworkFollowerStatusView(status: .unknown(code: -1))
NetworkFollowerStatusView(status: nil)
}
.previewLayout(.sizeThatFits)
}
}
#endif
import SwiftUI
struct NetworkHealthStatusView: View {
var status: Bool?
var body: some View {
switch status {
case .some(true):
Label("Healthy", systemImage: "wifi")
.foregroundColor(.green)
case .some(false):
Label("Unhealthy", systemImage: "bolt.horizontal.fill")
.foregroundColor(.red)
case .none:
Label("Unknown", systemImage: "questionmark")
}
}
}
#if DEBUG
struct NetworkHealthStatusView_Previews: PreviewProvider {
static var previews: some View {
Group {
NetworkHealthStatusView(status: true)
NetworkHealthStatusView(status: false)
NetworkHealthStatusView(status: nil)
}
.previewLayout(.sizeThatFits)
}
}
#endif
import Combine
import ComposableArchitecture import ComposableArchitecture
import ElixxirDAppsSDK
import ErrorFeature
public struct SessionState: Equatable { public struct SessionState: Equatable {
public init() {} public init(
id: UUID,
networkFollowerStatus: NetworkFollowerStatus? = nil,
isNetworkHealthy: Bool? = nil,
error: ErrorState? = nil
) {
self.id = id
self.networkFollowerStatus = networkFollowerStatus
self.isNetworkHealthy = isNetworkHealthy
self.error = error
}
public var id: UUID
public var networkFollowerStatus: NetworkFollowerStatus?
public var isNetworkHealthy: Bool?
public var error: ErrorState?
} }
public enum SessionAction: Equatable { public enum SessionAction: Equatable {
case viewDidLoad case viewDidLoad
case updateNetworkFollowerStatus
case didUpdateNetworkFollowerStatus(NetworkFollowerStatus?)
case runNetworkFollower(Bool)
case networkFollowerDidFail(NSError)
case monitorNetworkHealth(Bool)
case didUpdateNetworkHealth(Bool?)
case didDismissError
case error(ErrorAction)
} }
public struct SessionEnvironment { public struct SessionEnvironment {
public init() {} public init(
getClient: @escaping () -> Client?,
bgScheduler: AnySchedulerOf<DispatchQueue>,
mainScheduler: AnySchedulerOf<DispatchQueue>
) {
self.getClient = getClient
self.bgScheduler = bgScheduler
self.mainScheduler = mainScheduler
}
public var getClient: () -> Client?
public var bgScheduler: AnySchedulerOf<DispatchQueue>
public var mainScheduler: AnySchedulerOf<DispatchQueue>
} }
public let sessionReducer = Reducer<SessionState, SessionAction, SessionEnvironment> public let sessionReducer = Reducer<SessionState, SessionAction, SessionEnvironment>
{ state, action, env in { state, action, env in
switch action { switch action {
case .viewDidLoad: case .viewDidLoad:
return .merge([
.init(value: .updateNetworkFollowerStatus),
.init(value: .monitorNetworkHealth(true)),
])
case .updateNetworkFollowerStatus:
return Effect.future { fulfill in
let status = env.getClient()?.networkFollower.status()
fulfill(.success(.didUpdateNetworkFollowerStatus(status)))
}
.subscribe(on: env.bgScheduler)
.receive(on: env.mainScheduler)
.eraseToEffect()
case .didUpdateNetworkFollowerStatus(let status):
state.networkFollowerStatus = status
return .none
case .runNetworkFollower(let start):
state.networkFollowerStatus = start ? .starting : .stopping
return Effect.run { subscriber in
do {
if start {
try env.getClient()?.networkFollower.start(timeoutMS: 30_000)
} else {
try env.getClient()?.networkFollower.stop()
}
} catch {
subscriber.send(.networkFollowerDidFail(error as NSError))
}
let status = env.getClient()?.networkFollower.status()
subscriber.send(.didUpdateNetworkFollowerStatus(status))
subscriber.send(completion: .finished)
return AnyCancellable {}
}
.subscribe(on: env.bgScheduler)
.receive(on: env.mainScheduler)
.eraseToEffect()
case .networkFollowerDidFail(let error):
state.error = ErrorState(error: error)
return .none
case .monitorNetworkHealth(let start):
struct MonitorEffectId: Hashable {
var id: UUID
}
let effectId = MonitorEffectId(id: state.id)
if start {
return Effect.run { subscriber in
var cancellable = env.getClient()?.monitorNetworkHealth { isHealthy in
subscriber.send(.didUpdateNetworkHealth(isHealthy))
}
return AnyCancellable {
cancellable?.cancel()
}
}
.subscribe(on: env.bgScheduler)
.receive(on: env.mainScheduler)
.eraseToEffect()
.cancellable(id: effectId, cancelInFlight: true)
} else {
return Effect.cancel(id: effectId)
.subscribe(on: env.bgScheduler)
.eraseToEffect()
}
case .didUpdateNetworkHealth(let isHealthy):
state.isNetworkHealthy = isHealthy
return .none
case .didDismissError:
state.error = nil
return .none
case .error(_):
return .none return .none
} }
} }
#if DEBUG #if DEBUG
extension SessionEnvironment { extension SessionEnvironment {
public static let failing = SessionEnvironment() public static let failing = SessionEnvironment(
getClient: { .failing },
bgScheduler: .failing,
mainScheduler: .failing
)
} }
#endif #endif
import ComposableArchitecture import ComposableArchitecture
import ComposablePresentation
import ElixxirDAppsSDK
import ErrorFeature
import SwiftUI import SwiftUI
public struct SessionView: View { public struct SessionView: View {
...@@ -9,16 +12,58 @@ public struct SessionView: View { ...@@ -9,16 +12,58 @@ public struct SessionView: View {
let store: Store<SessionState, SessionAction> let store: Store<SessionState, SessionAction>
struct ViewState: Equatable { struct ViewState: Equatable {
init(state: SessionState) {} let networkFollowerStatus: NetworkFollowerStatus?
let isNetworkHealthy: Bool?
init(state: SessionState) {
networkFollowerStatus = state.networkFollowerStatus
isNetworkHealthy = state.isNetworkHealthy
}
} }
public var body: some View { public var body: some View {
WithViewStore(store.scope(state: ViewState.init)) { viewStore in WithViewStore(store.scope(state: ViewState.init)) { viewStore in
Text("SessionView") Form {
Section {
NetworkFollowerStatusView(status: viewStore.networkFollowerStatus)
Button {
viewStore.send(.runNetworkFollower(true))
} label: {
Text("Start")
}
.disabled(viewStore.networkFollowerStatus != .stopped)
Button {
viewStore.send(.runNetworkFollower(false))
} label: {
Text("Stop")
}
.disabled(viewStore.networkFollowerStatus != .running)
} header: {
Text("Network follower")
}
Section {
NetworkHealthStatusView(status: viewStore.isNetworkHealthy)
} header: {
Text("Network health")
}
}
.navigationTitle("Session") .navigationTitle("Session")
.task { .task {
viewStore.send(.viewDidLoad) viewStore.send(.viewDidLoad)
} }
.sheet(
store.scope(
state: \.error,
action: SessionAction.error
),
onDismiss: {
viewStore.send(.didDismissError)
},
content: ErrorView.init(store:)
)
} }
} }
} }
...@@ -27,7 +72,7 @@ public struct SessionView: View { ...@@ -27,7 +72,7 @@ public struct SessionView: View {
public struct SessionView_Previews: PreviewProvider { public struct SessionView_Previews: PreviewProvider {
public static var previews: some View { public static var previews: some View {
SessionView(store: .init( SessionView(store: .init(
initialState: .init(), initialState: .init(id: UUID()),
reducer: .empty, reducer: .empty,
environment: () environment: ()
)) ))
......
...@@ -7,10 +7,12 @@ import XCTest ...@@ -7,10 +7,12 @@ import XCTest
final class AppFeatureTests: XCTestCase { final class AppFeatureTests: XCTestCase {
func testViewDidLoad() throws { func testViewDidLoad() throws {
let newId = UUID()
let hasClient = PassthroughSubject<Bool, Never>() let hasClient = PassthroughSubject<Bool, Never>()
let mainScheduler = DispatchQueue.test let mainScheduler = DispatchQueue.test
var env = AppEnvironment.failing var env = AppEnvironment.failing
env.makeId = { newId }
env.hasClient = hasClient.eraseToAnyPublisher() env.hasClient = hasClient.eraseToAnyPublisher()
env.mainScheduler = mainScheduler.eraseToAnyScheduler() env.mainScheduler = mainScheduler.eraseToAnyScheduler()
...@@ -31,7 +33,7 @@ final class AppFeatureTests: XCTestCase { ...@@ -31,7 +33,7 @@ final class AppFeatureTests: XCTestCase {
mainScheduler.advance() mainScheduler.advance()
store.receive(.clientDidChange(hasClient: true)) { store.receive(.clientDidChange(hasClient: true)) {
$0.scene = .session(SessionState()) $0.scene = .session(SessionState(id: newId))
} }
hasClient.send(true) hasClient.send(true)
...@@ -41,7 +43,7 @@ final class AppFeatureTests: XCTestCase { ...@@ -41,7 +43,7 @@ final class AppFeatureTests: XCTestCase {
mainScheduler.advance() mainScheduler.advance()
store.receive(.clientDidChange(hasClient: false)) { store.receive(.clientDidChange(hasClient: false)) {
$0.scene = .landing(LandingState()) $0.scene = .landing(LandingState(id: newId))
} }
hasClient.send(completion: .finished) hasClient.send(completion: .finished)
......
...@@ -9,7 +9,7 @@ final class LandingFeatureTests: XCTestCase { ...@@ -9,7 +9,7 @@ final class LandingFeatureTests: XCTestCase {
env.clientStorage.hasStoredClient = { true } env.clientStorage.hasStoredClient = { true }
let store = TestStore( let store = TestStore(
initialState: LandingState(), initialState: LandingState(id: UUID()),
reducer: landingReducer, reducer: landingReducer,
environment: env environment: env
) )
...@@ -33,7 +33,7 @@ final class LandingFeatureTests: XCTestCase { ...@@ -33,7 +33,7 @@ final class LandingFeatureTests: XCTestCase {
env.mainScheduler = mainScheduler.eraseToAnyScheduler() env.mainScheduler = mainScheduler.eraseToAnyScheduler()
let store = TestStore( let store = TestStore(
initialState: LandingState(), initialState: LandingState(id: UUID()),
reducer: landingReducer, reducer: landingReducer,
environment: env environment: env
) )
...@@ -68,7 +68,7 @@ final class LandingFeatureTests: XCTestCase { ...@@ -68,7 +68,7 @@ final class LandingFeatureTests: XCTestCase {
env.mainScheduler = mainScheduler.eraseToAnyScheduler() env.mainScheduler = mainScheduler.eraseToAnyScheduler()
let store = TestStore( let store = TestStore(
initialState: LandingState(), initialState: LandingState(id: UUID()),
reducer: landingReducer, reducer: landingReducer,
environment: env environment: env
) )
...@@ -101,7 +101,7 @@ final class LandingFeatureTests: XCTestCase { ...@@ -101,7 +101,7 @@ final class LandingFeatureTests: XCTestCase {
env.mainScheduler = mainScheduler.eraseToAnyScheduler() env.mainScheduler = mainScheduler.eraseToAnyScheduler()
let store = TestStore( let store = TestStore(
initialState: LandingState(), initialState: LandingState(id: UUID()),
reducer: landingReducer, reducer: landingReducer,
environment: env environment: env
) )
...@@ -133,7 +133,7 @@ final class LandingFeatureTests: XCTestCase { ...@@ -133,7 +133,7 @@ final class LandingFeatureTests: XCTestCase {
env.mainScheduler = mainScheduler.eraseToAnyScheduler() env.mainScheduler = mainScheduler.eraseToAnyScheduler()
let store = TestStore( let store = TestStore(
initialState: LandingState(), initialState: LandingState(id: UUID()),
reducer: landingReducer, reducer: landingReducer,
environment: env environment: env
) )
...@@ -167,7 +167,7 @@ final class LandingFeatureTests: XCTestCase { ...@@ -167,7 +167,7 @@ final class LandingFeatureTests: XCTestCase {
env.mainScheduler = mainScheduler.eraseToAnyScheduler() env.mainScheduler = mainScheduler.eraseToAnyScheduler()
let store = TestStore( let store = TestStore(
initialState: LandingState(), initialState: LandingState(id: UUID()),
reducer: landingReducer, reducer: landingReducer,
environment: env environment: env
) )
......
import ComposableArchitecture import ComposableArchitecture
import ElixxirDAppsSDK
import ErrorFeature
import XCTest import XCTest
@testable import SessionFeature @testable import SessionFeature
final class SessionFeatureTests: XCTestCase { final class SessionFeatureTests: XCTestCase {
func testViewDidLoad() throws { func testViewDidLoad() {
var networkFollowerStatus: NetworkFollowerStatus!
var didStartMonitoringNetworkHealth = 0
var didStopMonitoringNetworkHealth = 0
var networkHealthCallback: ((Bool) -> Void)!
let bgScheduler = DispatchQueue.test
let mainScheduler = DispatchQueue.test
var env = SessionEnvironment.failing
env.getClient = {
var client = Client.failing
client.networkFollower.status.status = { networkFollowerStatus }
client.monitorNetworkHealth.listen = { callback in
networkHealthCallback = callback
didStartMonitoringNetworkHealth += 1
return Cancellable {
didStopMonitoringNetworkHealth += 1
}
}
return client
}
env.bgScheduler = bgScheduler.eraseToAnyScheduler()
env.mainScheduler = mainScheduler.eraseToAnyScheduler()
let store = TestStore( let store = TestStore(
initialState: SessionState(), initialState: SessionState(id: UUID()),
reducer: sessionReducer, reducer: sessionReducer,
environment: .failing environment: env
) )
store.send(.viewDidLoad) store.send(.viewDidLoad)
store.receive(.updateNetworkFollowerStatus)
store.receive(.monitorNetworkHealth(true))
networkFollowerStatus = .stopped
bgScheduler.advance()
mainScheduler.advance()
store.receive(.didUpdateNetworkFollowerStatus(.stopped)) {
$0.networkFollowerStatus = .stopped
}
XCTAssertEqual(didStartMonitoringNetworkHealth, 1)
XCTAssertEqual(didStopMonitoringNetworkHealth, 0)
networkHealthCallback(true)
bgScheduler.advance()
mainScheduler.advance()
store.receive(.didUpdateNetworkHealth(true)) {
$0.isNetworkHealthy = true
}
store.send(.monitorNetworkHealth(false))
bgScheduler.advance()
XCTAssertEqual(didStartMonitoringNetworkHealth, 1)
XCTAssertEqual(didStopMonitoringNetworkHealth, 1)
}
func testStartStopNetworkFollower() {
var networkFollowerStatus: NetworkFollowerStatus!
var didStartNetworkFollowerWithTimeout = [Int]()
var didStopNetworkFollower = 0
var networkFollowerStartError: NSError?
let bgScheduler = DispatchQueue.test
let mainScheduler = DispatchQueue.test
var env = SessionEnvironment.failing
env.getClient = {
var client = Client.failing
client.networkFollower.status.status = {
networkFollowerStatus
}
client.networkFollower.start.start = {
didStartNetworkFollowerWithTimeout.append($0)
if let error = networkFollowerStartError {
throw error
}
}
client.networkFollower.stop.stop = {
didStopNetworkFollower += 1
}
return client
}
env.bgScheduler = bgScheduler.eraseToAnyScheduler()
env.mainScheduler = mainScheduler.eraseToAnyScheduler()
let store = TestStore(
initialState: SessionState(id: UUID()),
reducer: sessionReducer,
environment: env
)
store.send(.runNetworkFollower(true)) {
$0.networkFollowerStatus = .starting
}
networkFollowerStatus = .running
bgScheduler.advance()
mainScheduler.advance()
XCTAssertEqual(didStartNetworkFollowerWithTimeout, [30_000])
XCTAssertEqual(didStopNetworkFollower, 0)
store.receive(.didUpdateNetworkFollowerStatus(.running)) {
$0.networkFollowerStatus = .running
}
store.send(.runNetworkFollower(false)) {
$0.networkFollowerStatus = .stopping
}
networkFollowerStatus = .stopped
bgScheduler.advance()
mainScheduler.advance()
XCTAssertEqual(didStartNetworkFollowerWithTimeout, [30_000])
XCTAssertEqual(didStopNetworkFollower, 1)
store.receive(.didUpdateNetworkFollowerStatus(.stopped)) {
$0.networkFollowerStatus = .stopped
}
store.send(.runNetworkFollower(true)) {
$0.networkFollowerStatus = .starting
}
networkFollowerStartError = NSError(domain: "test", code: 1234)
networkFollowerStatus = .stopped
bgScheduler.advance()
mainScheduler.advance()
XCTAssertEqual(didStartNetworkFollowerWithTimeout, [30_000, 30_000])
XCTAssertEqual(didStopNetworkFollower, 1)
store.receive(.networkFollowerDidFail(networkFollowerStartError!)) {
$0.error = ErrorState(error: networkFollowerStartError!)
}
store.receive(.didUpdateNetworkFollowerStatus(.stopped)) {
$0.networkFollowerStatus = .stopped
}
store.send(.didDismissError) {
$0.error = nil
}
} }
} }
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment