Skip to content

[feature] the microphone says so on the lock screen, and in your hand - #393

Merged
Lanznx merged 1 commit into
mainfrom
ios-live-activity
Sep 18, 2026
Merged

Lanznx merged 1 commit into
mainfrom
ios-live-activity

Conversation

@Lanznx

@Lanznx Lanznx commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Parley can hold the microphone open for up to an hour. Until now the only thing that said so was iOS's own orange dot, plus three surfaces you had to go and look at: the Settings picker footer, the Record tab's bar, and the keyboard's "Mic ready" chip. Lock the screen and all three are gone.

This adds the Live Activity that fills the gap — Dynamic Island and lock screen — and the haptics that go with it.

Design: docs/design/ios-live-activity.md.

One microphone, one card

Three modes, never three activities: .meeting (red), .dictation (blue), .standby (iOS's own privacy-indicator orange, the same value the keyboard's chip uses). MeetingRecorder.start already calls DictationCoordinator.yieldMicrophone() — there is one microphone, and a card per subsystem would only have been a way for two cards to disagree about it.

MicActivityState.derive is the single place that precedence is written, as a free function in ParleyKit so it is testable without a main actor or a device.

Every clock is a Text(timerInterval:)

The most load-bearing decision here, and it is defensive. It is not established that activity.update(...) reaches a backgrounded app holding a recording audio session — reports say background updates work under background location and PiP and not under the audio background mode, and the simulator gives a false pass. Parley holds a recording session rather than playing audio, which may or may not be the same case.

So the card is built to be correct with no update at all: the system ticks every clock inside the widget process. An update changes the mode; it is never what keeps the card true. MicActivityPolicy.staleAfter is the one constant the device spike flips, and both behaviours are already written.

This needs measuring on a device before it ships — see the spike note in the design doc. Everything else is verified.

The buttons were mostly free

Three of the four already had a listener: they write the same App Group mailboxes the keyboard has always written, so the card is a second keyboard as far as the app is concerned. Only the meeting's Stop is new, and MeetingControl copies MicWindowControl exactly — a timestamp, not a flag, so a control file left behind by a crash cannot stop the next recording.

None of the four touches the microphone, which is why they work from a locked screen over a backgrounded app.

Haptics

Event Before Now
The microphone opens one .medium .medium → .heavy, rising
Keyboard swiped away mid-session nothing .heavy → .light, falling
Transcript lands in the field .success unchanged
✕ discards .rigid unchanged

Touch carries direction reliably and texture badly, and out of sight is the entire situation. The falling beat fires only when a session is live — dismissing the keyboard is a constant action and buzzing every time is how a signal becomes noise. The rise starts at .medium rather than .light on purpose: the report was I did not feel it, so nothing about the millisecond of the press is allowed to get quieter.

Two platform rules found the hard way

Both cost a build failure, both are now commented at the code and in the design doc:

  • #if os(iOS), never #if canImport(ActivityKit). The macOS SDK ships ActivityKit and AppIntents, so canImport is true there while the types are @available(macOS, unavailable). ParleyKit builds for macOS so its logic can be tested without a simulator, which is what makes this reachable.
  • An App Intent's title must come from the main bundle. ExtractAppIntentsMetadata rejects a LocalizedStringResource naming another bundle, and ParleyKit's strings are in Bundle.module. The intent titles are therefore bare literals (Shortcuts metadata, all four isDiscoverable = false); the card's button words come from MicActivityCopy. Labelling from the titles instead would compile, look right in English, and ship a Chinese-less card.

Deliberately not here

"Continue recording" is not in this PR. If the app is swiped away the recording stops — no iOS API brings it back — so the card says so rather than pretending. Resuming means stitching a new audio segment onto an existing recording, which is a change to the file model and not to anything on a lock screen; it is worth costing separately. The local notification that would go with it is held back too, because it would be this app's first notification-permission prompt and that should arrive attached to something that works.

No Delete on the card, and no transcript on it. Dictation's ✕ throws away text that was never inserted and can be said again; a meeting's delete removes an audio file, and nothing that destroys recorded audio is reachable from a locked screen. The transcript is off the card in both modes because a lock screen is a public surface — the phone is on the table and the person opposite can read it.

Verification

  • swift test --package-path ios/ParleyKit → 422 tests, 1 skipped, 0 failures (20 new).
  • xcodegen generate + xcodebuild -scheme Parley -configuration Debug -destination generic/platform=iOS → BUILD SUCCEEDED, all three targets, both .appexes embedded.
  • Both locales confirmed present for every new string in all three catalogs.
  • Not verified: the on-device background-update question above, and the visual result on real hardware.

One pre-existing wart left alone rather than swept into this diff: Haptics.swift carries nonisolated(unsafe) annotations that the current language mode calls unnecessary. They were there before (4 of them) and this change follows the file's established style rather than opening an unrelated cleanup.

🤖 Generated with Claude Code

Parley can hold the microphone for up to an hour, and the only thing that
said so was iOS's own orange dot plus three places you had to go and look
at: the Settings picker footer, the Record tab's bar, and the keyboard's
"Mic ready" chip. Lock the screen and all three are gone.

One Live Activity with three modes — meeting, dictation, standby — because
there is one microphone. `MeetingRecorder.start` already takes it from
`DictationCoordinator`, so a card per subsystem would have been a way for
two cards to disagree about a fact the app has only one of.

Every clock on the card is a `Text(timerInterval:)`, ticked by the system
inside the widget process. That is defensive: it is unresolved whether
`activity.update(...)` reaches a backgrounded app holding a recording audio
session, and it is exactly then that the card matters most. An update is an
enrichment here, never the thing that keeps the card true.
`MicActivityPolicy.staleAfter` is the one lever the device spike flips.

Three of the card's four buttons needed no app-side work: they write the
same App Group mailboxes the keyboard has always written, so the card is a
second keyboard as far as the app is concerned. Only the meeting's Stop is
new, and it copies `MicWindowControl` exactly — a timestamp, not a flag, so
a control file left behind by a crash cannot stop the next recording.

Haptics: the mic opening becomes a rising two-beat, and swiping the keyboard
away mid-dictation — which until now was silent, and takes the live
transcript off screen with it — becomes a falling one. Touch carries
direction reliably and texture badly, and out of sight is the whole
situation. The start's first beat stays `.medium`: the report was "I did not
feel it", so nothing about the moment of the press may get quieter.

Nothing that destroys recorded audio is reachable from a locked screen, and
the transcript is not on the card in either mode — a lock screen is a public
surface and the person opposite can read it.

See docs/design/ios-live-activity.md.
@github-actions

Copy link
Copy Markdown

✅ SonarQube Quality Gate passed — pathorsAI_parley

0 open issues on this PR.

@Lanznx
Lanznx merged commit 7211bda into main Sep 18, 2026
3 checks passed
Lanznx added a commit that referenced this pull request Sep 18, 2026
For iOS this release is #393 and nothing else — the four other commits since
ios-v1.14 are desktop, Android, the website, and 1.14's own release notes.
What's New says so in both locales, and no screenshots were re-captured
because nothing inside the app moved: the card is a lock-screen surface.

The part that would otherwise have cost a twenty-minute CI run: #393 embedded
a third target, and `asc_signing.py` had the two bundle ids written into it
twice over — once as a tuple to iterate and once as a pair of outputs. A
target with no provisioning profile does not fail at the point it is added.
It fails at export, saying `No profiles for '…' were found`, about a bundle id
nobody was thinking about. That list is now `SIGNED_BUNDLES`, and RELEASING.md
says out loud that adding an embedded target means adding it there and
registering it on the portal first.

`com.pathors.parley.ios.activities` still has to be registered on the
developer portal, with App Groups enabled and joined to
group.com.pathors.parley.ios, before a tag will build.

All three targets move to 1.15 (27) together; a mismatch is rejected at
upload rather than at build.

Co-authored-by: YJack0000 <jack@pathors.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants