| /* |
| * 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")); |
| } |
| } |