Skip to content

Feat/native call lifecycle spike - #1481

Draft
eleboucher wants to merge 47 commits into
devfrom
feat/native-call-lifecycle-spike
Draft

Feat/native call lifecycle spike#1481
eleboucher wants to merge 47 commits into
devfrom
feat/native-call-lifecycle-spike

Conversation

@eleboucher

@eleboucher eleboucher commented Jul 29, 2026

Copy link
Copy Markdown
Member

Description

Fixes #

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings

AI disclosure:

  • Partially AI assisted (clarify which code was AI assisted and briefly explain what it does).
  • Fully AI generated (explain what all the generated code does in moderate detail).

@eleboucher
eleboucher marked this pull request as draft July 29, 2026 14:12
@eleboucher
eleboucher force-pushed the feat/native-call-lifecycle-spike branch from 1daf36f to 6992828 Compare July 29, 2026 15:07
@eleboucher
eleboucher marked this pull request as ready for review July 30, 2026 11:58
@eleboucher
eleboucher marked this pull request as draft July 30, 2026 11:58
Fold in semantic follow-ups from rebasing onto origin/dev:

- generated tauri bindings: take dev's regeneration wholesale; the spike
  nets no surviving typegen output (its vendored lifecycle plugin is
  removed again), and dropping it silently removed dev's
  start/stopCallForegroundService bindings - restore dev's bytes exactly.
- Cargo.lock: reset to dev's resolution (fixes auto-merge downgrades of
  windows-sys/toml/libloading) and re-add only tauri-plugin-livekit-mobile
  at 77d263f via cargo metadata.
- ios-project.yml: drop the spike's duplicate
  NSMicrophone/NSCameraUsageDescription; dev already ships its own wording
  for both keys, and YAML duplicate keys would override dev's text.
- Cargo.toml: drop a leftover blank line from conflict resolution.
@eleboucher
eleboucher force-pushed the feat/native-call-lifecycle-spike branch from 866cda8 to 9ae03b1 Compare July 30, 2026 12:19
- resolve LiveKit participants to Matrix users for names and avatars
- show participants in audio calls instead of an empty grid
- add a device preview and apply the chosen devices when publishing
- apply prescreen mic/camera/sound choices on both backends
- gate publishing on E2EE readiness
- show device errors, add speaker selection
- derive publish controls from the token grants
- stop a failed session blocking calls in other rooms
- keep a remote key from masking a local import failure
- only fall back to the legacy provisioning endpoint on 404/405
- restore the recovery-key guard dropped by an earlier rebase
@github-actions

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

Status Preview URL Commit Alias Updated (UTC)
✅ Deployment successful! https://pr-1481-sable.raspy-dream-bb1d.workers.dev f2afeec pr-1481 Fri, 31 Jul 2026 11:29:16 GMT

tauri-plugin-livekit-mobile links the LiveKit Swift SDK through SPM, and that
package already pins webrtc-xcframework 144.7559.11 and
livekit-uniffi-xcframework 0.0.6, the same builds the script fetched. Listing
them again under bundle.iOS.frameworks embedded a second copy of both.

Also declare the audio background mode the plugin requires for call audio to
survive backgrounding.
- centre the audio-call layout; the column had no width so it hugged the left
- resolve the local participant from the client, and index the legacy
  user:device identity alongside the anonymised one, so tiles stop reading
  "Unknown participant"
- log an unresolved identity with the keys we do know
- give the speaker menu an icon and the device menus a caret; MediaDeviceMenu
  renders an empty button and leaves the affordance to CSS
- stop the muted-red style leaking onto device menu buttons via aria-pressed
CallStatusRenderer only knew about the Element Call embed, so the bottom bar
disappeared entirely on the LiveKit path.

Reuse the existing bar (live chip, room name, member glance and the control
buttons) driven by the LiveKit local participant. Sound moves to a shared
atom so the bar and the call surface agree on it.
… path

RTCEncryptionManager emits our own key under `userId:deviceId` unless
unstableSendStickyEvents is set, which we never set, while
CallMembership.rtcBackendIdentity is a SHA-256 for sticky-event memberships.
The native controller was already fixed; the JS path still passed the hashed
value, so the provider never matched its own key, e2eeReady stayed false, and
the control bar and initial media publish never happened.

Also green the suite and lint: point the native controller fixtures at the
real own identity, follow the hangup button's rename to "End call", type the
lifecycle test mocks, and attach rejection handlers before flushing timers so
vitest stops reporting unhandled errors.
The native surface derived a label by string-parsing the LiveKit identity, so
tiles showed a raw MXID and initials cut from it, with no avatar. Reuse the
identity map and profile lookup the LiveKit JS surface already uses.

Also include connectionQuality in the participant equality check; without it a
snapshot that only changed quality was dropped and the quality dot went stale.
A remote hangup arrives as an idle snapshot, which was passed to cleanup as a
failure, so the user got a red "Call failed" screen reading "Native call
ended." with a Dismiss button. Clear the session instead, as the LiveKit JS
path already does.

Failure snapshots now map NativeCallFailureCode to user-facing copy, and setup
errors no longer append the raw exception: a user could be shown a TypeError
message. The detail is logged instead.
…x keyboard reveal

CallStatusRenderer never consulted nativeCallAtom, so navigating away from the
room during a native call left no way to hang up. Extract the bar shell both
engines share and add a native control cluster.

On mobile the call view defaults to 30% height, sized for the Element Call
participant list. A native call renders video tiles and a control bar in that
space, which clipped the floating local preview and collapsed grid rows to
name strips; give it at least 75%.

The web control bar hides itself with visibility:hidden, which removes it from
the tab order, and nothing else inside the surface is focusable, so the
existing focus reveal could never fire from a Tab press. Reveal on keydown too,
and let the control pill wrap so End call cannot be clipped off a narrow
viewport. The test that covered this synthesised focusIn on the surface and
passed for a scenario the browser cannot produce.
Sliding sync only ships m.room.member for $ME, so isValidMembership
discarded every RTC membership including our own, leaving
session.memberships empty. The join survived only because
waitForOwnMembership polls server state, but the empty roster also left
RTCEncryptionManager with no recipients, so media keys were never
distributed to the other participants.
livekit-client defaults adaptiveStream and dynacast to false, so a
multi-party call received every published layer at full quality and kept
sending video nobody displayed. The native engine already enables both.

ConnectionFeedback also treated SignalReconnecting and Connecting as a
lost connection, showing critical red while media was still flowing.
…etime

The room page stays mounted for the whole mobile slide transition, so
geometry alone kept the native video painted over the outgoing page.
Drive overlay visibility off route selection instead, and give the remote
overlay the occlusion check the local one already had.

getAudioRoutes and setAudioRoute were implemented natively but never
called, so wire them to a control that only appears once the platform
reports routes. Feature a remote screen share over a camera, since iOS
already projects one. Media toggles now follow the connected state, which
is what the native setters require. The quality dot carries state through
colour alone, so give it a label and Sable's own tokens.
Add the JitPack repository that livekit-android's audioswitch dependency
is published on, enable the core library desugaring the plugin AAR
requires, and resolve the protobuf-java/protobuf-javalite duplicate
classes in favour of the full runtime that tink's generated code needs.

These live in Tauri-generated files, so tauri android init would drop them.
The identifier was switched to moe.sable.dev for local device signing,
which orphans src-tauri/gen/android and makes the Android build fail.
The dependency was a path override pointing at a local checkout, which
breaks CI and every other machine.
@eleboucher
eleboucher force-pushed the feat/native-call-lifecycle-spike branch from f2afeec to 0ef377d Compare July 31, 2026 19:41
The plugin renamed its commands to snake_case, which Tauri's docs
prescribe, so the invoke strings move with it. sendDTMF is gone from the
plugin: it had no caller and only played a CallKit tone.
… access

Keeping the camera running in Picture in Picture needs
AVCaptureSession's multitasking camera access. Below iOS 16 that requires
the com.apple.developer.avfoundation.multitasking-camera-access
entitlement, which has no public request process, so 15.0 could not
support it.
The native video view sits above the webview, so the route picker opened
underneath it and looked like nothing happened. The occlusion probe cannot
catch this: it only runs on resize, scroll and intersection, and a menu
appearing above the slot moves nothing.
The call canvas, tiles, control pill and menus were painted with hardcoded
dark hex values, so the call ignored the active theme. They now use the
same tokens as the rest of the app. The scrim over a video tile and its
text stay light on dark, because they sit on video rather than on a themed
surface, as does the hangup button.

The route picker rendered the wire's category marker as the option name,
so the list read current and input. It now shows the platform's display
name and ticks the active route.
…r the oldest transport

The app published legacy memberships while requesting the modern
/get_token endpoint first. Those endpoints grant different LiveKit
participant identities, and LiveKit binds E2EE keys strictly by identity,
so against an SFU service that implements /get_token the call would
connect, fill the roster and decrypt nothing in either direction. It
worked only because the deployment falls back. One flag now drives both
the endpoint and the join config so they cannot drift, and the identity
comes from the EncryptionKeyChanged event rather than being reconstructed.

focus_active advertises oldest_membership, which obliges everyone to use
the oldest member's transport. We always used our own, so a peer whose
homeserver advertises a different SFU shared a Matrix session with us and
no media at all.

Memberships also expired after four hours with no delayed events and no
leave on page hide, leaving ghost participants that suppressed the ring
for later joiners.
The local tile rendered a generic person icon while remote tiles resolved
a Matrix profile, so with the camera off your own tile was the only one
without an avatar. The profile hook already resolves the local user from
its isLocal argument whatever identity the SFU handed us.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant