| /* | |
| * Copyright 2016 The WebRTC Project Authors. All rights reserved. | |
| * | |
| * Use of this source code is governed by a BSD-style license | |
| * that can be found in the LICENSE file in the root of the source | |
| * tree. An additional intellectual property rights grant can be found | |
| * in the file PATENTS. All contributing project authors may | |
| * be found in the AUTHORS file in the root of the source tree. | |
| */ | |
| package org.appspot.apprtc; | |
| import android.util.Log; | |
| import androidx.annotation.Nullable; | |
| import java.util.ArrayList; | |
| import java.util.concurrent.ExecutorService; | |
| import java.util.concurrent.Executors; | |
| import java.util.regex.Matcher; | |
| import java.util.regex.Pattern; | |
| import org.json.JSONArray; | |
| import org.json.JSONException; | |
| import org.json.JSONObject; | |
| import org.webrtc.IceCandidate; | |
| import org.webrtc.SessionDescription; | |
| /** | |
| * Implementation of AppRTCClient that uses direct TCP connection as the signaling channel. | |
| * This eliminates the need for an external server. This class does not support loopback | |
| * connections. | |
| */ | |
| public class DirectRTCClient implements AppRTCClient, TCPChannelClient.TCPChannelEvents { | |
| private static final String TAG = "DirectRTCClient"; | |
| private static final int DEFAULT_PORT = 8888; | |
| // Regex pattern used for checking if room id looks like an IP. | |
| static final Pattern IP_PATTERN = Pattern.compile("(" | |
| // IPv4 | |
| + "((\\d+\\.){3}\\d+)|" | |
| // IPv6 | |
| + "\\[((([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4})?::" | |
| + "(([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4})?)\\]|" | |
| + "\\[(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4})\\]|" | |
| // IPv6 without [] | |
| + "((([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4})?::(([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4})?)|" | |
| + "(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4})|" | |
| // Literals | |
| + "localhost" | |
| + ")" | |
| // Optional port number | |
| + "(:(\\d+))?"); | |
| private final ExecutorService executor; | |
| private final SignalingEvents events; | |
| @Nullable | |
| private TCPChannelClient tcpClient; | |
| private RoomConnectionParameters connectionParameters; | |
| private enum ConnectionState { NEW, CONNECTED, CLOSED, ERROR } | |
| // All alterations of the room state should be done from inside the looper thread. | |
| private ConnectionState roomState; | |
| public DirectRTCClient(SignalingEvents events) { | |
| this.events = events; | |
| executor = Executors.newSingleThreadExecutor(); | |
| roomState = ConnectionState.NEW; | |
| } | |
| /** | |
| * Connects to the room, roomId in connectionsParameters is required. roomId must be a valid | |
| * IP address matching IP_PATTERN. | |
| */ | |
| @Override | |
| public void connectToRoom(RoomConnectionParameters connectionParameters) { | |
| this.connectionParameters = connectionParameters; | |
| if (connectionParameters.loopback) { | |
| reportError("Loopback connections aren't supported by DirectRTCClient."); | |
| } | |
| executor.execute(new Runnable() { | |
| @Override | |
| public void run() { | |
| connectToRoomInternal(); | |
| } | |
| }); | |
| } | |
| @Override | |
| public void disconnectFromRoom() { | |
| executor.execute(new Runnable() { | |
| @Override | |
| public void run() { | |
| disconnectFromRoomInternal(); | |
| } | |
| }); | |
| } | |
| /** | |
| * Connects to the room. | |
| * | |
| * Runs on the looper thread. | |
| */ | |
| private void connectToRoomInternal() { | |
| this.roomState = ConnectionState.NEW; | |
| String endpoint = connectionParameters.roomId; | |
| Matcher matcher = IP_PATTERN.matcher(endpoint); | |
| if (!matcher.matches()) { | |
| reportError("roomId must match IP_PATTERN for DirectRTCClient."); | |
| return; | |
| } | |
| String ip = matcher.group(1); | |
| String portStr = matcher.group(matcher.groupCount()); | |
| int port; | |
| if (portStr != null) { | |
| try { | |
| port = Integer.parseInt(portStr); | |
| } catch (NumberFormatException e) { | |
| reportError("Invalid port number: " + portStr); | |
| return; | |
| } | |
| } else { | |
| port = DEFAULT_PORT; | |
| } | |
| tcpClient = new TCPChannelClient(executor, this, ip, port); | |
| } | |
| /** | |
| * Disconnects from the room. | |
| * | |
| * Runs on the looper thread. | |
| */ | |
| private void disconnectFromRoomInternal() { | |
| roomState = ConnectionState.CLOSED; | |
| if (tcpClient != null) { | |
| tcpClient.disconnect(); | |
| tcpClient = null; | |
| } | |
| executor.shutdown(); | |
| } | |
| @Override | |
| public void sendOfferSdp(final SessionDescription sdp) { | |
| executor.execute(new Runnable() { | |
| @Override | |
| public void run() { | |
| if (roomState != ConnectionState.CONNECTED) { | |
| reportError("Sending offer SDP in non connected state."); | |
| return; | |
| } | |
| JSONObject json = new JSONObject(); | |
| jsonPut(json, "sdp", sdp.description); | |
| jsonPut(json, "type", "offer"); | |
| sendMessage(json.toString()); | |
| } | |
| }); | |
| } | |
| @Override | |
| public void sendAnswerSdp(final SessionDescription sdp) { | |
| executor.execute(new Runnable() { | |
| @Override | |
| public void run() { | |
| JSONObject json = new JSONObject(); | |
| jsonPut(json, "sdp", sdp.description); | |
| jsonPut(json, "type", "answer"); | |
| sendMessage(json.toString()); | |
| } | |
| }); | |
| } | |
| @Override | |
| public void sendLocalIceCandidate(final IceCandidate candidate) { | |
| executor.execute(new Runnable() { | |
| @Override | |
| public void run() { | |
| JSONObject json = new JSONObject(); | |
| jsonPut(json, "type", "candidate"); | |
| jsonPut(json, "label", candidate.sdpMLineIndex); | |
| jsonPut(json, "id", candidate.sdpMid); | |
| jsonPut(json, "candidate", candidate.sdp); | |
| if (roomState != ConnectionState.CONNECTED) { | |
| reportError("Sending ICE candidate in non connected state."); | |
| return; | |
| } | |
| sendMessage(json.toString()); | |
| } | |
| }); | |
| } | |
| /** Send removed Ice candidates to the other participant. */ | |
| @Override | |
| public void sendLocalIceCandidateRemovals(final IceCandidate[] candidates) { | |
| executor.execute(new Runnable() { | |
| @Override | |
| public void run() { | |
| JSONObject json = new JSONObject(); | |
| jsonPut(json, "type", "remove-candidates"); | |
| JSONArray jsonArray = new JSONArray(); | |
| for (final IceCandidate candidate : candidates) { | |
| jsonArray.put(toJsonCandidate(candidate)); | |
| } | |
| jsonPut(json, "candidates", jsonArray); | |
| if (roomState != ConnectionState.CONNECTED) { | |
| reportError("Sending ICE candidate removals in non connected state."); | |
| return; | |
| } | |
| sendMessage(json.toString()); | |
| } | |
| }); | |
| } | |
| // ------------------------------------------------------------------- | |
| // TCPChannelClient event handlers | |
| /** | |
| * If the client is the server side, this will trigger onConnectedToRoom. | |
| */ | |
| @Override | |
| public void onTCPConnected(boolean isServer) { | |
| if (isServer) { | |
| roomState = ConnectionState.CONNECTED; | |
| SignalingParameters parameters = new SignalingParameters( | |
| // Ice servers are not needed for direct connections. | |
| new ArrayList<>(), | |
| isServer, // Server side acts as the initiator on direct connections. | |
| null, // clientId | |
| null, // wssUrl | |
| null, // wwsPostUrl | |
| null, // offerSdp | |
| null // iceCandidates | |
| ); | |
| events.onConnectedToRoom(parameters); | |
| } | |
| } | |
| @Override | |
| public void onTCPMessage(String msg) { | |
| try { | |
| JSONObject json = new JSONObject(msg); | |
| String type = json.optString("type"); | |
| if (type.equals("candidate")) { | |
| events.onRemoteIceCandidate(toJavaCandidate(json)); | |
| } else if (type.equals("remove-candidates")) { | |
| JSONArray candidateArray = json.getJSONArray("candidates"); | |
| IceCandidate[] candidates = new IceCandidate[candidateArray.length()]; | |
| for (int i = 0; i < candidateArray.length(); ++i) { | |
| candidates[i] = toJavaCandidate(candidateArray.getJSONObject(i)); | |
| } | |
| events.onRemoteIceCandidatesRemoved(candidates); | |
| } else if (type.equals("answer")) { | |
| SessionDescription sdp = new SessionDescription( | |
| SessionDescription.Type.fromCanonicalForm(type), json.getString("sdp")); | |
| events.onRemoteDescription(sdp); | |
| } else if (type.equals("offer")) { | |
| SessionDescription sdp = new SessionDescription( | |
| SessionDescription.Type.fromCanonicalForm(type), json.getString("sdp")); | |
| SignalingParameters parameters = new SignalingParameters( | |
| // Ice servers are not needed for direct connections. | |
| new ArrayList<>(), | |
| false, // This code will only be run on the client side. So, we are not the initiator. | |
| null, // clientId | |
| null, // wssUrl | |
| null, // wssPostUrl | |
| sdp, // offerSdp | |
| null // iceCandidates | |
| ); | |
| roomState = ConnectionState.CONNECTED; | |
| events.onConnectedToRoom(parameters); | |
| } else { | |
| reportError("Unexpected TCP message: " + msg); | |
| } | |
| } catch (JSONException e) { | |
| reportError("TCP message JSON parsing error: " + e.toString()); | |
| } | |
| } | |
| @Override | |
| public void onTCPError(String description) { | |
| reportError("TCP connection error: " + description); | |
| } | |
| @Override | |
| public void onTCPClose() { | |
| events.onChannelClose(); | |
| } | |
| // -------------------------------------------------------------------- | |
| // Helper functions. | |
| private void reportError(final String errorMessage) { | |
| Log.e(TAG, errorMessage); | |
| executor.execute(new Runnable() { | |
| @Override | |
| public void run() { | |
| if (roomState != ConnectionState.ERROR) { | |
| roomState = ConnectionState.ERROR; | |
| events.onChannelError(errorMessage); | |
| } | |
| } | |
| }); | |
| } | |
| private void sendMessage(final String message) { | |
| executor.execute(new Runnable() { | |
| @Override | |
| public void run() { | |
| tcpClient.send(message); | |
| } | |
| }); | |
| } | |
| // Put a `key`->`value` mapping in `json`. | |
| private static void jsonPut(JSONObject json, String key, Object value) { | |
| try { | |
| json.put(key, value); | |
| } catch (JSONException e) { | |
| throw new RuntimeException(e); | |
| } | |
| } | |
| // Converts a Java candidate to a JSONObject. | |
| private static JSONObject toJsonCandidate(final IceCandidate candidate) { | |
| JSONObject json = new JSONObject(); | |
| jsonPut(json, "label", candidate.sdpMLineIndex); | |
| jsonPut(json, "id", candidate.sdpMid); | |
| jsonPut(json, "candidate", candidate.sdp); | |
| return json; | |
| } | |
| // Converts a JSON candidate to a Java object. | |
| private static IceCandidate toJavaCandidate(JSONObject json) throws JSONException { | |
| return new IceCandidate( | |
| json.getString("id"), json.getInt("label"), json.getString("candidate")); | |
| } | |
| } |