[#997] Keys missing handling (#1000)

- changelog update
- the keys missing error state has been tweaked to try 3 retry attempts because of unresponsiveness keychain API
- in case of true missing keys, the user is no longer locked on a splash screen but rather let land to the Account tab so the rest of the Zashi can be used
- unit tests fixed + implemented new ones for the 3-attempt retry logic
This commit is contained in:
Lukas Korba 2024-01-29 04:28:18 +01:00 committed by GitHub
parent b6248fdd3c
commit 79ab841f75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 118 additions and 9 deletions

View File

@ -9,6 +9,9 @@ directly impact users rather than highlighting other crucial architectural updat
### Added
- Share QR code of addresses via system share dialog.
### Fixed
- `Keys Missing` error dialog was sometimes triggered as a false positive due to system overload and keychain API unresponsivity in expected time. Retry logic was implemented to pass this state. Also the app always lands users to the Account tab instead of lock them on a splash screen with no options to solve this state.
## 0.2.0 build 12 (2024-01-20)
### Added

View File

@ -82,6 +82,8 @@ public struct RecoveryPhraseDisplayView: View {
.padding(.bottom, 50)
} else {
Text(L10n.RecoveryPhraseDisplay.noWords)
.font(.custom(FontFamily.Inter.regular.name, size: 14))
.multilineTextAlignment(.center)
}
}
.padding(.horizontal, 60)

View File

@ -25,6 +25,7 @@ extension RootReducer {
case initialSetups
case initializationFailed(ZcashError)
case initializationSuccessfullyDone(UnifiedAddress?)
case retryKeychainRead(InitializationState)
case nukeWallet
case nukeWalletRequest
case respondToWalletInitializationState(InitializationState)
@ -183,12 +184,10 @@ extension RootReducer {
switch walletState {
case .failed:
state.appInitializationState = .failed
state.alert = AlertState.walletStateFailed(walletState)
return .none
return .send(.initialization(.retryKeychainRead(walletState)))
case .keysMissing:
state.appInitializationState = .keysMissing
state.alert = AlertState.walletStateFailed(walletState)
return .none
return .send(.initialization(.retryKeychainRead(walletState)))
case .initialized, .filesMissing:
if walletState == .filesMissing {
state.appInitializationState = .filesMissing
@ -206,6 +205,18 @@ extension RootReducer {
.cancellable(id: CancelId.timer, cancelInFlight: true)
}
case .initialization(.retryKeychainRead(let walletState)):
if state.keychainReadRetries < state.maxKeychainReadRetries {
state.keychainReadRetries += 1
return .run { [retries = state.keychainReadRetries] send in
try await mainQueue.sleep(for: .seconds(0.1 * Double(retries)))
await send(.initialization(.checkWalletInitialization))
}
} else {
state.alert = AlertState.walletStateFailed(walletState)
return .send(.destination(.updateDestination(RootReducer.DestinationState.Destination.tabs)))
}
/// Stored wallet is present, database files may or may not be present, trying to initialize app state variables and environments.
/// When initialization succeeds user is taken to the home screen.
case .initialization(.initializeSDK(let walletMode)):

View File

@ -40,6 +40,8 @@ public struct RootReducer: Reducer {
public var destinationState: DestinationState
public var exportLogsState: ExportLogsReducer.State
public var isRestoringWallet = false
public var keychainReadRetries = 0
public var maxKeychainReadRetries = 3
public var onboardingState: OnboardingFlowReducer.State
public var phraseDisplayState: RecoveryPhraseDisplayReducer.State
public var sandboxState: SandboxReducer.State
@ -55,6 +57,8 @@ public struct RootReducer: Reducer {
destinationState: DestinationState,
exportLogsState: ExportLogsReducer.State,
isRestoringWallet: Bool = false,
keychainReadRetries: Int = 0,
maxKeychainReadRetries: Int = 3,
onboardingState: OnboardingFlowReducer.State,
phraseDisplayState: RecoveryPhraseDisplayReducer.State,
sandboxState: SandboxReducer.State,
@ -68,6 +72,8 @@ public struct RootReducer: Reducer {
self.destinationState = destinationState
self.exportLogsState = exportLogsState
self.isRestoringWallet = isRestoringWallet
self.keychainReadRetries = keychainReadRetries
self.maxKeychainReadRetries = maxKeychainReadRetries
self.onboardingState = onboardingState
self.phraseDisplayState = phraseDisplayState
self.sandboxState = sandboxState

View File

@ -306,8 +306,8 @@ public enum L10n {
}
/// The following 24 words are the keys to your funds and are the only way to recover your funds if you get locked out or get a new device. Protect your ZEC by storing this phrase in a place you trust and never share it with anyone!
public static let description = L10n.tr("Localizable", "recoveryPhraseDisplay.description", fallback: "The following 24 words are the keys to your funds and are the only way to recover your funds if you get locked out or get a new device. Protect your ZEC by storing this phrase in a place you trust and never share it with anyone!")
/// Oops no words
public static let noWords = L10n.tr("Localizable", "recoveryPhraseDisplay.noWords", fallback: "Oops no words")
/// The keys are missing. No backup phrase is stored in the keychain.
public static let noWords = L10n.tr("Localizable", "recoveryPhraseDisplay.noWords", fallback: "The keys are missing. No backup phrase is stored in the keychain.")
/// Your Secret
public static let titlePart1 = L10n.tr("Localizable", "recoveryPhraseDisplay.titlePart1", fallback: "Your Secret")
/// Recovery Phrase

View File

@ -39,7 +39,7 @@
"recoveryPhraseDisplay.description" = "The following 24 words are the keys to your funds and are the only way to recover your funds if you get locked out or get a new device. Protect your ZEC by storing this phrase in a place you trust and never share it with anyone!";
"recoveryPhraseDisplay.button.wroteItDown" = "I got it!";
"recoveryPhraseDisplay.button.copyToBuffer" = "Copy To Buffer";
"recoveryPhraseDisplay.noWords" = "Oops no words";
"recoveryPhraseDisplay.noWords" = "The keys are missing. No backup phrase is stored in the keychain.";
"recoveryPhraseDisplay.birthdayHeight" = "Wallet birthday height: %@";
"recoveryPhraseDisplay.alert.failed.title" = "Failed to load stored wallet";
"recoveryPhraseDisplay.alert.failed.message" = "Attempt to load the stored wallet from the keychain failed. Error: %@ (code: %@)";

View File

@ -174,8 +174,11 @@ class AppInitializationTests: XCTestCase {
/// Integration test validating the side effects work together properly when no wallet is stored but database files are present.
@MainActor func testDidFinishLaunching_to_KeysMissing() async throws {
var initialState = RootReducer.State.initial
initialState.keychainReadRetries = 3
let store = TestStore(
initialState: .initial
initialState: initialState
) {
RootReducer(tokenName: "ZEC", zcashNetwork: ZcashNetworkBuilder.network(for: .testnet))
}
@ -199,9 +202,79 @@ class AppInitializationTests: XCTestCase {
await store.receive(.initialization(.respondToWalletInitializationState(.keysMissing))) { state in
state.appInitializationState = .keysMissing
}
await store.receive(.initialization(.retryKeychainRead(.keysMissing))) { state in
state.alert = AlertState.walletStateFailed(.keysMissing)
}
await store.receive(.destination(.updateDestination(.tabs))) { state in
state.destinationState.internalDestination = .tabs
state.destinationState.previousDestination = .welcome
}
await store.finish()
}
@MainActor func testDidFinishLaunching_to_KeysMissing_3attempts() async throws {
let store = TestStore(
initialState: .initial
) {
RootReducer(tokenName: "ZEC", zcashNetwork: ZcashNetworkBuilder.network(for: .testnet))
}
store.dependencies.databaseFiles = .noOp
store.dependencies.databaseFiles.areDbFilesPresentFor = { _ in true }
store.dependencies.walletStorage = .noOp
store.dependencies.mainQueue = .immediate
store.dependencies.walletConfigProvider = .noOp
store.dependencies.crashReporter = .noOp
store.dependencies.restoreWalletStorage = .noOp
// Root of the test, the app finished the launch process and triggers the checks and initializations.
await store.send(.initialization(.appDelegate(.didFinishLaunching)))
await store.receive(.initialization(.initialSetups))
await store.receive(.initialization(.configureCrashReporter))
// initial attempt
await store.receive(.initialization(.checkWalletInitialization))
await store.receive(.initialization(.respondToWalletInitializationState(.keysMissing))) { state in
state.appInitializationState = .keysMissing
}
await store.receive(.initialization(.retryKeychainRead(.keysMissing))) { state in
state.keychainReadRetries = 1
}
// 1st attempt
await store.receive(.initialization(.checkWalletInitialization))
await store.receive(.initialization(.respondToWalletInitializationState(.keysMissing)))
await store.receive(.initialization(.retryKeychainRead(.keysMissing))) { state in
state.keychainReadRetries = 2
}
// 2nd attempt
await store.receive(.initialization(.checkWalletInitialization))
await store.receive(.initialization(.respondToWalletInitializationState(.keysMissing)))
await store.receive(.initialization(.retryKeychainRead(.keysMissing))) { state in
state.keychainReadRetries = 3
}
// 3rd attempt
await store.receive(.initialization(.checkWalletInitialization))
await store.receive(.initialization(.respondToWalletInitializationState(.keysMissing)))
await store.receive(.initialization(.retryKeychainRead(.keysMissing))) { state in
state.alert = AlertState.walletStateFailed(.keysMissing)
}
await store.receive(.destination(.updateDestination(.tabs))) { state in
state.destinationState.internalDestination = .tabs
state.destinationState.previousDestination = .welcome
}
await store.finish()
}

View File

@ -133,7 +133,21 @@ class RootTests: XCTestCase {
await store.send(.initialization(.respondToWalletInitializationState(.keysMissing))) { state in
state.appInitializationState = .keysMissing
state.alert = AlertState.walletStateFailed(.keysMissing)
}
await store.receive(.initialization(.retryKeychainRead(.keysMissing))) { state in
state.keychainReadRetries = 1
}
await store.receive(.initialization(.checkWalletInitialization))
await store.receive(.initialization(.respondToWalletInitializationState(.uninitialized))) { state in
state.appInitializationState = .uninitialized
}
await store.receive(.destination(.updateDestination(.onboarding))) { state in
state.destinationState.internalDestination = .onboarding
state.destinationState.previousDestination = .welcome
}
await store.finish()