@@ -13,6 +13,37 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
1313 LogManager . shared. log ( category: . general, message: " App started " )
1414 LogManager . shared. cleanupOldLogs ( )
1515
16+ // Before-First-Unlock detection. isProtectedDataAvailable is false on ANY
17+ // locked launch, so it alone isn't a BFU signal — post-first-unlock
18+ // UserDefaults (class C) reads fine while locked. Only true BFU makes a key
19+ // that should exist read as absent. Suspect only when ALL presence probes
20+ // are absent, so an existing user who updated but hasn't foregrounded this
21+ // build isn't misread on an ordinary locked launch. Probes cover every user
22+ // shape (marker / migrated / consented / NS-configured); presence, not value.
23+ _ = Storage . shared // ensure every StorageValue is registered before recovery
24+ let storageConfirmedReadable = StorageReadiness . markerExists
25+ || Storage . shared. migrationStep. exists
26+ || Storage . shared. telemetryConsentDecisionMade. exists
27+ || Storage . shared. url. exists
28+ let suspectBFU = !UIApplication. shared. isProtectedDataAvailable && !storageConfirmedReadable
29+ StorageReadiness . configure ( suspectBFU: suspectBFU)
30+ LogManager . shared. log ( category: . general, message: " BFU check: isProtectedDataAvailable= \( UIApplication . shared. isProtectedDataAvailable) , storageConfirmedReadable= \( storageConfirmedReadable) , suspectBFU= \( suspectBFU) " )
31+
32+ if suspectBFU {
33+ // Driven here, not MainViewController: on a BG-only launch (BGAppRefreshTask,
34+ // BLE wake) the home VC may not exist yet. protectedDataDidBecomeAvailable is
35+ // authoritative; willEnterForeground is a fallback.
36+ let nc = NotificationCenter . default
37+ nc. addObserver ( self , selector: #selector( protectedDataDidBecomeAvailable) , name: UIApplication . protectedDataDidBecomeAvailableNotification, object: nil )
38+ nc. addObserver ( self , selector: #selector( handleWillEnterForeground) , name: UIApplication . willEnterForegroundNotification, object: nil )
39+
40+ // Race guard: protected data may have become available between the check
41+ // above and the observer registration just now.
42+ if UIApplication . shared. isProtectedDataAvailable {
43+ completeStorageRecovery ( )
44+ }
45+ }
46+
1647 let options : UNAuthorizationOptions = [ . alert, . sound, . badge]
1748 notificationCenter. requestAuthorization ( options: options) {
1849 didAllow, _ in
@@ -46,42 +77,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
4677
4778 BackgroundRefreshManager . shared. register ( )
4879
49- // Telemetry: record this cold launch (used by the rolling
50- // coldLaunches7d signal). If the running build's SHA differs from
51- // the one we last sent for, fire an immediate ping — the scheduler
52- // alone can't notice an app update. Otherwise let the 24h scheduler
53- // handle cadence: its first run is lastSentAt + 24h, so a relaunch
54- // a few hours after the previous send simply waits out the
55- // remainder. See Helpers/Telemetry.swift.
56- TelemetryClient . shared. recordColdLaunch ( )
57- Task . detached {
58- if TelemetryClient . shared. buildShaChangedSinceLastSend ( ) {
59- await TelemetryClient . shared. maybeSend ( )
80+ // Telemetry mutates rolling history (coldLaunches7d), so defer past a BFU
81+ // window — poisoned defaults would discard real history. Runs synchronously
82+ // on a normal launch.
83+ StorageReadiness . whenReady {
84+ // SHA change fires an immediate ping (the scheduler can't notice an app
85+ // update); otherwise the 24h scheduler handles cadence. See Telemetry.swift.
86+ TelemetryClient . shared. recordColdLaunch ( )
87+ Task . detached {
88+ if TelemetryClient . shared. buildShaChangedSinceLastSend ( ) {
89+ await TelemetryClient . shared. maybeSend ( )
90+ }
91+ TelemetryClient . shared. scheduleRecurring ( )
6092 }
61- TelemetryClient . shared. scheduleRecurring ( )
62- }
63-
64- // Detect Before-First-Unlock launch. If protected data is unavailable here,
65- // StorageValues were cached from encrypted UserDefaults and need a reload
66- // once the device is unlocked.
67- let bfu = !UIApplication. shared. isProtectedDataAvailable
68- Storage . shared. needsBFUReload = bfu
69- LogManager . shared. log ( category: . general, message: " BFU check: isProtectedDataAvailable= \( !bfu) , needsBFUReload= \( bfu) " )
70-
71- // Recovery is driven from AppDelegate (not MainViewController) because under
72- // the SwiftUI App lifecycle the home tab's UIHostingController is materialized
73- // lazily — on a BG-only launch (BGAppRefreshTask, BLE wake) MainViewController
74- // may not exist when the device is unlocked, and would miss willEnterForeground.
75- // protectedDataDidBecomeAvailable fires the moment file protection lifts and
76- // is the authoritative signal; willEnterForeground is a fallback.
77- let nc = NotificationCenter . default
78- nc. addObserver ( self , selector: #selector( protectedDataDidBecomeAvailable) , name: UIApplication . protectedDataDidBecomeAvailableNotification, object: nil )
79- nc. addObserver ( self , selector: #selector( handleWillEnterForeground) , name: UIApplication . willEnterForegroundNotification, object: nil )
80-
81- // Race guard: protected data may have become available between the check
82- // above and the observer registration just now.
83- if Storage . shared. needsBFUReload, UIApplication . shared. isProtectedDataAvailable {
84- performBFUReloadIfNeeded ( )
8593 }
8694
8795 return true
@@ -90,19 +98,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
9098 // MARK: - BFU recovery
9199
92100 @objc private func protectedDataDidBecomeAvailable( ) {
93- performBFUReloadIfNeeded ( )
101+ completeStorageRecovery ( )
94102 }
95103
96104 @objc private func handleWillEnterForeground( ) {
97- performBFUReloadIfNeeded ( )
105+ completeStorageRecovery ( )
98106 }
99107
100- private func performBFUReloadIfNeeded( ) {
101- guard Storage . shared. needsBFUReload else { return }
102- Storage . shared. needsBFUReload = false
103- LogManager . shared. log ( category: . general, message: " BFU reload triggered — reloading all StorageValues " )
104- Storage . shared. reloadAll ( )
105- LogManager . shared. log ( category: . general, message: " BFU reload complete: url=' \( Storage . shared. url. value) ' " )
108+ private func completeStorageRecovery( ) {
109+ // recover() hydrates every value and opens the gate; true only on the one
110+ // transition that did the work, so the notification fires exactly once.
111+ guard StorageReadiness . recover ( ) else { return }
112+ LogManager . shared. log ( category: . general, message: " BFU recovery complete: url=' \( Storage . shared. url. value) ' " )
106113 NotificationCenter . default. post ( name: . bfuReloadCompleted, object: nil )
107114 }
108115
@@ -207,7 +214,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
207214
208215extension Notification . Name {
209216 /// Posted by AppDelegate after a Before-First-Unlock recovery completes
210- /// (Storage.reloadAll has run with the now-decrypted UserDefaults).
217+ /// (StorageReadiness.recover has hydrated every value from the now-decrypted
218+ /// UserDefaults).
211219 static let bfuReloadCompleted = Notification . Name ( " LoopFollow.bfuReloadCompleted " )
212220}
213221
0 commit comments