From 0878c155e8bac5cb5a0ae5626b0bcca7410a5ae6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 12:54:04 -0400 Subject: [PATCH 1/6] feat(onboarding): resume onboarding after a kill and on re-login A kill on the name or push screen relaunched straight to home, because registration sets the current account before onboarding finishes. A reinstall and re-login skipped the push prompt for the same reason. Track a per-owner onboarding-completed marker in UserDefaults (absent means completed, so upgraded users stay on home). Registration and interactive logins write false before the account becomes current; the end of onboarding writes true. A launch with a false marker holds the account and resumes at the display-name step when the profile has no name, otherwise at the push step. Seed entry, the account picker and switchAccount go through the same gate. --- .../Core/Screens/Onboarding/IntroScreen.swift | 4 + .../Core/Screens/Onboarding/LoginScreen.swift | 2 +- .../Onboarding/OnboardingViewModel.swift | 75 +++++++++- .../Core/Session/OnboardingProgress.swift | 39 +++++ .../Core/Session/SessionAuthenticator.swift | 67 +++++++-- Flipcash/Keychain/Defaults.swift | 1 + FlipcashTests/OnboardingResumeTests.swift | 133 ++++++++++++++++++ 7 files changed, 301 insertions(+), 20 deletions(-) create mode 100644 Flipcash/Core/Session/OnboardingProgress.swift create mode 100644 FlipcashTests/OnboardingResumeTests.swift diff --git a/Flipcash/Core/Screens/Onboarding/IntroScreen.swift b/Flipcash/Core/Screens/Onboarding/IntroScreen.swift index 659ad8999..f0094b36a 100644 --- a/Flipcash/Core/Screens/Onboarding/IntroScreen.swift +++ b/Flipcash/Core/Screens/Onboarding/IntroScreen.swift @@ -138,6 +138,10 @@ private struct IntroScreenContent: View { } } } + .task(id: sessionAuthenticator.pendingOnboarding?.keyAccount.ownerPublicKey.base58) { + guard let account = sessionAuthenticator.pendingOnboarding else { return } + await viewModel.resumeOnboarding(with: account) + } } } diff --git a/Flipcash/Core/Screens/Onboarding/LoginScreen.swift b/Flipcash/Core/Screens/Onboarding/LoginScreen.swift index 4420ce719..ba2abc271 100644 --- a/Flipcash/Core/Screens/Onboarding/LoginScreen.swift +++ b/Flipcash/Core/Screens/Onboarding/LoginScreen.swift @@ -137,7 +137,7 @@ struct LoginScreen: View { buttonState = .success try await Task.delay(milliseconds: 500) - sessionAuthenticator.completeLogin(with: initializedAccount) + sessionAuthenticator.beginOnboarding(with: initializedAccount) } catch { try await Task.delay(milliseconds: 500) buttonState = .normal diff --git a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift index 762e20a2c..ad000d47b 100644 --- a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift +++ b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift @@ -8,6 +8,7 @@ import SwiftUI import FlipcashUI import FlipcashCore +import UserNotifications @Observable class OnboardingViewModel { @@ -23,6 +24,9 @@ class OnboardingViewModel { @ObservationIgnored private let container: Container @ObservationIgnored private let sessionAuthenticator: SessionAuthenticator @ObservationIgnored private var initializedAccount: InitializedAccount? + @ObservationIgnored private let pushStatus: () async -> UNAuthorizationStatus + @ObservationIgnored private let hasDisplayName: (InitializedAccount) async -> Bool? + @ObservationIgnored private let finishLogin: (InitializedAccount) -> Void /// Built on first call to ``navigateToDisplayName``; the display-name step /// runs pre-login, so it owns its submission against the in-flight owner. @@ -30,9 +34,29 @@ class OnboardingViewModel { // MARK: - Init - - init(container: Container) { + /// The closures are seams for tests; the defaults read the real notification status and + /// profile, and open the session. + /// + /// - Parameters: + /// - pushStatus: the current notification authorization. + /// - hasDisplayName: whether the account's profile already has a display name, or `nil` + /// when that can't be determined. + /// - finishLogin: opens the session once onboarding is done. + init( + container: Container, + pushStatus: @escaping () async -> UNAuthorizationStatus = { await PushController.fetchStatus() }, + hasDisplayName: ((InitializedAccount) async -> Bool?)? = nil, + finishLogin: ((InitializedAccount) -> Void)? = nil + ) { self.container = container self.sessionAuthenticator = container.sessionAuthenticator + self.pushStatus = pushStatus + self.hasDisplayName = hasDisplayName ?? { [flipClient = container.flipClient, accountManager = container.accountManager] account in + await Self.lookUpDisplayName(for: account, flipClient: flipClient, accountManager: accountManager) + } + self.finishLogin = finishLogin ?? { [authenticator = container.sessionAuthenticator] account in + authenticator.completeLogin(with: account) + } } // MARK: - Action - @@ -133,7 +157,7 @@ class OnboardingViewModel { /// undetermined, otherwise finishes login. Contacts access is requested /// later, in-app. private func advanceFromNameStep() async { - let pushStatus = await PushController.fetchStatus() + let pushStatus = await pushStatus() switch pushStatus { case .notDetermined: navigateToPushNotifications() @@ -160,7 +184,7 @@ class OnboardingViewModel { ) try await Task.delay(milliseconds: 500) - sessionAuthenticator.completeLogin(with: initializedAccount) + sessionAuthenticator.beginOnboarding(with: initializedAccount) } catch { // Login failed silently } @@ -238,11 +262,50 @@ class OnboardingViewModel { return } - sessionAuthenticator.completeLogin(with: initializedAccount) + OnboardingProgress.setCompleted(true, owner: initializedAccount.keyAccount.ownerPublicKey) + finishLogin(initializedAccount) Analytics.onboardingCompleted() } + // MARK: - Resume - + + /// Runs the onboarding steps still owed by an account that is already signed in: a launch + /// after a kill mid-onboarding, or an interactive login (seed entry, account picker, deep + /// link). Asks for a display name only when the profile has none, then applies the same + /// push rule as a new account, so already-decided permissions finish straight away. + func resumeOnboarding(with account: InitializedAccount) async { + initializedAccount = account + nameViewModel = nil + + let named = await hasDisplayName(account) + guard named == false else { + await advanceFromNameStep() + return + } + + navigateToDisplayName(owner: account.keyAccount.owner) + } + + private static func lookUpDisplayName( + for account: InitializedAccount, + flipClient: FlipClient, + accountManager: AccountManager + ) async -> Bool? { + do { + let profile = try await flipClient.fetchProfile(userID: account.userID, owner: account.keyAccount.owner) + accountManager.cacheProfile(profile, ownerPublicKey: account.keyAccount.ownerPublicKey) + return profile.displayName?.isEmpty == false + } catch { + // Offline: trust the name cached on the account's keychain entry, and when that + // is blank too, skip the step rather than block the way home. + let cached = accountManager.fetchHistorical() + .first { $0.account.ownerPublicKey == account.keyAccount.ownerPublicKey }? + .displayName + return cached?.isEmpty == false ? true : nil + } + } + // MARK: - Navigation - func navigateToRoot() { @@ -269,10 +332,10 @@ class OnboardingViewModel { path.append(.pushNotificationsDenied) } - func navigateToDisplayName() { + func navigateToDisplayName(owner: KeyPair? = nil) { if nameViewModel == nil { let vm = OnboardingNameViewModel( - owner: inflightMnemonic.solanaKeyPair(), + owner: owner ?? inflightMnemonic.solanaKeyPair(), flipClient: container.flipClient, ) vm.onComplete = { [weak self] in diff --git a/Flipcash/Core/Session/OnboardingProgress.swift b/Flipcash/Core/Session/OnboardingProgress.swift new file mode 100644 index 000000000..028d60b9a --- /dev/null +++ b/Flipcash/Core/Session/OnboardingProgress.swift @@ -0,0 +1,39 @@ +// +// OnboardingProgress.swift +// Flipcash +// + +import Foundation +import FlipcashCore + +/// Per-account record of whether onboarding finished, so a launch after a kill mid-flow can +/// pick up where the user left off. +/// +/// An account with no entry counts as completed: builds that predate the marker never wrote +/// one, and those users must not be sent back through onboarding. +enum OnboardingProgress { + + enum LaunchRoute: Equatable { + case home + case resumeOnboarding + } + + @Defaults(.onboardingCompleted) + private static var markers: [String: Bool]? + + /// False only when the account was registered or signed in and never finished onboarding. + static func isCompleted(owner: PublicKey) -> Bool { + markers?[owner.base58] ?? true + } + + static func setCompleted(_ completed: Bool, owner: PublicKey) { + var updated = markers ?? [:] + updated[owner.base58] = completed + markers = updated + } + + /// Where a relaunch with `owner` as the current account should land. + static func launchRoute(for owner: PublicKey) -> LaunchRoute { + isCompleted(owner: owner) ? .home : .resumeOnboarding + } +} diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index a407440b2..101f5e9ec 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -30,6 +30,10 @@ final class SessionAuthenticator { /// Whether the user has been unlocked (biometrics/passcode verified). private(set) var isUnlocked: Bool = false + /// An account that is signed in but has not finished onboarding. The logged-out + /// screen picks it up and runs the remaining steps before calling ``completeLogin``. + private(set) var pendingOnboarding: InitializedAccount? + /// The top-level authentication state driving the view hierarchy. private(set) var state: AuthenticationState = .migrating @@ -109,12 +113,7 @@ final class SessionAuthenticator { SharedKeychainMigration.migrateOwnerKeyIfNeeded() initializeState { userAccount in - let initializedAccount = InitializedAccount( - keyAccount: userAccount.keyAccount, - userID: userAccount.userID - ) - - self.completeLogin(with: initializedAccount) + self.resolveLaunch(of: userAccount) } didFindRecentAccount: { [weak self] description in guard let self else { return } @@ -143,7 +142,7 @@ final class SessionAuthenticator { // launch backfills the ID and this branch stops being reached. Task { do { - let account = try await self.initialize(using: description.account.mnemonic, isRegistration: false) + let account = try await self.initialize(using: description.account.mnemonic, isRegistration: false, interactive: false) self.completeLogin(with: account) Analytics.track(event: Analytics.GeneralEvent.autoLoginComplete) } catch { @@ -153,6 +152,31 @@ final class SessionAuthenticator { } } + /// Launch with a current account: goes home, or holds the account in ``pendingOnboarding`` + /// when the app was killed before its onboarding finished. + private func resolveLaunch(of userAccount: UserAccount) { + let account = InitializedAccount( + keyAccount: userAccount.keyAccount, + userID: userAccount.userID + ) + + switch OnboardingProgress.launchRoute(for: userAccount.keyAccount.ownerPublicKey) { + case .home: + completeLogin(with: account) + case .resumeOnboarding: + logger.info("Resuming onboarding", metadata: ["owner": "\(account.keyAccount.ownerPublicKey)"]) + pendingOnboarding = account + state = .loggedOut + } + } + + /// Hands a freshly authenticated account to the onboarding flow instead of opening the + /// session, so permissions and the display name are checked before home. + func beginOnboarding(with initializedAccount: InitializedAccount) { + pendingOnboarding = initializedAccount + state = .loggedOut + } + private func initializeState(count: Int = 0, didAuthenticate: @escaping (UserAccount) -> Void, didFindRecentAccount: @escaping (AccountDescription) -> Void) { logger.info("initializeState called", metadata: ["count": "\(count)"]) @@ -368,14 +392,21 @@ final class SessionAuthenticator { // MARK: - Login - - func initialize(using mnemonic: MnemonicPhrase, isRegistration: Bool) async throws -> InitializedAccount { + /// Registers or logs in. `interactive` is true when the user just asked for this login + /// (seed entry, account picker, deep link) and false for a silent relaunch; interactive + /// logins and registrations reopen the account's onboarding marker. + func initialize(using mnemonic: MnemonicPhrase, isRegistration: Bool, interactive: Bool = true) async throws -> InitializedAccount { loginButtonState = .loading defer { loginButtonState = .normal } do { - return try await authenticate(using: mnemonic, isRegistration: isRegistration) + return try await authenticate( + using: mnemonic, + isRegistration: isRegistration, + marksOnboardingIncomplete: isRegistration || interactive + ) } catch { ErrorReporting.captureError( error, @@ -388,7 +419,11 @@ final class SessionAuthenticator { /// Registers or logs the owner in and makes sure its accounts exist, without touching /// ``loginButtonState`` or reporting the failure. ``initialize`` adds both for a login /// the user is watching; ``refreshRecentAccount`` wants neither. - private func authenticate(using mnemonic: MnemonicPhrase, isRegistration: Bool) async throws -> InitializedAccount { + /// + /// - Parameter marksOnboardingIncomplete: writes the account's onboarding marker as `false` + /// before the account becomes current, so a kill between the two relaunches into onboarding + /// instead of home. Written after instead, that window skips onboarding for good. + private func authenticate(using mnemonic: MnemonicPhrase, isRegistration: Bool, marksOnboardingIncomplete: Bool) async throws -> InitializedAccount { let derivedKey: DerivedKey = .derive( using: .primary(), mnemonic: mnemonic @@ -424,6 +459,10 @@ final class SessionAuthenticator { derivationIndex: 0 ) + if marksOnboardingIncomplete { + OnboardingProgress.setCompleted(false, owner: keyAccount.ownerPublicKey) + } + accountManager.set( keyAccount: keyAccount, userID: userID @@ -446,7 +485,7 @@ final class SessionAuthenticator { /// request; here that case is a warning and the next launch tries again. private func refreshRecentAccount(_ keyAccount: KeyAccount) async { do { - _ = try await authenticate(using: keyAccount.mnemonic, isRegistration: false) + _ = try await authenticate(using: keyAccount.mnemonic, isRegistration: false, marksOnboardingIncomplete: false) } catch ErrorLoginAccount.denied { logger.error( "Login denied for a stored account, logging out", @@ -470,6 +509,7 @@ final class SessionAuthenticator { initializedAccount: initializedAccount ) + pendingOnboarding = nil state = .loggedIn(session) session.quickActionsController.configure() UserDefaults.wasLoggedIn = true @@ -497,7 +537,7 @@ final class SessionAuthenticator { logout() try await Task.delay(seconds: 1) - completeLogin( + beginOnboarding( with: try await initialize( using: mnemonic, isRegistration: false @@ -539,6 +579,7 @@ final class SessionAuthenticator { // another account's Coinbase orders. CoinbaseOrderEmail.unverifiedEmail = nil + pendingOnboarding = nil state = .loggedOut requiresForceLogout = false UserDefaults.wasLoggedIn = false @@ -1048,7 +1089,7 @@ struct InitializedAccount { let owner: AccountCluster let userID: UserID - fileprivate init(keyAccount: KeyAccount, userID: UserID) { + init(keyAccount: KeyAccount, userID: UserID) { self.keyAccount = keyAccount self.owner = .init( authority: .derive(using: .primary(), mnemonic: keyAccount.mnemonic), diff --git a/Flipcash/Keychain/Defaults.swift b/Flipcash/Keychain/Defaults.swift index e082082f6..e6a0759a7 100644 --- a/Flipcash/Keychain/Defaults.swift +++ b/Flipcash/Keychain/Defaults.swift @@ -10,6 +10,7 @@ import Foundation enum DefaultsKey: String { case wasLoggedIn = "com.flipcash.state.wasLoggedIn" case launchCount = "com.flipcash.state.launchCount" + case onboardingCompleted = "com.flipcash.state.onboardingCompleted" case cameraAutoStartDisabled = "com.flipcash.camera.autoStartDisabled" diff --git a/FlipcashTests/OnboardingResumeTests.swift b/FlipcashTests/OnboardingResumeTests.swift new file mode 100644 index 000000000..2242b3c76 --- /dev/null +++ b/FlipcashTests/OnboardingResumeTests.swift @@ -0,0 +1,133 @@ +// +// OnboardingResumeTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import UserNotifications +import FlipcashCore +@testable import Flipcash + +@Suite("Onboarding resume", .serialized) +@MainActor +struct OnboardingResumeTests { + + private static let userID = UUID(uuidString: "00000000-0000-0000-0000-0000000000BB")! + private static let owner = KeyAccount.mock.ownerPublicKey + + private final class Finished { + var accounts: [InitializedAccount] = [] + } + + private func makeViewModel( + push: UNAuthorizationStatus, + named: Bool?, + finished: Finished + ) -> OnboardingViewModel { + OnboardingViewModel( + container: Container(), + pushStatus: { push }, + hasDisplayName: { _ in named }, + finishLogin: { finished.accounts.append($0) } + ) + } + + private func account() -> InitializedAccount { + InitializedAccount(keyAccount: .mock, userID: Self.userID) + } + + // MARK: - Launch - + + @Test("An account whose marker is false relaunches into onboarding, not home") + func markerFalse_resumesOnboarding() { + OnboardingProgress.setCompleted(false, owner: Self.owner) + defer { OnboardingProgress.setCompleted(true, owner: Self.owner) } + + #expect(OnboardingProgress.launchRoute(for: Self.owner) == .resumeOnboarding) + } + + /// Builds that predate the marker never wrote one; sending those users back through + /// onboarding on upgrade is the regression this guards. + @Test("An account with no marker goes home, so upgraded users skip onboarding") + func markerAbsent_goesHome() { + let stranger = MnemonicPhrase.generate(.words12).solanaKeyPair().publicKey + + #expect(OnboardingProgress.isCompleted(owner: stranger)) + #expect(OnboardingProgress.launchRoute(for: stranger) == .home) + } + + @Test("Resuming with no display name starts at the name step and does not log in") + func resume_withoutName_startsAtName() async { + let finished = Finished() + let viewModel = makeViewModel(push: .notDetermined, named: false, finished: finished) + + await viewModel.resumeOnboarding(with: account()) + + #expect(viewModel.path == [.displayName]) + #expect(finished.accounts.isEmpty) + } + + @Test("Resuming with a name and undecided push starts at the push step and does not log in") + func resume_withName_startsAtPush() async { + let finished = Finished() + let viewModel = makeViewModel(push: .notDetermined, named: true, finished: finished) + + await viewModel.resumeOnboarding(with: account()) + + #expect(viewModel.path == [.pushNotifications]) + #expect(finished.accounts.isEmpty) + } + + @Test("An unreadable profile skips the name step rather than blocking the way home") + func resume_unknownName_skipsName() async { + let finished = Finished() + let viewModel = makeViewModel(push: .notDetermined, named: nil, finished: finished) + + await viewModel.resumeOnboarding(with: account()) + + #expect(viewModel.path == [.pushNotifications]) + } + + // MARK: - Interactive login - + + @Test("An interactive login with push already decided goes straight home and marks onboarding done") + func interactive_pushAuthorized_goesHome() async { + OnboardingProgress.setCompleted(false, owner: Self.owner) + defer { OnboardingProgress.setCompleted(true, owner: Self.owner) } + let finished = Finished() + let viewModel = makeViewModel(push: .authorized, named: true, finished: finished) + + await viewModel.resumeOnboarding(with: account()) + + #expect(finished.accounts.count == 1) + #expect(viewModel.path.isEmpty) + #expect(OnboardingProgress.isCompleted(owner: Self.owner)) + } + + @Test("An interactive login with undecided push shows the push step and stays in onboarding") + func interactive_pushUndetermined_showsPush() async { + OnboardingProgress.setCompleted(false, owner: Self.owner) + defer { OnboardingProgress.setCompleted(true, owner: Self.owner) } + let finished = Finished() + let viewModel = makeViewModel(push: .notDetermined, named: true, finished: finished) + + await viewModel.resumeOnboarding(with: account()) + + #expect(viewModel.path == [.pushNotifications]) + #expect(finished.accounts.isEmpty) + #expect(!OnboardingProgress.isCompleted(owner: Self.owner)) + } + + @Test("Beginning onboarding holds the account without opening the session") + func beginOnboarding_holdsAccount() { + let authenticator = SessionAuthenticator(container: Container()) + + authenticator.beginOnboarding(with: account()) + + #expect(authenticator.pendingOnboarding?.keyAccount.ownerPublicKey == Self.owner) + #expect(!authenticator.isLoggedIn) + } +} From ea3f34d855bdb2ac1610c40977589a0c0d768d16 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 13:02:15 -0400 Subject: [PATCH 2/6] feat(onboarding): offer the push step again after a reinstall The keychain keeps the signed-in account across an uninstall but UserDefaults does not, so a reinstall found no onboarding marker and went straight home with notification permission reset. A first launch that already has a current account now reopens the marker. --- Flipcash/Core/Session/OnboardingProgress.swift | 13 ++++++++++--- Flipcash/Core/Session/SessionAuthenticator.swift | 9 +++++---- FlipcashTests/OnboardingResumeTests.swift | 11 +++++++++++ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/Flipcash/Core/Session/OnboardingProgress.swift b/Flipcash/Core/Session/OnboardingProgress.swift index 028d60b9a..b49ce53da 100644 --- a/Flipcash/Core/Session/OnboardingProgress.swift +++ b/Flipcash/Core/Session/OnboardingProgress.swift @@ -32,8 +32,15 @@ enum OnboardingProgress { markers = updated } - /// Where a relaunch with `owner` as the current account should land. - static func launchRoute(for owner: PublicKey) -> LaunchRoute { - isCompleted(owner: owner) ? .home : .resumeOnboarding + /// Where a launch with `owner` as the current account should land. + /// + /// The keychain outlives an uninstall but UserDefaults does not, so a first launch that + /// already finds a signed-in account is a reinstall: the permission decisions are gone and + /// the marker with them. That case reopens the marker so the push step is offered again. + static func launchRoute(for owner: PublicKey, isFreshInstall: Bool = false) -> LaunchRoute { + if isFreshInstall { + setCompleted(false, owner: owner) + } + return isCompleted(owner: owner) ? .home : .resumeOnboarding } } diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 101f5e9ec..2f0bb2686 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -89,7 +89,8 @@ final class SessionAuthenticator { // Update launch state. // This is a fresh install. - if UserDefaults.launchCount == nil { + let isFreshInstall = UserDefaults.launchCount == nil + if isFreshInstall { logger.debug("First launch...") } @@ -113,7 +114,7 @@ final class SessionAuthenticator { SharedKeychainMigration.migrateOwnerKeyIfNeeded() initializeState { userAccount in - self.resolveLaunch(of: userAccount) + self.resolveLaunch(of: userAccount, isFreshInstall: isFreshInstall) } didFindRecentAccount: { [weak self] description in guard let self else { return } @@ -154,13 +155,13 @@ final class SessionAuthenticator { /// Launch with a current account: goes home, or holds the account in ``pendingOnboarding`` /// when the app was killed before its onboarding finished. - private func resolveLaunch(of userAccount: UserAccount) { + private func resolveLaunch(of userAccount: UserAccount, isFreshInstall: Bool) { let account = InitializedAccount( keyAccount: userAccount.keyAccount, userID: userAccount.userID ) - switch OnboardingProgress.launchRoute(for: userAccount.keyAccount.ownerPublicKey) { + switch OnboardingProgress.launchRoute(for: userAccount.keyAccount.ownerPublicKey, isFreshInstall: isFreshInstall) { case .home: completeLogin(with: account) case .resumeOnboarding: diff --git a/FlipcashTests/OnboardingResumeTests.swift b/FlipcashTests/OnboardingResumeTests.swift index 2242b3c76..2739660ed 100644 --- a/FlipcashTests/OnboardingResumeTests.swift +++ b/FlipcashTests/OnboardingResumeTests.swift @@ -59,6 +59,17 @@ struct OnboardingResumeTests { #expect(OnboardingProgress.launchRoute(for: stranger) == .home) } + /// The keychain keeps the signed-in account across an uninstall while UserDefaults does + /// not, so the reinstall looks like an upgrade (no marker) unless the first launch says otherwise. + @Test("A first launch that finds a signed-in account is a reinstall and resumes onboarding") + func freshInstall_withAccount_resumesOnboarding() { + let owner = MnemonicPhrase.generate(.words12).solanaKeyPair().publicKey + defer { OnboardingProgress.setCompleted(true, owner: owner) } + + #expect(OnboardingProgress.launchRoute(for: owner, isFreshInstall: true) == .resumeOnboarding) + #expect(OnboardingProgress.launchRoute(for: owner) == .resumeOnboarding) + } + @Test("Resuming with no display name starts at the name step and does not log in") func resume_withoutName_startsAtName() async { let finished = Finished() From 8dd2c707cb3a33d3834ca13caa7889f1536884a5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 13:08:48 -0400 Subject: [PATCH 3/6] fix(onboarding): hold on the name step when the profile can't be read --- .../Onboarding/OnboardingViewModel.swift | 19 +++++++++---------- FlipcashTests/OnboardingResumeTests.swift | 13 ++++++++----- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift index ad000d47b..8ea021c9a 100644 --- a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift +++ b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift @@ -25,7 +25,7 @@ class OnboardingViewModel { @ObservationIgnored private let sessionAuthenticator: SessionAuthenticator @ObservationIgnored private var initializedAccount: InitializedAccount? @ObservationIgnored private let pushStatus: () async -> UNAuthorizationStatus - @ObservationIgnored private let hasDisplayName: (InitializedAccount) async -> Bool? + @ObservationIgnored private let hasDisplayName: (InitializedAccount) async -> Bool @ObservationIgnored private let finishLogin: (InitializedAccount) -> Void /// Built on first call to ``navigateToDisplayName``; the display-name step @@ -39,13 +39,12 @@ class OnboardingViewModel { /// /// - Parameters: /// - pushStatus: the current notification authorization. - /// - hasDisplayName: whether the account's profile already has a display name, or `nil` - /// when that can't be determined. + /// - hasDisplayName: whether the account's profile already has a display name. /// - finishLogin: opens the session once onboarding is done. init( container: Container, pushStatus: @escaping () async -> UNAuthorizationStatus = { await PushController.fetchStatus() }, - hasDisplayName: ((InitializedAccount) async -> Bool?)? = nil, + hasDisplayName: ((InitializedAccount) async -> Bool)? = nil, finishLogin: ((InitializedAccount) -> Void)? = nil ) { self.container = container @@ -278,8 +277,7 @@ class OnboardingViewModel { initializedAccount = account nameViewModel = nil - let named = await hasDisplayName(account) - guard named == false else { + guard !(await hasDisplayName(account)) else { await advanceFromNameStep() return } @@ -291,18 +289,19 @@ class OnboardingViewModel { for account: InitializedAccount, flipClient: FlipClient, accountManager: AccountManager - ) async -> Bool? { + ) async -> Bool { do { let profile = try await flipClient.fetchProfile(userID: account.userID, owner: account.keyAccount.owner) accountManager.cacheProfile(profile, ownerPublicKey: account.keyAccount.ownerPublicKey) return profile.displayName?.isEmpty == false } catch { - // Offline: trust the name cached on the account's keychain entry, and when that - // is blank too, skip the step rather than block the way home. + // Unreadable profile: trust a name cached on the account's keychain entry, and + // otherwise treat the name as missing. Matches Android (a null profile counts as + // no display name), and every account needs a name for tips and chat. let cached = accountManager.fetchHistorical() .first { $0.account.ownerPublicKey == account.keyAccount.ownerPublicKey }? .displayName - return cached?.isEmpty == false ? true : nil + return cached?.isEmpty == false } } diff --git a/FlipcashTests/OnboardingResumeTests.swift b/FlipcashTests/OnboardingResumeTests.swift index 2739660ed..a67ea8764 100644 --- a/FlipcashTests/OnboardingResumeTests.swift +++ b/FlipcashTests/OnboardingResumeTests.swift @@ -24,7 +24,7 @@ struct OnboardingResumeTests { private func makeViewModel( push: UNAuthorizationStatus, - named: Bool?, + named: Bool, finished: Finished ) -> OnboardingViewModel { OnboardingViewModel( @@ -92,14 +92,17 @@ struct OnboardingResumeTests { #expect(finished.accounts.isEmpty) } - @Test("An unreadable profile skips the name step rather than blocking the way home") - func resume_unknownName_skipsName() async { + /// An unreadable profile is reported as "no name", the same as Android's null-profile check. + /// Resume and interactive login share this gate, so one case covers both. + @Test("An unreadable profile holds the user on the name step") + func resume_unreadableProfile_holdsOnName() async { let finished = Finished() - let viewModel = makeViewModel(push: .notDetermined, named: nil, finished: finished) + let viewModel = makeViewModel(push: .authorized, named: false, finished: finished) await viewModel.resumeOnboarding(with: account()) - #expect(viewModel.path == [.pushNotifications]) + #expect(viewModel.path == [.displayName]) + #expect(finished.accounts.isEmpty) } // MARK: - Interactive login - From bb995890e0d536f73cdf75f682566e979dfc9131 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 13:16:15 -0400 Subject: [PATCH 4/6] fix(onboarding): skip the intro flash and back-out on a resumed flow While an account waits on its resumed step the intro is replaced by the launch background, and the stack is created with the path already seeded so the first screen has no push animation. The resumed first screen hides its back button, and the push step can return to the name step only when the name step is in the flow. The profile lookup gives up after 5 seconds. --- .../Core/Screens/Onboarding/IntroScreen.swift | 34 +++++++++-- .../Onboarding/OnboardingViewModel.swift | 61 ++++++++++++++++++- FlipcashTests/OnboardingResumeTests.swift | 47 ++++++++++++++ 3 files changed, 133 insertions(+), 9 deletions(-) diff --git a/Flipcash/Core/Screens/Onboarding/IntroScreen.swift b/Flipcash/Core/Screens/Onboarding/IntroScreen.swift index f0094b36a..22184859a 100644 --- a/Flipcash/Core/Screens/Onboarding/IntroScreen.swift +++ b/Flipcash/Core/Screens/Onboarding/IntroScreen.swift @@ -39,7 +39,30 @@ private struct IntroScreenContent: View { // MARK: - Body - + /// An account is waiting on its resumed step, so the intro would only flash before it. + /// Rendering this instead keeps the stack uncreated until the path is seeded, which lets + /// the first resumed screen appear without a push animation. + private var isAwaitingResume: Bool { + sessionAuthenticator.pendingOnboarding != nil && viewModel.path.isEmpty + } + var body: some View { + if isAwaitingResume { + Background(color: .backgroundMain) { + LoadingView(color: .white) + } + .task(id: sessionAuthenticator.pendingOnboarding?.keyAccount.ownerPublicKey.base58, resumeIfPending) + } else { + introStack + } + } + + private func resumeIfPending() async { + guard let account = sessionAuthenticator.pendingOnboarding else { return } + await viewModel.resumeOnboarding(with: account) + } + + private var introStack: some View { NavigationStack(path: $viewModel.path) { Background(color: .backgroundMain) { VStack { @@ -123,25 +146,24 @@ private struct IntroScreenContent: View { LoginScreen() case .accessKey: AccessKeyScreen(viewModel: viewModel) - .navigationBarBackButtonHidden(true) + .navigationBarBackButtonHidden(viewModel.hidesBackButton(for: .accessKey)) case .accessKeyHelp: AccessKeyHelpScreen() case .pushNotifications: NotificationPermissionScreen(viewModel: viewModel) + .navigationBarBackButtonHidden(viewModel.hidesBackButton(for: .pushNotifications)) case .pushNotificationsDenied: NotificationPermissionDeniedScreen(viewModel: viewModel) + .navigationBarBackButtonHidden(viewModel.hidesBackButton(for: .pushNotificationsDenied)) case .displayName: if let nameVM = viewModel.nameViewModel { OnboardingNameScreen(viewModel: nameVM) - .navigationBarBackButtonHidden(true) + .navigationBarBackButtonHidden(viewModel.hidesBackButton(for: .displayName)) } } } } - .task(id: sessionAuthenticator.pendingOnboarding?.keyAccount.ownerPublicKey.base58) { - guard let account = sessionAuthenticator.pendingOnboarding else { return } - await viewModel.resumeOnboarding(with: account) - } + .task(id: sessionAuthenticator.pendingOnboarding?.keyAccount.ownerPublicKey.base58, resumeIfPending) } } diff --git a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift index 8ea021c9a..6ef071e28 100644 --- a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift +++ b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift @@ -24,6 +24,15 @@ class OnboardingViewModel { @ObservationIgnored private let container: Container @ObservationIgnored private let sessionAuthenticator: SessionAuthenticator @ObservationIgnored private var initializedAccount: InitializedAccount? + + /// True once ``resumeOnboarding(with:)`` has taken over: the flow was entered from a launch + /// or an interactive login rather than from "Create a New Account". + private(set) var isResumedFlow = false + + /// True from ``resumeOnboarding(with:)`` until its first step replaces the path, so that + /// step becomes the stack's first screen instead of stacking over the intro or login. + @ObservationIgnored private var awaitingResumedRoot = false + @ObservationIgnored private var resumedOwner: PublicKey? @ObservationIgnored private let pushStatus: () async -> UNAuthorizationStatus @ObservationIgnored private let hasDisplayName: (InitializedAccount) async -> Bool @ObservationIgnored private let finishLogin: (InitializedAccount) -> Void @@ -274,8 +283,13 @@ class OnboardingViewModel { /// link). Asks for a display name only when the profile has none, then applies the same /// push rule as a new account, so already-decided permissions finish straight away. func resumeOnboarding(with account: InitializedAccount) async { + // The task that calls this restarts when the intro swaps for the stack; one run is enough. + guard resumedOwner != account.keyAccount.ownerPublicKey else { return } + resumedOwner = account.keyAccount.ownerPublicKey initializedAccount = account nameViewModel = nil + isResumedFlow = true + awaitingResumedRoot = true guard !(await hasDisplayName(account)) else { await advanceFromNameStep() @@ -285,13 +299,18 @@ class OnboardingViewModel { navigateToDisplayName(owner: account.keyAccount.owner) } + /// Longest the resume screen waits on the profile before treating it as unreadable. + private static let profileLookupTimeout: Double = 5 + private static func lookUpDisplayName( for account: InitializedAccount, flipClient: FlipClient, accountManager: AccountManager ) async -> Bool { do { - let profile = try await flipClient.fetchProfile(userID: account.userID, owner: account.keyAccount.owner) + let profile = try await withTimeout(seconds: profileLookupTimeout) { + try await flipClient.fetchProfile(userID: account.userID, owner: account.keyAccount.owner) + } accountManager.cacheProfile(profile, ownerPublicKey: account.keyAccount.ownerPublicKey) return profile.displayName?.isEmpty == false } catch { @@ -323,8 +342,29 @@ class OnboardingViewModel { path = [.accessKey] } + /// Whether `step` hides its back button, which also turns off swipe-back. + /// + /// A new account hides it on the access key and name steps only. A resumed flow also hides + /// it on its first screen so it can't back out to the intro or login; a later push step may + /// return to the name step only when the name step is in this flow's path. + func hidesBackButton(for step: OnboardingPath) -> Bool { + guard isResumedFlow else { + return step == .accessKey || step == .displayName + } + return step == .displayName || path.first == step + } + + private func place(_ step: OnboardingPath) { + if awaitingResumedRoot { + awaitingResumedRoot = false + path = [step] + } else { + path.append(step) + } + } + func navigateToPushNotifications() { - path.append(.pushNotifications) + place(.pushNotifications) } func navigateToPushNotificationsDenied() { @@ -342,7 +382,7 @@ class OnboardingViewModel { } nameViewModel = vm } - path.append(.displayName) + place(.displayName) } } @@ -358,3 +398,18 @@ nonisolated enum OnboardingPath { case pushNotifications case pushNotificationsDenied } + +private struct LookupTimedOut: Error {} + +/// Runs `operation`, throwing ``LookupTimedOut`` if it hasn't finished within `seconds`. +private func withTimeout(seconds: Double, _ operation: @escaping @Sendable () async throws -> T) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(for: .seconds(seconds)) + throw LookupTimedOut() + } + defer { group.cancelAll() } + return try await group.next()! + } +} diff --git a/FlipcashTests/OnboardingResumeTests.swift b/FlipcashTests/OnboardingResumeTests.swift index a67ea8764..cb51cef44 100644 --- a/FlipcashTests/OnboardingResumeTests.swift +++ b/FlipcashTests/OnboardingResumeTests.swift @@ -144,4 +144,51 @@ struct OnboardingResumeTests { #expect(authenticator.pendingOnboarding?.keyAccount.ownerPublicKey == Self.owner) #expect(!authenticator.isLoggedIn) } + + // MARK: - Back navigation - + + @Test("A resumed flow's first screen hides its back button, so it can't back out to the intro") + func resumed_rootHidesBack() async { + let viewModel = makeViewModel(push: .notDetermined, named: true, finished: Finished()) + + await viewModel.resumeOnboarding(with: account()) + + #expect(viewModel.path == [.pushNotifications]) + #expect(viewModel.hidesBackButton(for: .pushNotifications)) + } + + @Test("In a resumed flow the push step can go back to the name step, and denied back to push") + func resumed_pushAfterName_canGoBack() async { + let viewModel = makeViewModel(push: .notDetermined, named: false, finished: Finished()) + + await viewModel.resumeOnboarding(with: account()) + #expect(viewModel.hidesBackButton(for: .displayName)) + + viewModel.navigateToPushNotifications() + viewModel.navigateToPushNotificationsDenied() + + #expect(viewModel.path == [.displayName, .pushNotifications, .pushNotificationsDenied]) + #expect(!viewModel.hidesBackButton(for: .pushNotifications)) + #expect(!viewModel.hidesBackButton(for: .pushNotificationsDenied)) + } + + @Test("A new account keeps today's back behavior: only the access key and name steps hide it") + func freshFlow_backUnchanged() { + let viewModel = makeViewModel(push: .notDetermined, named: true, finished: Finished()) + + #expect(viewModel.hidesBackButton(for: .accessKey)) + #expect(viewModel.hidesBackButton(for: .displayName)) + #expect(!viewModel.hidesBackButton(for: .pushNotifications)) + #expect(!viewModel.hidesBackButton(for: .pushNotificationsDenied)) + } + + @Test("A resume that starts from a login screen replaces it, so back can't return to the login") + func resume_replacesLoginPath() async { + let viewModel = makeViewModel(push: .notDetermined, named: true, finished: Finished()) + viewModel.path = [.login] + + await viewModel.resumeOnboarding(with: account()) + + #expect(viewModel.path == [.pushNotifications]) + } } From c59e849dbd1a598f6f47789d10cb94f1a5444fdb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 13:42:42 -0400 Subject: [PATCH 5/6] fix(onboarding): enforce the profile lookup timeout without waiting on the call --- .../Onboarding/OnboardingViewModel.swift | 37 +++++++++++++++---- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift index 6ef071e28..fc21cddf4 100644 --- a/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift +++ b/Flipcash/Core/Screens/Onboarding/OnboardingViewModel.swift @@ -402,14 +402,37 @@ nonisolated enum OnboardingPath { private struct LookupTimedOut: Error {} /// Runs `operation`, throwing ``LookupTimedOut`` if it hasn't finished within `seconds`. +/// +/// Races two unstructured tasks rather than a task group: a group waits for every child, and a +/// gRPC call that ignores cancellation would hold the caller past the deadline. private func withTimeout(seconds: Double, _ operation: @escaping @Sendable () async throws -> T) async throws -> T { - try await withThrowingTaskGroup(of: T.self) { group in - group.addTask { try await operation() } - group.addTask { - try await Task.sleep(for: .seconds(seconds)) - throw LookupTimedOut() + let gate = OnceGate() + return try await withCheckedThrowingContinuation { continuation in + Task { + do { + let value = try await operation() + if gate.claim() { continuation.resume(returning: value) } + } catch { + if gate.claim() { continuation.resume(throwing: error) } + } } - defer { group.cancelAll() } - return try await group.next()! + Task { + try? await Task.sleep(for: .seconds(seconds)) + if gate.claim() { continuation.resume(throwing: LookupTimedOut()) } + } + } +} + +private final class OnceGate: @unchecked Sendable { + private let lock = NSLock() + private var claimed = false + + /// True for the first caller only. + func claim() -> Bool { + lock.lock() + defer { lock.unlock() } + if claimed { return false } + claimed = true + return true } } From 7df8366516362177b848bc672b1e3207a73cdd84 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 14:12:12 -0400 Subject: [PATCH 6/6] fix(tests): split the seeded-churn row builder so it type-checks --- .../Regressions/Regression_6ae1d311c6c10d79fa57232c.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/FlipcashTests/Regressions/Regression_6ae1d311c6c10d79fa57232c.swift b/FlipcashTests/Regressions/Regression_6ae1d311c6c10d79fa57232c.swift index 364f8b702..1a0bde1a6 100644 --- a/FlipcashTests/Regressions/Regression_6ae1d311c6c10d79fa57232c.swift +++ b/FlipcashTests/Regressions/Regression_6ae1d311c6c10d79fa57232c.swift @@ -251,8 +251,10 @@ struct Regression_6ae1d311c6c10d79fa57232c { var rng = SplitMix(seed: seed) let source = Source() let cards = (10..<22).map { Self.groupCard(UInt8($0)) } - var rows: [(id: String, text: String, card: LinkCard?)] = (0..<60).map { i in - (id: "r\(i)", text: "row \(i)", card: i % 5 == 0 ? cards[(i / 5) % cards.count] : nil) + var rows: [(id: String, text: String, card: LinkCard?)] = [] + for i in 0..<60 { + let card: LinkCard? = i % 5 == 0 ? cards[(i / 5) % cards.count] : nil + rows.append((id: "r\(i)", text: "row \(i)", card: card)) } func items() -> [ChatItem] { rows.map { carded($0.id, $0.text, card: $0.card) } }