[#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:
parent
bd21770016
commit
eccaf2a99a
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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("It’s not you, it’s 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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue