From 01517ba687c4889e5f063ddb051670b77cb35234 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Thu, 20 Aug 2026 13:12:53 +0100 Subject: [PATCH] onboarding: replay on a fresh/reinstalled install, not every launch The keychain-first completed flag persists across reinstalls (same bundle id), so a new install was treated as already-completed and onboarding silently skipped while permissions still prompted. Add install identity: - Store an install id in BOTH the app-own container defaults (wiped on reinstall, persists across launches) and keychain (survives reinstall). - isFreshInstall() is true on first-ever launch or when the local id is missing/different from keychain (i.e. reinstalled); it seeds a fresh common id so the next launch of the same install is not 'fresh' again. - shouldShowOnboarding() = not completed || fresh install -> onboarding now replays once per fresh/reinstall, never on ordinary launches. Works on free SideStore (no app-group), using the app's own defaults domain as the same-install signal. --- FuelBoard/ContentView.swift | 2 +- .../FuelBoardSharedTests/FuelBoardTests.swift | 12 ++++++ Shared/FuelStore.swift | 37 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index 1fca183..07f98ae 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -321,7 +321,7 @@ struct ContentView: View { #else let shouldSkipOnboarding = false #endif - if FuelStore.loadHasCompletedOnboarding() + if !FuelStore.shouldShowOnboarding() || shouldSkipOnboarding { locationManager.startForegroundTracking() // Geofences and the Live Activity must follow the user even in diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index d16c7b5..86047a8 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -671,3 +671,15 @@ final class RoadDistanceCacheTests: XCTestCase { XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001) } } + +// MARK: - Install identity + +final class InstallIdentityTests: XCTestCase { + func testFreshInstallIsStableAfterFirstCall() { + // The first call seeds the common install id (local == keychain), so + // any subsequent call in the same process must report NOT-fresh. This + // holds regardless of persisted keychain/defaults state from prior runs. + _ = FuelStore.isFreshInstall() + XCTAssertFalse(FuelStore.isFreshInstall()) + } +} diff --git a/Shared/FuelStore.swift b/Shared/FuelStore.swift index eccb247..33f354c 100644 --- a/Shared/FuelStore.swift +++ b/Shared/FuelStore.swift @@ -949,6 +949,43 @@ struct FuelStore { saveString(completed ? "1" : "0", service: onboardingCompletedKey) } + // MARK: Install identity — distinguish a genuinely fresh install (where + // onboarding should replay) from later launches of the same install. + + /// Keychain copy of the install id (survives reinstall). + private static let installIDKey = "fuelboard.installID" + /// Local (app-own container) copy — wiped on reinstall, persists across + /// normal launches. Free SideStore accounts have no app-group container, + /// so this app-own defaults domain is the reliable "same install" signal. + private static let localInstallIDKey = "fuelboard.installID.local" + + /// True when this is a first-ever install OR the app was just reinstalled + /// (local install id missing/different from the keychain id). Seeds a fresh + /// id into both stores so the next launch within the same install is not a + /// "fresh install" any more. + static func isFreshInstall() -> Bool { + let local = UserDefaults.standard.string(forKey: localInstallIDKey) + let remote = loadString(service: installIDKey) + if let local, let remote, local == remote { return false } + // Fresh / mismatched install (or first launch). Pattern a common id so + // subsequent launches of this install are recognised as the same one. + let id = UUID().uuidString + UserDefaults.standard.set(id, forKey: localInstallIDKey) + saveString(id, service: installIDKey) + return true + } + + /// Onboarding should present when it hasn't been completed in this install + /// OR this is a freshly-installed app (so the walkthrough replays on a new + /// install, e.g. after a SideStore reinstall, without re-showing on every + /// ordinary launch). The install check always runs (it seeds the id) rather + /// than short-circuiting, so a brand-new install is recorded before the + /// user ever fills in onboarding. + static func shouldShowOnboarding() -> Bool { + let fresh = isFreshInstall() + return !loadHasCompletedOnboarding() || fresh + } + // MARK: Road distances (Apple-Maps-matched, computed by the app) /// Cached road/routed distances (metres) keyed by station ID, computed by