M2-04: Location opt-in and the permission prompt #45

Merged
wk merged 1 commit from m2-04-location-opt-in into main 2026-08-09 17:37:28 -04:00
Member

Closes #31.

A Settings toggle, off on a fresh install, that asks iOS for location and holds it. Nothing
collects a coordinate.
LocationCollection.isAvailable is false, the screen says so in as many
words, and both go with the change that lands collection — a permission granted against a promise
this build does not keep would be worse than the feature being absent.

The screen shows what iOS actually permits rather than what was asked for, which is the failure the
issue names: a toggle that reads as on while iOS is refusing.

The invariant

A stored opt-in exists only while iOS is granting. The preference is true exactly when the
toggle reads on, so there is never consent on disk with nothing on screen to show it or clear it.
Three behaviours follow, and each looks like a bug without it:

  • A tap under a refusal clears the preference. The toggle reads off there, so the only thing a
    tap can write is on; a preference standing behind a refusal is unclearable and would take
    effect by itself if the permission came back.
  • A tap before the phone has been asked stores nothing and asks. The answer commits it. Stored
    at the tap, it outlives a process killed while the prompt is up.
  • A return to .notDetermined clears it, like a refusal. Allow Once reverts to that status once
    the app stops being used.

And a permission granted in iOS Settings that Stash never asked for does not turn the feature on.

Decisions in this PR

Always authorization, superseding the When In Use decision of 2026-08-08. Wes: "Go full time
access."
What it buys is that the blue lock screen indicator stops being compulsory. What it costs
belongs in any description of this feature: iOS is no longer what enforces "only while
recording"
— under When In Use the system would have cut location off the moment Stash was not in
use, so #33's rule is now a promise only our code keeps. The superseded reasoning is kept rather
than deleted.

No accuracy request anywhere. Wes: "I don't want to re-enable access with each recording."
requestTemporaryFullAccuracyAuthorization is the only API for full accuracy and it lapses when the
app stops being engaged with, so using it would mean re-prompting around every recording. Precise
Location granted at the first system sheet is already permanent; reduced accuracy gets a sentence
and a route to iOS Settings, and still collects — a coarse route is a degraded feature, not a
disabled one.

location joins UIBackgroundModes before anything uses it, because setting
allowsBackgroundLocationUpdates while the mode is absent is documented as a fatal error rather
than a failed call.

Shape

  • Stash/Location/LocationOptIn.swift — the rules as pure functions: the resting state, what a tap
    does, and whether a stored opt-in survives what iOS just said. Same separation as PlaybackGate,
    and here because it is the one decision that can tell somebody their whereabouts are not being
    recorded when they are.
  • Stash/Location/LocationPermission.swift — the shared observable state. Owns a
    CLLocationManager for authorization only and makes no decisions of its own.
  • Stash/Location/LocationPreferenceStore.swift, LocationCopy.swift, LocationCollection.swift
  • Stash/Views/SettingsView.swift, project.yml

The copy is one string source because M2-10's heat map empty state offers the same opt-in and the
two must not drift.

Codex

Four passes, in .agent/m2-04-location-opt-in/, with every finding's disposition in SUMMARY.md.
The three that changed the design:

  • Plan review — the copy promised a GPX file this build never writes. Hence the disclosure row.
  • Code review — the toggle read on for an unanswered prompt, which Allow Once reverts to.
  • Security review — consent was stored before the prompt was answered, so a killed launch or a
    lapsed Allow Once left it saved behind a toggle reading off. This produced the invariant above.

One rejection: no test covers the toggle's Binding getter. A SwiftUI binding needs a UI test
target that does not exist, and adding one for a one-expression getter is not worth maintaining. It
is covered by the device pass and named as unverified by the suite rather than left implied.

The new rules were mutation-checked — inverting the refusal branches turns four tests red.

Validated

  • make test — 358 tests green. make lint — clean.
  • plutil -p on the built plist: both usage descriptions, audio and location in
    UIBackgroundModes, and no temporary-accuracy dictionary.
  • Simulator: the section renders with the toggle off and the disclosure on a fresh state; the
    denial row and Open Settings render with location revoked; a permission granted from outside the
    app leaves the toggle off, which is the invariant working.
  • Device pass on wes-iphone — Wes ran it and reported it good.

Not validated

Synthetic input does not reach the Simulator on this machine, so nothing behind a tap is
exercised by the automated pass
— the prompt, a grant committing the preference, a denial, and
the reduced-accuracy row are all covered only by the device run above and by no test.

.woodpecker/build.yml still has no macOS agent, so it sits pending rather than green.

Also

The decision file landed in stash-docs on the m2-03-transcript-seek-decision branch by mistake,
so it is on stash-docs PR #6 rather than one of its own. Restoring that branch needed a force
push, which was declined, so it is left where it is rather than worked around.

Closes #31. A Settings toggle, off on a fresh install, that asks iOS for location and holds it. **Nothing collects a coordinate.** `LocationCollection.isAvailable` is `false`, the screen says so in as many words, and both go with the change that lands collection — a permission granted against a promise this build does not keep would be worse than the feature being absent. The screen shows what iOS actually permits rather than what was asked for, which is the failure the issue names: a toggle that reads as on while iOS is refusing. ## The invariant **A stored opt-in exists only while iOS is granting.** The preference is true exactly when the toggle reads on, so there is never consent on disk with nothing on screen to show it or clear it. Three behaviours follow, and each looks like a bug without it: - A tap under a refusal **clears** the preference. The toggle reads off there, so the only thing a tap can write is *on*; a preference standing behind a refusal is unclearable and would take effect by itself if the permission came back. - A tap before the phone has been asked **stores nothing** and asks. The answer commits it. Stored at the tap, it outlives a process killed while the prompt is up. - A return to `.notDetermined` **clears** it, like a refusal. Allow Once reverts to that status once the app stops being used. And a permission granted in iOS Settings that Stash never asked for does not turn the feature on. ## Decisions in this PR **Always authorization, superseding the When In Use decision of 2026-08-08.** Wes: *"Go full time access."* What it buys is that the blue lock screen indicator stops being compulsory. What it costs belongs in any description of this feature: **iOS is no longer what enforces "only while recording"** — under When In Use the system would have cut location off the moment Stash was not in use, so #33's rule is now a promise only our code keeps. The superseded reasoning is kept rather than deleted. **No accuracy request anywhere.** Wes: *"I don't want to re-enable access with each recording."* `requestTemporaryFullAccuracyAuthorization` is the only API for full accuracy and it lapses when the app stops being engaged with, so using it would mean re-prompting around every recording. Precise Location granted at the first system sheet is already permanent; reduced accuracy gets a sentence and a route to iOS Settings, and still collects — a coarse route is a degraded feature, not a disabled one. **`location` joins `UIBackgroundModes`** before anything uses it, because setting `allowsBackgroundLocationUpdates` while the mode is absent is documented as a fatal error rather than a failed call. ## Shape - `Stash/Location/LocationOptIn.swift` — the rules as pure functions: the resting state, what a tap does, and whether a stored opt-in survives what iOS just said. Same separation as `PlaybackGate`, and here because it is the one decision that can tell somebody their whereabouts are not being recorded when they are. - `Stash/Location/LocationPermission.swift` — the shared observable state. Owns a `CLLocationManager` for authorization only and makes no decisions of its own. - `Stash/Location/LocationPreferenceStore.swift`, `LocationCopy.swift`, `LocationCollection.swift` - `Stash/Views/SettingsView.swift`, `project.yml` The copy is one string source because M2-10's heat map empty state offers the same opt-in and the two must not drift. ## Codex Four passes, in `.agent/m2-04-location-opt-in/`, with every finding's disposition in `SUMMARY.md`. The three that changed the design: - **Plan review** — the copy promised a GPX file this build never writes. Hence the disclosure row. - **Code review** — the toggle read on for an unanswered prompt, which Allow Once reverts to. - **Security review** — consent was stored before the prompt was answered, so a killed launch or a lapsed Allow Once left it saved behind a toggle reading off. This produced the invariant above. One rejection: no test covers the toggle's `Binding` getter. A SwiftUI binding needs a UI test target that does not exist, and adding one for a one-expression getter is not worth maintaining. It is covered by the device pass and named as unverified by the suite rather than left implied. The new rules were mutation-checked — inverting the refusal branches turns four tests red. ## Validated - `make test` — 358 tests green. `make lint` — clean. - `plutil -p` on the built plist: both usage descriptions, `audio` and `location` in `UIBackgroundModes`, and no temporary-accuracy dictionary. - Simulator: the section renders with the toggle off and the disclosure on a fresh state; the denial row and Open Settings render with location revoked; a permission granted from outside the app leaves the toggle off, which is the invariant working. - Device pass on `wes-iphone` — Wes ran it and reported it good. ## Not validated Synthetic input does not reach the Simulator on this machine, so **nothing behind a tap is exercised by the automated pass** — the prompt, a grant committing the preference, a denial, and the reduced-accuracy row are all covered only by the device run above and by no test. `.woodpecker/build.yml` still has no macOS agent, so it sits pending rather than green. ## Also The decision file landed in `stash-docs` on the `m2-03-transcript-seek-decision` branch by mistake, so it is on **stash-docs PR #6** rather than one of its own. Restoring that branch needed a force push, which was declined, so it is left where it is rather than worked around.
A Settings toggle, off on a fresh install, that asks iOS for location and
holds it. Nothing collects a coordinate: `LocationCollection.isAvailable`
is false and the screen says so, both of which go with the change that
lands collection.

The screen shows what iOS permits rather than what was asked for, and the
invariant behind that is that a stored opt-in exists only while iOS is
granting — so there is never consent on disk with nothing on screen to
show or clear it. A tap under a refusal clears the preference, a tap
before the phone has been asked stores nothing until the answer arrives,
and a return to not-determined clears it like a refusal, because Allow
Once reverts to that status once the app stops being used.

Always authorization, superseding the When In Use decision of 2026-08-08.
What it buys is that the lock screen indicator stops being compulsory;
what it costs is that iOS no longer enforces "only while recording", so
that rule becomes a promise only Stash's code keeps. Recorded in AGENTS.md
and in stash-docs with the reasoning it supersedes.

No accuracy request anywhere. The only API for full accuracy grants it
temporarily, which would mean re-asking around every recording; Precise
Location granted at the first sheet is already permanent, so reduced
accuracy gets a sentence and a route to iOS Settings instead.

`location` joins UIBackgroundModes before anything uses it, because
setting allowsBackgroundLocationUpdates while the mode is absent is a
fatal error rather than a failed call.

Codex plan, code, test and security reviews are in .agent/, with every
finding's disposition in SUMMARY.md. Untested until the device pass:
everything behind a tap — the prompt, a grant, a denial, and the
reduced-accuracy row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wk merged commit 26eee60a38 into main 2026-08-09 17:37:28 -04:00
wk deleted branch m2-04-location-opt-in 2026-08-09 17:37:28 -04:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Stash/stash-ios!45
No description provided.