Skip to content

Recording bot (internal participant) never receives audio from the Android app in mesh calls — offerer election ignores internal peers #6423

Description

@alauzon

Steps to reproduce

  1. Set up a Talk conversation on a deployment using an external (HPB) signaling server in mesh mode (hasMCU=false, i.e. no MCU/SFU in front of the call).
  2. Join the conversation with at least two participants: one on a web browser (desktop), one on the native Android app.
  3. Start a recording of the conversation.
  4. Stop the recording and listen back to the resulting file.

I reproduced this in a controlled bench (no real users involved): a 3-way call (2 Android emulators running the Talk app + 1 desktop browser as guest) plus the recording bot, each audio source fed a distinct synthetic tone through a virtual microphone (880 Hz for the two Android clients, 440 Hz for the browser), so the presence/absence of each source in the output could be measured objectively instead of relying on ear.

Expected behaviour

The recording captures audio from every participant in the call, including those using the native Android (and presumably iOS) app.

Actual behaviour

Audio from participants on the native Android app is completely absent from the recording — not choppy, not intermittent, never established. Only the participant on the web client is captured.

Spectral analysis (ffmpeg bandpass + volumedetect) of the bench recording:

Source Tone Level in recording Verdict
Web browser 440 Hz −9.0 dB captured
Android (×2 emulators) 880 Hz −39.4 dB absent (at noise floor)
Silence control 1750 Hz −53.3 dB noise floor

The Android tone is ~30 dB below the web tone — indistinguishable from background noise, confirming no media from the Android clients ever reaches the recorder.

This was also confirmed in production (real, non-bench call): a recording session with a mix of web and native-app participants only ever captures whichever participant happened to be on a web client; native-app participants are silent for the entire recording regardless of connection stability during the call.

Root cause (traced via packet capture + logcat)

  • A packet capture on the recording server during the bench run shows ~26,000 RTP packets exchanged between the recorder and the web-browser participant, and zero media packets between the recorder and either Android client (only STUN/signaling traffic with the HPB).
  • Android logcat for the same run shows the offerer-election debug output for the peer connection with the recorder:
    OFFERER-DEBUG localSession=<android session> remoteSession=<recorder session> hasMCU=false hasInitiated=false
    
    hasInitiated=false → the Android client decides it is not the offerer for this peer, and waits for an offer from the other side.
  • The recording bot joins the call as an internal: true participant. It is a passive subscriber that never creates an offer on its own.
  • Result: neither side ever creates an SDP offer for this pair → the peer connection never forms → no media flows.
  • In PeerConnectionWrapper.java, the offerer role is decided purely by lexicographic comparison of session IDs (isOfferer(localSession, remoteSession)), with no special case for a remote participant flagged internal. There is a requestoffer signaling fallback in the codebase, but it is only sent when hasMCU == true — so in mesh/no-MCU deployments (like the one above) there is no fallback at all for this case, and it deadlocks outright.
  • I also checked whether the participant's internal flag (participant.internal / ParticipantUiState.isInternal) is read anywhere along the WebRTC/offerer path — it's only used for UI purposes (participant list rendering), never in PeerConnectionWrapper or in the peer-connection-creation call site in CallActivity.

This is not caused or fixed by #6169 (cross-namespace session ID comparison under HPB): I reproduced the identical deadlock both on stock v24.0.2 and on a build with the #6169 fix applied. #6169 only fixes the comparison between two regular (non-internal) peers; it doesn't touch the internal-participant case at all. Both before and after that fix, isOfferer/hasInitiated never account for internal.

I have not verified whether the iOS app has the same or a different offerer-election path, so I can't say whether it's equally affected — flagging this only for Android based on code + reproduction.

Suggested direction

When the remote peer of a connection is flagged internal (recording bot, SIP dial-in, or any other passive/internal participant), the local client shouldn't rely on the lexicographic election at all — it should either always take the offerer role toward that peer, or send requestoffer regardless of hasMCU, since such participants are inherently passive and never initiate on their own. This is presumably close to what the web client already does, since it does establish media with the recorder correctly.

Device information

Server information

  • Custom Signaling server configured: Yes (external/HPB signaling)
  • Deployment: mesh mode (no MCU/SFU)
  • Nextcloud server version / Talk version: not included here, happy to provide on request if useful — the bug is client-side (offerer election logic) and reproduced identically across at least two Talk app builds, so it shouldn't be server-version-dependent

Additional information

Related: #6169 (different root cause — cross-namespace session ID comparison for regular peers — but same file/mechanism area: PeerConnectionWrapper.isOfferer). Happy to share the bench script (Playwright + Android emulators + synthetic tones) and detailed logs if useful for triage.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions