M1-07: Live Activity — lock screen recording control #20
No reviewers
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!20
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "m1-07-live-activity"
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?
Closes #7 — Live Activity with a lock screen stop button, validated on
wes-iphone(iOS 26.5.2).Start a recording, lock the phone, stop it from the lock screen without unlocking. That works, and so does swapping the microphone while locked, which is where most of this branch went.
What is here
The Live Activity (
StashWidgets, a new app-extension target). ActivityKit only lets an extension declare one, so the lock screen UI lives outside the app binary even though the recording it controls does not.Stash/Activitycompiles into both targets — the widget needs the attribute and intent types to render, the app needs them to start the activity and perform the intent.The stop button is a
LiveActivityIntent, which performs in the app process. A plainAppIntentwould run in the extension, which has no recorder to stop. It reaches the recording throughRecordingControl, a one-property indirection that exists so the intent type compiled into the widget never linksAudioRecorder.The elapsed time is
Text(timerInterval:), counted locally by the widget. Pushing the clock throughActivity.updatewould spend the whole update budget on something the system can draw itself.Route repair. Most of the diff. A microphone swap with the phone locked ended the recording, and fixing it took four attempts because the failure looked like a background kill and was a crash. Read the issue comment for the full trail; the short version is that
installTapOnBusraises anNSException, Swift cannot catch one, and it aborts the process.ObjCExceptions.guarded— the only Objective-C in the app — turns that into an error the retry loop already handles.Reviewing it
Seven commits, each one standing alone.
de4f46bis the interesting one and the rest is scaffolding around it.c954629was written against the wrong diagnosis. It is kept because keeping the process alive across a gap where no audio flows is correct on its own terms, but it is not what fixed anything and the commit says so.AudioRecorderwas split three times to stay under the 400-line limit —RecordingStore,RecordingError,SessionEvents,RecordingSession,RepairAssertion. Those are moves, not changes.Two things to decide
CaptureDiagnosticsand the diagnostics section inDebugCaptureVieware debug scaffolding that shipped to find the crash. They write fourUserDefaultskeys. Fine to keep for now, worth removing alongside the debug capture view.A call arriving while the phone is locked is untested. It shares the repair path, and the background assertion that covers it is finite — a long call will outlive it. If that turns out to be broken it wants its own issue rather than more commits here.
Nothing in this branch is covered by an automated test. It is a hardware path end to end.
🤖 Generated with Claude Code
The app was not being killed by iOS. It was crashing, and it had been the same crash the whole time. EXC_CRASH (SIGABRT), abort() called, procRole "Non UI" objc_exception_throw +[NSException raise:format:] AVAudioEngineImpl::InstallTapOnNode AudioRecorder.startEngine(into:) AudioRecorder.attemptRestart(into:allowSessionRestart:) Three crash reports in one afternoon, identical, the last of them eleven minutes into the build that added a background assertion to survive a kill that was never happening. `installTapOnBus` raises an Objective-C exception rather than returning an error, Swift has no `@catch`, and the raise unwinds past every `do`/`catch` into `abort()`. Every symptom this was read from — `stop()` never called, a truncated but playable file, a cold relaunch, a Live Activity still claiming to record — is that abort. Three fixes: `ObjCExceptions.guarded` wraps the AVAudioEngine calls on the repair path in an `@try`/`@catch` shim, turning a raise into a failed attempt the retry loop already knows how to handle. This is the only Objective-C in the app. `startEngine` now 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 instead of only on the one allowed to reactivate the session. Those were coupled for no reason — only the reactivation is refused in the background — which left the background path reusing an engine whose input hardware had gone, which is the state that reports the format that raises. A failed repair now records why, so the next one leaves evidence.