M1-07: Live Activity — lock screen recording control #7
Labels
No labels
area/agent
area/capture
area/editor
area/geo
area/security
area/sync
area/transcription
area/ui
area/vault
area/voice-memos
blocked
milestone/M1
milestone/M2
milestone/M3
milestone/M4
needs-decision
needs-hardware
p0
p1
p2
type/bug
type/enhancement
type/feature
type/idea
type/infrastructure
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Stash/stash-ios#7
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
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.
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
StashWidgetsapp-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, notAppIntent— that distinction is the whole feature. ALiveActivityIntentis performed in the app's process, so it can reach the running recorder. A plainAppIntentfrom a widget runs in the extension, which has no engine, no file and nothing to stop.AudioRecorderis 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 usesAudioRecorder.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
RecordingActivityreturns quietly and the recorder does not check the result.A Swift 6 trap worth knowing
Activityis a class, is notSendable, and itsupdate/endarenonisolated 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.RecordingActivitystores the activity's id and looks it up inside anonisolatedfunction, so the object is created and consumed in one isolation domain and never travels. InAGENTS.md.What I validated
make lintclean at--strictacross 14 files.StashWidgets.appexis actually embedded in the built app — checked inPlugIns/, not inferred from the project file.NSSupportsLiveActivitiespresent in the built Info.plist, on the device build.wes-iphone, extension and all.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:
The test: start a recording from Settings → Capture (debug), lock the phone, and try to stop it from the lock screen without unlocking.
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.
didBecomeActiveNotificationnow 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".currentInputNamewas 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:
Backoff behaves, and recovery lands in about 1.7 seconds. Before this change the first failure ended it. I also confirmed the
becameActivepath fires on foreground and correctly does nothing when the engine is healthy, so it does not cause spurious rebuilds.make lintclean, builds, installed and running onwes-iphone.Housekeeping:
AudioRecorderwent past the 400-line lint limit, so file naming and location moved toRecordingStoreand the error vocabulary toRecordingError. 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:
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.
Validated on
wes-iphone, iOS 26.5.2, across the seven commits onm1-07-live-activity.Works
Text(timerInterval:)so it counts locally and does not spend the update budget.LiveActivityIntent, so it performs in the app process and reaches the running recorder rather than the widget extension, which has no engine.stash://capture.Device diagnostics after the last run:
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 abeginBackgroundTaskassertion to cover the gap where no audio flows. None of them changed the outcome.iOS had been writing a crash report every time.
installTapOnBusraises an Objective-C exception rather than returning an error. Swift has no@catch, so it unwinds past everydo/catchand callsabort(). Three identical reports in one afternoon, the last of them eleven minutes into the build that added the background assertion.Fixed in
de4f46b:ObjCExceptions.guardedwraps the AVAudioEngine calls on the repair path in an@try/@catchshim. A raise becomes an ordinary error the retry loop already handles. This is the only Objective-C in the app.startEnginerejects a format with zero channels, not just a zero sample rate. AVFoundation checks both insideinstallTapOnBus, and a route change reports the second one on the way past.attemptRestartbuilds a freshAVAudioEngineon 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
ActivityAuthorizationInfois checked and a failure to start one does not fail the recording, but neither has been seen on device.endStaleActivities()runs at launch; it has not been tested against an activity actually orphaned by a crash.Debug scaffolding in this branch
CaptureDiagnosticsand theLast route repairrow inDebugCaptureViewwere added to diagnose the crash above and are still shipping. They write four keys toUserDefaultsand cost nothing, but they are debug surface. Worth a decision on whether they come out when the debug capture view does.