Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 7 additions & 9 deletions webrtc-jni/src/main/cpp/src/JNI_MediaStream.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ JNIEXPORT jstring JNICALL Java_dev_onvoid_webrtc_media_MediaStream_id
webrtc::MediaStreamInterface * stream = GetHandle<webrtc::MediaStreamInterface>(env, caller);
CHECK_HANDLEV(stream, nullptr);

return jni::JavaString::toJava(env, stream->id());
return jni::JavaString::toJava(env, stream->id()).release();
}

JNIEXPORT jobjectArray JNICALL Java_dev_onvoid_webrtc_media_MediaStream_getAudioTracks
Expand Down Expand Up @@ -110,13 +110,11 @@ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_MediaStream_dispose
webrtc::MediaStreamInterface * stream = GetHandle<webrtc::MediaStreamInterface>(env, caller);
CHECK_HANDLE(stream);

webrtc::RefCountReleaseStatus status = stream->Release();

if (status != webrtc::RefCountReleaseStatus::kDroppedLastRef) {
RTC_LOG(LS_WARNING) << "Native object was not deleted. A reference is still around somewhere.";
}

// Java MediaStreams are only made by PeerConnectionObserver::OnAddTrack,
// as views of the peer connection's remote streams that hold no reference.
// Releasing one here dropped the peer connection's reference instead, and
// could free the stream while WebRTC still used it. Only the Java object
// is detached. A Java MediaStream made with a reference of its own would
// have to be told apart here, as MediaStreamTrack tells its views apart.
SetHandle<std::nullptr_t>(env, caller, nullptr);

stream = nullptr;
}
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ default void onRenegotiationNeeded() {
*
* @param receiver The created RTP receiver.
* @param mediaStreams Associated remote MediaStreams of the negotiated
* media track.
* media track. They are views of streams the peer
* connection owns; see {@link MediaStream#dispose()}.
*/
default void onAddTrack(RTCRtpReceiver receiver, MediaStream[] mediaStreams) {
}
Expand Down
9 changes: 9 additions & 0 deletions webrtc/src/main/java/dev/onvoid/webrtc/media/MediaStream.java
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ private MediaStream() {
*/
public native void removeTrack(MediaStreamTrack track);

/**
* Detaches this object from its native stream.
* <p>
* A MediaStream is handed out by {@link
* dev.onvoid.webrtc.PeerConnectionObserver#onAddTrack} as a view of a
* remote stream that the peer connection owns, and holds no reference to
* it. Disposing it leaves the stream and its tracks as they are. A view is
* valid for as long as the peer connection keeps the stream.
*/
@Override
public native void dispose();

Expand Down
166 changes: 166 additions & 0 deletions webrtc/src/test/java/dev/onvoid/webrtc/MediaStreamViewTests.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
* 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.*;

import dev.onvoid.webrtc.media.MediaStream;
import dev.onvoid.webrtc.media.audio.AudioOptions;
import dev.onvoid.webrtc.media.audio.AudioTrack;
import dev.onvoid.webrtc.media.audio.AudioTrackSource;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;

/**
* The remote streams handed to onAddTrack are views of streams the peer
* connection owns: disposing one must not release the stream.
*/
@Execution(ExecutionMode.SAME_THREAD)
class MediaStreamViewTests extends TestBase {

private final List<String> streamIds = Collections.synchronizedList(new ArrayList<>());

private final List<Throwable> errors = Collections.synchronizedList(new ArrayList<>());

private RTCPeerConnection caller;

private RTCPeerConnection callee;

private AudioTrackSource audioSource;

private AudioTrack audioTrack;

private RTCRtpSender sender;


@BeforeEach
void init() {
// TestBase keeps one instance for all tests of the class.
streamIds.clear();
errors.clear();

RTCConfiguration config = new RTCConfiguration();

caller = factory.createPeerConnection(config, candidate -> { });
callee = factory.createPeerConnection(config, new PeerConnectionObserver() {

@Override
public void onIceCandidate(RTCIceCandidate candidate) {
}

@Override
public void onAddTrack(RTCRtpReceiver receiver, MediaStream[] mediaStreams) {
try {
for (MediaStream stream : mediaStreams) {
streamIds.add(stream.id());

stream.dispose();
}

receiver.dispose();
}
catch (Throwable e) {
errors.add(e);
}
}
});

audioSource = factory.createAudioSource(new AudioOptions());
audioTrack = factory.createAudioTrack("audioTrack", audioSource);
sender = caller.addTrack(audioTrack, Collections.singletonList("stream-0"));
}

@AfterEach
void dispose() {
// Closing the callee tears down the remote stream, which fails if a
// disposed view released the reference the peer connection holds.
sender.dispose();
caller.close();
callee.close();
audioTrack.dispose();
audioSource.dispose();
}

@Test
void disposeRemoteStreamView() throws Exception {
negotiate();

assertTrue(errors.isEmpty(), () -> "Errors: " + errors);
assertEquals(Collections.singletonList("stream-0"), streamIds);

// A second offer goes over the same remote stream again.
negotiate();

assertTrue(errors.isEmpty(), () -> "Errors: " + errors);
}

@Test
void removeTrackAfterDisposingStreamView() throws Exception {
negotiate();

// Removing the track empties the remote stream, which the peer
// connection then takes apart: a stream freed early by a disposed
// view is used after it was freed.
caller.removeTrack(sender);
negotiate();

assertTrue(errors.isEmpty(), () -> "Errors: " + errors);
assertEquals(Collections.singletonList("stream-0"), streamIds);
}

private void negotiate() throws Exception {
RTCSessionDescription offer = createDescription(caller, true);
setRemoteDescription(callee, offer);

RTCSessionDescription answer = createDescription(callee, false);
setRemoteDescription(caller, answer);
}

private static RTCSessionDescription createDescription(RTCPeerConnection connection, boolean offer) throws Exception {
TestCreateDescObserver createObserver = new TestCreateDescObserver();
TestSetDescObserver setObserver = new TestSetDescObserver();

if (offer) {
connection.createOffer(new RTCOfferOptions(), createObserver);
}
else {
connection.createAnswer(new RTCAnswerOptions(), createObserver);
}

RTCSessionDescription description = createObserver.get();

connection.setLocalDescription(description, setObserver);
setObserver.get();

return description;
}

private static void setRemoteDescription(RTCPeerConnection connection, RTCSessionDescription description) throws Exception {
TestSetDescObserver setObserver = new TestSetDescObserver();

connection.setRemoteDescription(description, setObserver);
setObserver.get();
}
}
Loading