Let the location stream say why it has no fix - #22
Merged
Conversation
RISCfuture
force-pushed
the
told/location-authorization-diagnostics
branch
4 times, most recently
from
September 3, 2026 22:50
79af634 to
a1e9ec7
Compare
The nearest-airport picker is the diversion path, and a pilot could get stuck on its "Location Access Needed" screen until the view was torn down. The streamer asked for authorization eagerly in its initializer, then its start path hit `case .notDetermined:` and returned with no way to resume. The only thing that could have resumed it was `locationManager(_:didChangeAuthorization:)` — the pre-iOS-14 selector, which the modern SDK never calls. So the answer never arrived, and NearestView compensated with two overlapping workarounds: a `didBecomeActiveNotification` handler and a 1 Hz timer polling `authorizationStatus` on the main thread, each building a throwaway CLLocationManager to read a status the app had no other way to learn. Core Location already reports all of this on the updates themselves. `CLLocationUpdate` carries `authorizationRequestInProgress`, `authorizationDenied`, `authorizationDeniedGlobally`, `authorizationRestricted` and `locationUnavailable`, and it requests authorization on its own when iteration over the stream begins. Reading those flags into a `LocationAvailability` makes the state a value the view switches on, so the prompt resolves in place and both polls go away — along with the CLLocationManager, its delegate, and the inert `desiredAccuracy` assignment that `liveUpdates()` never consulted. Deleting the notification handler would have cost the one thing it was also quietly doing: noticing that someone granted access in Settings and came back. A `scenePhase` change gated on a refusal now restarts the stream — one hook where there were two polls, and it fires only when the state it repairs is actually present. Two things the diagnostics made reachable are fixed alongside them. The Nearest tab no longer hides itself behind `locationServicesEnabled()`, a blocking call that needed a detached task to stay off the main thread; a device-wide switch-off is now a message rather than a vanishing tab. And "denied" is three different situations — this app refused, Location Services off entirely, or a restriction set by Screen Time or device management — of which only the first is fixable at the Settings page the button opens, so each now says so. ContentView built a fresh streamer on every body evaluation, which would have reset the new state continuously; the environment default is already a single shared instance. That change exposed a start/stop imbalance in turn: the view model started the streamer without ever stopping it, and the subscription bookkeeping could drive the listener count negative, so the GPS ran for the life of the process after one visit to the tab. Subscribing now counts as the listener, and the view model can be cancelled. A release that arrives before its acquire — a view cancelled inside a single runloop turn — is ignored rather than counted, because a tally below zero could never climb back to one and would wedge the stream for the life of the process. Two changes bring this in line with its two sibling apps, which read location the same way. `liveUpdates()` now asks for `.airborne`, the configuration tuned for aircraft-rate motion — the nearest-airport picker is the diversion path, and it is used in flight. And a UI test can now script the fix or the refusal through `LOCATION=<lat>,<lon>` and `LOCATION-DENIED`, in the same shape the weather and NOTAM stand-ins already use, so no test has to answer a system permission alert. The real streamer is still built lazily by the environment default, on first read, so a test that never opens the picker never constructs one at all. Verified on iPhone 17 Pro, fresh install: the prompt appears on opening Nearest rather than at launch, granting populates the list without backgrounding the app, the location indicator clears on leaving the tab, denial shows the app-specific copy, and granting in Settings and resuming the same process recovers. Shared unit tests 386/386; swiftlint, swift-format, docbuild and periphery all clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XgsEB3UyjyDLFn5duGQsSn
RISCfuture
force-pushed
the
told/location-authorization-diagnostics
branch
from
September 3, 2026 23:18
a1e9ec7 to
853adad
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The nearest-airport picker is the diversion path, and a pilot could get stuck on its
"Location Access Needed" screen until the view was torn down.
CoreLocationStreamer.initrequested authorization eagerly,_start()hitcase .notDetermined:and returned with no resume path, and the only thing that couldhave resumed it was
locationManager(_:didChangeAuthorization:)— the pre-iOS-14selector, which the modern SDK never calls. It was dead code.
NearestViewcompensatedwith two overlapping workarounds: a
didBecomeActiveNotificationhandler and a 1 HzTimer.publishpollingauthorizationStatuson the main thread, each constructing athrowaway
CLLocationManagerto read a status the app had no other way to learn.Key design
Core Location already reports all of this on the updates themselves.
CLLocationUpdatecarries
authorizationRequestInProgress,authorizationDenied,authorizationDeniedGlobally,authorizationRestrictedandlocationUnavailable(alliOS 18; the floor here is 26), and per Configuring your app to use location services it
requests authorization on its own when iteration over the stream begins. Those flags
become a
LocationAvailabilitythe view switches on, so the prompt resolves in place.What that retires: the eager
requestWhenInUseAuthorization(), theCLLocationManager,its delegate extension, the authorization
switch, the inertdesiredAccuracyassignmentthat
liveUpdates()never consulted, and both polls.apply(_:)resolves a refusal first and only calls an update.availableonce a fixactually arrives, so
availabilitystaysnil— a spinner, not an empty list — whileCore Location is still acquiring.
insufficientlyInUsefolds into.locationUnavailable;accuracyLimiteddeliberately does not, because a coarse fix is ample for a 50 NM search.No
CLServiceSession(it isAPI_UNAVAILABLE(macos)) and noCLRequireExplicitServiceSession.The subtlety worth catching in review
Deleting the
didBecomeActiveNotificationhandler would have cost the one thing it wasalso quietly doing: noticing that someone granted access in Settings and came back.
Without a replacement this would have traded one stuck screen for another. A
scenePhasechange gated on a refusal now restarts the stream — one hook where there were two
polls, and it fires only when the state it repairs is actually present.
Also
AirportPickergated it onCLLocationManager.locationServicesEnabled(), a blocking call that needed a detachedtask to stay off the main thread.
.authorizationDeniedGloballynow explains thesituation instead of vanishing the tab. (The app never used significant-location
monitoring, the gate's other condition.)
device-wide, or a restriction from Screen Time or device management — and only the first
is fixable at the Settings page the button opens.
LocationDeniedViewnow says which,and hides the button where it cannot help.
ContentViewbuilt a fresh streamer on every body evaluation, which would have resetthe new state continuously. The environment default is already a single shared instance.
NearestAirportViewModelstarted the streamerand never stopped it, and
locationUpdates()'s self-start could drive the listener countnegative — so the GPS ran for the life of the process after one visit to the tab.
Subscribing now is the listener, and the view model can be cancelled. A release that
arrives before its acquire (a view cancelled inside one runloop turn) is ignored
rather than counted, since a tally below zero could never climb back to one and would
wedge the stream permanently — a failure mode a sibling app of mine hit in production.
Generate_Screenshots.swiftwaited 5 s for a springboard permission alert that only everappeared because of the eager request from
ContentView.body; that flow never opens theNearest tab, so the block is gone.
LocationErroris deleted (its one case is subsumed, and it never conformed toLocalizedErroras CLAUDE.md requires); the DocC topic list is updated to match, whichmatters because
docbuildruns with warnings-as-errors.Normalized with its siblings
This app reads location the same way as two sibling apps of mine, and they had diverged.
Two changes close that:
liveUpdates(.airborne)— the configuration tuned for aircraft-rate motion, which bothsiblings already pass. The nearest-airport picker is the diversion path; it is used in flight.
LOCATION=<lat>,<lon>andLOCATION-DENIEDscript a fix or a refusal,in the same shape
UITestingHelper's weather and NOTAM stand-ins already use, so no UI testhas to answer a system permission alert. This matters more after this PR, because the Nearest
tab is now permanently visible and a future test will eventually reach it. The real streamer
is still built lazily by the environment default, so a test that never opens the picker never
constructs one.
Three differences from the siblings are deliberate and commented:
accuracyLimitedis treatedas available here (the fix is only a sort key, never displayed and never fed to a calculation),
serviceSessionRequiredis not modelled (it can only be true underCLRequireExplicitServiceSession, which this app does not adopt), andinsufficientlyInUsefolds into
.locationUnavailable(this app is foreground when-in-use on a visible screen).Verification
On iPhone 17 Pro, app freshly installed with location privacy reset:
LocationPermissionPromptViewreachableauthorizationRequestInProgressfiresscenePhaseretryswift format lint --stricton changed files: cleanswiftlint --strict: 0 violations (config-deprecation notices only)xcodebuild build: succeededxcodebuild test -testPlan "SF50 Shared Unit Tests": 386/386 passedxcodebuild docbuild(warnings-as-errors): succeededperiphery scan --strict: No unused code detectedLOCATION=37.7214,-122.2208populates thelist with no permission prompt at all;
LOCATION-DENIEDrenders the denied copy..airborneswitch: prompt on opening Nearest, list populateson grant.
No new unit tests:
CLLocationUpdatehas no public initializer, so the flag→availabilitymapping cannot be constructed in a test without inventing an indirection that exists only
for the test. The mock now carries a settable
availability, andNearestViewgainedpreviews for the awaiting-permission and denied states.
🤖 Generated with Claude Code
https://claude.ai/code/session_01XgsEB3UyjyDLFn5duGQsSn