Negotiate receiver reference time (RFC 3611 rtcp-xr:rcvr-rtt) in SDP

Add SDP support for negotiating non-sender RTT (RRTR/DLRR). A single
MediaContentDescription::receive_non_sender_rtt flag is the source of
truth; webrtc_sdp.cc is the only place that translates between the wire
forms and the flag:

- Parse: set the flag from either the standard a=rtcp-xr:rcvr-rtt or the
  legacy non-standard a=rtcp-fb:<pt> rrtr.
- Generate: when the flag is set, emit BOTH forms (rtcp-xr plus, for
  interim backward-compat, the legacy rtcp-fb rrtr per codec).

Advertised in offers/answers gated by the
WebRTC-RtcpXrReceiverReferenceTime field trial (default on, IsDisabled
to roll back). This CL only adds negotiation; enabling RRTR from the
flag is the stacked follow-up. When the legacy rtcp-fb form is dropped
later, only the parser/generator need change.

Bug: webrtc:516205747
Change-Id: Id7ad1e9b550bdfa372ddcf3dfdb0298310d407eb
Reviewed-on: https://webrtc-review.googlesource.com/c/src/+/479240
Reviewed-by: Harald Alvestrand <hta@webrtc.org>
Reviewed-by: Danil Chapovalov <danilchap@webrtc.org>
Commit-Queue: Harald Alvestrand <hta@webrtc.org>
Cr-Commit-Position: refs/heads/main@{#48051}
diff --git a/AUTHORS b/AUTHORS
index e9f6f9b..7c4ea2c 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -148,6 +148,7 @@
 Tarun Chawla <trnkumarchawla@gmail.com>
 Todd Wong <todd.wong.ndq@gmail.com>
 Tomas Popela <tomas.popela@gmail.com>
+Tomohiro Matsuzawa <mabtomoh@amazon.com>
 Trevor Hayes <trevor.axiom@gmail.com>
 Uladzislau Susha <landby@gmail.com>
 Vicken Simonian <vsimon@gmail.com>
diff --git a/api/webrtc_sdp.cc b/api/webrtc_sdp.cc
index dc0d488..42e0650 100644
--- a/api/webrtc_sdp.cc
+++ b/api/webrtc_sdp.cc
@@ -101,6 +101,9 @@
 const char kAttributeBundleOnly[] = "bundle-only";
 const char kAttributeRtcpMux[] = "rtcp-mux";
 const char kAttributeRtcpReducedSize[] = "rtcp-rsize";
+const char kAttributeRtcpXr[] = "rtcp-xr";
+const char kRtcpXrFormatRcvrRtt[] = "rcvr-rtt";
+const char kRtcpXrFormatRcvrRttPrefix[] = "rcvr-rtt=";
 const char kAttributeSsrc[] = "ssrc";
 const char kSsrcAttributeCname[] = "cname";
 const char kAttributeExtmapAllowMixed[] = "extmap-allow-mixed";
@@ -1381,6 +1384,25 @@
     AddLine(os.str(), message);
   }
 
+  // RFC 3611
+  // a=rtcp-xr:rcvr-rtt=all
+  if (media_desc->receive_non_sender_rtt()) {
+    InitAttrLine(kAttributeRtcpXr, &os);
+    os << kSdpDelimiterColon << kRtcpXrFormatRcvrRtt << "=all";
+    AddLine(os.str(), message);
+    // Interim backward-compat: also advertise the non-standard
+    // a=rtcp-fb:<pt> rrtr for peers that do not understand rtcp-xr. rrtr is
+    // no longer carried as a codec feedback param (the parser folds it into
+    // receive_non_sender_rtt), so it is emitted here from the flag rather
+    // than via AddRtcpFbLines.
+    for (const Codec& codec : media_desc->codecs()) {
+      StringBuilder fb_os;
+      WriteRtcpFbHeader(codec.id, &fb_os);
+      fb_os << " " << kRtcpFbParamRrtr;
+      AddLine(fb_os.str(), message);
+    }
+  }
+
   if (media_desc->conference_mode()) {
     InitAttrLine(kAttributeXGoogleFlag, &os);
     os << kSdpDelimiterColon << kValueConference;
@@ -2535,6 +2557,14 @@
   }
   const FeedbackParam feedback_param(id, param);
 
+  // The non-standard "rrtr" rtcp-fb is the legacy way of signaling receiver
+  // reference time reports (RFC 3611). Translate it to the single internal
+  // flag rather than storing it as a per-codec feedback param.
+  if (id == kRtcpFbParamRrtr) {
+    media_desc->set_receive_non_sender_rtt(true);
+    return true;
+  }
+
   if (media_type == MediaType::AUDIO || media_type == MediaType::VIDEO) {
     UpdateCodec(media_desc, payload_type, feedback_param);
   }
@@ -2811,6 +2841,21 @@
         media_desc->set_rtcp_mux(true);
       } else if (HasAttribute(*line, kAttributeRtcpReducedSize)) {
         media_desc->set_rtcp_reduced_size(true);
+      } else if (HasAttribute(*line, kAttributeRtcpXr)) {
+        // RFC 3611: a=rtcp-xr:<format>[ <format>]... Consume the rcvr-rtt
+        // format (receiver reference time report); accept rcvr-rtt=all and
+        // rcvr-rtt=sender. Match whole space-separated tokens, not a
+        // substring, and require the '=' so partial matches are rejected.
+        std::string xr_value;
+        if (GetValue(*line, kAttributeRtcpXr, &xr_value, error)) {
+          for (absl::string_view format :
+               split(xr_value, kSdpDelimiterSpaceChar)) {
+            if (absl::StartsWith(format, kRtcpXrFormatRcvrRttPrefix)) {
+              media_desc->set_receive_non_sender_rtt(true);
+              break;
+            }
+          }
+        }
       } else if (HasAttribute(*line, kAttributeRtcpRemoteEstimate)) {
         media_desc->set_remote_estimate(true);
       } else if (HasAttribute(*line, kAttributeSframe)) {
diff --git a/api/webrtc_sdp_unittest.cc b/api/webrtc_sdp_unittest.cc
index aecc5601..eb3a1d1 100644
--- a/api/webrtc_sdp_unittest.cc
+++ b/api/webrtc_sdp_unittest.cc
@@ -62,6 +62,7 @@
 
 using ::testing::ElementsAre;
 using ::testing::Field;
+using ::testing::HasSubstr;
 using ::testing::IsNull;
 using ::testing::NotNull;
 using ::testing::Property;
@@ -3367,6 +3368,51 @@
   TestSerialize(jdesc_output);
 }
 
+TEST_F(WebRtcSdpTest, DeserializeSerializeRtcpXrRcvrRtt) {
+  const char kSdpWithRcvrRtt[] =
+      "v=0\r\n"
+      "o=- 18446744069414584320 18446462598732840960 IN IP4 127.0.0.1\r\n"
+      "s=-\r\n"
+      "t=0 0\r\n"
+      "m=audio 9 RTP/SAVPF 111\r\n"
+      "a=rtpmap:111 opus/48000/2\r\n"
+      "a=rtcp-xr:rcvr-rtt=all\r\n";
+
+  // Deserialize: rcvr-rtt sets the single receive-non-sender-RTT flag.
+  std::unique_ptr<SessionDescriptionInterface> jdesc =
+      SdpDeserialize(kSdpWithRcvrRtt);
+  ASSERT_THAT(jdesc, NotNull());
+  const AudioContentDescription* acd =
+      GetFirstAudioContentDescription(jdesc->description());
+  ASSERT_THAT(acd, NotNull());
+  EXPECT_TRUE(acd->receive_non_sender_rtt());
+
+  // Serialize: the flag emits BOTH the standard rtcp-xr and the legacy
+  // rtcp-fb rrtr wire forms (interim backward-compat).
+  std::string reserialized = SdpSerialize(*jdesc);
+  EXPECT_THAT(reserialized, HasSubstr("a=rtcp-xr:rcvr-rtt=all"));
+  EXPECT_THAT(reserialized, HasSubstr("a=rtcp-fb:111 rrtr"));
+}
+
+// The legacy non-standard a=rtcp-fb:<pt> rrtr also sets the flag on parse.
+TEST_F(WebRtcSdpTest, DeserializeLegacyRtcpFbRrtr) {
+  const char kSdpWithFbRrtr[] =
+      "v=0\r\n"
+      "o=- 18446744069414584320 18446462598732840960 IN IP4 127.0.0.1\r\n"
+      "s=-\r\n"
+      "t=0 0\r\n"
+      "m=audio 9 RTP/SAVPF 111\r\n"
+      "a=rtpmap:111 opus/48000/2\r\n"
+      "a=rtcp-fb:111 rrtr\r\n";
+  std::unique_ptr<SessionDescriptionInterface> jdesc =
+      SdpDeserialize(kSdpWithFbRrtr);
+  ASSERT_THAT(jdesc, NotNull());
+  const AudioContentDescription* acd =
+      GetFirstAudioContentDescription(jdesc->description());
+  ASSERT_THAT(acd, NotNull());
+  EXPECT_TRUE(acd->receive_non_sender_rtt());
+}
+
 TEST_F(WebRtcSdpTest, DeserializeVideoFmtp) {
   const char kSdpWithFmtpString[] =
       "v=0\r\n"
diff --git a/experiments/field_trials.py b/experiments/field_trials.py
index 8cf948d..90efd96 100755
--- a/experiments/field_trials.py
+++ b/experiments/field_trials.py
@@ -206,6 +206,9 @@
     FieldTrial('WebRTC-RtcEventLogEncodeNetEqSetMinimumDelayKillSwitch',
                42225058,
                date(2024, 4, 1)),
+    FieldTrial('WebRTC-RtcpXrReceiverReferenceTime',
+               516205747,
+               date(2027, 12, 1)),
     FieldTrial('WebRTC-Sctp-Snap',
                426480601,
                date(2026, 1, 1)),
diff --git a/pc/media_session.cc b/pc/media_session.cc
index 4f1a44d..bbad292 100644
--- a/pc/media_session.cc
+++ b/pc/media_session.cc
@@ -363,9 +363,12 @@
     const RtpHeaderExtensions& rtp_extensions,
     UniqueRandomIdGenerator* ssrc_generator,
     StreamParamsVec* current_streams,
-    MediaContentDescription* offer) {
+    MediaContentDescription* offer,
+    const FieldTrialsView& field_trials) {
   offer->set_rtcp_mux(session_options.rtcp_mux_enabled);
   offer->set_rtcp_reduced_size(true);
+  offer->set_receive_non_sender_rtt(
+      !field_trials.IsDisabled("WebRTC-RtcpXrReceiverReferenceTime"));
 
   // Build the vector of header extensions with directions for this
   // media_description's options.
@@ -409,7 +412,7 @@
 
   return CreateContentOffer(media_description_options, session_options,
                             rtp_extensions, ssrc_generator, current_streams,
-                            offer);
+                            offer, field_trials);
 }
 
 // Adds all extensions from `reference_extensions` to `offered_extensions` that
@@ -603,7 +606,8 @@
     bool bundle_enabled,
     MediaContentDescription* answer,
     PayloadTypeSuggester& suggester,
-    RtpTransceiverIdDomain id_domain) {
+    RtpTransceiverIdDomain id_domain,
+    const FieldTrialsView& field_trials) {
   answer->set_extmap_allow_mixed_level(offer->extmap_allow_mixed_level());
   const RtpExtension::Filter extensions_filter =
       enable_encrypted_rtp_header_extensions
@@ -646,6 +650,9 @@
 
   answer->set_rtcp_mux(session_options.rtcp_mux_enabled && offer->rtcp_mux());
   answer->set_rtcp_reduced_size(offer->rtcp_reduced_size());
+  answer->set_receive_non_sender_rtt(
+      offer->receive_non_sender_rtt() &&
+      !field_trials.IsDisabled("WebRTC-RtcpXrReceiverReferenceTime"));
   answer->set_remote_estimate(offer->remote_estimate());
 
   AddSimulcastToMediaDescription(media_description_options, answer);
@@ -1328,9 +1335,9 @@
     }
   }
 
-  auto error = CreateContentOffer(media_description_options, session_options,
-                                  RtpHeaderExtensions(), ssrc_generator(),
-                                  current_streams, data.get());
+  auto error = CreateContentOffer(
+      media_description_options, session_options, RtpHeaderExtensions(),
+      ssrc_generator(), current_streams, data.get(), env_.field_trials());
   if (!error.ok()) {
     return error;
   }
@@ -1475,7 +1482,8 @@
           *codec_lookup_helper_->PayloadTypeSuggester(),
           offer_description->extmap_allow_mixed()
               ? RtpTransceiverIdDomain::kTwoByteAllowed
-              : RtpTransceiverIdDomain::kOneByteOnly)) {
+              : RtpTransceiverIdDomain::kOneByteOnly,
+          env_.field_trials())) {
     return RTC_LOG_ERROR(RTCError(RTCErrorType::INTERNAL_ERROR)
                          << "Failed to create answer");
   }
@@ -1554,7 +1562,8 @@
             *codec_lookup_helper_->PayloadTypeSuggester(),
             offer_description->extmap_allow_mixed()
                 ? RtpTransceiverIdDomain::kTwoByteAllowed
-                : RtpTransceiverIdDomain::kOneByteOnly)) {
+                : RtpTransceiverIdDomain::kOneByteOnly,
+            env_.field_trials())) {
       return RTC_LOG_ERROR(RTCError(RTCErrorType::INTERNAL_ERROR)
                            << "Failed to create answer");
     }
diff --git a/pc/media_session_unittest.cc b/pc/media_session_unittest.cc
index 064122a..183ddc0 100644
--- a/pc/media_session_unittest.cc
+++ b/pc/media_session_unittest.cc
@@ -1038,6 +1038,40 @@
   EXPECT_EQ(acd->protocol(), kMediaProtocolDtlsSavpf);
 }
 
+// With the RRTR field trial enabled (default), an offer enables non-sender
+// RTT on the negotiated description. The wire-format translation (rtcp-xr and
+// the legacy rtcp-fb rrtr) is covered in webrtc_sdp_unittest.
+TEST_F(MediaSessionDescriptionFactoryTest,
+       CreateAudioOfferEnablesReceiveNonSenderRtt) {
+  std::unique_ptr<SessionDescription> offer =
+      f1_.CreateOfferOrError(CreateAudioMediaSession(), nullptr).MoveValue();
+  ASSERT_TRUE(offer.get());
+  const MediaContentDescription* acd =
+      GetFirstAudioContentDescription(offer.get());
+  ASSERT_TRUE(acd);
+  EXPECT_TRUE(acd->receive_non_sender_rtt());
+}
+
+class MediaSessionDescriptionFactoryRcvrRttDisabledTest
+    : public MediaSessionDescriptionFactoryTest {
+ protected:
+  MediaSessionDescriptionFactoryRcvrRttDisabledTest()
+      : MediaSessionDescriptionFactoryTest(
+            "WebRTC-RtcpXrReceiverReferenceTime/Disabled/") {}
+};
+
+// With the field trial disabled, the offer must not enable non-sender RTT.
+TEST_F(MediaSessionDescriptionFactoryRcvrRttDisabledTest,
+       CreateAudioOfferDoesNotEnableReceiveNonSenderRtt) {
+  std::unique_ptr<SessionDescription> offer =
+      f1_.CreateOfferOrError(CreateAudioMediaSession(), nullptr).MoveValue();
+  ASSERT_TRUE(offer.get());
+  const MediaContentDescription* acd =
+      GetFirstAudioContentDescription(offer.get());
+  ASSERT_TRUE(acd);
+  EXPECT_FALSE(acd->receive_non_sender_rtt());
+}
+
 // Create an offer with just Opus and RED.
 TEST_F(MediaSessionDescriptionFactoryTest,
        TestCreateAudioOfferWithJustOpusAndRed) {
diff --git a/pc/session_description.h b/pc/session_description.h
index f8c44ec..fbc1e50 100644
--- a/pc/session_description.h
+++ b/pc/session_description.h
@@ -105,6 +105,13 @@
     rtcp_reduced_size_ = reduced_size;
   }
 
+  // Whether RFC 3611 rcvr-rtt (receiver reference time report) was
+  // negotiated, enabling non-sender RTT (RRTR/DLRR) on this m-section.
+  bool receive_non_sender_rtt() const { return receive_non_sender_rtt_; }
+  void set_receive_non_sender_rtt(bool enable) {
+    receive_non_sender_rtt_ = enable;
+  }
+
   // Indicates support for the remote network estimate packet type. This
   // functionality is experimental and subject to change without notice.
   bool remote_estimate() const { return remote_estimate_; }
@@ -342,6 +349,7 @@
  private:
   bool rtcp_mux_ = false;
   bool rtcp_reduced_size_ = false;
+  bool receive_non_sender_rtt_ = false;
   bool remote_estimate_ = false;
   bool rtcp_fb_ack_ccfb_ = false;
   int bandwidth_ = kAutoBandwidth;