diff --git a/ElixxirDAppsSDK.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElixxirDAppsSDK.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1c4301a9b6a5409df6f637d7eb64e425929b7ca2..0ee0d575b6265a1319b61f4e9167c7a059296261 100644 --- a/ElixxirDAppsSDK.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElixxirDAppsSDK.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,6 +9,15 @@ "version" : "0.5.3" } }, + { + "identity" : "keychainaccess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kishikawakatsumi/KeychainAccess.git", + "state" : { + "revision" : "84e546727d66f1adc5439debad16270d0fdd04e7", + "version" : "4.2.2" + } + }, { "identity" : "swift-case-paths", "kind" : "remoteSourceControl", diff --git a/Example/ExampleApp/Package.swift b/Example/ExampleApp/Package.swift index 409928d46b38efc2d747f0fdb1418c5a74e91761..da060c7500d26453edaf44515cc5decedbf78074 100644 --- a/Example/ExampleApp/Package.swift +++ b/Example/ExampleApp/Package.swift @@ -31,6 +31,10 @@ let package = Package( url: "https://github.com/darrarski/swift-composable-presentation.git", .upToNextMajor(from: "0.5.2") ), + .package( + url: "https://github.com/kishikawakatsumi/KeychainAccess.git", + .upToNextMajor(from: "4.2.2") + ), ], targets: [ .target( @@ -50,6 +54,10 @@ let package = Package( name: "ComposablePresentation", package: "swift-composable-presentation" ), + .product( + name: "KeychainAccess", + package: "KeychainAccess" + ), ] ), .testTarget( @@ -65,6 +73,10 @@ let package = Package( name: "ComposableArchitecture", package: "swift-composable-architecture" ), + .product( + name: "ElixxirDAppsSDK", + package: "elixxir-dapps-sdk-swift" + ), ] ), .testTarget( diff --git a/Example/ExampleApp/Sources/AppFeature/App.swift b/Example/ExampleApp/Sources/AppFeature/App.swift index 0374fa69236b9ad17bcbe76cf552fe9b773625ea..b7446b7e0e0849b549d7b802e255038ec0e0f243 100644 --- a/Example/ExampleApp/Sources/AppFeature/App.swift +++ b/Example/ExampleApp/Sources/AppFeature/App.swift @@ -1,4 +1,6 @@ +import Combine import ComposableArchitecture +import ElixxirDAppsSDK import LandingFeature import SessionFeature import SwiftUI @@ -18,8 +20,24 @@ struct App: SwiftUI.App { extension AppEnvironment { static func live() -> AppEnvironment { - AppEnvironment( - landing: LandingEnvironment(), + let clientSubject = CurrentValueSubject<Client?, Never>(nil) + let mainScheduler = DispatchQueue.main.eraseToAnyScheduler() + let bgScheduler = DispatchQueue( + label: "xx.network.dApps.ExampleApp.bg", + qos: .background + ).eraseToAnyScheduler() + + return AppEnvironment( + hasClient: clientSubject.map { $0 != nil }.eraseToAnyPublisher(), + mainScheduler: mainScheduler, + landing: LandingEnvironment( + clientStorage: .live( + passwordStorage: .keychain + ), + setClient: { clientSubject.send($0) }, + bgScheduler: bgScheduler, + mainScheduler: mainScheduler + ), session: SessionEnvironment() ) } diff --git a/Example/ExampleApp/Sources/AppFeature/AppFeature.swift b/Example/ExampleApp/Sources/AppFeature/AppFeature.swift index 07d6812aab59216d9397acc3404a12e2774f0e30..b99904b08b9a7f800fb7d244e5e274f3f3715c57 100644 --- a/Example/ExampleApp/Sources/AppFeature/AppFeature.swift +++ b/Example/ExampleApp/Sources/AppFeature/AppFeature.swift @@ -1,3 +1,4 @@ +import Combine import ComposableArchitecture import ComposablePresentation import LandingFeature @@ -38,11 +39,14 @@ extension AppState.Scene { enum AppAction: Equatable { case viewDidLoad + case clientDidChange(hasClient: Bool) case landing(LandingAction) case session(SessionAction) } struct AppEnvironment { + var hasClient: AnyPublisher<Bool, Never> + var mainScheduler: AnySchedulerOf<DispatchQueue> var landing: LandingEnvironment var session: SessionEnvironment } @@ -51,6 +55,22 @@ let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, env in switch action { case .viewDidLoad: + struct HasClientEffectId: Hashable {} + return env.hasClient + .removeDuplicates() + .map(AppAction.clientDidChange(hasClient:)) + .receive(on: env.mainScheduler) + .eraseToEffect() + .cancellable(id: HasClientEffectId(), cancelInFlight: true) + + case .clientDidChange(let hasClient): + if hasClient { + let sessionState = state.scene.asSession ?? SessionState() + state.scene = .session(sessionState) + } else { + let landingState = state.scene.asLanding ?? LandingState() + state.scene = .landing(landingState) + } return .none case .landing(_), .session(_): @@ -75,6 +95,8 @@ let appReducer = Reducer<AppState, AppAction, AppEnvironment> #if DEBUG extension AppEnvironment { static let failing = AppEnvironment( + hasClient: Empty().eraseToAnyPublisher(), + mainScheduler: .failing, landing: .failing, session: .failing ) diff --git a/Example/ExampleApp/Sources/AppFeature/PasswordStorage+Keychain.swift b/Example/ExampleApp/Sources/AppFeature/PasswordStorage+Keychain.swift new file mode 100644 index 0000000000000000000000000000000000000000..c28a05b8a5b4cabfd4ef71e494b37a7e02d7ae11 --- /dev/null +++ b/Example/ExampleApp/Sources/AppFeature/PasswordStorage+Keychain.swift @@ -0,0 +1,14 @@ +import ElixxirDAppsSDK +import KeychainAccess + +extension PasswordStorage { + static let keychain: PasswordStorage = { + let keychain = KeychainAccess.Keychain( + service: "xx.network.client" + ) + return PasswordStorage( + save: { password in keychain[data: "password"] = password}, + load: { try keychain[data: "password"] ?? { throw PasswordStorageMissingPasswordError() }() } + ) + }() +} diff --git a/Example/ExampleApp/Sources/LandingFeature/LandingFeature.swift b/Example/ExampleApp/Sources/LandingFeature/LandingFeature.swift index b145b2500009159d0df667e3befcf6c7e21fed53..a3b1c3b83b19384da34d95c44dce9151ee373bb7 100644 --- a/Example/ExampleApp/Sources/LandingFeature/LandingFeature.swift +++ b/Example/ExampleApp/Sources/LandingFeature/LandingFeature.swift @@ -1,27 +1,122 @@ +import Combine import ComposableArchitecture +import ElixxirDAppsSDK public struct LandingState: Equatable { - public init() {} + public init( + hasStoredClient: Bool = false, + isMakingClient: Bool = false, + isRemovingClient: Bool = false + ) { + self.hasStoredClient = hasStoredClient + self.isMakingClient = isMakingClient + self.isRemovingClient = isRemovingClient + } + + var hasStoredClient: Bool + var isMakingClient: Bool + var isRemovingClient: Bool } public enum LandingAction: Equatable { case viewDidLoad + case makeClient + case didMakeClient + case didFailMakingClient(NSError) + case removeStoredClient + case didRemoveStoredClient + case didFailRemovingStoredClient(NSError) } public struct LandingEnvironment { - public init() {} + public init( + clientStorage: ClientStorage, + setClient: @escaping (Client) -> Void, + bgScheduler: AnySchedulerOf<DispatchQueue>, + mainScheduler: AnySchedulerOf<DispatchQueue> + ) { + self.clientStorage = clientStorage + self.setClient = setClient + self.bgScheduler = bgScheduler + self.mainScheduler = mainScheduler + } + + public var clientStorage: ClientStorage + public var setClient: (Client) -> Void + public var bgScheduler: AnySchedulerOf<DispatchQueue> + public var mainScheduler: AnySchedulerOf<DispatchQueue> } public let landingReducer = Reducer<LandingState, LandingAction, LandingEnvironment> { state, action, env in switch action { case .viewDidLoad: + state.hasStoredClient = env.clientStorage.hasStoredClient() + return .none + + case .makeClient: + state.isMakingClient = true + return Effect.future { fulfill in + do { + if env.clientStorage.hasStoredClient() { + env.setClient(try env.clientStorage.loadClient()) + } else { + env.setClient(try env.clientStorage.createClient()) + } + fulfill(.success(.didMakeClient)) + } catch { + fulfill(.success(.didFailMakingClient(error as NSError))) + } + } + .subscribe(on: env.bgScheduler) + .receive(on: env.mainScheduler) + .eraseToEffect() + + case .didMakeClient: + state.isMakingClient = false + state.hasStoredClient = env.clientStorage.hasStoredClient() + return .none + + case .didFailMakingClient(let error): + state.isMakingClient = false + state.hasStoredClient = env.clientStorage.hasStoredClient() + // TODO: handle error + return .none + + case .removeStoredClient: + state.isRemovingClient = true + return Effect.future { fulfill in + do { + try env.clientStorage.removeClient() + fulfill(.success(.didRemoveStoredClient)) + } catch { + fulfill(.success(.didFailRemovingStoredClient(error as NSError))) + } + } + .subscribe(on: env.bgScheduler) + .receive(on: env.mainScheduler) + .eraseToEffect() + + case .didRemoveStoredClient: + state.isRemovingClient = false + state.hasStoredClient = env.clientStorage.hasStoredClient() + return .none + + case .didFailRemovingStoredClient(let error): + state.isRemovingClient = false + state.hasStoredClient = env.clientStorage.hasStoredClient() + // TODO: handle error return .none } } #if DEBUG extension LandingEnvironment { - public static let failing = LandingEnvironment() + public static let failing = LandingEnvironment( + clientStorage: .failing, + setClient: { _ in fatalError() }, + bgScheduler: .failing, + mainScheduler: .failing + ) } #endif diff --git a/Example/ExampleApp/Sources/LandingFeature/LandingView.swift b/Example/ExampleApp/Sources/LandingFeature/LandingView.swift index 5ebc5e89f90862a08cf02ed96e9541c108fcb67c..325cbe635645c7ed4d98784f3dfb380a2ebcdb61 100644 --- a/Example/ExampleApp/Sources/LandingFeature/LandingView.swift +++ b/Example/ExampleApp/Sources/LandingFeature/LandingView.swift @@ -9,15 +9,56 @@ public struct LandingView: View { let store: Store<LandingState, LandingAction> struct ViewState: Equatable { - init(state: LandingState) {} + let hasStoredClient: Bool + let isMakingClient: Bool + let isRemovingClient: Bool + + init(state: LandingState) { + hasStoredClient = state.hasStoredClient + isMakingClient = state.isMakingClient + isRemovingClient = state.isRemovingClient + } + + var isLoading: Bool { + isMakingClient || + isRemovingClient + } } public var body: some View { WithViewStore(store.scope(state: ViewState.init)) { viewStore in - Text("LandingView") - .task { - viewStore.send(.viewDidLoad) + Form { + Button { + viewStore.send(.makeClient) + } label: { + HStack { + Text(viewStore.hasStoredClient ? "Load stored client" : "Create new client") + Spacer() + if viewStore.isMakingClient { + ProgressView() + } + } + } + + if viewStore.hasStoredClient { + Button(role: .destructive) { + viewStore.send(.removeStoredClient) + } label: { + HStack { + Text("Remove stored client") + Spacer() + if viewStore.isRemovingClient { + ProgressView() + } + } + } } + } + .navigationTitle("Landing") + .disabled(viewStore.isLoading) + .task { + viewStore.send(.viewDidLoad) + } } } } diff --git a/Example/ExampleApp/Sources/SessionFeature/SessionView.swift b/Example/ExampleApp/Sources/SessionFeature/SessionView.swift index 781ee440613cdf84130a362ea97c4397c7a13d0f..eb1fcf0071a4e534f867c443b2913cbab1ec0779 100644 --- a/Example/ExampleApp/Sources/SessionFeature/SessionView.swift +++ b/Example/ExampleApp/Sources/SessionFeature/SessionView.swift @@ -15,6 +15,7 @@ public struct SessionView: View { public var body: some View { WithViewStore(store.scope(state: ViewState.init)) { viewStore in Text("SessionView") + .navigationTitle("Session") .task { viewStore.send(.viewDidLoad) } diff --git a/Example/ExampleApp/Tests/AppFeatureTests/AppFeatureTests.swift b/Example/ExampleApp/Tests/AppFeatureTests/AppFeatureTests.swift index 2fe7038c8df495223184b2fa8303ecf31678a02b..96a0b07759825f391e32584051116f09acf55934 100644 --- a/Example/ExampleApp/Tests/AppFeatureTests/AppFeatureTests.swift +++ b/Example/ExampleApp/Tests/AppFeatureTests/AppFeatureTests.swift @@ -1,15 +1,50 @@ +import Combine import ComposableArchitecture +import LandingFeature +import SessionFeature import XCTest @testable import AppFeature final class AppFeatureTests: XCTestCase { func testViewDidLoad() throws { + let hasClient = PassthroughSubject<Bool, Never>() + let mainScheduler = DispatchQueue.test + + var env = AppEnvironment.failing + env.hasClient = hasClient.eraseToAnyPublisher() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + let store = TestStore( initialState: AppState(), reducer: appReducer, - environment: .failing + environment: env ) store.send(.viewDidLoad) + + hasClient.send(false) + mainScheduler.advance() + + store.receive(.clientDidChange(hasClient: false)) + + hasClient.send(true) + mainScheduler.advance() + + store.receive(.clientDidChange(hasClient: true)) { + $0.scene = .session(SessionState()) + } + + hasClient.send(true) + mainScheduler.advance() + + hasClient.send(false) + mainScheduler.advance() + + store.receive(.clientDidChange(hasClient: false)) { + $0.scene = .landing(LandingState()) + } + + hasClient.send(completion: .finished) + mainScheduler.advance() } } diff --git a/Example/ExampleApp/Tests/LandingFeatureTests/LandingFeatureTests.swift b/Example/ExampleApp/Tests/LandingFeatureTests/LandingFeatureTests.swift index 389863f66901186aecf835bc3c55da017b155bca..3083d97ab257c31f4d1d2450ec50cd75ff1cc772 100644 --- a/Example/ExampleApp/Tests/LandingFeatureTests/LandingFeatureTests.swift +++ b/Example/ExampleApp/Tests/LandingFeatureTests/LandingFeatureTests.swift @@ -4,12 +4,182 @@ import XCTest final class LandingFeatureTests: XCTestCase { func testViewDidLoad() throws { + var env = LandingEnvironment.failing + env.clientStorage.hasStoredClient = { true } + + let store = TestStore( + initialState: LandingState(), + reducer: landingReducer, + environment: env + ) + + store.send(.viewDidLoad) { + $0.hasStoredClient = true + } + } + + func testCreateClient() { + var hasStoredClient = false + var didSetClient = false + let bgScheduler = DispatchQueue.test + let mainScheduler = DispatchQueue.test + + var env = LandingEnvironment.failing + env.clientStorage.hasStoredClient = { hasStoredClient } + env.clientStorage.createClient = { .failing } + env.setClient = { _ in didSetClient = true } + env.bgScheduler = bgScheduler.eraseToAnyScheduler() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + + let store = TestStore( + initialState: LandingState(), + reducer: landingReducer, + environment: env + ) + + store.send(.makeClient) { + $0.isMakingClient = true + } + + bgScheduler.advance() + + XCTAssertTrue(didSetClient) + + hasStoredClient = true + mainScheduler.advance() + + store.receive(.didMakeClient) { + $0.isMakingClient = false + $0.hasStoredClient = true + } + } + + func testLoadStoredClient() { + var didSetClient = false + let bgScheduler = DispatchQueue.test + let mainScheduler = DispatchQueue.test + + var env = LandingEnvironment.failing + env.clientStorage.hasStoredClient = { true } + env.clientStorage.loadClient = { .failing } + env.setClient = { _ in didSetClient = true } + env.bgScheduler = bgScheduler.eraseToAnyScheduler() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + + let store = TestStore( + initialState: LandingState(), + reducer: landingReducer, + environment: env + ) + + store.send(.makeClient) { + $0.isMakingClient = true + } + + bgScheduler.advance() + + XCTAssertTrue(didSetClient) + + mainScheduler.advance() + + store.receive(.didMakeClient) { + $0.isMakingClient = false + $0.hasStoredClient = true + } + } + + func testMakeClientFailure() { + let error = NSError(domain: "test", code: 1234) + let bgScheduler = DispatchQueue.test + let mainScheduler = DispatchQueue.test + + var env = LandingEnvironment.failing + env.clientStorage.hasStoredClient = { false } + env.clientStorage.createClient = { throw error } + env.bgScheduler = bgScheduler.eraseToAnyScheduler() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + + let store = TestStore( + initialState: LandingState(), + reducer: landingReducer, + environment: env + ) + + store.send(.makeClient) { + $0.isMakingClient = true + } + + bgScheduler.advance() + mainScheduler.advance() + + store.receive(.didFailMakingClient(error)) { + $0.isMakingClient = false + $0.hasStoredClient = false + } + } + + func testRemoveStoredClient() { + var hasStoredClient = true + var didRemoveClient = false + let bgScheduler = DispatchQueue.test + let mainScheduler = DispatchQueue.test + + var env = LandingEnvironment.failing + env.clientStorage.hasStoredClient = { hasStoredClient } + env.clientStorage.removeClient = { didRemoveClient = true } + env.bgScheduler = bgScheduler.eraseToAnyScheduler() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + let store = TestStore( initialState: LandingState(), reducer: landingReducer, - environment: .failing + environment: env ) - store.send(.viewDidLoad) + store.send(.removeStoredClient) { + $0.isRemovingClient = true + } + + bgScheduler.advance() + + XCTAssertTrue(didRemoveClient) + + hasStoredClient = false + mainScheduler.advance() + + store.receive(.didRemoveStoredClient) { + $0.isRemovingClient = false + $0.hasStoredClient = false + } + } + + func testRemoveStoredClientFailure() { + let error = NSError(domain: "test", code: 1234) + let bgScheduler = DispatchQueue.test + let mainScheduler = DispatchQueue.test + + var env = LandingEnvironment.failing + env.clientStorage.hasStoredClient = { true } + env.clientStorage.removeClient = { throw error } + env.bgScheduler = bgScheduler.eraseToAnyScheduler() + env.mainScheduler = mainScheduler.eraseToAnyScheduler() + + let store = TestStore( + initialState: LandingState(), + reducer: landingReducer, + environment: env + ) + + store.send(.removeStoredClient) { + $0.isRemovingClient = true + } + + bgScheduler.advance() + mainScheduler.advance() + + store.receive(.didFailRemovingStoredClient(error)) { + $0.isRemovingClient = false + $0.hasStoredClient = true + } } }