M1-07: Live Activity — lock screen recording control #7

Closed
opened 2026-08-06 10:53:41 -04:00 by agent · 3 comments
Member

A live notification on the lock screen while recording, with a working stop button.

Wes's requirement: "It displays a live notification when it is recording with the ability to
hit a stop button on that recording, or on that notification."

This matters more than it sounds. Roughly all of his recordings happen with the screen locked
while walking — pulling the phone out, unlocking it and finding the app to stop a recording
is the friction the whole project exists to remove.

Scope

  • ActivityKit Live Activity showing recording state and elapsed time.
  • An interactive stop button via App Intents.
  • Correct behaviour when the activity is dismissed, when the app is killed, and when the
    recording ends by other means.

Note for review

Live Activities cannot be meaningfully previewed in Xcode — this is on-device only, which is
why it sits after issue 04.

Done when

Wes starts a recording, locks the phone, and stops it from the lock screen without unlocking.

Milestone 1 of the Stash v3 rebuild, scoped 2026-08-06. M1 is a functioning iOS app on Wes's
phone with no sync at all — the entire goal is recording voice memos reliably and getting
them off the device by hand. Sync arrives in M3.

Working agreement for every issue in this repo: feature branch, tested before the PR opens
(on-device where the label says so), then Wes reviews the PR and we walk the code together.
Docs update in the same commit. A PR that takes more than 20 minutes to review is too big —
say so and split it.

A live notification on the lock screen while recording, with a working stop button. Wes's requirement: *"It displays a live notification when it is recording with the ability to hit a stop button on that recording, or on that notification."* This matters more than it sounds. Roughly all of his recordings happen with the screen locked while walking — pulling the phone out, unlocking it and finding the app to stop a recording is the friction the whole project exists to remove. ## Scope - ActivityKit Live Activity showing recording state and elapsed time. - An **interactive stop button** via App Intents. - Correct behaviour when the activity is dismissed, when the app is killed, and when the recording ends by other means. ## Note for review Live Activities cannot be meaningfully previewed in Xcode — this is on-device only, which is why it sits after issue 04. ## Done when Wes starts a recording, locks the phone, and stops it from the lock screen without unlocking. --- *Milestone 1 of the Stash v3 rebuild, scoped 2026-08-06. M1 is a functioning iOS app on Wes's phone with **no sync at all** — the entire goal is recording voice memos reliably and getting them off the device by hand. Sync arrives in M3.* *Working agreement for every issue in this repo: feature branch, tested before the PR opens (on-device where the label says so), then Wes reviews the PR and we walk the code together. Docs update in the same commit. A PR that takes more than 20 minutes to review is too big — say so and split it.*
Author
Member

Branch m1-07-live-activity, two commits, installed on your phone. No PR — Live Activities cannot be previewed in Xcode and this is entirely a device test, which is yours.

What's there

A new StashWidgets app-extension target, embedded in the app. ActivityKit only lets an extension declare a Live Activity, so the lock screen UI has to live outside the app binary even though the recording it controls does not.

The activity shows recording state, elapsed time, and the current microphone, on the lock screen and in the Dynamic Island (compact, expanded and minimal). Stop button on all of them.

LiveActivityIntent, not AppIntent — that distinction is the whole feature. A LiveActivityIntent is performed in the app's process, so it can reach the running recorder. A plain AppIntent from a widget runs in the extension, which has no engine, no file and nothing to stop.

AudioRecorder is now a singleton, for the same reason: the stop button is performed by no view, so there is no instance to hand it, and two recorders would mean the button stopping one while the other kept writing. The debug view uses AudioRecorder.shared.

Elapsed time is counted by the widget, not pushed. Text(timerInterval:) runs its own clock from the start date. Live Activity updates are rate limited by the system, and pushing one a second to move a clock would spend the entire budget on something the widget can work out for itself. Updates are reserved for facts that actually change — losing the microphone, or getting it back.

Stale activities are ended at launch. An activity outlives the process that started it, so an app killed mid-recording leaves a lock screen control for a recording that no longer exists, offering a stop button that would do nothing.

Nothing here can fail a recording. Live Activities can be switched off per app in Settings, refused when too many are running, or unavailable outright. Every path in RecordingActivity returns quietly and the recorder does not check the result.

A Swift 6 trap worth knowing

Activity is a class, is not Sendable, and its update/end are nonisolated async. Holding one on the main actor and awaiting those methods hands a non-Sendable reference across an isolation boundary, which Swift 6 rejects outright. RecordingActivity stores the activity's id and looks it up inside a nonisolated function, so the object is created and consumed in one isolation domain and never travels. In AGENTS.md.

What I validated

  • Builds clean, make lint clean at --strict across 14 files.
  • StashWidgets.appex is actually embedded in the built app — checked in PlugIns/, not inferred from the project file.
  • NSSupportsLiveActivities present in the built Info.plist, on the device build.
  • Installed and running on wes-iphone, extension and all.
  • Clean clone regenerates both Info.plists and builds — the second commit untracks StashWidgets/Info.plist, which XcodeGen writes and I had committed by mistake.

What I did NOT validate — which is essentially all of it

No Live Activity has ever been seen. Everything below is unverified:

  • Whether the activity appears on the lock screen at all.
  • Whether the stop button stops the recording, and whether it does so without unlocking.
  • Dynamic Island rendering in any of its three presentations.
  • Whether the microphone name and interrupted state update correctly mid-recording.
  • Stale-activity cleanup after a kill.
  • Layout at any size. I have written the views blind — expect the lock screen one to need tweaks.

The test: start a recording from Settings → Capture (debug), lock the phone, and try to stop it from the lock screen without unlocking.

Branch `m1-07-live-activity`, two commits, **installed on your phone**. No PR — Live Activities cannot be previewed in Xcode and this is entirely a device test, which is yours. ## What's there A new **`StashWidgets`** app-extension target, embedded in the app. ActivityKit only lets an extension declare a Live Activity, so the lock screen UI has to live outside the app binary even though the recording it controls does not. The activity shows recording state, elapsed time, and the current microphone, on the lock screen and in the Dynamic Island (compact, expanded and minimal). Stop button on all of them. **`LiveActivityIntent`, not `AppIntent`** — that distinction is the whole feature. A `LiveActivityIntent` is performed **in the app's process**, so it can reach the running recorder. A plain `AppIntent` from a widget runs in the extension, which has no engine, no file and nothing to stop. **`AudioRecorder` is now a singleton**, for the same reason: the stop button is performed by no view, so there is no instance to hand it, and two recorders would mean the button stopping one while the other kept writing. The debug view uses `AudioRecorder.shared`. **Elapsed time is counted by the widget, not pushed.** `Text(timerInterval:)` runs its own clock from the start date. Live Activity updates are rate limited by the system, and pushing one a second to move a clock would spend the entire budget on something the widget can work out for itself. Updates are reserved for facts that actually change — losing the microphone, or getting it back. **Stale activities are ended at launch.** An activity outlives the process that started it, so an app killed mid-recording leaves a lock screen control for a recording that no longer exists, offering a stop button that would do nothing. **Nothing here can fail a recording.** Live Activities can be switched off per app in Settings, refused when too many are running, or unavailable outright. Every path in `RecordingActivity` returns quietly and the recorder does not check the result. ## A Swift 6 trap worth knowing `Activity` is a class, is **not** `Sendable`, and its `update`/`end` are `nonisolated async`. Holding one on the main actor and awaiting those methods hands a non-Sendable reference across an isolation boundary, which Swift 6 rejects outright. `RecordingActivity` stores the activity's **id** and looks it up inside a `nonisolated` function, so the object is created and consumed in one isolation domain and never travels. In `AGENTS.md`. ## What I validated - Builds clean, `make lint` clean at `--strict` across 14 files. - **`StashWidgets.appex` is actually embedded** in the built app — checked in `PlugIns/`, not inferred from the project file. - `NSSupportsLiveActivities` present in the built Info.plist, on the device build. - Installed and running on `wes-iphone`, extension and all. - Clean clone regenerates both Info.plists and builds — the second commit untracks `StashWidgets/Info.plist`, which XcodeGen writes and I had committed by mistake. ## What I did NOT validate — which is essentially all of it **No Live Activity has ever been seen.** Everything below is unverified: - Whether the activity appears on the lock screen at all. - Whether the stop button stops the recording, and whether it does so without unlocking. - Dynamic Island rendering in any of its three presentations. - Whether the microphone name and interrupted state update correctly mid-recording. - Stale-activity cleanup after a kill. - Layout at any size. I have written the views blind — expect the lock screen one to need tweaks. **The test:** start a recording from Settings → Capture (debug), lock the phone, and try to stop it from the lock screen without unlocking.
Author
Member

Device results from Wes: the Live Activity appears on lock, shows the current microphone, and the stop button works. Two of the three unknowns closed.

The swap-while-locked failure he found is fixed and pushed — installed on your phone, worth retesting.

What was actually wrong

Three faults, all in the route-change path, and the first one is the real culprit.

A route change notification means the route is changing, not that it is ready. Reading the input format at that instant usually gives zero, so the rebuild throws. That is ordinary and expected — but the rebuild was one attempt per notification, so an ordinary failure became terminal. The old code comment said it outright: "the next route change gets another attempt." That is precisely why unplugging and replugging was the only cure — it manufactured the next notification by hand.

Restarts now retry with backoff, and the task is cancellable so a second change supersedes the first rather than racing it.

Nothing re-checked on unlock. There was no foreground observer at all. didBecomeActiveNotification now compares the remembered input against the session's actual route and rebuilds when they disagree, or when the engine is not running. That is the direct fix for "unlocked and it still said recording".

currentInputName was only set on a successful start, so a failed switch left the previous microphone's name on screen and on the lock screen. That is the most misleading thing the UI could have said, and it is why the name did not update until you replugged. It is now read from the session on every route change, whatever happens next.

There was also a latent loop: reactivating the session during a rebuild posts its own route change with reason .categoryChange, so a restart could trigger a restart. That reason is now filtered.

What I validated

I cannot unplug headphones from here, so I tested the mechanism rather than the scenario — by injecting three consecutive restart failures and watching it recover:

PROBE routeChanged mic=MicrophoneBuiltIn
PROBE restart FORCED-FAIL (2 left)      +0ms
PROBE restart FORCED-FAIL (1 left)      +185ms
PROBE restart FORCED-FAIL (0 left)      +428ms
PROBE restart SUCCEEDED mic=...         +1064ms

Backoff behaves, and recovery lands in about 1.7 seconds. Before this change the first failure ended it. I also confirmed the becameActive path fires on foreground and correctly does nothing when the engine is healthy, so it does not cause spurious rebuilds.

make lint clean, builds, installed and running on wes-iphone.

Housekeeping: AudioRecorder went past the 400-line lint limit, so file naming and location moved to RecordingStore and the error vocabulary to RecordingError. Neither was the recorder's job.

What I did NOT validate

The actual scenario. No headphones have been plugged into anything here — the retry mechanism is proven, the real route transition is not. Specifically unknown:

  • Whether four attempts over ~4 seconds is enough for a real wired-headphone switch while locked. If it still stalls, the delays are the first thing to lengthen.
  • Whether audio is lost across the switch, and how much.
  • Whether the Live Activity updates to the new microphone name promptly on the lock screen.
  • The AirPods case, which is a slower Bluetooth negotiation and may need more patience than a wired swap.

Same test as before, please: record, lock, plug in wired headphones, and watch whether the lock screen goes back to "Recording" with the new microphone named — then unlock and check the app agrees.

Filed #19 for the control widget, scheduled after #12 as you asked.

Device results from Wes: the Live Activity appears on lock, shows the current microphone, and **the stop button works**. Two of the three unknowns closed. The swap-while-locked failure he found is fixed and pushed — **installed on your phone**, worth retesting. ## What was actually wrong Three faults, all in the route-change path, and the first one is the real culprit. **A route change notification means the route is *changing*, not that it is ready.** Reading the input format at that instant usually gives zero, so the rebuild throws. That is ordinary and expected — but the rebuild was **one attempt per notification**, so an ordinary failure became terminal. The old code comment said it outright: *"the next route change gets another attempt."* That is precisely why unplugging and replugging was the only cure — it manufactured the next notification by hand. Restarts now retry with backoff, and the task is cancellable so a second change supersedes the first rather than racing it. **Nothing re-checked on unlock.** There was no foreground observer at all. `didBecomeActiveNotification` now compares the remembered input against the session's actual route and rebuilds when they disagree, or when the engine is not running. That is the direct fix for "unlocked and it still said recording". **`currentInputName` was only set on a *successful* start**, so a failed switch left the previous microphone's name on screen and on the lock screen. That is the most misleading thing the UI could have said, and it is why the name did not update until you replugged. It is now read from the session on every route change, whatever happens next. There was also a latent loop: reactivating the session during a rebuild posts its own route change with reason `.categoryChange`, so a restart could trigger a restart. That reason is now filtered. ## What I validated I cannot unplug headphones from here, so I tested the mechanism rather than the scenario — by **injecting three consecutive restart failures** and watching it recover: ``` PROBE routeChanged mic=MicrophoneBuiltIn PROBE restart FORCED-FAIL (2 left) +0ms PROBE restart FORCED-FAIL (1 left) +185ms PROBE restart FORCED-FAIL (0 left) +428ms PROBE restart SUCCEEDED mic=... +1064ms ``` Backoff behaves, and recovery lands in about 1.7 seconds. Before this change the first failure ended it. I also confirmed the `becameActive` path fires on foreground and correctly does **nothing** when the engine is healthy, so it does not cause spurious rebuilds. `make lint` clean, builds, installed and running on `wes-iphone`. Housekeeping: `AudioRecorder` went past the 400-line lint limit, so file naming and location moved to `RecordingStore` and the error vocabulary to `RecordingError`. Neither was the recorder's job. ## What I did NOT validate **The actual scenario.** No headphones have been plugged into anything here — the retry mechanism is proven, the real route transition is not. Specifically unknown: - Whether four attempts over ~4 seconds is enough for a real wired-headphone switch while locked. If it still stalls, the delays are the first thing to lengthen. - Whether audio is lost across the switch, and how much. - Whether the Live Activity updates to the new microphone name promptly on the lock screen. - The AirPods case, which is a slower Bluetooth negotiation and may need more patience than a wired swap. **Same test as before, please:** record, lock, plug in wired headphones, and watch whether the lock screen goes back to "Recording" with the new microphone named — then unlock and check the app agrees. Filed #19 for the control widget, scheduled after #12 as you asked.
Author
Member

Validated on wes-iphone, iOS 26.5.2, across the seven commits on m1-07-live-activity.

Works

  • Live Activity appears on the lock screen while recording, with the elapsed time and the microphone iOS is actually using. The clock is Text(timerInterval:) so it counts locally and does not spend the update budget.
  • The stop button on the lock screen ends the recording. It is a LiveActivityIntent, so it performs in the app process and reaches the running recorder rather than the widget extension, which has no engine.
  • Tapping the activity deep links to the capture view via stash://capture.
  • Swapping to wired EarPods with the phone locked keeps the recording going and updates the microphone name on the lock screen.

Device diagnostics after the last run:

diag.lastRepair = "route repair succeeded on EarPods Microphone at 2026-08-06T21:41:47Z"
diag.lastStop   = "Stop button in the app at 2026-08-06T21:41:52Z"

The failure this took four attempts to find

The microphone swap while locked ended the recording, and every symptom said the OS had killed the app: stop() never called, a truncated but playable file, a cold relaunch, and a Live Activity left claiming to record. Three fixes went into that theory — a retry loop with backoff, not reactivating the session in the background, and a beginBackgroundTask assertion to cover the gap where no audio flows. None of them changed the outcome.

iOS had been writing a crash report every time.

EXC_CRASH (SIGABRT), abort() called, procRole "Non UI"
  objc_exception_throw
  +[NSException raise:format:]
  AVAudioEngineImpl::InstallTapOnNode
  AudioRecorder.startEngine(into:)
  AudioRecorder.attemptRestart(into:allowSessionRestart:)

installTapOnBus raises an Objective-C exception rather than returning an error. Swift has no @catch, so it unwinds past every do/catch and calls abort(). Three identical reports in one afternoon, the last of them eleven minutes into the build that added the background assertion.

Fixed in de4f46b:

  • ObjCExceptions.guarded wraps the AVAudioEngine calls on the repair path in an @try/@catch shim. A raise becomes an ordinary error the retry loop already handles. This is the only Objective-C in the app.
  • startEngine rejects a format with zero channels, not just a zero sample rate. AVFoundation checks both inside installTapOnBus, and a route change reports the second one on the way past.
  • attemptRestart builds a fresh AVAudioEngine on every attempt rather than only on the one allowed to reactivate the session. Those were coupled for no reason — only the reactivation is refused in the background.

Not validated

  • An incoming call while locked. The interruption path shares the repair code but has not been exercised on hardware. This is the test Wes said he would run once he could borrow a second phone.
  • A long interruption. The background assertion is finite, roughly half a minute. A call longer than that will outlive it and the behaviour past that point is unknown.
  • Bluetooth. Only wired EarPods were tested. An AirPods route change is a different transition and HFP negotiates a different sample rate.
  • Dismissing the Live Activity by hand, and running with Live Activities switched off in Settings. ActivityAuthorizationInfo is checked and a failure to start one does not fail the recording, but neither has been seen on device.
  • Stale activity cleanup after a real kill. endStaleActivities() runs at launch; it has not been tested against an activity actually orphaned by a crash.
  • The Dynamic Island. All three presentations are written and none has been confirmed on hardware.
  • Nothing here is covered by an automated test. It is a hardware path end to end.

Debug scaffolding in this branch

CaptureDiagnostics and the Last route repair row in DebugCaptureView were added to diagnose the crash above and are still shipping. They write four keys to UserDefaults and cost nothing, but they are debug surface. Worth a decision on whether they come out when the debug capture view does.

Validated on `wes-iphone`, iOS 26.5.2, across the seven commits on `m1-07-live-activity`. ## Works - Live Activity appears on the lock screen while recording, with the elapsed time and the microphone iOS is actually using. The clock is `Text(timerInterval:)` so it counts locally and does not spend the update budget. - The stop button on the lock screen ends the recording. It is a `LiveActivityIntent`, so it performs in the app process and reaches the running recorder rather than the widget extension, which has no engine. - Tapping the activity deep links to the capture view via `stash://capture`. - Swapping to wired EarPods with the phone locked keeps the recording going and updates the microphone name on the lock screen. Device diagnostics after the last run: ``` diag.lastRepair = "route repair succeeded on EarPods Microphone at 2026-08-06T21:41:47Z" diag.lastStop = "Stop button in the app at 2026-08-06T21:41:52Z" ``` ## The failure this took four attempts to find The microphone swap while locked ended the recording, and every symptom said the OS had killed the app: `stop()` never called, a truncated but playable file, a cold relaunch, and a Live Activity left claiming to record. Three fixes went into that theory — a retry loop with backoff, not reactivating the session in the background, and a `beginBackgroundTask` assertion to cover the gap where no audio flows. None of them changed the outcome. iOS had been writing a crash report every time. ``` EXC_CRASH (SIGABRT), abort() called, procRole "Non UI" objc_exception_throw +[NSException raise:format:] AVAudioEngineImpl::InstallTapOnNode AudioRecorder.startEngine(into:) AudioRecorder.attemptRestart(into:allowSessionRestart:) ``` `installTapOnBus` raises an Objective-C exception rather than returning an error. Swift has no `@catch`, so it unwinds past every `do`/`catch` and calls `abort()`. Three identical reports in one afternoon, the last of them eleven minutes into the build that added the background assertion. Fixed in de4f46b: - `ObjCExceptions.guarded` wraps the AVAudioEngine calls on the repair path in an `@try`/`@catch` shim. A raise becomes an ordinary error the retry loop already handles. This is the only Objective-C in the app. - `startEngine` rejects a format with zero channels, not just a zero sample rate. AVFoundation checks both inside `installTapOnBus`, and a route change reports the second one on the way past. - `attemptRestart` builds a fresh `AVAudioEngine` on every attempt rather than only on the one allowed to reactivate the session. Those were coupled for no reason — only the reactivation is refused in the background. ## Not validated - **An incoming call while locked.** The interruption path shares the repair code but has not been exercised on hardware. This is the test Wes said he would run once he could borrow a second phone. - **A long interruption.** The background assertion is finite, roughly half a minute. A call longer than that will outlive it and the behaviour past that point is unknown. - **Bluetooth.** Only wired EarPods were tested. An AirPods route change is a different transition and HFP negotiates a different sample rate. - **Dismissing the Live Activity by hand**, and running with Live Activities switched off in Settings. `ActivityAuthorizationInfo` is checked and a failure to start one does not fail the recording, but neither has been seen on device. - **Stale activity cleanup after a real kill.** `endStaleActivities()` runs at launch; it has not been tested against an activity actually orphaned by a crash. - **The Dynamic Island.** All three presentations are written and none has been confirmed on hardware. - **Nothing here is covered by an automated test.** It is a hardware path end to end. ## Debug scaffolding in this branch `CaptureDiagnostics` and the `Last route repair` row in `DebugCaptureView` were added to diagnose the crash above and are still shipping. They write four keys to `UserDefaults` and cost nothing, but they are debug surface. Worth a decision on whether they come out when the debug capture view does.
wk closed this issue 2026-08-06 17:48:21 -04:00
Sign in to join this conversation.
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#7
No description provided.