I am trying to implement one-way video stream with WebRTC, from an Android client to a browser. I have my own working signaling server, and for ICE I connect to 2 Google's STUN servers and a public TURN server by Open Relay Project, which seem to work correctly on their own.
When I try to establish peer connection, both sides exchange offers and answers successfully, signaling state in browser only moves to have-remote-offer and then to stable. There seems to be a problem with ICE candidates - every server triggers a 701 error ("TURN host lookup received error."), even though I seem to obtain host and srflx candidates successfully.
Chrome's webrtc-internals page shows that a candidate pair has been found and is in succeeded state, but it never gets nominated.
The peer connection and DTLS stay stuck in connecting state, eventually moving to failed. Needless to say, no media track transmission occurs.
So far I tried to add code for negotiation needed event, which still only gets me to the same frozen state. I also opened a range of ports for UDP in my Windows 10 firewall settings, but to no avail.
What causes this behaviour and what should I try to successfully establish P2P connection?
Logs and statistics from a sample connection attempt:
https://jsfiddle.net/08cLawvd/
Code I use to initiate the connection and send offer (always from Android device):
// connection factory setup
PeerConnectionFactory.InitializationOptions initializationOptions = PeerConnectionFactory.InitializationOptions
.builder(context)
.setEnableInternalTracer(true)
.createInitializationOptions();
PeerConnectionFactory.initialize(initializationOptions);
PeerConnectionFactory.Options options = new PeerConnectionFactory.Options();
options.networkIgnoreMask = 16; // ADAPTER_TYPE_LOOPBACK
options.disableNetworkMonitor = true;
factory = PeerConnectionFactory.builder()
.setOptions(options)
.setVideoEncoderFactory(new DefaultVideoEncoderFactory(rootEglBase.getEglBaseContext(), false, false))
.setVideoDecoderFactory(new DefaultVideoDecoderFactory(rootEglBase.getEglBaseContext()))
.createPeerConnectionFactory();
// video setup
videoSource = factory.createVideoSource(false);
videoTrackFromCamera = factory.createVideoTrack("WebRTC_track_v1", videoSource);
capturer = new CustomVideoCapturer();
SurfaceTextureHelper sth = SurfaceTextureHelper.create("CaptureThreadOne", rootEglBase.getEglBaseContext());
capturer.initialize(sth, context, videoSource.getCapturerObserver());
videoTrackFromCamera.setEnabled(true);
// peer connection setup and creation
PeerConnection.RTCConfiguration config = new PeerConnection.RTCConfiguration(IceServerService.getIceServers());
config.tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.DISABLED;
config.bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE;
config.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE;
config.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY;
config.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN;
config.iceTransportsType = PeerConnection.IceTransportsType.ALL;
config.keyType = PeerConnection.KeyType.ECDSA;
connection = factory.createPeerConnection(config, new CustomPeerConnectionObserver() {
@Override
public void onIceCandidate(IceCandidate iceCandidate) {
sendIceCandidate(iceCandidate); // sends SDP string, sdpMid and sdpMLineIndex to other peer
}
@Override
public void onRenegotiationNeeded() {
sendOffer();
}
});
// sendOffer code
RtpTransceiver.RtpTransceiverInit videoConstraint = new RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.SEND_ONLY);
connection.addTransceiver(MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO, videoConstraint);
RtpTransceiver.RtpTransceiverInit audioConstraint = new RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.INACTIVE);
connection.addTransceiver(MediaStreamTrack.MediaType.MEDIA_TYPE_AUDIO, audioConstraint);
MediaConstraints constraints = new MediaConstraints();
constraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveVideo", String.valueOf(false)));
constraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveAudio", String.valueOf(false)));
connection.createOffer(new CustomSdpObserver() {
@Override
public void onCreateSuccess(SessionDescription sessionDescription) {
connection.setLocalDescription(new CustomSdpObserver(), sessionDescription);
LinkedTreeMap<String, Object> offer = new LinkedTreeMap<String, Object>();
offer.put("type", sessionDescription.type.canonicalForm());
offer.put("sdp", sessionDescription.description);
Message message = new Message(MessageType.SIGNALLING_ANDROID, new Gson().toJson(offer));
messageConsumer.accept(message);
}
}, constraints);
Code which accepts the offer and should consume the video track:
let peerConnection;
const PC_CONFIG = {
bundlePolicy: "max-bundle",
iceServers: [
{ urls: 'stun:stun.l.google.com:19302?transport=udp' },
{ urls: 'stun:stun1.l.google.com:19302?transport=udp' },
{
urls: "turns:openrelay.metered.ca:443",
username: "…",
credential: "…",
},
]
};
let onIceCandidate = (event) => {
if (event.candidate) {
WebSocketSend("SIGNALLING_BROWSER", JSON.stringify({
type: 'candidate',
sdpMid: event.sdpMid,
sdpMLineIndex: event.sdpMLineIndex,
candidate: event.candidate
}));
}
};
let onAddTrack = (event) => {
// triggers successfully, but nothing is transmitted
stream.srcObject = event.streams[0];
};
let setAndSendLocalDescription = (sessionDescription) => {
peerConnection.setLocalDescription(sessionDescription);
WebSocketSend("SIGNALLING_BROWSER", JSON.stringify(sessionDescription));
};
let sendAnswer = () => {
peerConnection.createAnswer().then(
setAndSendLocalDescription,
(error) => { console.error('Send answer failed: ', error); }
);
};
let sendOffer = () => {
peerConnection.createOffer().then(
setAndSendLocalDescription,
(error) => { console.error('Send offer failed: ', error); }
);
}
let createPeerConnection = (remoteDescription) => {
try {
if (peerConnection == null || peerConnection.signalingState === 'closed') {
peerConnection = new RTCPeerConnection(PC_CONFIG);
peerConnection.addTransceiver("video", { direction: "recvonly" });
peerConnection.addTransceiver("audio", { direction: "inactive" });
peerConnection.onnegotiationneeded = sendOffer;
peerConnection.ontrack = onAddTrack;
peerConnection.onicecandidate = onIceCandidate;
}
peerConnection.setRemoteDescription(remoteDescription);
} catch (error) {
console.error('PeerConnection failed: ', error);
}
};
let handleSignalingData = (e) => {
let type = e.type.toUpperCase();
switch (type) {
case 'OFFER':
createPeerConnection(new RTCSessionDescription(e));
sendAnswer();
break;
case 'ANSWER':
case 'PRANSWER':
peerConnection.setRemoteDescription(new RTCSessionDescription(e));
break;
case 'CANDIDATE':
let candidate = new RTCIceCandidate({
candidate: e.candidate,
sdpMid: e.sdpMid,
sdpMLineIndex: parseInt(e.sdpMLineIndex)
});
peerConnection.addIceCandidate(candidate);
break;
}
};
UPDATE
Today I tried the connection with caller and callee on different networks. The 701 error with ICE candidate gathering still persist even on a different network and machine, so in theory, that should lower the probability of incorrect network setup on my end - I think it might be an issue somewhere in the code instead, but I'm not sure.