diff --git a/docs/guide/data/data-channels.md b/docs/guide/data/data-channels.md index a222ca9e6..3f024590a 100644 --- a/docs/guide/data/data-channels.md +++ b/docs/guide/data/data-channels.md @@ -62,13 +62,15 @@ config.id = -1; // Let WebRTC assign an ID // Configure sub-protocol (default: null) config.protocol = "my-protocol"; // Optional sub-protocol name -// Configure priority (default: LOW) -config.priority = RTCPriorityType.LOW; +// Configure priority (default: LOW), announced to the remote peer +config.priority = RTCPriorityType.HIGH; // Create the data channel with this configuration RTCDataChannel dataChannel = peerConnection.createDataChannel("myChannel", config); ``` +`getPriority()` returns a channel's priority; a channel the remote peer opened has the priority it announced. WebRTC only uses the priority to share the connection between channels when SCTP message interleaving is on, which needs the field trial `WebRTC-DataChannelMessageInterleaving/Enabled/` on both peers (see [Field Trials](/guide/advanced/field-trials)). Without it, channels take turns regardless of priority. + ## Handling Data Channel Events To receive events from a data channel, you need to implement the `RTCDataChannelObserver` interface and register it with the data channel: diff --git a/docs/guide/get-started.md b/docs/guide/get-started.md index 969b362d0..b69172e2f 100644 --- a/docs/guide/get-started.md +++ b/docs/guide/get-started.md @@ -215,6 +215,54 @@ signalingChannel.onIceCandidate(candidateMessage -> { }); ``` +### Letting the Peer Connection Create the Description + +`setLocalDescription` can also be called with only an observer. It then creates the offer or the answer, whichever the signaling state calls for, and applies it in one step, so nothing can change in between. That makes it the simpler way to implement "perfect negotiation". Read the result with `getLocalDescription()` once the observer reports success: + +```java +peerConnection.setLocalDescription(new SetSessionDescriptionObserver() { + @Override + public void onSuccess() { + signalingChannel.send(peerConnection.getLocalDescription()); + } + + @Override + public void onFailure(String error) { + System.err.println("Failed to set local description: " + error); + } +}); +``` + +### Adding Candidates and Knowing the Outcome + +`addIceCandidate(candidate)` reports nothing and fails silently when the remote description is not set yet. The form that takes an `AddIceCandidateObserver` is queued behind any pending offer/answer operation, as the standard requires, so a candidate can be added right after `setRemoteDescription()` without waiting for it, and the observer says whether it was accepted: + +```java +peerConnection.addIceCandidate(candidate, new AddIceCandidateObserver() { + @Override + public void onSuccess() { + } + + @Override + public void onFailure(String error) { + // e.g. "[INVALID_STATE] The remote description was null" + System.err.println("Candidate rejected: " + error); + } +}); +``` + +A local candidate can also go away, for example when its network interface does. The observer's `onIceCandidateRemoved(RTCIceCandidate)` reports it; send it to the remote peer, which passes it to `removeIceCandidate(candidate)`. + +Once ICE has picked the candidate pair it uses, `onSelectedCandidatePairChanged(RTCCandidatePairChangeEvent)` reports both candidates of the pair and why it was chosen. It fires again whenever ICE switches pairs: + +```java +@Override +public void onSelectedCandidatePairChanged(RTCCandidatePairChangeEvent event) { + System.out.println("Using " + event.getLocal().sdp + " <-> " + event.getRemote().sdp + + " (" + event.getReason() + ")"); +} +``` + ## Media Streams ### Accessing Media Devices diff --git a/docs/guide/media/directionality.md b/docs/guide/media/directionality.md index ae0f9bcde..c96d40a56 100644 --- a/docs/guide/media/directionality.md +++ b/docs/guide/media/directionality.md @@ -118,12 +118,10 @@ transceiver.setDirection(RTCRtpTransceiverDirection.INACTIVE); transceiver.setDirection(RTCRtpTransceiverDirection.SEND_RECV); // After changing directions, create a new offer and perform negotiation. -RTCOfferOptions opts = new RTCOfferOptions(); -pc.createOffer(opts, new CreateSessionDescriptionObserver() { +pc.setLocalDescription(new SetSessionDescriptionObserver() { @Override - public void onSuccess(RTCSessionDescription description) { - pc.setLocalDescription(description, /* observer */ null); - // Send to remote and await/set remote answer accordingly + public void onSuccess() { + // Send pc.getLocalDescription() to the remote peer and set its answer } @Override public void onFailure(String error) { /* handle error */ } diff --git a/docs/guide/networking/peer-connection-config.md b/docs/guide/networking/peer-connection-config.md index f8e980b52..50259d49f 100644 --- a/docs/guide/networking/peer-connection-config.md +++ b/docs/guide/networking/peer-connection-config.md @@ -69,17 +69,33 @@ config.cryptoOptions = crypto; `cryptexPolicy` encrypts the RTP header extensions and CSRCs as a whole (RFC 9335) where the peer supports it (`NEGOTIATE`) or always (`REQUIRE`). +`certificates` takes the DTLS certificate a peer connection authenticates with, as an `RTCCertificatePEM`; without one, WebRTC generates a certificate per connection. A certificate that does not parse makes `createPeerConnection()` and `setConfiguration()` throw. + ## Signaling | Setting | Effect | |---|---| | `offerExtmapAllowMixed` | Allows one- and two-byte header extensions to be mixed in offers. Default true. | -| `enableImplicitRollback` | Rolls a pending local offer back when a remote offer arrives, as perfect negotiation needs. Default false. | +| `enableImplicitRollback` | Rolls a pending local offer back when a remote offer arrives, as perfect negotiation needs. Default false. Pair it with `setLocalDescription(observer)`, which creates the offer or answer itself. | | `alwaysNegotiateDataChannels` | Includes data channels in offers before any is created. Default false. | +## Changing a Running Connection + +A few controls are methods of `RTCPeerConnection` rather than configuration fields, and can be used at any time. + +`setBitrate(min, current, max)` limits the bandwidth all streams sent by the connection may use together, in bits per second. A `null` value leaves that limit as it is, and `current` resets the bandwidth estimate to that value. Limits from the SDP and from each sender's encoding parameters still apply. Values that are negative or not ordered as min ≤ current ≤ max make it throw. + +```java +// Start from 1 Mbps and never use more than 2.5 Mbps. +peerConnection.setBitrate(null, 1_000_000, 2_500_000); +``` + +`setAudioPlayout(false)` stops the audio device from playing received audio; the audio is still decoded and its statistics still updated. `setAudioRecording(false)` stops the audio device from recording, so tracks fed by it send nothing, while tracks fed by a `CustomAudioSource` keep sending. Both are on by default and act on the audio device module, which all connections of a `PeerConnectionFactory` share. + ## Related API - `RTCConfiguration` — the configuration of a peer connection. - `RTCPeerConnection.getConfiguration()`, `setConfiguration()` — read and change it. +- `RTCPeerConnection.setBitrate()`, `setAudioPlayout()`, `setAudioRecording()` — controls of a running connection. - `RTCCryptoOptions` — the SRTP cipher suites. - `PortAllocatorConfig` — ports and interfaces, see [Port Allocator Configuration](/guide/networking/port-allocator-config). diff --git a/webrtc-jni/src/main/cpp/include/JNI_RTCDataChannel.h b/webrtc-jni/src/main/cpp/include/JNI_RTCDataChannel.h index a9c54436d..9adc51368 100644 --- a/webrtc-jni/src/main/cpp/include/JNI_RTCDataChannel.h +++ b/webrtc-jni/src/main/cpp/include/JNI_RTCDataChannel.h @@ -79,6 +79,14 @@ extern "C" { JNIEXPORT jboolean JNICALL Java_dev_onvoid_webrtc_RTCDataChannel_isNegotiated (JNIEnv *, jobject); + /* + * Class: dev_onvoid_webrtc_RTCDataChannel + * Method: getPriority + * Signature: ()Ldev/onvoid/webrtc/RTCPriorityType; + */ + JNIEXPORT jobject JNICALL Java_dev_onvoid_webrtc_RTCDataChannel_getPriority + (JNIEnv *, jobject); + /* * Class: dev_onvoid_webrtc_RTCDataChannel * Method: getId diff --git a/webrtc-jni/src/main/cpp/include/JNI_RTCPeerConnection.h b/webrtc-jni/src/main/cpp/include/JNI_RTCPeerConnection.h index 32ac991ae..a6d626daa 100644 --- a/webrtc-jni/src/main/cpp/include/JNI_RTCPeerConnection.h +++ b/webrtc-jni/src/main/cpp/include/JNI_RTCPeerConnection.h @@ -135,6 +135,14 @@ extern "C" { JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_setLocalDescription (JNIEnv *, jobject, jobject, jobject); + /* + * Class: dev_onvoid_webrtc_RTCPeerConnection + * Method: setLocalDescriptionImplicit + * Signature: (Ldev/onvoid/webrtc/SetSessionDescriptionObserver;)V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_setLocalDescriptionImplicit + (JNIEnv *, jobject, jobject); + /* * Class: dev_onvoid_webrtc_RTCPeerConnection * Method: setRemoteDescription @@ -151,6 +159,46 @@ extern "C" { JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_addIceCandidate (JNIEnv *, jobject, jobject); + /* + * Class: dev_onvoid_webrtc_RTCPeerConnection + * Method: addIceCandidateWithObserver + * Signature: (Ldev/onvoid/webrtc/RTCIceCandidate;Ldev/onvoid/webrtc/AddIceCandidateObserver;)V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_addIceCandidateWithObserver + (JNIEnv *, jobject, jobject, jobject); + + /* + * Class: dev_onvoid_webrtc_RTCPeerConnection + * Method: removeIceCandidate + * Signature: (Ldev/onvoid/webrtc/RTCIceCandidate;)Z + */ + JNIEXPORT jboolean JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_removeIceCandidate + (JNIEnv *, jobject, jobject); + + /* + * Class: dev_onvoid_webrtc_RTCPeerConnection + * Method: setBitrate + * Signature: (Ljava/lang/Integer;Ljava/lang/Integer;Ljava/lang/Integer;)V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_setBitrate + (JNIEnv *, jobject, jobject, jobject, jobject); + + /* + * Class: dev_onvoid_webrtc_RTCPeerConnection + * Method: setAudioPlayout + * Signature: (Z)V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_setAudioPlayout + (JNIEnv *, jobject, jboolean); + + /* + * Class: dev_onvoid_webrtc_RTCPeerConnection + * Method: setAudioRecording + * Signature: (Z)V + */ + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_setAudioRecording + (JNIEnv *, jobject, jboolean); + /* * Class: dev_onvoid_webrtc_RTCPeerConnection * Method: getSignalingState diff --git a/webrtc-jni/src/main/cpp/include/api/AddIceCandidateObserver.h b/webrtc-jni/src/main/cpp/include/api/AddIceCandidateObserver.h new file mode 100644 index 000000000..ee30e1cb8 --- /dev/null +++ b/webrtc-jni/src/main/cpp/include/api/AddIceCandidateObserver.h @@ -0,0 +1,61 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef JNI_WEBRTC_API_ADD_ICE_CANDIDATE_OBSERVER_H_ +#define JNI_WEBRTC_API_ADD_ICE_CANDIDATE_OBSERVER_H_ + +#include "JavaClass.h" +#include "JavaRef.h" + +#include "api/rtc_error.h" + +#include +#include + +namespace jni +{ + // Reports the outcome of PeerConnectionInterface::AddIceCandidate to a + // Java AddIceCandidateObserver, exactly once. + class AddIceCandidateObserver + { + public: + AddIceCandidateObserver(JNIEnv * env, const JavaGlobalRef & observer); + ~AddIceCandidateObserver(); + + AddIceCandidateObserver(const AddIceCandidateObserver &) = delete; + AddIceCandidateObserver & operator=(const AddIceCandidateObserver &) = delete; + + void OnComplete(webrtc::RTCError error) noexcept; + + private: + class JavaAddIceCandidateObserverClass : public JavaClass + { + public: + explicit JavaAddIceCandidateObserverClass(JNIEnv * env); + + jmethodID onSuccess; + jmethodID onFailure; + }; + + void Notify(const char * error) noexcept; + + JavaGlobalRef observer; + + const std::shared_ptr javaClass; + }; +} + +#endif diff --git a/webrtc-jni/src/main/cpp/include/api/PeerConnectionObserver.h b/webrtc-jni/src/main/cpp/include/api/PeerConnectionObserver.h index 183b15079..82ca0833e 100644 --- a/webrtc-jni/src/main/cpp/include/api/PeerConnectionObserver.h +++ b/webrtc-jni/src/main/cpp/include/api/PeerConnectionObserver.h @@ -45,6 +45,7 @@ namespace jni void OnIceGatheringChange(webrtc::PeerConnectionInterface::IceGatheringState state) override; void OnIceCandidate(const webrtc::IceCandidateInterface * candidate) override; void OnIceCandidateError(const std::string & address, int port, const std::string & url, int error_code, const std::string & error_text) override; + void OnIceCandidateRemoved(const webrtc::IceCandidate * candidate) override; void OnIceConnectionReceivingChange(bool receiving) override; void OnIceSelectedCandidatePairChanged(const webrtc::CandidatePairChangeEvent & event) override; @@ -65,11 +66,16 @@ namespace jni jmethodID onIceGatheringChange; jmethodID onIceCandidate; jmethodID onIceCandidateError; + jmethodID onIceCandidateRemoved; jmethodID onIceConnectionReceivingChange; jmethodID onSelectedCandidatePairChanged; }; private: + // Reports a Java exception thrown by a callback without letting it + // unwind through WebRTC. + void ReportException(JNIEnv * env) noexcept; + JavaGlobalRef observer; const std::shared_ptr javaClass; diff --git a/webrtc-jni/src/main/cpp/include/api/RTCCandidatePairChangeEvent.h b/webrtc-jni/src/main/cpp/include/api/RTCCandidatePairChangeEvent.h new file mode 100644 index 000000000..248b39cf6 --- /dev/null +++ b/webrtc-jni/src/main/cpp/include/api/RTCCandidatePairChangeEvent.h @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#ifndef JNI_WEBRTC_API_RTC_CANDIDATE_PAIR_CHANGE_EVENT_H_ +#define JNI_WEBRTC_API_RTC_CANDIDATE_PAIR_CHANGE_EVENT_H_ + +#include "JavaClass.h" +#include "JavaRef.h" + +#include "p2p/base/port.h" + +#include + +namespace jni +{ + namespace RTCCandidatePairChangeEvent + { + class JavaRTCCandidatePairChangeEventClass : public JavaClass + { + public: + explicit JavaRTCCandidatePairChangeEventClass(JNIEnv * env); + + jclass cls; + jmethodID ctor; + }; + + JavaLocalRef toJava(JNIEnv * env, const webrtc::CandidatePairChangeEvent & event); + }; +} + +#endif diff --git a/webrtc-jni/src/main/cpp/include/api/WebRTCUtils.h b/webrtc-jni/src/main/cpp/include/api/WebRTCUtils.h index 166f8144a..f45fe07d0 100644 --- a/webrtc-jni/src/main/cpp/include/api/WebRTCUtils.h +++ b/webrtc-jni/src/main/cpp/include/api/WebRTCUtils.h @@ -77,6 +77,12 @@ namespace jni } std::string RTCErrorToString(const webrtc::RTCError & error); + + // Hands an exception thrown by a Java callback to the calling thread's + // uncaught exception handler, as the JVM does for an exception escaping a + // Java thread. Callbacks that WebRTC invokes use it, because a C++ + // exception must not unwind through WebRTC's frames. + void ReportUncaughtException(JNIEnv * env, jthrowable exception); } #endif \ No newline at end of file diff --git a/webrtc-jni/src/main/cpp/src/JNI_PeerConnectionFactory.cpp b/webrtc-jni/src/main/cpp/src/JNI_PeerConnectionFactory.cpp index af4ad80ee..4a9a4740b 100644 --- a/webrtc-jni/src/main/cpp/src/JNI_PeerConnectionFactory.cpp +++ b/webrtc-jni/src/main/cpp/src/JNI_PeerConnectionFactory.cpp @@ -355,7 +355,16 @@ JNIEXPORT jobject JNICALL Java_dev_onvoid_webrtc_PeerConnectionFactory_createPee webrtc::PeerConnectionFactoryInterface * factory = GetHandle(env, caller); CHECK_HANDLEV(factory, nullptr); - webrtc::PeerConnectionInterface::RTCConfiguration configuration = jni::RTCConfiguration::toNative(env, jni::JavaLocalRef(env, jConfig)); + webrtc::PeerConnectionInterface::RTCConfiguration configuration; + + try { + configuration = jni::RTCConfiguration::toNative(env, jni::JavaLocalRef(env, jConfig)); + } + catch (...) { + ThrowCxxJavaException(env); + return nullptr; + } + webrtc::PeerConnectionObserver * observer = new jni::PeerConnectionObserver(env, jni::JavaGlobalRef(env, jobserver)); webrtc::PeerConnectionDependencies dependencies(observer); diff --git a/webrtc-jni/src/main/cpp/src/JNI_RTCDataChannel.cpp b/webrtc-jni/src/main/cpp/src/JNI_RTCDataChannel.cpp index eef0a8a17..2cdcea0db 100644 --- a/webrtc-jni/src/main/cpp/src/JNI_RTCDataChannel.cpp +++ b/webrtc-jni/src/main/cpp/src/JNI_RTCDataChannel.cpp @@ -27,6 +27,7 @@ #include "api/data_channel_interface.h" #include "rtc_base/logging.h" +#include #include JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_RTCDataChannel_registerObserver @@ -119,6 +120,27 @@ JNIEXPORT jboolean JNICALL Java_dev_onvoid_webrtc_RTCDataChannel_isNegotiated return static_cast(channel->negotiated()); } +JNIEXPORT jobject JNICALL Java_dev_onvoid_webrtc_RTCDataChannel_getPriority +(JNIEnv * env, jobject caller) +{ + webrtc::DataChannelInterface * channel = GetHandle(env, caller); + CHECK_HANDLEV(channel, nullptr); + + // The priority is a number on the wire; a remote peer may announce one + // between the four levels, which is rounded up to the next level. + const uint16_t value = channel->priority().value(); + webrtc::Priority priority = webrtc::Priority::kHigh; + + for (auto level : { webrtc::Priority::kVeryLow, webrtc::Priority::kLow, webrtc::Priority::kMedium }) { + if (value <= webrtc::PriorityValue(level).value()) { + priority = level; + break; + } + } + + return jni::JavaEnums::toJava(env, priority).release(); +} + JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_RTCDataChannel_getId (JNIEnv * env, jobject caller) { diff --git a/webrtc-jni/src/main/cpp/src/JNI_RTCPeerConnection.cpp b/webrtc-jni/src/main/cpp/src/JNI_RTCPeerConnection.cpp index f41af06a7..2fd24bdeb 100644 --- a/webrtc-jni/src/main/cpp/src/JNI_RTCPeerConnection.cpp +++ b/webrtc-jni/src/main/cpp/src/JNI_RTCPeerConnection.cpp @@ -15,6 +15,7 @@ */ #include "JNI_RTCPeerConnection.h" +#include "api/AddIceCandidateObserver.h" #include "api/CreateSessionDescriptionObserver.h" #include "api/SetSessionDescriptionObserver.h" #include "api/RTCAnswerOptions.h" @@ -33,15 +34,19 @@ #include "JavaIterable.h" #include "JavaList.h" #include "JavaNullPointerException.h" +#include "JavaPrimitive.h" #include "JavaRef.h" #include "JavaRuntimeException.h" #include "JavaString.h" #include "JavaUtils.h" #include "api/peer_connection_interface.h" +#include "api/transport/bitrate_settings.h" #include "api/rtp_receiver_interface.h" #include "api/rtp_sender_interface.h" +#include +#include #include #include @@ -384,6 +389,27 @@ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_setLocalDescript } } +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_setLocalDescriptionImplicit +(JNIEnv * env, jobject caller, jobject jobserver) +{ + if (jobserver == nullptr) { + env->Throw(jni::JavaNullPointerException(env, "SetSessionDescriptionObserver must not be null")); + return; + } + + webrtc::PeerConnectionInterface * pc = GetHandle(env, caller); + CHECK_HANDLE(pc); + + try { + auto observer = new webrtc::RefCountedObject(env, jni::JavaGlobalRef(env, jobserver)); + + pc->SetLocalDescription(observer); + } + catch (...) { + ThrowCxxJavaException(env); + } +} + JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_setRemoteDescription (JNIEnv * env, jobject caller, jobject jSessionDesc, jobject jobserver) { @@ -431,6 +457,106 @@ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_addIceCandidate } } +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_addIceCandidateWithObserver +(JNIEnv * env, jobject caller, jobject jCandidate, jobject jObserver) +{ + if (jCandidate == nullptr) { + env->Throw(jni::JavaNullPointerException(env, "RTCIceCandidate must not be null")); + return; + } + if (jObserver == nullptr) { + env->Throw(jni::JavaNullPointerException(env, "AddIceCandidateObserver must not be null")); + return; + } + + webrtc::PeerConnectionInterface * pc = GetHandle(env, caller); + CHECK_HANDLE(pc); + + try { + auto candidate = jni::RTCIceCandidate::toNative(env, jni::JavaLocalRef(env, jCandidate)); + + // std::function has to be copyable, the observer reports only once. + auto observer = std::make_shared(env, jni::JavaGlobalRef(env, jObserver)); + + pc->AddIceCandidate(std::move(candidate), [observer](webrtc::RTCError error) { + observer->OnComplete(std::move(error)); + }); + } + catch (...) { + ThrowCxxJavaException(env); + } +} + +JNIEXPORT jboolean JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_removeIceCandidate +(JNIEnv * env, jobject caller, jobject jCandidate) +{ + if (jCandidate == nullptr) { + env->Throw(jni::JavaNullPointerException(env, "RTCIceCandidate must not be null")); + return false; + } + + webrtc::PeerConnectionInterface * pc = GetHandle(env, caller); + CHECK_HANDLEV(pc, false); + + try { + auto candidate = jni::RTCIceCandidate::toNative(env, jni::JavaLocalRef(env, jCandidate)); + + return static_cast(pc->RemoveIceCandidate(candidate.get())); + } + catch (...) { + ThrowCxxJavaException(env); + return false; + } +} + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_setBitrate +(JNIEnv * env, jobject caller, jobject jMinBitrate, jobject jCurrentBitrate, jobject jMaxBitrate) +{ + webrtc::PeerConnectionInterface * pc = GetHandle(env, caller); + CHECK_HANDLE(pc); + + auto toOptional = [env](jobject value) -> std::optional { + if (value == nullptr) { + return std::nullopt; + } + return jni::Integer::getValue(env, value); + }; + + try { + webrtc::BitrateSettings settings; + settings.min_bitrate_bps = toOptional(jMinBitrate); + settings.start_bitrate_bps = toOptional(jCurrentBitrate); + settings.max_bitrate_bps = toOptional(jMaxBitrate); + + webrtc::RTCError error = pc->SetBitrate(settings); + + if (!error.ok()) { + env->Throw(jni::JavaRuntimeException(env, jni::RTCErrorToString(error).c_str())); + } + } + catch (...) { + ThrowCxxJavaException(env); + } +} + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_setAudioPlayout +(JNIEnv * env, jobject caller, jboolean playout) +{ + webrtc::PeerConnectionInterface * pc = GetHandle(env, caller); + CHECK_HANDLE(pc); + + pc->SetAudioPlayout(static_cast(playout)); +} + +JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_setAudioRecording +(JNIEnv * env, jobject caller, jboolean recording) +{ + webrtc::PeerConnectionInterface * pc = GetHandle(env, caller); + CHECK_HANDLE(pc); + + pc->SetAudioRecording(static_cast(recording)); +} + JNIEXPORT jobject JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_getSignalingState (JNIEnv * env, jobject caller) { @@ -487,12 +613,17 @@ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_RTCPeerConnection_setConfiguration webrtc::PeerConnectionInterface * pc = GetHandle(env, caller); CHECK_HANDLE(pc); - auto config = jni::RTCConfiguration::toNative(env, jni::JavaLocalRef(env, jConfig)); + try { + auto config = jni::RTCConfiguration::toNative(env, jni::JavaLocalRef(env, jConfig)); - webrtc::RTCError error = pc->SetConfiguration(config); + webrtc::RTCError error = pc->SetConfiguration(config); - if (!error.ok()) { - env->Throw(jni::JavaRuntimeException(env, jni::RTCErrorToString(error).c_str())); + if (!error.ok()) { + env->Throw(jni::JavaRuntimeException(env, jni::RTCErrorToString(error).c_str())); + } + } + catch (...) { + ThrowCxxJavaException(env); } } diff --git a/webrtc-jni/src/main/cpp/src/api/AddIceCandidateObserver.cpp b/webrtc-jni/src/main/cpp/src/api/AddIceCandidateObserver.cpp new file mode 100644 index 000000000..fd257d783 --- /dev/null +++ b/webrtc-jni/src/main/cpp/src/api/AddIceCandidateObserver.cpp @@ -0,0 +1,96 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "api/AddIceCandidateObserver.h" +#include "api/WebRTCUtils.h" +#include "JavaClasses.h" +#include "JavaString.h" +#include "JavaUtils.h" +#include "JNI_WebRTC.h" + +#include + +namespace jni +{ + AddIceCandidateObserver::AddIceCandidateObserver(JNIEnv * env, const JavaGlobalRef & observer) : + observer(observer), + javaClass(JavaClasses::get(env)) + { + } + + AddIceCandidateObserver::~AddIceCandidateObserver() + { + // WebRTC drops an operation it has queued, together with its callback, + // when the peer connection goes away before running it. + Notify("[INVALID_STATE] AddIceCandidate was discarded before completion"); + } + + void AddIceCandidateObserver::OnComplete(webrtc::RTCError error) noexcept + { + try { + if (error.ok()) { + Notify(nullptr); + } + else { + Notify(RTCErrorToString(error).c_str()); + } + } + catch (...) { + Notify("[INTERNAL_ERROR] Could not report the AddIceCandidate result"); + } + } + + void AddIceCandidateObserver::Notify(const char * error) noexcept + { + JavaGlobalRef callback(std::move(observer)); + + if (!callback.get()) { + return; + } + + JNIEnv * env = AttachCurrentThread(); + + if (env == nullptr) { + return; + } + + if (error == nullptr) { + env->CallVoidMethod(callback.get(), javaClass->onSuccess); + } + else { + JavaLocalRef message(env, env->NewStringUTF(error)); + + if (!env->ExceptionCheck()) { + env->CallVoidMethod(callback.get(), javaClass->onFailure, message.get()); + } + } + + // A Java exception must not escape into WebRTC's signaling thread. + if (env->ExceptionCheck()) { + JavaLocalRef exception(env, env->ExceptionOccurred()); + env->ExceptionClear(); + ReportUncaughtException(env, exception.get()); + } + } + + AddIceCandidateObserver::JavaAddIceCandidateObserverClass::JavaAddIceCandidateObserverClass(JNIEnv * env) + { + jclass cls = FindClass(env, PKG"AddIceCandidateObserver"); + + onSuccess = GetMethod(env, cls, "onSuccess", "()V"); + onFailure = GetMethod(env, cls, "onFailure", "(" STRING_SIG ")V"); + } +} diff --git a/webrtc-jni/src/main/cpp/src/api/PeerConnectionObserver.cpp b/webrtc-jni/src/main/cpp/src/api/PeerConnectionObserver.cpp index c89f88bfc..48b2430b0 100644 --- a/webrtc-jni/src/main/cpp/src/api/PeerConnectionObserver.cpp +++ b/webrtc-jni/src/main/cpp/src/api/PeerConnectionObserver.cpp @@ -15,6 +15,7 @@ */ #include "api/PeerConnectionObserver.h" +#include "api/RTCCandidatePairChangeEvent.h" #include "api/RTCIceCandidate.h" #include "api/RTCPeerConnectionIceErrorEvent.h" #include "api/WebRTCUtils.h" @@ -220,6 +221,28 @@ namespace jni ExceptionCheck(env); } + void PeerConnectionObserver::OnIceCandidateRemoved(const webrtc::IceCandidate * candidate) + { + JNIEnv * env = AttachCurrentThread(); + + if (env == nullptr || candidate == nullptr) { + return; + } + + try { + JavaLocalRef jCandidate = RTCIceCandidate::toJava(env, candidate); + + if (!env->ExceptionCheck()) { + env->CallVoidMethod(observer, javaClass->onIceCandidateRemoved, jCandidate.get()); + } + } + catch (...) { + ThrowCxxJavaException(env); + } + + ReportException(env); + } + void PeerConnectionObserver::OnIceConnectionReceivingChange(bool receiving) { JNIEnv * env = AttachCurrentThread(); @@ -241,21 +264,27 @@ namespace jni return; } - const webrtc::Candidate & remote = event.selected_candidate_pair.remote_candidate(); - - std::string ip = remote.address().ipaddr().ToString(); - int port = remote.address().port(); - - const auto typeName = remote.type_name(); - std::string type(typeName.data(), typeName.size()); + try { + JavaLocalRef jEvent = RTCCandidatePairChangeEvent::toJava(env, event); - JavaLocalRef jAddress = JavaString::toJava(env, ip); - JavaLocalRef jType = JavaString::toJava(env, type); + if (!env->ExceptionCheck()) { + env->CallVoidMethod(observer, javaClass->onSelectedCandidatePairChanged, jEvent.get()); + } + } + catch (...) { + ThrowCxxJavaException(env); + } - env->CallVoidMethod(observer, javaClass->onSelectedCandidatePairChanged, - jAddress.get(), static_cast(port), jType.get()); + ReportException(env); + } - ExceptionCheck(env); + void PeerConnectionObserver::ReportException(JNIEnv * env) noexcept + { + if (env->ExceptionCheck()) { + JavaLocalRef exception(env, env->ExceptionOccurred()); + env->ExceptionClear(); + ReportUncaughtException(env, exception.get()); + } } PeerConnectionObserver::JavaPeerConnectionObserverClass::JavaPeerConnectionObserverClass(JNIEnv * env) @@ -273,7 +302,8 @@ namespace jni onIceGatheringChange = GetMethod(env, cls, "onIceGatheringChange", "(L" PKG "RTCIceGatheringState;)V"); onIceCandidate = GetMethod(env, cls, "onIceCandidate", "(L" PKG "RTCIceCandidate;)V"); onIceCandidateError = GetMethod(env, cls, "onIceCandidateError", "(L" PKG "RTCPeerConnectionIceErrorEvent;)V"); + onIceCandidateRemoved = GetMethod(env, cls, "onIceCandidateRemoved", "(L" PKG "RTCIceCandidate;)V"); onIceConnectionReceivingChange = GetMethod(env, cls, "onIceConnectionReceivingChange", "(Z)V"); - onSelectedCandidatePairChanged = GetMethod(env, cls, "onSelectedCandidatePairChanged", "(Ljava/lang/String;ILjava/lang/String;)V"); + onSelectedCandidatePairChanged = GetMethod(env, cls, "onSelectedCandidatePairChanged", "(L" PKG "RTCCandidatePairChangeEvent;)V"); } } diff --git a/webrtc-jni/src/main/cpp/src/api/RTCCandidatePairChangeEvent.cpp b/webrtc-jni/src/main/cpp/src/api/RTCCandidatePairChangeEvent.cpp new file mode 100644 index 000000000..b11a99f38 --- /dev/null +++ b/webrtc-jni/src/main/cpp/src/api/RTCCandidatePairChangeEvent.cpp @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#include "api/RTCCandidatePairChangeEvent.h" +#include "api/RTCIceCandidate.h" +#include "JavaClasses.h" +#include "JavaString.h" +#include "JavaUtils.h" +#include "JNI_WebRTC.h" + +#include "api/jsep.h" + +#include + +namespace jni +{ + namespace RTCCandidatePairChangeEvent + { + JavaLocalRef toJava(JNIEnv * env, const webrtc::CandidatePairChangeEvent & event) + { + const auto javaClass = JavaClasses::get(env); + + const webrtc::Candidate & local = event.selected_candidate_pair.local_candidate(); + const webrtc::Candidate & remote = event.selected_candidate_pair.remote_candidate(); + + // The pair belongs to a transport, not to an m-line; WebRTC reports + // removed candidates in the same form. + const webrtc::IceCandidate localCandidate(event.transport_name, -1, local); + const webrtc::IceCandidate remoteCandidate(event.transport_name, -1, remote); + + const auto typeName = remote.type_name(); + + jobject jEvent = env->NewObject(javaClass->cls, javaClass->ctor, + RTCIceCandidate::toJava(env, &localCandidate).get(), + RTCIceCandidate::toJava(env, &remoteCandidate).get(), + static_cast(event.last_data_received_ms), + JavaString::toJava(env, event.reason).get(), + static_cast(event.estimated_disconnected_time_ms), + JavaString::toJava(env, remote.address().ipaddr().ToString()).get(), + static_cast(remote.address().port()), + JavaString::toJava(env, std::string(typeName.data(), typeName.size())).get()); + + return JavaLocalRef(env, jEvent); + } + + JavaRTCCandidatePairChangeEventClass::JavaRTCCandidatePairChangeEventClass(JNIEnv * env) + { + cls = FindClass(env, PKG"RTCCandidatePairChangeEvent"); + + ctor = GetMethod(env, cls, "", "(L" PKG "RTCIceCandidate;L" PKG "RTCIceCandidate;J" STRING_SIG "J" STRING_SIG "I" STRING_SIG ")V"); + } + } +} diff --git a/webrtc-jni/src/main/cpp/src/api/RTCConfiguration.cpp b/webrtc-jni/src/main/cpp/src/api/RTCConfiguration.cpp index 88f3cb994..742abec30 100644 --- a/webrtc-jni/src/main/cpp/src/api/RTCConfiguration.cpp +++ b/webrtc-jni/src/main/cpp/src/api/RTCConfiguration.cpp @@ -24,11 +24,14 @@ #include "JavaEnums.h" #include "JavaIterable.h" #include "JavaList.h" +#include "JavaNullPointerException.h" #include "JavaRef.h" #include "JavaObject.h" #include "JavaPrimitive.h" +#include "JavaRuntimeException.h" #include "JavaString.h" #include "JavaUtils.h" +#include "JavaWrappedException.h" #include "JNI_WebRTC.h" namespace jni @@ -187,13 +190,19 @@ namespace jni configuration.rtcp_mux_policy = JavaEnums::toNative(env, mp); for (auto & item : JavaIterable(env, cr)) { - auto certificate = webrtc::RTCCertificate::FromPEM(jni::RTCCertificatePEM::toNative(env, item)); + if (item.get() == nullptr) { + throw JavaWrappedException(JavaLocalRef(env, + JavaNullPointerException(env, "RTCCertificatePEM must not be null"))); + } - RTC_CHECK(certificate != nullptr) << "Supplied certificate is malformed"; + auto certificate = webrtc::RTCCertificate::FromPEM(jni::RTCCertificatePEM::toNative(env, item)); - if (certificate != nullptr) { - configuration.certificates.push_back(certificate); + if (certificate == nullptr) { + throw JavaWrappedException(JavaLocalRef(env, + JavaRuntimeException(env, "Supplied certificate is malformed"))); } + + configuration.certificates.push_back(certificate); } if (pac.get() != nullptr) { diff --git a/webrtc-jni/src/main/cpp/src/api/RTCDataChannelInit.cpp b/webrtc-jni/src/main/cpp/src/api/RTCDataChannelInit.cpp index b8d2335a6..597328002 100644 --- a/webrtc-jni/src/main/cpp/src/api/RTCDataChannelInit.cpp +++ b/webrtc-jni/src/main/cpp/src/api/RTCDataChannelInit.cpp @@ -16,6 +16,7 @@ #include "api/RTCDataChannelInit.h" #include "JavaClasses.h" +#include "JavaEnums.h" #include "JavaObject.h" #include "JavaString.h" #include "JNI_WebRTC.h" @@ -38,6 +39,13 @@ namespace jni init.id = obj.getInt(javaClass->id); init.protocol = JavaString::toNative(env, obj.getString(javaClass->protocol)); + // Left unset when null, which WebRTC treats as low priority. + JavaLocalRef priority = obj.getObject(javaClass->priority); + + if (priority.get()) { + init.priority = webrtc::PriorityValue(JavaEnums::toNative(env, priority)); + } + return init; } diff --git a/webrtc-jni/src/main/cpp/src/api/RTCDataChannelSendObserver.cpp b/webrtc-jni/src/main/cpp/src/api/RTCDataChannelSendObserver.cpp index 9758efc3d..023e54e1c 100644 --- a/webrtc-jni/src/main/cpp/src/api/RTCDataChannelSendObserver.cpp +++ b/webrtc-jni/src/main/cpp/src/api/RTCDataChannelSendObserver.cpp @@ -3,44 +3,6 @@ #include "JavaString.h" #include "JNI_WebRTC.h" -namespace -{ - // Hands an exception thrown by the observer to the calling thread's uncaught - // exception handler, as the JVM does for an exception escaping a Java thread. - // ExceptionDescribe would print the "Exception in thread" prefix straight to - // native stderr and the stack trace to System.err, splitting the report - // across two streams and leaving the prefix on an unterminated line. - void ReportUncaughtException(JNIEnv * env, jthrowable exception) - { - jni::JavaLocalRef threadClass(env, env->FindClass("java/lang/Thread")); - jni::JavaLocalRef handlerClass(env, env->FindClass("java/lang/Thread$UncaughtExceptionHandler")); - if (env->ExceptionCheck()) { - env->ExceptionClear(); - return; - } - - jmethodID currentThread = env->GetStaticMethodID(threadClass.get(), "currentThread", "()Ljava/lang/Thread;"); - jmethodID getHandler = env->GetMethodID(threadClass.get(), "getUncaughtExceptionHandler", - "()Ljava/lang/Thread$UncaughtExceptionHandler;"); - jmethodID uncaughtException = env->GetMethodID(handlerClass.get(), "uncaughtException", - "(Ljava/lang/Thread;Ljava/lang/Throwable;)V"); - if (env->ExceptionCheck()) { - env->ExceptionClear(); - return; - } - - jni::JavaLocalRef thread(env, env->CallStaticObjectMethod(threadClass.get(), currentThread)); - jni::JavaLocalRef handler(env, env->CallObjectMethod(thread.get(), getHandler)); - if (!env->ExceptionCheck() && handler.get()) { - env->CallVoidMethod(handler.get(), uncaughtException, thread.get(), exception); - } - // Like the JVM, ignore an exception thrown by the handler itself. - if (env->ExceptionCheck()) { - env->ExceptionClear(); - } - } -} - namespace jni { RTCDataChannelSendObserver::RTCDataChannelSendObserver(JNIEnv * env, jobject observer) : diff --git a/webrtc-jni/src/main/cpp/src/api/WebRTCUtils.cpp b/webrtc-jni/src/main/cpp/src/api/WebRTCUtils.cpp index 77b0454d7..2d3b4a250 100644 --- a/webrtc-jni/src/main/cpp/src/api/WebRTCUtils.cpp +++ b/webrtc-jni/src/main/cpp/src/api/WebRTCUtils.cpp @@ -15,6 +15,7 @@ */ #include "api/WebRTCUtils.h" +#include "JavaRef.h" namespace jni { @@ -25,4 +26,37 @@ namespace jni return "[" + type + "] " + message; } -} \ No newline at end of file + + // ExceptionDescribe would print the "Exception in thread" prefix straight to + // native stderr and the stack trace to System.err, splitting the report + // across two streams and leaving the prefix on an unterminated line. + void ReportUncaughtException(JNIEnv * env, jthrowable exception) + { + JavaLocalRef threadClass(env, env->FindClass("java/lang/Thread")); + JavaLocalRef handlerClass(env, env->FindClass("java/lang/Thread$UncaughtExceptionHandler")); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + return; + } + + jmethodID currentThread = env->GetStaticMethodID(threadClass.get(), "currentThread", "()Ljava/lang/Thread;"); + jmethodID getHandler = env->GetMethodID(threadClass.get(), "getUncaughtExceptionHandler", + "()Ljava/lang/Thread$UncaughtExceptionHandler;"); + jmethodID uncaughtException = env->GetMethodID(handlerClass.get(), "uncaughtException", + "(Ljava/lang/Thread;Ljava/lang/Throwable;)V"); + if (env->ExceptionCheck()) { + env->ExceptionClear(); + return; + } + + JavaLocalRef thread(env, env->CallStaticObjectMethod(threadClass.get(), currentThread)); + JavaLocalRef handler(env, env->CallObjectMethod(thread.get(), getHandler)); + if (!env->ExceptionCheck() && handler.get()) { + env->CallVoidMethod(handler.get(), uncaughtException, thread.get(), exception); + } + // Like the JVM, ignore an exception thrown by the handler itself. + if (env->ExceptionCheck()) { + env->ExceptionClear(); + } + } +} diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/AddIceCandidateObserver.java b/webrtc/src/main/java/dev/onvoid/webrtc/AddIceCandidateObserver.java new file mode 100644 index 000000000..2cdf26138 --- /dev/null +++ b/webrtc/src/main/java/dev/onvoid/webrtc/AddIceCandidateObserver.java @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc; + +/** + * Callback interface used to get notified when the {@link RTCPeerConnection} + * has applied, or rejected, a remote ICE candidate passed to {@link + * RTCPeerConnection#addIceCandidate(RTCIceCandidate, AddIceCandidateObserver) + * addIceCandidate}. + *

+ * The callbacks run on the peer connection's signaling thread. An exception + * thrown by them is reported to the thread's uncaught exception handler. + * + * @author Alex Andres + */ +public interface AddIceCandidateObserver { + + /** + * The candidate has been added to the remote description and handed to + * the ICE agent. + */ + void onSuccess(); + + /** + * The candidate could not be added, for example because there is no remote + * description yet, or because its media section does not exist. + * + * @param error The error type in brackets followed by the error message. + */ + void onFailure(String error); + +} diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/PeerConnectionObserver.java b/webrtc/src/main/java/dev/onvoid/webrtc/PeerConnectionObserver.java index 2c64f5284..3648de920 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/PeerConnectionObserver.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/PeerConnectionObserver.java @@ -89,6 +89,20 @@ default void onIceGatheringChange(RTCIceGatheringState state) { default void onIceCandidateError(RTCPeerConnectionIceErrorEvent event) { } + /** + * A local ICE candidate that was announced through {@link #onIceCandidate} + * has been removed, for example because its network interface went away + * or a TURN port was pruned. The remote peer should be told, so that it + * can pass the candidate to {@link RTCPeerConnection#removeIceCandidate}. + *

+ * The candidate's {@code sdpMLineIndex} is -1; its {@code sdpMid} + * identifies the transport. + * + * @param candidate The removed ICE candidate. + */ + default void onIceCandidateRemoved(RTCIceCandidate candidate) { + } + /** * Media is received on a new stream from the remote peer. * @@ -178,4 +192,21 @@ default void onTrack(RTCRtpTransceiver transceiver) { default void onSelectedCandidatePairChanged(String remoteAddress, int remotePort, String candidateType) { } + /** + * ICE has selected (or later re-selected) a candidate pair for this + * connection, with both candidates of the pair and why it was chosen. + * Fires at the same moments as {@link + * #onSelectedCandidatePairChanged(String, int, String)}. + *

+ * The default implementation passes the remote candidate's address, port + * and type on to {@link #onSelectedCandidatePairChanged(String, int, + * String)}, so an observer overrides one of the two. + * + * @param event The candidate pair change event. + */ + default void onSelectedCandidatePairChanged(RTCCandidatePairChangeEvent event) { + onSelectedCandidatePairChanged(event.remoteAddress, event.remotePort, + event.remoteType); + } + } diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/RTCCandidatePairChangeEvent.java b/webrtc/src/main/java/dev/onvoid/webrtc/RTCCandidatePairChangeEvent.java new file mode 100644 index 000000000..355d0afed --- /dev/null +++ b/webrtc/src/main/java/dev/onvoid/webrtc/RTCCandidatePairChangeEvent.java @@ -0,0 +1,127 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc; + +/** + * This event occurs when ICE selects, or later re-selects, the candidate pair + * that carries a transport's traffic. + * + * @author Alex Andres + * + * @see PeerConnectionObserver#onSelectedCandidatePairChanged(RTCCandidatePairChangeEvent) + */ +public class RTCCandidatePairChangeEvent { + + private final RTCIceCandidate local; + + private final RTCIceCandidate remote; + + private final long lastDataReceivedMs; + + private final String reason; + + private final long estimatedDisconnectedTimeMs; + + /* + * The remote candidate's address, port and type, as the older + * PeerConnectionObserver callback reports them. + */ + final String remoteAddress; + + final int remotePort; + + final String remoteType; + + + RTCCandidatePairChangeEvent(RTCIceCandidate local, RTCIceCandidate remote, + long lastDataReceivedMs, String reason, + long estimatedDisconnectedTimeMs, String remoteAddress, + int remotePort, String remoteType) { + this.local = local; + this.remote = remote; + this.lastDataReceivedMs = lastDataReceivedMs; + this.reason = reason; + this.estimatedDisconnectedTimeMs = estimatedDisconnectedTimeMs; + this.remoteAddress = remoteAddress; + this.remotePort = remotePort; + this.remoteType = remoteType; + } + + /** + * Returns the local candidate of the selected pair. Its {@code sdpMid} is + * the MID of the transport the pair belongs to, its {@code sdpMLineIndex} + * is -1 and its {@code sdp} is the candidate attribute. + * + * @return The local candidate of the selected pair. + */ + public RTCIceCandidate getLocal() { + return local; + } + + /** + * Returns the remote candidate of the selected pair, in the same form as + * {@link #getLocal()}. For a relayed connection its address is the TURN + * relay's address. + * + * @return The remote candidate of the selected pair. + */ + public RTCIceCandidate getRemote() { + return remote; + } + + /** + * Returns when the transport last received data, in milliseconds on + * WebRTC's monotonic clock. It is only meaningful when compared with + * another value from the same clock, such as the one of a later event. + * + * @return The time data was last received, in milliseconds. + */ + public long getLastDataReceivedMs() { + return lastDataReceivedMs; + } + + /** + * Returns why ICE switched to this pair, as a human-readable text that + * WebRTC does not define further, for example "remote candidate + * generation maybe changed". + * + * @return The reason for the change. + */ + public String getReason() { + return reason; + } + + /** + * Returns how long WebRTC estimates the transport was disconnected before + * this pair was selected, in milliseconds: the time since the previous + * pair, or the transport, last received anything. It is 0 for the first + * pair selected on a transport. + * + * @return The estimated time without a working pair, in milliseconds. + */ + public long getEstimatedDisconnectedTimeMs() { + return estimatedDisconnectedTimeMs; + } + + @Override + public String toString() { + return String.format("%s@%d [local=%s, remote=%s, lastDataReceivedMs=%d, reason=%s, estimatedDisconnectedTimeMs=%d]", + RTCCandidatePairChangeEvent.class.getSimpleName(), hashCode(), + local, remote, lastDataReceivedMs, reason, + estimatedDisconnectedTimeMs); + } +} diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/RTCDataChannel.java b/webrtc/src/main/java/dev/onvoid/webrtc/RTCDataChannel.java index 46b06ac07..66244953b 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/RTCDataChannel.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/RTCDataChannel.java @@ -115,6 +115,18 @@ private RTCDataChannel() { */ public native boolean isNegotiated(); + /** + * Returns the priority of this RTCDataChannel, as set in {@link + * RTCDataChannelInit#priority}. A channel that is announced by the remote + * peer has the priority the remote peer gave it; a negotiated channel has + * the one it was created with on each side. + * A numeric priority that lies between two levels is reported as the + * nearest level above it. + * + * @return The priority of this data channel. + */ + public native RTCPriorityType getPriority(); + /** * Returns the ID for this RTCDataChannel. The value is initially {@code * null}, which is what will be returned if the ID was not provided at diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/RTCDataChannelInit.java b/webrtc/src/main/java/dev/onvoid/webrtc/RTCDataChannelInit.java index a24d6d7f2..e5cf17f5e 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/RTCDataChannelInit.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/RTCDataChannelInit.java @@ -66,7 +66,12 @@ public class RTCDataChannelInit { public String protocol; /** - * Priority of this channel. + * Priority of this channel. It is announced to the remote peer when the + * channel opens. WebRTC only uses it to share the SCTP transport between + * channels when SCTP message interleaving is enabled with the field trial + * "WebRTC-DataChannelMessageInterleaving/Enabled/" on both peers; otherwise + * channels take turns regardless of priority. {@code null} means {@link + * RTCPriorityType#LOW}. */ public RTCPriorityType priority = RTCPriorityType.LOW; diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/RTCPeerConnection.java b/webrtc/src/main/java/dev/onvoid/webrtc/RTCPeerConnection.java index e9a81eeee..ce0ab52a2 100644 --- a/webrtc/src/main/java/dev/onvoid/webrtc/RTCPeerConnection.java +++ b/webrtc/src/main/java/dev/onvoid/webrtc/RTCPeerConnection.java @@ -287,6 +287,25 @@ public native void createAnswer(RTCAnswerOptions options, public native void setLocalDescription(RTCSessionDescription description, SetSessionDescriptionObserver observer); + /** + * Creates an offer or an answer, whichever the signaling state calls for, + * and applies it as the local description. Unlike calling {@link + * #createOffer} or {@link #createAnswer} first, nothing can change between + * creating the description and applying it, which makes this the simpler + * way to implement "perfect negotiation". The applied description can be + * read with {@link #getLocalDescription()} once the observer reports + * success. + *

+ * An offer is created with default {@link RTCOfferOptions}, so an ICE + * restart has to be requested with {@link #restartIce()} beforehand. + * + * @param observer The observer to be notified once the description has + * been successfully set. + */ + public void setLocalDescription(SetSessionDescriptionObserver observer) { + setLocalDescriptionImplicit(observer); + } + /** * Instructs the RTCPeerConnection to apply the supplied * RTCSessionDescription as the remote offer or answer. Setting the remote @@ -308,6 +327,86 @@ public native void setRemoteDescription(RTCSessionDescription description, */ public native void addIceCandidate(RTCIceCandidate candidate); + /** + * Adds a remote ICE candidate to the ICE Agent and reports the outcome. + * Unlike {@link #addIceCandidate(RTCIceCandidate)}, the candidate is + * queued behind any pending offer/answer operation, as the standard + * requires, so it can be called right after {@link #setRemoteDescription} + * without waiting for it to complete. + * + * @param candidate A new ICE candidate received from the remote peer over a + * signaling channel. + * @param observer The observer to be notified once the candidate has been + * added, or has been rejected. + */ + public void addIceCandidate(RTCIceCandidate candidate, + AddIceCandidateObserver observer) { + addIceCandidateWithObserver(candidate, observer); + } + + /** + * Removes a remote ICE candidate that was added before, for example + * because the remote peer reported it through {@link + * PeerConnectionObserver#onIceCandidateRemoved}. The candidate is matched + * by its {@code sdpMid} and its candidate attribute. + * + * @param candidate The remote ICE candidate to remove. + * + * @return {@code true} if the candidate was found in the remote + * description and removed, {@code false} if it was not found, there is no + * remote description, or the connection is closed. + */ + public native boolean removeIceCandidate(RTCIceCandidate candidate); + + /** + * Limits the bandwidth that all RTP streams sent by this connection may + * use together, in bits per second. A {@code null} value leaves that + * limit as it is. Other limits still apply, such as a "b=AS" line in the + * SDP or the {@link RTCRtpEncodingParameters} of a sender. + *

+ * Setting {@code currentBitrate} resets the bandwidth estimate to that + * value, which is where the estimate starts from after a call that + * already knows its network. + * + * @param minBitrate The minimum bitrate, or {@code null}. + * @param currentBitrate The bitrate to restart estimation from, or + * {@code null}. + * @param maxBitrate The maximum bitrate, or {@code null}. + * + * @throws RuntimeException If a value is negative, they are not ordered + * as min ≤ current ≤ max, or the connection + * is closed. + */ + public native void setBitrate(Integer minBitrate, Integer currentBitrate, + Integer maxBitrate); + + /** + * Enables or disables playout of all received audio streams of this + * connection. Playout is enabled by default. While disabled, the audio + * device does not play, but received audio is still decoded and its + * statistics are still updated. + *

+ * The switch applies to the audio device module, which is shared by all + * connections of the same {@link PeerConnectionFactory}. + * + * @param playout {@code true} to play received audio. + */ + public native void setAudioPlayout(boolean playout); + + /** + * Enables or disables recording of the audio this connection sends. + * Recording is enabled by default. While disabled, the audio device does + * not record, so audio tracks fed by it send nothing. Tracks fed by a + * {@link dev.onvoid.webrtc.media.audio.CustomAudioSource} do not use the + * device and keep sending. + *

+ * The switch applies to the audio device module, which is shared by all + * connections of the same {@link PeerConnectionFactory}. + * + * @param recording {@code true} to record audio. + */ + public native void setAudioRecording(boolean recording); + /** * Returns the signaling state of the RTCPeerConnection. * @@ -425,4 +524,10 @@ private native RTCRtpSender addTrackInternal(MediaStreamTrack track, private native RTCRtpTransceiver addTransceiverInternal( MediaStreamTrack track, RTCRtpTransceiverInit init); + private native void setLocalDescriptionImplicit( + SetSessionDescriptionObserver observer); + + private native void addIceCandidateWithObserver(RTCIceCandidate candidate, + AddIceCandidateObserver observer); + } diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/RTCConfigurationFieldsTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/RTCConfigurationFieldsTests.java index 955bedb1b..4d977fcce 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/RTCConfigurationFieldsTests.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/RTCConfigurationFieldsTests.java @@ -151,6 +151,41 @@ void invalidConfigurationFailsReadably() { assertTrue(error.getMessage().matches("Create PeerConnection failed: [A-Z_]+ .+"), error.getMessage()); } + @Test + void malformedCertificateThrows() { + // Used to abort the JVM through an RTC_CHECK. + RTCConfiguration config = new RTCConfiguration(); + config.certificates.add(new RTCCertificatePEM("no key", "no certificate", 0)); + + RuntimeException error = assertThrows(RuntimeException.class, + () -> factory.createPeerConnection(config, new Observer())); + + assertEquals("Supplied certificate is malformed", error.getMessage()); + + config.certificates.set(0, null); + + assertThrows(NullPointerException.class, + () -> factory.createPeerConnection(config, new Observer())); + } + + @Test + void setConfigurationRejectsMalformedCertificate() { + RTCPeerConnection connection = factory.createPeerConnection(new RTCConfiguration(), new Observer()); + + try { + RTCConfiguration config = connection.getConfiguration(); + config.certificates.add(new RTCCertificatePEM("no key", "no certificate", 0)); + + RuntimeException error = assertThrows(RuntimeException.class, + () -> connection.setConfiguration(config)); + + assertEquals("Supplied certificate is malformed", error.getMessage()); + } + finally { + connection.close(); + } + } + @Test void setConfigurationApplies() { RTCPeerConnection connection = factory.createPeerConnection(new RTCConfiguration(), new Observer()); diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/RTCDataChannelTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/RTCDataChannelTests.java index 8841f4591..e1f1d0a3d 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/RTCDataChannelTests.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/RTCDataChannelTests.java @@ -36,6 +36,38 @@ class RTCDataChannelTests extends TestBase { + @Test + void priority() { + RTCPeerConnection connection = factory.createPeerConnection( + new RTCConfiguration(), candidate -> { }); + + try { + // The priority used to be ignored, so every channel was LOW. + for (RTCPriorityType priority : RTCPriorityType.values()) { + RTCDataChannelInit init = new RTCDataChannelInit(); + init.priority = priority; + + RTCDataChannel channel = connection.createDataChannel(priority.name(), init); + + assertEquals(priority, channel.getPriority()); + + channel.dispose(); + } + + RTCDataChannelInit init = new RTCDataChannelInit(); + init.priority = null; + + RTCDataChannel channel = connection.createDataChannel("unset", init); + + assertEquals(RTCPriorityType.LOW, channel.getPriority()); + + channel.dispose(); + } + finally { + connection.close(); + } + } + @Test void bufferedAmountChangeCallback() throws Exception { DataPeerConnection caller = new DataPeerConnection(factory); diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/RTCPeerConnectionSignalingTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/RTCPeerConnectionSignalingTests.java new file mode 100644 index 000000000..e6aa1bcb8 --- /dev/null +++ b/webrtc/src/test/java/dev/onvoid/webrtc/RTCPeerConnectionSignalingTests.java @@ -0,0 +1,333 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class RTCPeerConnectionSignalingTests extends TestBase { + + private Peer caller; + + private Peer callee; + + + @BeforeEach + void init() { + caller = new Peer(); + callee = new Peer(); + } + + @AfterEach + void dispose() { + caller.close(); + callee.close(); + } + + @Test + void implicitOfferAndAnswer() throws Exception { + caller.connection.createDataChannel("data", new RTCDataChannelInit()); + + negotiate(); + + assertEquals(RTCSdpType.OFFER, callee.connection.getRemoteDescription().sdpType); + assertEquals(RTCSdpType.ANSWER, caller.connection.getRemoteDescription().sdpType); + assertEquals(RTCSignalingState.STABLE, caller.connection.getSignalingState()); + assertEquals(RTCSignalingState.STABLE, callee.connection.getSignalingState()); + + exchangeCandidates(); + + assertTrue(caller.connected.await(10, TimeUnit.SECONDS), "Caller did not connect"); + assertTrue(callee.connected.await(10, TimeUnit.SECONDS), "Callee did not connect"); + } + + @Test + void candidatesAddedWithObserver() throws Exception { + caller.connection.createDataChannel("data", new RTCDataChannelInit()); + + negotiate(); + + List callerCandidates = caller.awaitCandidates(); + assertFalse(callerCandidates.isEmpty()); + + for (RTCIceCandidate candidate : callerCandidates) { + TestAddIceObserver observer = new TestAddIceObserver(); + callee.connection.addIceCandidate(candidate, observer); + observer.get(5, TimeUnit.SECONDS); + } + + RTCIceCandidate added = callerCandidates.get(0); + // The SDP leaves out the candidate's ufrag when it is the session's. + String candidateLine = "a=" + added.sdp.substring(0, added.sdp.indexOf(" generation")); + + assertTrue(callee.connection.getRemoteDescription().sdp.contains(candidateLine)); + + // Removal matches the candidate by its MID and attribute. + assertTrue(callee.connection.removeIceCandidate(added)); + assertFalse(callee.connection.getRemoteDescription().sdp.contains(candidateLine)); + assertFalse(callee.connection.removeIceCandidate(added)); + } + + @Test + void unknownMidFailsWithObserver() throws Exception { + caller.connection.createDataChannel("data", new RTCDataChannelInit()); + + negotiate(); + + // The candidate parses, but no transport has this MID. + RTCIceCandidate candidate = new RTCIceCandidate("unknown", 5, + "candidate:1 1 udp 2122260223 192.168.1.10 54321 typ host generation 0"); + CountDownLatch done = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + + callee.connection.addIceCandidate(candidate, new AddIceCandidateObserver() { + @Override + public void onSuccess() { + done.countDown(); + } + + @Override + public void onFailure(String error) { + failure.set(error); + done.countDown(); + } + }); + + assertTrue(done.await(5, TimeUnit.SECONDS)); + assertNotNull(failure.get(), "An unknown media section was accepted"); + } + + @Test + void selectedCandidatePairEvent() throws Exception { + caller.connection.createDataChannel("data", new RTCDataChannelInit()); + + negotiate(); + exchangeCandidates(); + + assertTrue(caller.pairChanged.await(10, TimeUnit.SECONDS), "No pair change event"); + + RTCCandidatePairChangeEvent event = caller.pairChange.get(); + + assertNotNull(event.getLocal()); + assertNotNull(event.getRemote()); + assertNotNull(event.getLocal().sdpMid); + assertEquals(event.getLocal().sdpMid, event.getRemote().sdpMid); + assertEquals(-1, event.getRemote().sdpMLineIndex); + assertTrue(event.getLocal().sdp.startsWith("candidate:"), event.getLocal().sdp); + assertTrue(event.getRemote().sdp.startsWith("candidate:"), event.getRemote().sdp); + assertNotNull(event.getReason()); + assertFalse(event.getReason().isEmpty()); + + // The remote candidate in the event is one the callee gathered: its + // port is the one the older callback reports. + assertTrue(event.getRemote().sdp.contains(" " + event.remotePort + " typ "), + event.getRemote().sdp); + } + + @Test + void legacyPairCallbackStillFires() throws Exception { + CountDownLatch legacy = new CountDownLatch(1); + AtomicReference type = new AtomicReference<>(); + + Peer legacyCaller = new Peer() { + @Override + public void onSelectedCandidatePairChanged(String remoteAddress, + int remotePort, String candidateType) { + type.set(candidateType); + legacy.countDown(); + } + }; + + Peer previous = caller; + caller = legacyCaller; + previous.close(); + + caller.connection.createDataChannel("data", new RTCDataChannelInit()); + + negotiate(); + exchangeCandidates(); + + assertTrue(legacy.await(10, TimeUnit.SECONDS), "The older callback was not called"); + assertNotNull(type.get()); + } + + @Test + void remoteChannelHasAnnouncedPriority() throws Exception { + RTCDataChannelInit init = new RTCDataChannelInit(); + init.priority = RTCPriorityType.HIGH; + + RTCDataChannel channel = caller.connection.createDataChannel("data", init); + + negotiate(); + exchangeCandidates(); + + assertTrue(callee.dataChannelReceived.await(10, TimeUnit.SECONDS), "No data channel announced"); + + RTCDataChannel remote = callee.dataChannel.get(); + + assertEquals("data", remote.getLabel()); + assertEquals(RTCPriorityType.HIGH, remote.getPriority()); + + channel.dispose(); + remote.dispose(); + } + + /** + * Offer and answer through the implicit form of setLocalDescription. + */ + private void negotiate() throws Exception { + setLocal(caller); + setRemote(callee, caller.connection.getLocalDescription()); + setLocal(callee); + setRemote(caller, callee.connection.getLocalDescription()); + } + + private void exchangeCandidates() { + caller.sendCandidatesTo(callee); + callee.sendCandidatesTo(caller); + } + + private static void setLocal(Peer peer) throws Exception { + TestSetDescObserver observer = new TestSetDescObserver(); + peer.connection.setLocalDescription(observer); + observer.get(5, TimeUnit.SECONDS); + } + + private static void setRemote(Peer peer, RTCSessionDescription description) + throws Exception { + TestSetDescObserver observer = new TestSetDescObserver(); + peer.connection.setRemoteDescription(description, observer); + observer.get(5, TimeUnit.SECONDS); + } + + + + private class Peer implements PeerConnectionObserver { + + final RTCPeerConnection connection; + + final List candidates = new CopyOnWriteArrayList<>(); + + final CountDownLatch gathered = new CountDownLatch(1); + + final CountDownLatch connected = new CountDownLatch(1); + + final CountDownLatch pairChanged = new CountDownLatch(1); + + final AtomicReference pairChange = new AtomicReference<>(); + + final CountDownLatch dataChannelReceived = new CountDownLatch(1); + + final AtomicReference dataChannel = new AtomicReference<>(); + + private final List pending = new ArrayList<>(); + + /* Set once both descriptions are in place, guarded by this peer. */ + private Peer remote; + + + Peer() { + connection = factory.createPeerConnection(new RTCConfiguration(), this); + } + + @Override + public void onIceCandidate(RTCIceCandidate candidate) { + candidates.add(candidate); + + Peer target; + + synchronized (this) { + if (remote == null) { + pending.add(candidate); + return; + } + target = remote; + } + + target.connection.addIceCandidate(candidate, new TestAddIceObserver()); + } + + @Override + public void onIceGatheringChange(RTCIceGatheringState state) { + if (state == RTCIceGatheringState.COMPLETE) { + gathered.countDown(); + } + } + + @Override + public void onConnectionChange(RTCPeerConnectionState state) { + if (state == RTCPeerConnectionState.CONNECTED) { + connected.countDown(); + } + } + + @Override + public void onSelectedCandidatePairChanged(RTCCandidatePairChangeEvent event) { + pairChange.compareAndSet(null, event); + pairChanged.countDown(); + + PeerConnectionObserver.super.onSelectedCandidatePairChanged(event); + } + + @Override + public void onDataChannel(RTCDataChannel channel) { + dataChannel.compareAndSet(null, channel); + dataChannelReceived.countDown(); + } + + List awaitCandidates() throws InterruptedException { + assertTrue(gathered.await(10, TimeUnit.SECONDS), "Gathering did not complete"); + + return new ArrayList<>(candidates); + } + + /* + * Passes the candidates gathered so far, and all later ones, to the + * other peer. + */ + void sendCandidatesTo(Peer peer) { + List queued; + + synchronized (this) { + remote = peer; + queued = new ArrayList<>(pending); + pending.clear(); + } + + for (RTCIceCandidate candidate : queued) { + peer.connection.addIceCandidate(candidate, new TestAddIceObserver()); + } + } + + void close() { + connection.close(); + } + } +} diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/RTCPeerConnectionTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/RTCPeerConnectionTests.java index 0987f52fe..df80bf214 100644 --- a/webrtc/src/test/java/dev/onvoid/webrtc/RTCPeerConnectionTests.java +++ b/webrtc/src/test/java/dev/onvoid/webrtc/RTCPeerConnectionTests.java @@ -27,6 +27,7 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.AfterEach; @@ -463,6 +464,85 @@ void statesWhenClosed() { assertEquals(RTCIceConnectionState.CLOSED, peerConnection.getIceConnectionState()); } + @Test + void setBitrate() { + peerConnection.setBitrate(100_000, 300_000, 2_000_000); + peerConnection.setBitrate(null, null, 1_000_000); + peerConnection.setBitrate(null, null, null); + + RuntimeException error = assertThrows(RuntimeException.class, + () -> peerConnection.setBitrate(500_000, 300_000, null)); + assertTrue(error.getMessage().startsWith("[INVALID_PARAMETER]"), error.getMessage()); + + assertThrows(RuntimeException.class, + () -> peerConnection.setBitrate(null, null, -1)); + } + + @Test + void setAudioPlayoutAndRecording() { + peerConnection.setAudioPlayout(false); + peerConnection.setAudioRecording(false); + peerConnection.setAudioPlayout(true); + peerConnection.setAudioRecording(true); + } + + @Test + void addIceCandidateWithoutRemoteDescription() throws Exception { + RTCIceCandidate candidate = new RTCIceCandidate("0", 0, + "candidate:1 1 udp 2122260223 192.168.1.10 54321 typ host generation 0"); + CountDownLatch done = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + + peerConnection.addIceCandidate(candidate, new AddIceCandidateObserver() { + @Override + public void onSuccess() { + done.countDown(); + } + + @Override + public void onFailure(String error) { + failure.set(error); + done.countDown(); + } + }); + + assertTrue(done.await(5, TimeUnit.SECONDS)); + assertNotNull(failure.get()); + assertTrue(failure.get().startsWith("[INVALID_STATE]"), failure.get()); + + assertFalse(peerConnection.removeIceCandidate(candidate)); + } + + @Test + void iceCandidateNullParams() { + RTCIceCandidate candidate = new RTCIceCandidate("0", 0, + "candidate:1 1 udp 2122260223 192.168.1.10 54321 typ host generation 0"); + + assertThrows(NullPointerException.class, + () -> peerConnection.addIceCandidate(null, new TestAddIceObserver())); + assertThrows(NullPointerException.class, + () -> peerConnection.addIceCandidate(candidate, null)); + assertThrows(NullPointerException.class, + () -> peerConnection.removeIceCandidate(null)); + assertThrows(NullPointerException.class, + () -> peerConnection.setLocalDescription((SetSessionDescriptionObserver) null)); + } + + @Test + void implicitLocalDescriptionCreatesOffer() throws Exception { + peerConnection.createDataChannel("data", new RTCDataChannelInit()); + + TestSetDescObserver observer = new TestSetDescObserver(); + peerConnection.setLocalDescription(observer); + observer.get(5, TimeUnit.SECONDS); + + RTCSessionDescription local = peerConnection.getLocalDescription(); + + assertNotNull(local); + assertEquals(RTCSdpType.OFFER, local.sdpType); + assertEquals(RTCSignalingState.HAVE_LOCAL_OFFER, peerConnection.getSignalingState()); + } + @Test void closeManyConnectionsDoesNotCrash() { // Regression guard: close() previously never released the native diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/TestAddIceObserver.java b/webrtc/src/test/java/dev/onvoid/webrtc/TestAddIceObserver.java new file mode 100644 index 000000000..27d8cfe6a --- /dev/null +++ b/webrtc/src/test/java/dev/onvoid/webrtc/TestAddIceObserver.java @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Alex Andres + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package dev.onvoid.webrtc; + +import static java.util.Objects.nonNull; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +class TestAddIceObserver implements AddIceCandidateObserver { + + private final CountDownLatch latch = new CountDownLatch(1); + + private volatile String error; + + + @Override + public void onSuccess() { + latch.countDown(); + } + + @Override + public void onFailure(String error) { + this.error = error; + + latch.countDown(); + } + + void get(long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + if (!latch.await(timeout, unit)) { + throw new TimeoutException(); + } + if (nonNull(error)) { + throw new ExecutionException(error, new IllegalStateException()); + } + } +}