M1-13: start a recording from a control #38

Merged
wk merged 5 commits from m1-13-control-widget into main 2026-08-09 10:44:19 -04:00
Member

Closes #19.

A Record control for Control Center, the lock screen and the Action Button. Tapping it brings
Stash forward and starts a memo. Stopping stays on the Live Activity from issue 07, which appears
the moment the recording starts and already shows the elapsed time and the microphone.

The device answered the issue's open question, and it changed the design

The issue asked whether iOS would start a recording from a locked phone. The answer is broader:
an app cannot start audio recording from the background at all. A Control Center tap with the
phone unlocked was refused at AVAudioSession.setActive(true) — and nothing appeared at the tap,
because there was no foreground to show the failure in.

AudioRecordingIntent does not change that. It buys execution in the app's process and the system
recording indicator; it does not buy permission to start a session in the background.

So the control declares itself foreground. What it saves is finding the app, not the unlock. Wes
was given the choice between that and dropping the control, and kept it. The reasoning, including
the rejected alternatives, is in stash-docs.

What the reviews changed

  • The intent conforms to AudioRecordingIntent and LiveActivityIntent. Dropping either
    compiles and produces a control that records nothing, so both are asserted in tests.
  • Adopting AudioRecordingIntent makes the Live Activity a condition of the recording continuing —
    Apple stops the audio without one. So it is requested before the file writer exists, and a
    refusal fails the start with nothing left behind; and it is watched, so a recording whose activity
    is dismissed is finalised rather than left claiming to record.
  • Stop arriving during a start no longer reports success and then lets the recording begin anyway.
  • The elapsed clock counts from the engine, not from the file being claimed, so a cold launch's
    speech setup is not shown as recorded time.
  • Failed-start cleanup uses unlink rather than removeItem, deactivates the audio session, and
    ends transcription. The documents directory is the user's and writable from the Files app.
  • The intent is not discoverable in Siri, Spotlight or Shortcuts, and requires an unlocked device.
    A microphone anything can start is a bigger surface than a Control Center button asks for.

Validated

make lint clean. make test — 158 passing in 16 suites. make build and make run clean.

On the device, all eight tests passed: Control Center and lock screen placement, a warm start, a
cold start after force-quitting, a second tap while recording, stop from the Live Activity, the
locked case through Face ID, the Live-Activities-off refusal leaving no memo behind, and swiping the
activity away mid-recording.

Not validated

perform(), the StashApp handler registration, the failed-start cleanup, the activity watcher and
the launch-sweep race have no automated test. StashTests is logic only — no engine, no
ActivityKit, no device — so all five are covered by the device pass alone, and a regression in any
of them would be found by a person rather than by CI.

.woodpecker/build.yml is still pending for want of a macOS agent. Pending is not green.

Known limit, not fixed here

iOS ends a Live Activity after about eight hours, which ends a control-started recording. It ends
cleanly — the memo and transcript are complete — but it ends. Renewing the activity or capping a
recording's length is its own piece of work and worth its own issue.


Decision record: stash-docsdecisions/2026-08-08-starting-a-recording-from-a-control.md.
Plan, four Codex reviews and the dispositions: .agent/m1-13-control-widget/.

Closes #19. A **Record** control for Control Center, the lock screen and the Action Button. Tapping it brings Stash forward and starts a memo. Stopping stays on the Live Activity from issue 07, which appears the moment the recording starts and already shows the elapsed time and the microphone. ## The device answered the issue's open question, and it changed the design The issue asked whether iOS would start a recording from a locked phone. The answer is broader: **an app cannot start audio recording from the background at all.** A Control Center tap with the phone *unlocked* was refused at `AVAudioSession.setActive(true)` — and nothing appeared at the tap, because there was no foreground to show the failure in. `AudioRecordingIntent` does not change that. It buys execution in the app's process and the system recording indicator; it does not buy permission to start a session in the background. So the control declares itself foreground. What it saves is finding the app, not the unlock. Wes was given the choice between that and dropping the control, and kept it. The reasoning, including the rejected alternatives, is in `stash-docs`. ## What the reviews changed - The intent conforms to `AudioRecordingIntent` **and** `LiveActivityIntent`. Dropping either compiles and produces a control that records nothing, so both are asserted in tests. - Adopting `AudioRecordingIntent` makes the Live Activity a condition of the recording continuing — Apple stops the audio without one. So it is requested **before** the file writer exists, and a refusal fails the start with nothing left behind; and it is watched, so a recording whose activity is dismissed is finalised rather than left claiming to record. - Stop arriving during a start no longer reports success and then lets the recording begin anyway. - The elapsed clock counts from the engine, not from the file being claimed, so a cold launch's speech setup is not shown as recorded time. - Failed-start cleanup uses `unlink` rather than `removeItem`, deactivates the audio session, and ends transcription. The documents directory is the user's and writable from the Files app. - The intent is not discoverable in Siri, Spotlight or Shortcuts, and requires an unlocked device. A microphone anything can start is a bigger surface than a Control Center button asks for. ## Validated `make lint` clean. `make test` — 158 passing in 16 suites. `make build` and `make run` clean. **On the device, all eight tests passed:** Control Center and lock screen placement, a warm start, a cold start after force-quitting, a second tap while recording, stop from the Live Activity, the locked case through Face ID, the Live-Activities-off refusal leaving no memo behind, and swiping the activity away mid-recording. ## Not validated `perform()`, the `StashApp` handler registration, the failed-start cleanup, the activity watcher and the launch-sweep race have **no automated test**. `StashTests` is logic only — no engine, no ActivityKit, no device — so all five are covered by the device pass alone, and a regression in any of them would be found by a person rather than by CI. `.woodpecker/build.yml` is still pending for want of a macOS agent. Pending is not green. ## Known limit, not fixed here iOS ends a Live Activity after about eight hours, which ends a control-started recording. It ends cleanly — the memo and transcript are complete — but it ends. Renewing the activity or capping a recording's length is its own piece of work and worth its own issue. --- Decision record: `stash-docs` › `decisions/2026-08-08-starting-a-recording-from-a-control.md`. Plan, four Codex reviews and the dispositions: `.agent/m1-13-control-widget/`.
A Record control for Control Center, the lock screen and the Action Button.
Tapping it starts a memo without opening Stash; stopping stays on the Live
Activity, which already shows the elapsed time and the microphone.

StartRecordingIntent conforms to AudioRecordingIntent and LiveActivityIntent.
The first has it performed in the app's process, where the recorder is; the
second is what grants permission to start a Live Activity from the background.
Dropping either compiles and produces a control that records nothing, so both
are asserted in tests.

Apple requires an AudioRecordingIntent to keep a Live Activity alive for the
whole recording, so for control-started recordings that activity is
load-bearing. It is requested before the file writer exists, so a refusal fails
the start cleanly and leaves no memo behind, and it is watched, so a recording
whose activity is dismissed or expires is finalised rather than left claiming
to record with the file never closed.

When the start fails — a locked phone may refuse the microphone — the intent
asks to continue in the foreground and tries once more there. On no path does
it report a recording that did not begin.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
iOS will not let an app start audio recording from the background, and not
only from a locked phone. Measured: a Control Center tap with the phone
unlocked was refused at AVAudioSession.setActive(true), and nothing appeared
at the tap because there was no foreground to show it in.

AudioRecordingIntent does not change that. It buys execution in the app's
process and the system recording indicator; it does not buy permission to
start a session in the background.

So supportedModes is .foreground(.immediate): the app comes forward and the
recording starts there. From the lock screen that means unlocking first. What
the control saves is finding the app, not the unlock. The foreground fallback
built for what was thought to be only the locked case is gone, along with the
decision about when to take it.

Also from the review: the intent declares requiresLocalDeviceAuthentication
rather than inheriting always-allowed, a start cancelled during speech setup
no longer goes on to install the tap, and a failed start ends transcription
along with everything else it puts back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Stop arriving during a start no longer reports success and then lets the
recording begin anyway. The Live Activity and its Stop button are up before
the sink exists, and the foreground design leaves that window open long
enough on a cold launch to hit; stop() now cancels the pending start instead
of returning quietly.

The elapsed clock counts from the engine rather than from the file being
claimed, so a cold launch's speech setup is not shown as recorded time.

Failed-start cleanup uses unlink rather than removeItem. The documents
directory is the user's and writable from the Files app, and the cleanup runs
after an await — a path that had become a folder would have been deleted
recursively, which the rule against that exists to prevent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three of the round-one decisions were about the foreground-continuation
design that the device test removed. Marked as superseded rather than
rewritten, because how the change got here is the point of the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All eight device tests pass. The security review was run again after the
redesign, because moving the intent to the foreground changed the surface the
first pass had judged; it came back clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
wk merged commit 27ca9c3f98 into main 2026-08-09 10:44:19 -04:00
wk deleted branch m1-13-control-widget 2026-08-09 10:44:19 -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!38
No description provided.