diff --git a/secant/Dependencies/Mnemonic/MnemonicInterface.swift b/secant/Dependencies/Mnemonic/MnemonicInterface.swift index 2166aa0..cc78f72 100644 --- a/secant/Dependencies/Mnemonic/MnemonicInterface.swift +++ b/secant/Dependencies/Mnemonic/MnemonicInterface.swift @@ -22,7 +22,7 @@ struct MnemonicClient { /// Generate deterministic seed from mnemonic phrase var toSeed: (String) throws -> [UInt8] /// Get this mnemonic phrase as array of words - var asWords: (String) throws -> [String] + var asWords: (String) -> [String] /// Validates whether the given mnemonic is correct var isValid: (String) throws -> Void } diff --git a/secant/Features/Home/HomeStore.swift b/secant/Features/Home/HomeStore.swift index c4dee08..c271a95 100644 --- a/secant/Features/Home/HomeStore.swift +++ b/secant/Features/Home/HomeStore.swift @@ -21,6 +21,7 @@ struct HomeReducer: ReducerProtocol { case transactionHistory } + @BindingState var alert: AlertState? var balanceBreakdownState: BalanceBreakdownReducer.State var destination: Destination? var profileState: ProfileReducer.State @@ -56,11 +57,12 @@ struct HomeReducer: ReducerProtocol { enum Action: Equatable { case balanceBreakdown(BalanceBreakdownReducer.Action) case debugMenuStartup + case dismissAlert case onAppear case onDisappear case profile(ProfileReducer.Action) case request(RequestReducer.Action) - case rewindDone(Bool, SettingsReducer.Action) + case rewindDone(String?, SettingsReducer.Action) case send(SendFlowReducer.Action) case synchronizerStateChanged(SDKSynchronizerState) case walletEvents(WalletEventsFlowReducer.Action) @@ -141,9 +143,9 @@ struct HomeReducer: ReducerProtocol { return .run { send in do { try await sdkSynchronizer.rewind(.quick) - await send(.rewindDone(true, .quickRescan)) + await send(.rewindDone(nil, .quickRescan)) } catch { - await send(.rewindDone(false, .quickRescan)) + await send(.rewindDone(error.localizedDescription, .quickRescan)) } } @@ -152,9 +154,9 @@ struct HomeReducer: ReducerProtocol { return .run { send in do { try await sdkSynchronizer.rewind(.birthday) - await send(.rewindDone(true, .fullRescan)) + await send(.rewindDone(nil, .fullRescan)) } catch { - await send(.rewindDone(false, .fullRescan)) + await send(.rewindDone(error.localizedDescription, .fullRescan)) } } @@ -164,8 +166,15 @@ struct HomeReducer: ReducerProtocol { case .request: return .none - case .rewindDone: - // TODO: [#221] error we need to handle (https://github.com/zcash/secant-ios-wallet/issues/221) + case let .rewindDone(errorDescription, _): + if let errorDescription { + // TODO: [#221] Handle error more properly (https://github.com/zcash/secant-ios-wallet/issues/221) + state.alert = AlertState( + title: TextState("Rewind failed"), + message: TextState("Error: \(errorDescription)"), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) + } return .none case .walletEvents: @@ -186,6 +195,10 @@ struct HomeReducer: ReducerProtocol { case .debugMenuStartup: return .none + + case .dismissAlert: + state.alert = nil + return .none } } } diff --git a/secant/Features/Home/HomeView.swift b/secant/Features/Home/HomeView.swift index 6a1dd88..09b6b87 100644 --- a/secant/Features/Home/HomeView.swift +++ b/secant/Features/Home/HomeView.swift @@ -29,6 +29,7 @@ struct HomeView: View { .navigationBarHidden(true) .onAppear(perform: { viewStore.send(.onAppear) }) .onDisappear(perform: { viewStore.send(.onDisappear) }) + .alert(self.store.scope(state: \.alert), dismiss: .dismissAlert) .fullScreenCover(isPresented: viewStore.bindingForDestination(.balanceBreakdown)) { BalanceBreakdownView(store: store.balanceBreakdownStore()) } diff --git a/secant/Features/ImportWallet/ImportWalletStore.swift b/secant/Features/ImportWallet/ImportWalletStore.swift index ce7d5f5..5fa630a 100644 --- a/secant/Features/ImportWallet/ImportWalletStore.swift +++ b/secant/Features/ImportWallet/ImportWalletStore.swift @@ -124,8 +124,8 @@ struct ImportWalletReducer: ReducerProtocol { } catch { // TODO: [#221] Proper Error/Success handling (https://github.com/zcash/secant-ios-wallet/issues/221) state.alert = AlertState( - title: TextState("Wrong Seed Phrase"), - message: TextState("The seed phrase must be 24 words separated by space."), + title: TextState("Failed to restore wallet"), + message: TextState("Error: \(error.localizedDescription)"), dismissButton: .default( TextState("Ok"), action: .send(.dismissAlert) diff --git a/secant/Features/Root/RootDestination.swift b/secant/Features/Root/RootDestination.swift index ed2326d..9c189d9 100644 --- a/secant/Features/Root/RootDestination.swift +++ b/secant/Features/Root/RootDestination.swift @@ -40,6 +40,7 @@ extension RootReducer { case deeplink(URL) case deeplinkHome case deeplinkSend(Zatoshi, String, String) + case deeplinkFailed(URL, String) case dismissAlert case updateDestination(RootReducer.DestinationState.Destination) } @@ -94,7 +95,7 @@ extension RootReducer { ) ) } catch { - // TODO: [#221] error we need to handle (https://github.com/zcash/secant-ios-wallet/issues/221) + await send(.destination(.deeplinkFailed(url, error.localizedDescription))) } } @@ -111,6 +112,18 @@ extension RootReducer { state.homeState.sendState.memoState.text = memo.redacted return .none + case let .destination(.deeplinkFailed(url, errorDescription)): + // TODO: [#221] Handle error more properly (https://github.com/zcash/secant-ios-wallet/issues/221) + state.destinationState.alert = AlertState( + title: TextState("Failed to process deeplink."), + message: TextState("Deeplink: \(url))\nError: \(errorDescription)"), + dismissButton: .default( + TextState("Ok"), + action: .send(.destination(.dismissAlert)) + ) + ) + return .none + case .home(.walletEvents(.replyTo(let address))): guard let url = URL(string: "zcash:\(address)") else { return .none @@ -122,7 +135,7 @@ extension RootReducer { return .none case .home, .initialization, .onboarding, .phraseDisplay, .phraseValidation, - .sandbox, .welcome, .binding, .nukeWalletFailed, .nukeWalletSucceeded, .debug, .walletConfigLoaded: + .sandbox, .welcome, .binding, .nukeWalletFailed, .nukeWalletSucceeded, .debug, .walletConfigLoaded, .dismissAlert: return .none } diff --git a/secant/Features/Root/RootInitialization.swift b/secant/Features/Root/RootInitialization.swift index d77b3fb..8fe959c 100644 --- a/secant/Features/Root/RootInitialization.swift +++ b/secant/Features/Root/RootInitialization.swift @@ -83,11 +83,22 @@ extension RootReducer { case .initialization(.respondToWalletInitializationState(let walletState)): switch walletState { case .failed: - // TODO: [#221] error we need to handle (https://github.com/zcash/secant-ios-wallet/issues/221) + // TODO: [#221] Handle error more properly (https://github.com/zcash/secant-ios-wallet/issues/221) state.appInitializationState = .failed + state.alert = AlertState( + title: TextState("Wallet initialisation failed."), + message: TextState("App initialisation state: failed"), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) case .keysMissing: - // TODO: [#221] error we need to handle (https://github.com/zcash/secant-ios-wallet/issues/221) + // TODO: [#221] Handle error more properly (https://github.com/zcash/secant-ios-wallet/issues/221) state.appInitializationState = .keysMissing + state.alert = AlertState( + title: TextState("Wallet initialisation failed."), + message: TextState("App initialisation state: keysMissing."), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) + case .initialized, .filesMissing: if walletState == .filesMissing { state.appInitializationState = .filesMissing @@ -114,7 +125,12 @@ extension RootReducer { guard let storedWallet = state.storedWallet else { state.appInitializationState = .failed - // TODO: [#221] fatal error we need to handle (https://github.com/zcash/secant-ios-wallet/issues/221) + // TODO: [#221] Handle fatal error more properly (https://github.com/zcash/secant-ios-wallet/issues/221) + state.alert = AlertState( + title: TextState("Wallet initialisation failed."), + message: TextState("Can't load seed phrase from local storage."), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) return .none } @@ -136,32 +152,37 @@ extension RootReducer { try sdkSynchronizer.start() return .none } catch { - // TODO: [#221] error we need to handle (https://github.com/zcash/secant-ios-wallet/issues/221) state.appInitializationState = .failed + // TODO: [#221] Handle error more properly (https://github.com/zcash/secant-ios-wallet/issues/221) + state.alert = AlertState( + title: TextState("Failed to initialize the SDK"), + message: TextState("Error: \(error.localizedDescription)"), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) return .none } case .initialization(.checkBackupPhraseValidation): guard let storedWallet = state.storedWallet else { state.appInitializationState = .failed - // TODO: [#221] fatal error we need to handle (https://github.com/zcash/secant-ios-wallet/issues/221) + // TODO: [#221] Handle fatal error more properly (https://github.com/zcash/secant-ios-wallet/issues/221) + state.alert = AlertState( + title: TextState("Wallet initialisation failed."), + message: TextState("Can't load seed phrase from local storage."), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) return .none } var landingDestination = RootReducer.DestinationState.Destination.home - - if !storedWallet.hasUserPassedPhraseBackupTest && state.walletConfig.isEnabled(.testBackupPhraseFlow) { - do { - let phraseWords = try mnemonic.asWords(storedWallet.seedPhrase.value()) - let recoveryPhrase = RecoveryPhrase(words: phraseWords.map { $0.redacted }) - state.phraseDisplayState.phrase = recoveryPhrase - state.phraseValidationState = randomRecoveryPhrase.random(recoveryPhrase) - landingDestination = .phraseDisplay - } catch { - // TODO: [#201] - merge with issue 201 (https://github.com/zcash/secant-ios-wallet/issues/201) and its Error States - return .none - } + if !storedWallet.hasUserPassedPhraseBackupTest && state.walletConfig.isEnabled(.testBackupPhraseFlow) { + let phraseWords = mnemonic.asWords(storedWallet.seedPhrase.value()) + + let recoveryPhrase = RecoveryPhrase(words: phraseWords.map { $0.redacted }) + state.phraseDisplayState.phrase = recoveryPhrase + state.phraseValidationState = randomRecoveryPhrase.random(recoveryPhrase) + landingDestination = .phraseDisplay } state.appInitializationState = .initialized @@ -181,7 +202,7 @@ extension RootReducer { try walletStorage.importWallet(newRandomPhrase, birthday, .english, !state.walletConfig.isEnabled(.testBackupPhraseFlow)) // start the backup phrase validation test - let randomRecoveryPhraseWords = try mnemonic.asWords(newRandomPhrase) + let randomRecoveryPhraseWords = mnemonic.asWords(newRandomPhrase) let recoveryPhrase = RecoveryPhrase(words: randomRecoveryPhraseWords.map { $0.redacted }) state.phraseDisplayState.phrase = recoveryPhrase state.phraseValidationState = randomRecoveryPhrase.random(recoveryPhrase) @@ -192,6 +213,11 @@ extension RootReducer { ) } catch { // TODO: [#201] - merge with issue 221 (https://github.com/zcash/secant-ios-wallet/issues/221) and its Error States + state.alert = AlertState( + title: TextState("Wallet initialisation failed."), + message: TextState("Can't create new wallet. Error: \(error.localizedDescription)"), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) } return .none @@ -201,6 +227,11 @@ extension RootReducer { try walletStorage.markUserPassedPhraseBackupTest(true) } catch { // TODO: [#221] error we need to handle, issue #221 (https://github.com/zcash/secant-ios-wallet/issues/221) + state.alert = AlertState( + title: TextState("Wallet initialisation failed."), + message: TextState("Can't store information that user passed phrase backup test. Error: \(error.localizedDescription)"), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) } return .none @@ -245,6 +276,12 @@ extension RootReducer { case .nukeWalletFailed: // TODO: [#221] error we need to handle, issue #221 (https://github.com/zcash/secant-ios-wallet/issues/221) + state.alert = AlertState( + title: TextState("Nuke of the wallet failed"), + message: TextState(""), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) + let backDestination: EffectTask if let previousDestination = state.destinationState.previousDestination { backDestination = EffectTask(value: .destination(.updateDestination(previousDestination))) @@ -280,6 +317,10 @@ extension RootReducer { ) return .none + case .dismissAlert: + state.alert = nil + return .none + case let .debug(.updateFlag(flag, isEnabled)): return walletConfigProvider.update(flag, !isEnabled) .receive(on: mainQueue) diff --git a/secant/Features/Root/RootStore.swift b/secant/Features/Root/RootStore.swift index abe2811..8532daf 100644 --- a/secant/Features/Root/RootStore.swift +++ b/secant/Features/Root/RootStore.swift @@ -10,6 +10,7 @@ struct RootReducer: ReducerProtocol { enum WalletConfigCancelId {} struct State: Equatable { + @BindingState var alert: AlertState? var appInitializationState: InitializationState = .uninitialized var destinationState: DestinationState var homeState: HomeReducer.State @@ -25,6 +26,7 @@ struct RootReducer: ReducerProtocol { enum Action: Equatable, BindableAction { case binding(BindingAction) case debug(DebugAction) + case dismissAlert case destination(DestinationAction) case home(HomeReducer.Action) case initialization(InitializationAction) diff --git a/secant/Features/Root/RootView.swift b/secant/Features/Root/RootView.swift index bfdea68..3767274 100644 --- a/secant/Features/Root/RootView.swift +++ b/secant/Features/Root/RootView.swift @@ -79,6 +79,7 @@ struct RootView: View { } } .onOpenURL(perform: { viewStore.goToDeeplink($0) }) + .alert(self.store.scope(state: \.alert), dismiss: .dismissAlert) } } } diff --git a/secant/Features/Scan/ScanStore.swift b/secant/Features/Scan/ScanStore.swift index 103314d..657c211 100644 --- a/secant/Features/Scan/ScanStore.swift +++ b/secant/Features/Scan/ScanStore.swift @@ -21,6 +21,7 @@ struct ScanReducer: ReducerProtocol { case unknown } + @BindingState var alert: AlertState? var isTorchAvailable = false var isTorchOn = false var isValidValue = false @@ -40,6 +41,7 @@ struct ScanReducer: ReducerProtocol { @Dependency(\.uriParser) var uriParser enum Action: Equatable { + case dismissAlert case onAppear case onDisappear case found(RedactableString) @@ -51,6 +53,10 @@ struct ScanReducer: ReducerProtocol { // swiftlint:disable:next cyclomatic_complexity func reduce(into state: inout State, action: Action) -> ComposableArchitecture.EffectTask { switch action { + case .dismissAlert: + state.alert = nil + return .none + case .onAppear: // reset the values state.scanStatus = .unknown @@ -60,7 +66,12 @@ struct ScanReducer: ReducerProtocol { do { state.isTorchAvailable = try captureDevice.isTorchAvailable() } catch { - // TODO: [#322] handle torch errors (https://github.com/zcash/secant-ios-wallet/issues/322) + // TODO: [#322] Handle error more properly (https://github.com/zcash/secant-ios-wallet/issues/322) + state.alert = AlertState( + title: TextState("Can't initialize the camera"), + message: TextState("Error: \(error.localizedDescription)"), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) } return .none @@ -105,6 +116,11 @@ struct ScanReducer: ReducerProtocol { state.isTorchOn.toggle() } catch { // TODO: [#322] handle torch errors (https://github.com/zcash/secant-ios-wallet/issues/322) + state.alert = AlertState( + title: TextState("Can't initialize the camera"), + message: TextState("Error: \(error.localizedDescription)"), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) } return .none } diff --git a/secant/Features/Scan/ScanView.swift b/secant/Features/Scan/ScanView.swift index 0f3b9e9..fc18803 100644 --- a/secant/Features/Scan/ScanView.swift +++ b/secant/Features/Scan/ScanView.swift @@ -50,6 +50,7 @@ struct ScanView: View { .applyScreenBackground() .onAppear { viewStore.send(.onAppear) } .onDisappear { viewStore.send(.onDisappear) } + .alert(self.store.scope(state: \.alert), dismiss: .dismissAlert) } .ignoresSafeArea() } diff --git a/secant/Features/Settings/SettingsStore.swift b/secant/Features/Settings/SettingsStore.swift index 6163de8..cb88481 100644 --- a/secant/Features/Settings/SettingsStore.swift +++ b/secant/Features/Settings/SettingsStore.swift @@ -48,6 +48,7 @@ struct SettingsReducer: ReducerProtocol { case exportLogs case fullRescan case logsExported + case logsExportFailed(String) case logsShareFinished case onAppear case phraseDisplay(RecoveryPhraseDisplayReducer.Action) @@ -83,12 +84,17 @@ struct SettingsReducer: ReducerProtocol { case .backupWallet: do { let storedWallet = try walletStorage.exportWallet() - let phraseWords = try mnemonic.asWords(storedWallet.seedPhrase.value()) + let phraseWords = mnemonic.asWords(storedWallet.seedPhrase.value()) let recoveryPhrase = RecoveryPhrase(words: phraseWords.map { $0.redacted }) state.phraseDisplayState.phrase = recoveryPhrase return EffectTask(value: .updateDestination(.backupPhrase)) } catch { // TODO: [#221] - merge with issue 221 (https://github.com/zcash/secant-ios-wallet/issues/221) and its Error States + state.alert = AlertState( + title: TextState("Can't backup wallet"), + message: TextState("Error: \(error.localizedDescription)"), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) return .none } @@ -118,7 +124,7 @@ struct SettingsReducer: ReducerProtocol { try await logsHandler.exportAndStoreLogs(state.tempSDKDir, state.tempTCADir, state.tempWalletDir) await send(.logsExported) } catch { - // TODO: [#527] address the error here https://github.com/zcash/secant-ios-wallet/issues/527 + await send(.logsExportFailed(error.localizedDescription)) } } @@ -127,6 +133,15 @@ struct SettingsReducer: ReducerProtocol { state.isSharingLogs = true return .none + case let .logsExportFailed(errorDescription): + // TODO: [#527] address the error here https://github.com/zcash/secant-ios-wallet/issues/527 + state.alert = AlertState( + title: TextState("Error when exporting logs"), + message: TextState("Error: \(errorDescription)"), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) + return .none + case .logsShareFinished: state.isSharingLogs = false return .none diff --git a/secantTests/HomeTests/HomeTests.swift b/secantTests/HomeTests/HomeTests.swift index dca3e5c..7a8490c 100644 --- a/secantTests/HomeTests/HomeTests.swift +++ b/secantTests/HomeTests/HomeTests.swift @@ -110,7 +110,7 @@ class HomeTests: XCTestCase { state.destination = nil } - await store.receive(.rewindDone(true, .quickRescan)) + await store.receive(.rewindDone(nil, .quickRescan)) } @MainActor func testFullRescan_ResetToHomeScreen() async throws { @@ -135,6 +135,6 @@ class HomeTests: XCTestCase { state.destination = nil } - await store.receive(.rewindDone(true, .fullRescan)) + await store.receive(.rewindDone(nil, .fullRescan)) } } diff --git a/secantTests/RootTests/AppInitializationTests.swift b/secantTests/RootTests/AppInitializationTests.swift index dade445..c6cae55 100644 --- a/secantTests/RootTests/AppInitializationTests.swift +++ b/secantTests/RootTests/AppInitializationTests.swift @@ -165,6 +165,11 @@ class AppInitializationTests: XCTestCase { await store.receive(.initialization(.respondToWalletInitializationState(.keysMissing))) { state in state.appInitializationState = .keysMissing + state.alert = AlertState( + title: TextState("Wallet initialisation failed."), + message: TextState("App initialisation state: keysMissing."), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) } await store.finish() diff --git a/secantTests/RootTests/RootTests.swift b/secantTests/RootTests/RootTests.swift index 4d0a2b5..50b9b52 100644 --- a/secantTests/RootTests/RootTests.swift +++ b/secantTests/RootTests/RootTests.swift @@ -118,16 +118,23 @@ class RootTests: XCTestCase { store.send(.initialization(.respondToWalletInitializationState(.keysMissing))) { state in state.appInitializationState = .keysMissing + state.alert = AlertState( + title: TextState("Wallet initialisation failed."), + message: TextState("App initialisation state: keysMissing."), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) } } func testRespondToWalletInitializationState_FilesMissing() throws { + let walletStorageError: Error = "export failed" + let store = TestStore( initialState: .placeholder, reducer: RootReducer() ) { dependencies in dependencies.walletStorage = .noOp - dependencies.walletStorage.exportWallet = { throw "export failed" } + dependencies.walletStorage.exportWallet = { throw walletStorageError } } store.send(.initialization(.respondToWalletInitializationState(.filesMissing))) { state in @@ -137,18 +144,30 @@ class RootTests: XCTestCase { store.receive(.initialization(.initializeSDK)) { state in // failed is expected because environment is throwing errors state.appInitializationState = .failed + state.alert = AlertState( + title: TextState("Failed to initialize the SDK"), + message: TextState("Error: \(walletStorageError.localizedDescription)"), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) } - store.receive(.initialization(.checkBackupPhraseValidation)) + store.receive(.initialization(.checkBackupPhraseValidation)) { state in + state.alert = AlertState( + title: TextState("Wallet initialisation failed."), + message: TextState("Can't load seed phrase from local storage."), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) + } } func testRespondToWalletInitializationState_Initialized() throws { + let walletStorageError: Error = "export failed" let store = TestStore( initialState: .placeholder, reducer: RootReducer() ) { dependencies in dependencies.walletStorage = .noOp - dependencies.walletStorage.exportWallet = { throw "export failed" } + dependencies.walletStorage.exportWallet = { throw walletStorageError } } store.send(.initialization(.respondToWalletInitializationState(.initialized))) @@ -156,9 +175,20 @@ class RootTests: XCTestCase { store.receive(.initialization(.initializeSDK)) { state in // failed is expected because environment is throwing errors state.appInitializationState = .failed + state.alert = AlertState( + title: TextState("Failed to initialize the SDK"), + message: TextState("Error: \(walletStorageError.localizedDescription)"), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) } - store.receive(.initialization(.checkBackupPhraseValidation)) + store.receive(.initialization(.checkBackupPhraseValidation)) { state in + state.alert = AlertState( + title: TextState("Wallet initialisation failed."), + message: TextState("Can't load seed phrase from local storage."), + dismissButton: .default(TextState("Ok"), action: .send(.dismissAlert)) + ) + } } func testWalletEventReplyTo_validAddress() throws {