Skip to content

Commit

Permalink
[Electric-Coin-Company#1086] Resolve interaction with the keychain fo…
Browse files Browse the repository at this point in the history
…r both fore and background app states

- draft

[Electric-Coin-Company#1086] Resolve interaction with the keychain for both fore and background app states

- revert

[Electric-Coin-Company#1086] Resolve interaction with the keychain for both fore and background app states

- initialization pipeline updated to handle state when BGTask runs with fresh app start, in such case Zashi wait and doesn't try to initialize SDK -> no keychain error is triggered

[Electric-Coin-Company#1086] Resolve interaction with the keychain for both fore and background app states

- code cleaned up
- finished the state handling
- closing the BGTask asap for state that is supposed to just wait

[Electric-Coin-Company#1086] Resolve interaction with the keychain for both fore and background app states

- unit tests fixed

[Electric-Coin-Company#1086] Resolve interaction with the keychain for both fore and background app states (Electric-Coin-Company#1091)

- Comments addressed

[Electric-Coin-Company#1086] Resolve interaction with the keychain for both fore and background app states (Electric-Coin-Company#1091)

- Unit tests fixed
- Block time of didFinishLaunching increased to 0.5s (from 0.02)
  • Loading branch information
LukasKorba committed Mar 3, 2024
1 parent 487abbd commit 557cae3
Show file tree
Hide file tree
Showing 8 changed files with 96 additions and 151 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,11 @@ public struct WalletStorage {
kSecAttrService as String: (zcashStoredWalletPrefix + forKey) as AnyObject,
kSecAttrAccount as String: account as AnyObject,
kSecClass as String: kSecClassGenericPassword,
/// The data in the keychain item can be accessed only after the device has been unlocked by the user
/// (aka won't be accessible after restart of the device until unlock).
/// The data in the keychain item can be accessed only while the device is unlocked by the user.
/// This is recommended for items that need to be accessible only while the application is in the foreground.
/// Items with this attribute do not migrate to a new device.
/// Thus, after restoring from a backup of a different device, these items will not be present.
// TODO: [#1071] ultimate solution to handle background sync and the keychain setup
// https://github.com/Electric-Coin-Company/zashi-ios/issues/1071
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]

return query
Expand Down
89 changes: 48 additions & 41 deletions modules/Sources/Features/Root/RootInitialization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ extension RootReducer {
case initialSetups
case initializationFailed(ZcashError)
case initializationSuccessfullyDone(UnifiedAddress?)
case retryKeychainRead(InitializationState)
case nukeWallet
case nukeWalletRequest
case respondToWalletInitializationState(InitializationState)
Expand All @@ -39,24 +38,52 @@ extension RootReducer {
public func initializationReduce() -> Reduce<RootReducer.State, RootReducer.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.timer, cancelInFlight: true)

case .initialization(.appDelegate(.willEnterForeground)):
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.timer)

case .initialization(.appDelegate(.backgroundTask(let task))):
state.bgTask = task
return .run { send in
await send(.initialization(.retryStart))
}

case .initialization(.appDelegate(.willEnterForeground)):
return .run { send in
try await mainQueue.sleep(for: .seconds(1))
await send(.initialization(.retryStart))
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.timer)
}
} 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)

Expand All @@ -77,8 +104,6 @@ extension RootReducer {
default: break
}

LoggerProxy.event("BGTask .synchronizerStateChanged(let latestState): \(snapshot.syncStatus)")

if finishBGTask {
LoggerProxy.event("BGTask setTaskCompleted(success: \(successOfBGTask)) from TCA")
state.bgTask?.setTaskCompleted(success: successOfBGTask)
Expand Down Expand Up @@ -130,14 +155,6 @@ extension RootReducer {
}
.cancellable(id: CancelStateId.timer, cancelInFlight: true)

case .initialization(.appDelegate(.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.02))
await send(.initialization(.initialSetups))
}

case .initialization(.checkWalletConfig):
return .publisher {
walletConfigProvider.load()
Expand Down Expand Up @@ -165,10 +182,7 @@ extension RootReducer {
/// 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 .concatenate(
Effect.send(.initialization(.configureCrashReporter)),
.run { send in
try await mainQueue.sleep(for: .seconds(0.02))
await send(.initialization(.checkWalletInitialization))
}
Effect.send(.initialization(.checkWalletInitialization))
)

/// Evaluate the wallet's state based on keychain keys and database files presence
Expand All @@ -185,10 +199,15 @@ extension RootReducer {
switch walletState {
case .failed:
state.appInitializationState = .failed
return .send(.initialization(.retryKeychainRead(walletState)))
state.alert = AlertState.walletStateFailed(walletState)
return .none
case .keysMissing:
state.appInitializationState = .keysMissing
return .send(.initialization(.retryKeychainRead(walletState)))
// TODO: [#1024] This is the case when this wallet migrated to another device
// https://github.com/Electric-Coin-Company/zashi-ios/issues/1024
// Temporary alert view until #1024 is implemented
state.alert = AlertState.tmpMigrationToBeDeveloped()
return .none
case .initialized, .filesMissing:
if walletState == .filesMissing {
state.appInitializationState = .filesMissing
Expand All @@ -211,24 +230,12 @@ extension RootReducer {
case .uninitialized:
state.appInitializationState = .uninitialized
return .run { send in
try await mainQueue.sleep(for: .seconds(3))
try await mainQueue.sleep(for: .seconds(2.5))
await send(.destination(.updateDestination(.onboarding)))
}
.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)):
Expand Down Expand Up @@ -290,7 +297,7 @@ extension RootReducer {
state.appInitializationState = .initialized

return .run { [landingDestination] send in
try await mainQueue.sleep(for: .seconds(3))
try await mainQueue.sleep(for: .seconds(2.5))
await send(.destination(.updateDestination(landingDestination)))
}
.cancellable(id: CancelId.timer, cancelInFlight: true)
Expand Down
21 changes: 15 additions & 6 deletions modules/Sources/Features/Root/RootStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,19 @@ public struct RootReducer: Reducer {
enum CancelStateId { case timer }
enum SynchronizerCancelId { case timer }
enum WalletConfigCancelId { case timer }
enum DidFinishLaunchingId { case timer }

public struct State: Equatable {
@PresentationState public var alert: AlertState<Action>?
public var appInitializationState: InitializationState = .uninitialized
public var appStartState: AppStartState = .unknown
public var bgTask: BGProcessingTask?
@PresentationState public var confirmationDialog: ConfirmationDialogState<Action.ConfirmationDialog>?
public var debugState: DebugState
public var destinationState: DestinationState
public var exportLogsState: ExportLogsReducer.State
public var isLockedInKeychainUnavailableState = false
public var isRestoringWallet = false
public var keychainReadRetries = 0
public var maxKeychainReadRetries = 3
public var onboardingState: OnboardingFlowReducer.State
public var phraseDisplayState: RecoveryPhraseDisplay.State
public var sandboxState: SandboxReducer.State
Expand All @@ -52,12 +53,12 @@ public struct RootReducer: Reducer {

public init(
appInitializationState: InitializationState = .uninitialized,
appStartState: AppStartState = .unknown,
debugState: DebugState,
destinationState: DestinationState,
exportLogsState: ExportLogsReducer.State,
isLockedInKeychainUnavailableState: Bool = false,
isRestoringWallet: Bool = false,
keychainReadRetries: Int = 0,
maxKeychainReadRetries: Int = 3,
onboardingState: OnboardingFlowReducer.State,
phraseDisplayState: RecoveryPhraseDisplay.State,
sandboxState: SandboxReducer.State,
Expand All @@ -67,12 +68,12 @@ public struct RootReducer: Reducer {
welcomeState: WelcomeReducer.State
) {
self.appInitializationState = appInitializationState
self.appStartState = appStartState
self.debugState = debugState
self.destinationState = destinationState
self.exportLogsState = exportLogsState
self.isLockedInKeychainUnavailableState = isLockedInKeychainUnavailableState
self.isRestoringWallet = isRestoringWallet
self.keychainReadRetries = keychainReadRetries
self.maxKeychainReadRetries = maxKeychainReadRetries
self.onboardingState = onboardingState
self.phraseDisplayState = phraseDisplayState
self.sandboxState = sandboxState
Expand Down Expand Up @@ -297,6 +298,14 @@ extension AlertState where Action == RootReducer.Action {
TextState(L10n.ImportWallet.Alert.Success.message)
}
}

public static func tmpMigrationToBeDeveloped() -> AlertState {
AlertState {
TextState("Automatic migration to be developed soon")
} message: {
TextState("This copy of Zashi has been migrated from another device. Your funds are safe provided that you have the seed phrase. This issue will be addressed soon; until then, delete Zashi and reinstall it, providing the seed phrase to restore your wallet.")
}
}
}

extension ConfirmationDialogState where Action == RootReducer.Action.ConfirmationDialog {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,11 @@ extension SecurityWarning {

extension SecurityWarning.State {
public static let placeholder = SecurityWarning.State(
recoveryPhraseDisplayState: RecoveryPhraseDisplayReducer.State(phrase: .placeholder)
recoveryPhraseDisplayState: RecoveryPhraseDisplay.State(phrase: .placeholder)
)

public static let initial = SecurityWarning.State(
recoveryPhraseDisplayState: RecoveryPhraseDisplayReducer.State(
recoveryPhraseDisplayState: RecoveryPhraseDisplay.State(
phrase: .initial
)
)
Expand Down
8 changes: 8 additions & 0 deletions modules/Sources/Models/InitializationState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@

import Foundation

public enum AppStartState: Equatable {
case backgroundTask
case didEnterBackground
case didFinishLaunching
case unknown
case willEnterForeground
}

public enum InitializationState: Equatable {
case failed
case initialized
Expand Down
10 changes: 5 additions & 5 deletions secant/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,13 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
guard !_XCTIsTesting else { return true }
walletLogger = OSLogger(logLevel: .debug, category: LoggerConstants.walletLogs)
#endif

handleBackgroundTask()

// set the default behavior for the NSDecimalNumber
NSDecimalNumber.defaultBehavior = Zatoshi.decimalHandler

rootStore.send(.initialization(.appDelegate(.didFinishLaunching)))

return true
}

Expand Down Expand Up @@ -67,10 +71,6 @@ extension AppDelegate {
}
monitor?.start(queue: workerQueue)

// set the default behavior for the NSDecimalNumber
NSDecimalNumber.defaultBehavior = Zatoshi.decimalHandler
rootStore.send(.initialization(.appDelegate(.didFinishLaunching)))

registerTasks()
}

Expand Down
Loading

0 comments on commit 557cae3

Please sign in to comment.