Skip to content

Let the location stream say why it has no fix - #22

Merged
RISCfuture merged 1 commit into
mainfrom
told/location-authorization-diagnostics
Sep 4, 2026
Merged

Let the location stream say why it has no fix#22
RISCfuture merged 1 commit into
mainfrom
told/location-authorization-diagnostics

Conversation

@RISCfuture

@RISCfuture RISCfuture commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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.init requested authorization eagerly, _start() hit
case .notDetermined: and returned with no resume path, and the only thing that could
have resumed it was locationManager(_:didChangeAuthorization:)the pre-iOS-14
selector, which the modern SDK never calls.
It was dead code. NearestView compensated
with two overlapping workarounds: a didBecomeActiveNotification handler and a 1 Hz
Timer.publish polling authorizationStatus on the main thread, each constructing a
throwaway CLLocationManager to read a status the app had no other way to learn.

Key design

Core Location already reports all of this on the updates themselves. CLLocationUpdate
carries authorizationRequestInProgress, authorizationDenied,
authorizationDeniedGlobally, authorizationRestricted and locationUnavailable (all
iOS 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 LocationAvailability the view switches on, so the prompt resolves in place.

What that retires: the eager requestWhenInUseAuthorization(), the CLLocationManager,
its delegate extension, the authorization switch, the inert desiredAccuracy assignment
that liveUpdates() never consulted, and both polls.

apply(_:) resolves a refusal first and only calls an update .available once a fix
actually arrives, so availability stays nil — a spinner, not an empty list — while
Core Location is still acquiring. insufficientlyInUse folds into .locationUnavailable;
accuracyLimited deliberately does not, because a coarse fix is ample for a 50 NM search.
No CLServiceSession (it is API_UNAVAILABLE(macos)) and no CLRequireExplicitServiceSession.

The subtlety worth catching in review

Deleting the didBecomeActiveNotification handler would have cost the one thing it was
also 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 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.

Also

  • The Nearest tab no longer hides itself. AirportPicker gated it on
    CLLocationManager.locationServicesEnabled(), a blocking call that needed a detached
    task to stay off the main thread. .authorizationDeniedGlobally now explains the
    situation instead of vanishing the tab. (The app never used significant-location
    monitoring, the gate's other condition.)
  • "Denied" is three situations, not one — this app refused, Location Services off
    device-wide, or a restriction from Screen Time or device management — and only the first
    is fixable at the Settings page the button opens. LocationDeniedView now says which,
    and hides the button where it cannot help.
  • 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 exposed a start/stop imbalance. NearestAirportViewModel started the streamer
    and never stopped it, and locationUpdates()'s self-start could drive the listener count
    negative — 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.swift waited 5 s for a springboard permission alert that only ever
    appeared because of the eager request from ContentView.body; that flow never opens the
    Nearest tab, so the block is gone.
  • LocationError is deleted (its one case is subsumed, and it never conformed to
    LocalizedError as CLAUDE.md requires); the DocC topic list is updated to match, which
    matters because docbuild runs 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 both
    siblings already pass. The nearest-airport picker is the diversion path; it is used in flight.
  • A UI-test seam. LOCATION=<lat>,<lon> and LOCATION-DENIED script a fix or a refusal,
    in the same shape UITestingHelper's weather and NOTAM stand-ins already use, so no UI test
    has 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: accuracyLimited is treated
as available here (the fix is only a sort key, never displayed and never fed to a calculation),
serviceSessionRequired is not modelled (it can only be true under
CLRequireExplicitServiceSession, which this app does not adopt), and insufficientlyInUse
folds 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:

Check Result
Prompt at launch None — it now appears only on opening Nearest, per Apple's guidance
LocationPermissionPromptView reachable Yes — visible behind the alert, confirming authorizationRequestInProgress fires
Grant → list populates without backgrounding Yes — OAK, SQL, 1C9 by distance. This is the bug
Leaving the tab Location indicator clears (it did not before)
Deny App-specific copy + Open Settings
Grant in Settings, resume same pid Denied screen replaced by the list — the scenePhase retry
  • swift format lint --strict on changed files: clean
  • swiftlint --strict: 0 violations (config-deprecation notices only)
  • xcodebuild build: succeeded
  • xcodebuild test -testPlan "SF50 Shared Unit Tests": 386/386 passed
  • xcodebuild docbuild (warnings-as-errors): succeeded
  • periphery scan --strict: No unused code detected
  • Scripted-fix seam on a privacy-reset simulator: LOCATION=37.7214,-122.2208 populates the
    list with no permission prompt at all; LOCATION-DENIED renders the denied copy.
  • Real stream re-verified after the .airborne switch: prompt on opening Nearest, list populates
    on grant.

No new unit tests: CLLocationUpdate has no public initializer, so the flag→availability
mapping cannot be constructed in a test without inventing an indirection that exists only
for the test. The mock now carries a settable availability, and NearestView gained
previews for the awaiting-permission and denied states.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XgsEB3UyjyDLFn5duGQsSn

@RISCfuture
RISCfuture force-pushed the told/location-authorization-diagnostics branch 4 times, most recently from 79af634 to a1e9ec7 Compare September 3, 2026 22:50
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
RISCfuture force-pushed the told/location-authorization-diagnostics branch from a1e9ec7 to 853adad Compare September 3, 2026 23:18
@RISCfuture
RISCfuture merged commit a201f48 into main Sep 4, 2026
8 checks passed
@RISCfuture
RISCfuture deleted the told/location-authorization-diagnostics branch September 4, 2026 01:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant