secant-ios-wallet/modules/Sources/Features/Root/RootInitialization.swift

666 lines
33 KiB
Swift

//
// RootInitialization.swift
// Zashi
//
// Created by Lukáš Korba on 01.12.2022.
//
import ComposableArchitecture
import Foundation
import ZcashLightClientKit
import Models
import NotEnoughFreeSpace
import Utils
import Generated
import WalletStorage
/// In this file is a collection of helpers that control all state and action related operations
/// for the `Root` with a connection to the app/wallet initialization and erasure of the wallet.
extension Root {
public enum Constants {
static let udIsRestoringWallet = "udIsRestoringWallet"
static let udLeavesScreenOpen = "udLeaves_screen_open"
static let noAuthenticationWithinXMinutes = 15
}
public enum InitializationAction {
case appDelegate(AppDelegateAction)
case checkBackupPhraseValidation
case checkRestoreWalletFlag(SyncStatus)
case checkWalletInitialization
case checkWalletConfig
case initializeSDK(WalletInitMode)
case initialSetups
case initializationFailed(ZcashError)
case initializationSuccessfullyDone
case loadedWalletAccounts([WalletAccount])
case resetZashi
case resetZashiRequest
case resetZashiRequestCanceled
case respondToWalletInitializationState(InitializationState)
case restoreExistingWallet
case seedValidationResult(Bool)
case synchronizerStartFailed(ZcashError)
case registerForSynchronizersUpdate
case retryStart
case walletConfigChanged(WalletConfig)
}
// swiftlint:disable:next cyclomatic_complexity function_body_length
public func initializationReduce() -> Reduce<Root.State, Root.Action> {
Reduce { state, action in
switch action {
case .initialization(.appDelegate(.didFinishLaunching)):
state.appStartState = .didFinishLaunching
// TODO: [#704], trigger the review request logic when approved by the team,
// https://github.com/Electric-Coin-Company/zashi-ios/issues/704
return .run { send in
try await mainQueue.sleep(for: .seconds(0.5))
await send(.initialization(.initialSetups))
}
.cancellable(id: DidFinishLaunchingId, cancelInFlight: true)
case .initialization(.appDelegate(.willEnterForeground)):
if state.featureFlags.appLaunchBiometric {
let now = Date()
let before = Date.init(timeIntervalSince1970: TimeInterval(state.lastAuthenticationTimestamp))
if let xMinutesAgo = Calendar.current.date(byAdding: .minute, value: -Constants.noAuthenticationWithinXMinutes, to: now),
before < xMinutesAgo {
state.splashAppeared = false
}
}
state.appStartState = .willEnterForeground
if state.isLockedInKeychainUnavailableState || !sdkSynchronizer.latestState().syncStatus.isPrepared {
return .send(.initialization(.initialSetups))
} else {
return .send(.initialization(.retryStart))
}
case .initialization(.appDelegate(.didEnterBackground)):
sdkSynchronizer.stop()
state.bgTask?.setTaskCompleted(success: false)
state.bgTask = nil
state.appStartState = .didEnterBackground
state.isLockedInKeychainUnavailableState = false
return .cancel(id: CancelStateId)
case .initialization(.appDelegate(.backgroundTask(let task))):
let keysPresent: Bool = (try? walletStorage.areKeysPresent()) ?? false
if state.appStartState == .didFinishLaunching {
state.appStartState = .backgroundTask
if keysPresent {
state.bgTask = task
return .none
} else {
state.isLockedInKeychainUnavailableState = true
task.setTaskCompleted(success: false)
return .cancel(id: DidFinishLaunchingId)
}
} else {
state.bgTask = task
state.appStartState = .backgroundTask
return .run { send in
await send(.initialization(.retryStart))
}
}
case .synchronizerStateChanged(let latestState):
let snapshot = SyncStatusSnapshot.snapshotFor(state: latestState.data.syncStatus)
guard let account = state.selectedWalletAccount else {
return .none
}
// update flexa balance
if let accountBalance = latestState.data.accountsBalances[account.id] {
let shieldedBalance = accountBalance.saplingBalance.spendableValue + accountBalance.orchardBalance.spendableValue
let shieldedWithPendingBalance = accountBalance.saplingBalance.total() + accountBalance.orchardBalance.total()
flexaHandler.updateBalance(shieldedWithPendingBalance, shieldedBalance)
}
// handle possible service unavailability
if case .error(let error) = snapshot.syncStatus, checkUnavailableService(error) {
if state.walletStatus != .disconnected {
state.alert = AlertState.serviceUnavailable()
}
state.wasRestoringWhenDisconnected = state.walletStatus == .restoring
state.$walletStatus.withLock { $0 = .disconnected }
} else if case .syncing = snapshot.syncStatus, state.walletStatus == .disconnected {
state.$walletStatus.withLock { $0 = state.wasRestoringWhenDisconnected ? .restoring : .none }
}
// handle BCGTask
guard state.bgTask != nil else {
return .send(.initialization(.checkRestoreWalletFlag(snapshot.syncStatus)))
}
var finishBGTask = false
var successOfBGTask = false
switch snapshot.syncStatus {
case .upToDate:
successOfBGTask = true
finishBGTask = true
case .stopped, .error:
successOfBGTask = false
finishBGTask = true
default: break
}
if finishBGTask {
LoggerProxy.event("BGTask setTaskCompleted(success: \(successOfBGTask)) from TCA")
state.bgTask?.setTaskCompleted(success: successOfBGTask)
state.bgTask = nil
return .cancel(id: CancelStateId)
}
return .send(.initialization(.checkRestoreWalletFlag(snapshot.syncStatus)))
case .initialization(.checkRestoreWalletFlag(let syncStatus)):
if state.isRestoringWallet && syncStatus == .upToDate {
state.isRestoringWallet = false
userDefaults.remove(Constants.udIsRestoringWallet)
state.$walletStatus.withLock { $0 = .none }
}
return .none
case .initialization(.synchronizerStartFailed):
return .none
case .initialization(.retryStart):
if !diskSpaceChecker.hasEnoughFreeSpaceForSync() {
state.destinationState.preNotEnoughFreeSpaceDestination = state.destinationState.internalDestination
return .send(.destination(.updateDestination(.notEnoughFreeSpace)))
} else if let preNotEnoughFreeSpaceDestination = state.destinationState.preNotEnoughFreeSpaceDestination {
state.destinationState.internalDestination = preNotEnoughFreeSpaceDestination
state.destinationState.preNotEnoughFreeSpaceDestination = nil
}
// Try the start only if the synchronizer has been already prepared
guard sdkSynchronizer.latestState().syncStatus.isPrepared else {
return .none
}
return .run { [state] send in
do {
try await sdkSynchronizer.start(true)
if state.bgTask != nil {
LoggerProxy.event("BGTask synchronizer.start() PASSED")
}
await send(.initialization(.registerForSynchronizersUpdate))
} catch {
if state.bgTask != nil {
LoggerProxy.event("BGTask synchronizer.start() failed \(error.toZcashError())")
}
await send(.initialization(.synchronizerStartFailed(error.toZcashError())))
}
}
case .initialization(.registerForSynchronizersUpdate):
let stateStreamEffect = Effect.publisher {
sdkSynchronizer.stateStream()
.throttle(for: .seconds(0.2), scheduler: mainQueue, latest: true)
.map { $0.redacted }
.map(Root.Action.synchronizerStateChanged)
}
.cancellable(id: CancelStateId, cancelInFlight: true)
if state.bgTask != nil {
return stateStreamEffect
} else {
return .merge(
stateStreamEffect,
.send(.home(.smartBanner(.evaluatePriority1)))
)
}
case .initialization(.checkWalletConfig):
return .publisher {
walletConfigProvider.load()
.receive(on: mainQueue)
.map(Root.Action.walletConfigLoaded)
}
.cancellable(id: WalletConfigCancelId, cancelInFlight: true)
case .walletConfigLoaded(let walletConfig):
if walletConfig == WalletConfig.initial {
return .send(.initialization(.initialSetups))
} else {
return .send(.initialization(.walletConfigChanged(walletConfig)))
}
case .initialization(.walletConfigChanged(let walletConfig)):
return .concatenate(
.send(.updateStateAfterConfigUpdate(walletConfig)),
.send(.initialization(.initialSetups))
)
case .initialization(.initialSetups):
if !diskSpaceChecker.hasEnoughFreeSpaceForSync() {
state.destinationState.preNotEnoughFreeSpaceDestination = state.destinationState.internalDestination
return .send(.destination(.updateDestination(.notEnoughFreeSpace)))
} else if let preNotEnoughFreeSpaceDestination = state.destinationState.preNotEnoughFreeSpaceDestination {
state.destinationState.internalDestination = preNotEnoughFreeSpaceDestination
state.destinationState.preNotEnoughFreeSpaceDestination = nil
}
// TODO: [#524] finish all the wallet events according to definition, https://github.com/Electric-Coin-Company/zashi-ios/issues/524
LoggerProxy.event(".appDelegate(.didFinishLaunching)")
/// We need to fetch data from keychain, in order to be 100% sure the keychain can be read we delay the check a bit
return .send(.initialization(.checkWalletInitialization))
/// Evaluate the wallet's state based on keychain keys and database files presence
case .initialization(.checkWalletInitialization):
let walletState = Root.walletInitializationState(
databaseFiles: databaseFiles,
walletStorage: walletStorage,
zcashNetwork: zcashSDKEnvironment.network
)
return .send(.initialization(.respondToWalletInitializationState(walletState)))
/// 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)
return .none
case .keysMissing:
state.appInitializationState = .keysMissing
return .send(.destination(.updateDestination(.onboarding)))
case .filesMissing:
state.appInitializationState = .filesMissing
state.isRestoringWallet = true
userDefaults.setValue(true, Constants.udIsRestoringWallet)
state.$walletStatus.withLock { $0 = .restoring }
return .concatenate(
.send(.initialization(.initializeSDK(.restoreWallet))),
.send(.initialization(.checkBackupPhraseValidation))
)
case .initialized:
if let isRestoringWallet = userDefaults.objectForKey(Constants.udIsRestoringWallet) as? Bool, isRestoringWallet {
state.isRestoringWallet = true
state.$walletStatus.withLock { $0 = .restoring }
return .concatenate(
.send(.initialization(.initializeSDK(.restoreWallet))),
.send(.initialization(.checkBackupPhraseValidation))
)
}
return .concatenate(
.send(.initialization(.initializeSDK(.existingWallet))),
.send(.initialization(.checkBackupPhraseValidation))
)
case .uninitialized:
state.appInitializationState = .uninitialized
return .run { send in
try await mainQueue.sleep(for: .seconds(0.5))
await send(.destination(.updateDestination(.onboarding)))
}
.cancellable(id: CancelId, cancelInFlight: true)
}
/// 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)):
do {
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())
return .run { send in
do {
try await sdkSynchronizer.prepareWith(
seedBytes,
birthday,
walletMode,
L10n.Accounts.zashi,
L10n.Accounts.zashi.lowercased()
)
let walletAccounts = try await sdkSynchronizer.walletAccounts()
await send(.initialization(.loadedWalletAccounts(walletAccounts)))
await send(.resolveMetadataEncryptionKeys)
try await sdkSynchronizer.start(false)
var selectedAccount: WalletAccount?
for account in walletAccounts {
if account.vendor == .zcash {
selectedAccount = account
}
}
if let account = selectedAccount {
let addressBookEncryptionKeys = try? walletStorage.exportAddressBookEncryptionKeys()
if addressBookEncryptionKeys == nil {
do {
var keys = AddressBookEncryptionKeys.empty
try keys.cacheFor(
seed: seedBytes,
account: account.account,
network: zcashSDKEnvironment.network.networkType
)
try walletStorage.importAddressBookEncryptionKeys(keys)
} catch {
// TODO: [#1408] error handling https://github.com/Electric-Coin-Company/zashi-ios/issues/1408
}
}
await send(.initialization(.initializationSuccessfullyDone))
} else {
await send(.initialization(.initializationSuccessfullyDone))
}
} catch {
await send(.initialization(.initializationFailed(error.toZcashError())))
}
}
} catch {
return .send(.initialization(.initializationFailed(error.toZcashError())))
}
case .initialization(.initializationSuccessfullyDone):
return .merge(
.send(.initialization(.registerForSynchronizersUpdate)),
.publisher {
autolockHandler.batteryStatePublisher()
.map(Root.Action.batteryStateChanged)
}
.cancellable(id: CancelBatteryStateId, cancelInFlight: true),
.send(.batteryStateChanged(nil)),
.send(.observeTransactions),
.send(.observeShieldingProcessor)
)
case .initialization(.loadedWalletAccounts(let walletAccounts)):
state.$walletAccounts.withLock { $0 = walletAccounts }
if state.selectedWalletAccount == nil {
for account in walletAccounts {
if account.vendor == .zcash {
state.$selectedWalletAccount.withLock { $0 = account }
state.$zashiWalletAccount.withLock { $0 = account }
break
}
}
}
return .merge(
.send(.loadContacts),
.send(.loadUserMetadata)
)
case .resolveMetadataEncryptionKeys:
do {
let storedWallet: StoredWallet
do {
storedWallet = try walletStorage.exportWallet()
} catch {
return .send(.destination(.updateDestination(.osStatusError)))
}
try mnemonic.isValid(storedWallet.seedPhrase.value())
let seedBytes = try mnemonic.toSeed(storedWallet.seedPhrase.value())
return .run { [walletAccounts = state.walletAccounts] send in
do {
for account in walletAccounts {
let userMetadataEncryptionKeys = try? walletStorage.exportUserMetadataEncryptionKeys(account.account)
if userMetadataEncryptionKeys == nil {
do {
var keys = UserMetadataEncryptionKeys.empty
try keys.cacheFor(
seed: seedBytes,
account: account.account,
network: zcashSDKEnvironment.network.networkType
)
try walletStorage.importUserMetadataEncryptionKeys(keys, account.account)
} catch {
// TODO: [#1408] error handling https://github.com/Electric-Coin-Company/zashi-ios/issues/1408
}
}
}
}
}
} catch { }
return .none
case .initialization(.checkBackupPhraseValidation):
do {
let _ = try walletStorage.exportWallet()
} catch {
return .send(.destination(.updateDestination(.osStatusError)))
}
state.appInitializationState = .initialized
let isAtDeeplinkWarningScreen = state.destinationState.destination == .deeplinkWarning
return .run { send in
try await mainQueue.sleep(for: .seconds(0.5))
if !isAtDeeplinkWarningScreen {
await send(.destination(.updateDestination(Root.DestinationState.Destination.home)))
}
}
.cancellable(id: CancelId, cancelInFlight: true)
case .initialization(.resetZashiRequest):
state.alert = AlertState.wipeRequest()
return .none
case .initialization(.resetZashiRequestCanceled):
state.alert = nil
for (id, element) in zip(state.settingsState.path.ids, state.settingsState.path) {
if element.is(\.resetZashi) {
return .send(.settings(.path(.element(id: id, action: .resetZashi(.deleteCanceled)))))
}
}
return .none
case .initialization(.resetZashi):
guard let wipePublisher = sdkSynchronizer.wipe() else {
return .send(.resetZashiSDKFailed)
}
return .publisher {
wipePublisher
.replaceEmpty(with: Void())
.map { _ in return Root.Action.resetZashiSDKSucceeded }
.replaceError(with: Root.Action.resetZashiSDKFailed)
.receive(on: mainQueue)
}
.cancellable(id: SynchronizerCancelId, cancelInFlight: true)
case .resetZashiSDKSucceeded:
if state.appInitializationState != .keysMissing {
state = .initial
}
state.splashAppeared = true
state.isRestoringWallet = false
userDefaults.remove(Constants.udIsRestoringWallet)
userDefaults.remove(Constants.udLeavesScreenOpen)
flexaHandler.signOut()
userStoredPreferences.removeAll()
try? readTransactionsStorage.resetZashi()
state.walletAccounts.forEach { account in
try? userMetadataProvider.resetAccount(account.account)
}
try? userMetadataProvider.reset()
state.$walletStatus.withLock { $0 = .none }
state.$selectedWalletAccount.withLock { $0 = nil }
// state.$selectedWalletAccountsUA.withLock { $0 = nil }
state.$walletAccounts.withLock { $0 = [] }
state.$zashiWalletAccount.withLock { $0 = nil }
state.$transactionMemos.withLock { $0 = [:] }
state.$addressBookContacts.withLock { $0 = .empty }
state.$transactions.withLock { $0 = [] }
state.path = nil
return .send(.resetZashiKeychainRequest)
case .resetZashiKeychainRequest:
return .run { send in
do {
try walletStorage.resetZashi()
await send(.resetZashiFinishProcessing)
} catch WalletStorage.KeychainError.unknown(let osStatus) {
await send(.resetZashiKeychainFailed(osStatus))
}
}
case .resetZashiFinishProcessing:
do {
let areKeysPresent = try walletStorage.areKeysPresent()
if areKeysPresent {
return .send(.resetZashiKeychainFailedWithCorruptedData("Keychain keys are still present"))
}
} catch WalletStorage.WalletStorageError.alreadyImported {
return .send(.resetZashiKeychainFailedWithCorruptedData("alreadyImported"))
} catch WalletStorage.WalletStorageError.uninitializedAddressBookEncryptionKeys {
return .send(.resetZashiKeychainFailedWithCorruptedData("uninitializedAddressBookEncryptionKeys"))
} catch WalletStorage.WalletStorageError.storageError(let error) {
return .send(.resetZashiKeychainFailedWithCorruptedData("storageError, \(error.localizedDescription)"))
} catch WalletStorage.WalletStorageError.unsupportedVersion(let version) {
return .send(.resetZashiKeychainFailedWithCorruptedData("unsupportedVersion \(version)"))
} catch WalletStorage.WalletStorageError.unsupportedLanguage(let language) {
return .send(.resetZashiKeychainFailedWithCorruptedData("unsupportedLanguage, \(language)"))
} catch WalletStorage.KeychainError.decoding {
return .send(.resetZashiKeychainFailedWithCorruptedData("decoding"))
} catch WalletStorage.KeychainError.duplicate {
return .send(.resetZashiKeychainFailedWithCorruptedData("duplicate"))
} catch WalletStorage.KeychainError.encoding {
return .send(.resetZashiKeychainFailedWithCorruptedData("encoding"))
} catch WalletStorage.KeychainError.noDataFound {
return .send(.resetZashiKeychainFailedWithCorruptedData("noDataFound"))
} catch WalletStorage.KeychainError.unknown(let osStatus) {
return .send(.resetZashiKeychainFailedWithCorruptedData("unknown, OSStatus \(osStatus)"))
} catch WalletStorage.WalletStorageError.uninitializedWallet {
// this is valid state and what we expect
} catch {
return .send(.resetZashiKeychainFailedWithCorruptedData(error.localizedDescription))
}
if state.appInitializationState == .keysMissing && state.onboardingState.destination == .importExistingWallet {
state.appInitializationState = .uninitialized
return .cancel(id: SynchronizerCancelId)
} else if state.appInitializationState == .keysMissing && state.onboardingState.destination == .createNewWallet {
state.appInitializationState = .uninitialized
return .concatenate(
.cancel(id: SynchronizerCancelId),
.send(.onboarding(.createNewWalletRequested))
)
} else {
return .concatenate(
.cancel(id: SynchronizerCancelId),
.send(.initialization(.checkWalletInitialization))
)
}
case .resetZashiKeychainFailedWithCorruptedData(let errMsg):
for element in state.settingsState.path {
if case .resetZashi(var resetZashiState) = element {
resetZashiState.isProcessing = false
break
}
}
state.alert = AlertState.wipeKeychainFailed(errMsg)
return .cancel(id: SynchronizerCancelId)
case .resetZashiKeychainFailed(let osStatus):
guard state.maxResetZashiAppAttempts == 0 else {
state.maxResetZashiAppAttempts -= 1
return .send(.resetZashiKeychainRequest)
}
state.maxResetZashiAppAttempts = ResetZashiConstants.maxResetZashiAppAttempts
for element in state.settingsState.path {
if case .resetZashi(var resetZashiState) = element {
resetZashiState.isProcessing = false
break
}
}
state.alert = AlertState.wipeFailed(osStatus)
return .cancel(id: SynchronizerCancelId)
case .resetZashiSDKFailed:
guard state.maxResetZashiSDKAttempts == 0 else {
state.maxResetZashiSDKAttempts -= 1
return .concatenate(
.cancel(id: SynchronizerCancelId),
.send(.initialization(.resetZashi))
)
}
state.maxResetZashiSDKAttempts = ResetZashiConstants.maxResetZashiSDKAttempts
for element in state.settingsState.path {
if case .resetZashi(var resetZashiState) = element {
resetZashiState.isProcessing = false
break
}
}
state.alert = AlertState.wipeFailed(Int32.max)
return .cancel(id: SynchronizerCancelId)
case .phraseDisplay(.finishedTapped), .onboarding(.newWalletSuccessfulyCreated):
state.destinationState.destination = .home
return .none
case .welcome(.debugMenuStartup)://, .tabs(.home(.walletBalances(.debugMenuStartup))):
return .concatenate(
Effect.cancel(id: CancelId),
.send(.destination(.updateDestination(.startup)))
)
case .onboarding(.createNewWalletTapped):
if state.appInitializationState == .keysMissing {
state.alert = AlertState.existingWallet()
return .none
} else {
return .send(.onboarding(.createNewWalletRequested))
}
case .initialization(.restoreExistingWallet):
return .run { send in
await send(.onboarding(.updateDestination(nil)))
try await mainQueue.sleep(for: .seconds(1))
await send(.onboarding(.importExistingWallet))
}
case .initialization(.seedValidationResult(let validSeed)):
if !validSeed {
state.alert = AlertState.differentSeed()
}
return .none
case .updateStateAfterConfigUpdate(let walletConfig):
state.walletConfig = walletConfig
state.onboardingState.walletConfig = walletConfig
return .none
case .initialization(.initializationFailed(let error)):
state.appInitializationState = .failed
state.alert = AlertState.initializationFailed(error)
return .none
default:
return .none
}
}
}
private func checkUnavailableService(_ error: Error) -> Bool {
switch error {
case ZcashError.serviceGetInfoFailed(.timeOut),
ZcashError.serviceLatestBlockFailed(.timeOut),
ZcashError.serviceLatestBlockHeightFailed(.timeOut),
ZcashError.serviceBlockRangeFailed(.timeOut),
ZcashError.serviceSubmitFailed(.timeOut),
ZcashError.serviceFetchTransactionFailed(.timeOut),
ZcashError.serviceFetchUTXOsFailed(.timeOut),
ZcashError.serviceBlockStreamFailed(.timeOut),
ZcashError.serviceSubtreeRootsStreamFailed(.timeOut):
return true
default: return false
}
}
}