nextcloud / nextcloud/talk-android

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

Open
#6,423 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Kotlin
Stars
739
Forks
321
Avg merge
14h 59m
Merged PRs (30d)
151

Description

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

  • Device brand and model: Android emulators (bench) + real hardware in production reproduction (not disclosing personal device details)
  • Android version: not precisely pinned down for the affected devices — happy to gather exact Build.VERSION on request
  • Nextcloud Talk app version: v24.0.2 (official build) reproduces the issue; also reproduced on a build with #6169 applied

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in PeerConnectionWrapper.java at isOfferer and follow the peer-connection creation call site in CallActivity, checking how the internal participant flag reaches this path. Reproduce a mesh call with a recording bot and verify that Android audio reaches the recording; done means the passive internal peer connection is established without regressing regular peers.

Written by the indexing model from the issue text.

Assessment

Tech stack
android, kotlin
Domain
audio-video-rtc, mobile-dev
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.