[#1419] Status page for keychain read failure

- OSStatusError screen implemented, UI is WIP
- Navigation + detection done
- Mocked failure for now

[#1419] Status page for keychain read failure

- finished UI layout, waiting on ES translation
- added contact support button
This commit is contained in:
Lukas Korba 2024-11-20 12:33:48 +01:00
parent bd21770016
commit eccaf2a99a
10 changed files with 284 additions and 9 deletions

View File

@ -44,6 +44,7 @@ let package = Package(
.library(name: "NotEnoughFreeSpace", targets: ["NotEnoughFreeSpace"]),
.library(name: "NumberFormatter", targets: ["NumberFormatter"]),
.library(name: "OnboardingFlow", targets: ["OnboardingFlow"]),
.library(name: "OSStatusError", targets: ["OSStatusError"]),
.library(name: "PartialProposalError", targets: ["PartialProposalError"]),
.library(name: "PartnerKeys", targets: ["PartnerKeys"]),
.library(name: "Pasteboard", targets: ["Pasteboard"]),
@ -446,6 +447,17 @@ let package = Package(
],
path: "Sources/Features/OnboardingFlow"
),
.target(
name: "OSStatusError",
dependencies: [
"Generated",
"SupportDataGenerator",
"UIComponents",
"Utils",
.product(name: "ComposableArchitecture", package: "swift-composable-architecture")
],
path: "Sources/Features/OSStatusError"
),
.target(
name: "PartialProposalError",
dependencies: [
@ -579,6 +591,7 @@ let package = Package(
"NotEnoughFreeSpace",
"NumberFormatter",
"OnboardingFlow",
"OSStatusError",
"Pasteboard",
"ReadTransactionsStorage",
"RecoveryPhraseDisplay",

View File

@ -74,6 +74,18 @@ public enum SupportDataGenerator {
return SupportData(toAddress: Constants.email, subject: Constants.subjectPPE, message: message)
}
public static func generateOSStatusError(osStatus: OSStatus) -> SupportData {
let data = SupportDataGenerator.generate()
let message =
"""
OSStatus: \(osStatus)
\(data.message)
"""
return SupportData(toAddress: Constants.email, subject: Constants.subjectPPE, message: message)
}
}
private protocol SupportDataGeneratorItem {

View File

@ -80,7 +80,13 @@ public struct WalletStorage {
}
public func exportWallet() throws -> StoredWallet {
guard let data = data(forKey: Constants.zcashStoredWallet) else {
#if targetEnvironment(simulator)
// TODO: can't go to the production
// FIXME: temporary debug only
throw KeychainError.unknown(errSecDuplicateItem)
#endif
guard let data = try data(forKey: Constants.zcashStoredWallet) else {
throw WalletStorageError.uninitializedWallet
}
@ -155,7 +161,7 @@ public struct WalletStorage {
}
public func exportAddressBookEncryptionKeys() throws -> AddressBookEncryptionKeys {
guard let data = data(forKey: Constants.zcashStoredAdressBookEncryptionKeys) else {
guard let data = try data(forKey: Constants.zcashStoredAdressBookEncryptionKeys) else {
throw WalletStorageError.uninitializedAddressBookEncryptionKeys
}
@ -219,11 +225,15 @@ public struct WalletStorage {
public func data(
forKey: String,
account: String = ""
) -> Data? {
) throws -> Data? {
let query = restoreQuery(forAccount: account, andKey: forKey)
var result: AnyObject?
_ = secItem.copyMatching(query as CFDictionary, &result)
let status = secItem.copyMatching(query as CFDictionary, &result)
guard status == errSecSuccess else {
throw KeychainError.unknown(status)
}
return result as? Data
}

View File

@ -0,0 +1,72 @@
//
// OSStatusErrorStore.swift
//
//
// Created by Lukáš Korba on 2024-11-20.
//
import Foundation
import ComposableArchitecture
import MessageUI
import SupportDataGenerator
@Reducer
public struct OSStatusError {
@ObservableState
public struct State: Equatable {
public var isExportingData: Bool
public var message: String
public var osStatus: OSStatus
public var supportData: SupportData?
public init(
isExportingData: Bool = false,
message: String,
osStatus: OSStatus,
supportData: SupportData? = nil
) {
self.isExportingData = isExportingData
self.message = message
self.osStatus = osStatus
self.supportData = supportData
}
}
public enum Action: Equatable {
case onAppear
case sendSupportMail
case sendSupportMailFinished
case shareFinished
}
public init() {}
public var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .onAppear:
state.isExportingData = false
return .none
case .sendSupportMail:
let supportData = SupportDataGenerator.generateOSStatusError(osStatus: state.osStatus)
if MFMailComposeViewController.canSendMail() {
state.supportData = supportData
} else {
state.message = supportData.message
state.isExportingData = true
}
return .none
case .sendSupportMailFinished:
state.supportData = nil
return .none
case .shareFinished:
state.isExportingData = false
return .none
}
}
}
}

View File

@ -0,0 +1,127 @@
//
// OSStatusErrorView.swift
// secant-testnet
//
// Created by Lukáš Korba on 2024-11-20.
//
import SwiftUI
import ComposableArchitecture
import Generated
import UIComponents
public struct OSStatusErrorView: View {
@Perception.Bindable var store: StoreOf<OSStatusError>
public init(store: StoreOf<OSStatusError>) {
self.store = store
}
public var body: some View {
WithPerceptionTracking {
VStack(spacing: 0) {
Spacer()
Asset.Assets.infoCircle.image
.zImage(size: 28, style: Design.Utility.ErrorRed._700)
.padding(18)
.background {
Circle()
.fill(Design.Utility.ErrorRed._100.color)
}
.rotationEffect(.degrees(180))
Text("Its not you, its us.")
.zFont(.semiBold, size: 24, style: Design.Text.primary)
.padding(.top, 16)
.padding(.bottom, 12)
Text("Your funds are safe but something happened while we were trying to retrieve the Keychain data. Close the Zashi app, and give it a fresh launch, we will try again.")
.zFont(size: 14, style: Design.Text.primary)
.multilineTextAlignment(.center)
.lineSpacing(1.5)
.padding(.bottom, 12)
Text("Error code: \(String(format: "%d", store.osStatus))")
.zFont(size: 14, style: Design.Text.primary)
.padding(.bottom, 100)
Spacer()
ZashiButton(L10n.ProposalPartial.contactSupport) {
store.send(.sendSupportMail)
}
.padding(.bottom, 24)
if let supportData = store.supportData {
UIMailDialogView(
supportData: supportData,
completion: {
store.send(.sendSupportMailFinished)
}
)
// UIMailDialogView only wraps MFMailComposeViewController presentation
// so frame is set to 0 to not break SwiftUIs layout
.frame(width: 0, height: 0)
}
shareMessageView()
}
.frame(maxWidth: .infinity)
.onAppear { store.send(.onAppear) }
.screenHorizontalPadding()
.applyErredScreenBackground()
}
}
}
private extension OSStatusErrorView {
@ViewBuilder func shareMessageView() -> some View {
if store.isExportingData {
UIShareDialogView(activityItems: [store.message]) {
store.send(.shareFinished)
}
// UIShareDialogView only wraps UIActivityViewController presentation
// so frame is set to 0 to not break SwiftUIs layout
.frame(width: 0, height: 0)
} else {
EmptyView()
}
}
}
// MARK: - Previews
#Preview {
NavigationView {
OSStatusErrorView(
store:
StoreOf<OSStatusError>(
initialState: OSStatusError.State(
message: "",
osStatus: errSecSuccess
)
) {
OSStatusError()
}
)
}
}
// MARK: Placeholders
extension OSStatusError.State {
public static let initial = OSStatusError.State(
message: "",
osStatus: errSecSuccess
)
}
extension OSStatusError {
public static let placeholder = StoreOf<OSStatusError>(
initialState: .initial
) {
OSStatusError()
}
}

View File

@ -22,6 +22,7 @@ extension Root {
case deeplinkWarning
case notEnoughFreeSpace
case onboarding
case osStatusError
case phraseDisplay
case startup
case tabs
@ -185,7 +186,7 @@ extension Root {
case .tabs, .initialization, .onboarding, .updateStateAfterConfigUpdate, .alert, .phraseDisplay, .synchronizerStateChanged,
.welcome, .binding, .resetZashiFailed, .resetZashiSucceeded, .debug, .walletConfigLoaded, .exportLogs, .confirmationDialog,
.notEnoughFreeSpace, .serverSetup, .serverSetupBindingUpdated, .batteryStateChanged, .cancelAllRunningEffects, .addressBookBinding, .addressBook, .addressBookContactBinding, .addressBookAccessGranted:
.notEnoughFreeSpace, .serverSetup, .serverSetupBindingUpdated, .batteryStateChanged, .cancelAllRunningEffects, .addressBookBinding, .addressBook, .addressBookContactBinding, .addressBookAccessGranted, .osStatusError:
return .none
}
}

View File

@ -245,6 +245,9 @@ extension Root {
/// Respond to all possible states of the wallet and initiate appropriate side effects including errors handling
case .initialization(.respondToWalletInitializationState(let walletState)):
switch walletState {
case .osStatus(let osStatus):
state.osStatusErrorState.osStatus = osStatus
return .send(.destination(.updateDestination(.osStatusError)))
case .failed:
state.appInitializationState = .failed
state.alert = AlertState.walletStateFailed(walletState)
@ -287,7 +290,12 @@ extension Root {
/// When initialization succeeds user is taken to the home screen.
case .initialization(.initializeSDK(let walletMode)):
do {
let storedWallet = try walletStorage.exportWallet()
let storedWallet: StoredWallet
do {
storedWallet = try walletStorage.exportWallet()
} catch {
return .send(.destination(.updateDestination(.osStatusError)))
}
let birthday = storedWallet.birthday?.value() ?? zcashSDKEnvironment.latestCheckpoint
try mnemonic.isValid(storedWallet.seedPhrase.value())
let seedBytes = try mnemonic.toSeed(storedWallet.seedPhrase.value())
@ -335,7 +343,12 @@ extension Root {
case .initialization(.checkBackupPhraseValidation):
do {
let storedWallet = try walletStorage.exportWallet()
let storedWallet: StoredWallet
do {
storedWallet = try walletStorage.exportWallet()
} catch {
return .send(.destination(.updateDestination(.osStatusError)))
}
var landingDestination = Root.DestinationState.Destination.tabs
if !storedWallet.hasUserPassedPhraseBackupTest {
@ -522,7 +535,7 @@ extension Root {
case .tabs, .destination, .onboarding, .phraseDisplay, .notEnoughFreeSpace, .serverSetup, .serverSetupBindingUpdated,
.welcome, .binding, .debug, .exportLogs, .alert, .splashFinished, .splashRemovalRequested,
.confirmationDialog, .batteryStateChanged, .cancelAllRunningEffects, .flexaOnTransactionRequest, .flexaTransactionFailed, .addressBookBinding, .addressBook, .addressBookContactBinding, .addressBookAccessGranted, .deeplinkWarning:
.confirmationDialog, .batteryStateChanged, .cancelAllRunningEffects, .flexaOnTransactionRequest, .flexaTransactionFailed, .addressBookBinding, .addressBook, .addressBookContactBinding, .addressBookAccessGranted, .deeplinkWarning, .osStatusError:
return .none
}
}

View File

@ -31,6 +31,7 @@ import AddressBook
import LocalAuthenticationHandler
import DeeplinkWarning
import URIParser
import OSStatusError
@Reducer
public struct Root {
@ -62,6 +63,7 @@ public struct Root {
@Shared(.appStorage(.lastAuthenticationTimestamp)) public var lastAuthenticationTimestamp: Int = 0
public var notEnoughFreeSpaceState: NotEnoughFreeSpace.State
public var onboardingState: OnboardingFlow.State
public var osStatusErrorState: OSStatusError.State
public var phraseDisplayState: RecoveryPhraseDisplay.State
public var serverSetupState: ServerSetup.State
public var serverSetupViewBinding: Bool = false
@ -83,6 +85,7 @@ public struct Root {
isRestoringWallet: Bool = false,
notEnoughFreeSpaceState: NotEnoughFreeSpace.State = .initial,
onboardingState: OnboardingFlow.State,
osStatusErrorState: OSStatusError.State = .initial,
phraseDisplayState: RecoveryPhraseDisplay.State,
tabsState: Tabs.State,
serverSetupState: ServerSetup.State = .initial,
@ -98,6 +101,7 @@ public struct Root {
self.isLockedInKeychainUnavailableState = isLockedInKeychainUnavailableState
self.isRestoringWallet = isRestoringWallet
self.onboardingState = onboardingState
self.osStatusErrorState = osStatusErrorState
self.notEnoughFreeSpaceState = notEnoughFreeSpaceState
self.phraseDisplayState = phraseDisplayState
self.serverSetupState = serverSetupState
@ -134,6 +138,7 @@ public struct Root {
case resetZashiFailed
case resetZashiSucceeded
case onboarding(OnboardingFlow.Action)
case osStatusError(OSStatusError.Action)
case phraseDisplay(RecoveryPhraseDisplay.Action)
case splashFinished
case splashRemovalRequested
@ -207,6 +212,10 @@ public struct Root {
RecoveryPhraseDisplay()
}
Scope(state: \.osStatusErrorState, action: \.osStatusError) {
OSStatusError()
}
initializationReduce()
destinationReduce()
@ -327,6 +336,8 @@ extension Root {
if databaseFiles.areDbFilesPresentFor(zcashNetwork) {
return .keysMissing
}
} catch WalletStorage.KeychainError.unknown(let osStatus) {
return .osStatus(osStatus)
} catch {
return .failed
}

View File

@ -14,6 +14,7 @@ import UIComponents
import ServerSetup
import AddressBook
import DeeplinkWarning
import OSStatusError
public struct RootView: View {
@Environment(\.scenePhase) var scenePhase
@ -94,6 +95,20 @@ private extension RootView {
store.send(.splashRemovalRequested)
}
case .osStatusError:
NavigationView {
OSStatusErrorView(
store: store.scope(
state: \.osStatusErrorState,
action: \.osStatusError
)
)
}
.navigationViewStyle(.stack)
.overlayedWithSplash(store.splashAppeared) {
store.send(.splashRemovalRequested)
}
case .tabs:
NavigationView {
TabsView(

View File

@ -17,9 +17,10 @@ public enum AppStartState: Equatable {
public enum InitializationState: Equatable {
case failed
case filesMissing
case initialized
case keysMissing
case filesMissing
case osStatus(OSStatus)
case uninitialized
}