1 /* 2 * Copyright 2016 The WebRTC Project Authors. All rights reserved. 3 * 4 * Use of this source code is governed by a BSD-style license 5 * that can be found in the LICENSE file in the root of the source 6 * tree. An additional intellectual property rights grant can be found 7 * in the file PATENTS. All contributing project authors may 8 * be found in the AUTHORS file in the root of the source tree. 9 */ 10 11 package org.appspot.apprtc; 12 13 import android.support.annotation.Nullable; 14 import android.util.Log; 15 import java.util.ArrayList; 16 import java.util.concurrent.ExecutorService; 17 import java.util.concurrent.Executors; 18 import java.util.regex.Matcher; 19 import java.util.regex.Pattern; 20 import org.json.JSONArray; 21 import org.json.JSONException; 22 import org.json.JSONObject; 23 import org.webrtc.IceCandidate; 24 import org.webrtc.SessionDescription; 25 26 /** 27 * Implementation of AppRTCClient that uses direct TCP connection as the signaling channel. 28 * This eliminates the need for an external server. This class does not support loopback 29 * connections. 30 */ 31 public class DirectRTCClient implements AppRTCClient, TCPChannelClient.TCPChannelEvents { 32 private static final String TAG = "DirectRTCClient"; 33 private static final int DEFAULT_PORT = 8888; 34 35 // Regex pattern used for checking if room id looks like an IP. 36 static final Pattern IP_PATTERN = Pattern.compile("(" 37 // IPv4 38 + "((\\d+\\.){3}\\d+)|" 39 // IPv6 40 + "\\[((([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4})?::" 41 + "(([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4})?)\\]|" 42 + "\\[(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4})\\]|" 43 // IPv6 without [] 44 + "((([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4})?::(([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4})?)|" 45 + "(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4})|" 46 // Literals 47 + "localhost" 48 + ")" 49 // Optional port number 50 + "(:(\\d+))?"); 51 52 private final ExecutorService executor; 53 private final SignalingEvents events; 54 @Nullable 55 private TCPChannelClient tcpClient; 56 private RoomConnectionParameters connectionParameters; 57 58 private enum ConnectionState { NEW, CONNECTED, CLOSED, ERROR } 59 60 // All alterations of the room state should be done from inside the looper thread. 61 private ConnectionState roomState; 62 DirectRTCClient(SignalingEvents events)63 public DirectRTCClient(SignalingEvents events) { 64 this.events = events; 65 66 executor = Executors.newSingleThreadExecutor(); 67 roomState = ConnectionState.NEW; 68 } 69 70 /** 71 * Connects to the room, roomId in connectionsParameters is required. roomId must be a valid 72 * IP address matching IP_PATTERN. 73 */ 74 @Override connectToRoom(RoomConnectionParameters connectionParameters)75 public void connectToRoom(RoomConnectionParameters connectionParameters) { 76 this.connectionParameters = connectionParameters; 77 78 if (connectionParameters.loopback) { 79 reportError("Loopback connections aren't supported by DirectRTCClient."); 80 } 81 82 executor.execute(new Runnable() { 83 @Override 84 public void run() { 85 connectToRoomInternal(); 86 } 87 }); 88 } 89 90 @Override disconnectFromRoom()91 public void disconnectFromRoom() { 92 executor.execute(new Runnable() { 93 @Override 94 public void run() { 95 disconnectFromRoomInternal(); 96 } 97 }); 98 } 99 100 /** 101 * Connects to the room. 102 * 103 * Runs on the looper thread. 104 */ connectToRoomInternal()105 private void connectToRoomInternal() { 106 this.roomState = ConnectionState.NEW; 107 108 String endpoint = connectionParameters.roomId; 109 110 Matcher matcher = IP_PATTERN.matcher(endpoint); 111 if (!matcher.matches()) { 112 reportError("roomId must match IP_PATTERN for DirectRTCClient."); 113 return; 114 } 115 116 String ip = matcher.group(1); 117 String portStr = matcher.group(matcher.groupCount()); 118 int port; 119 120 if (portStr != null) { 121 try { 122 port = Integer.parseInt(portStr); 123 } catch (NumberFormatException e) { 124 reportError("Invalid port number: " + portStr); 125 return; 126 } 127 } else { 128 port = DEFAULT_PORT; 129 } 130 131 tcpClient = new TCPChannelClient(executor, this, ip, port); 132 } 133 134 /** 135 * Disconnects from the room. 136 * 137 * Runs on the looper thread. 138 */ disconnectFromRoomInternal()139 private void disconnectFromRoomInternal() { 140 roomState = ConnectionState.CLOSED; 141 142 if (tcpClient != null) { 143 tcpClient.disconnect(); 144 tcpClient = null; 145 } 146 executor.shutdown(); 147 } 148 149 @Override sendOfferSdp(final SessionDescription sdp)150 public void sendOfferSdp(final SessionDescription sdp) { 151 executor.execute(new Runnable() { 152 @Override 153 public void run() { 154 if (roomState != ConnectionState.CONNECTED) { 155 reportError("Sending offer SDP in non connected state."); 156 return; 157 } 158 JSONObject json = new JSONObject(); 159 jsonPut(json, "sdp", sdp.description); 160 jsonPut(json, "type", "offer"); 161 sendMessage(json.toString()); 162 } 163 }); 164 } 165 166 @Override sendAnswerSdp(final SessionDescription sdp)167 public void sendAnswerSdp(final SessionDescription sdp) { 168 executor.execute(new Runnable() { 169 @Override 170 public void run() { 171 JSONObject json = new JSONObject(); 172 jsonPut(json, "sdp", sdp.description); 173 jsonPut(json, "type", "answer"); 174 sendMessage(json.toString()); 175 } 176 }); 177 } 178 179 @Override sendLocalIceCandidate(final IceCandidate candidate)180 public void sendLocalIceCandidate(final IceCandidate candidate) { 181 executor.execute(new Runnable() { 182 @Override 183 public void run() { 184 JSONObject json = new JSONObject(); 185 jsonPut(json, "type", "candidate"); 186 jsonPut(json, "label", candidate.sdpMLineIndex); 187 jsonPut(json, "id", candidate.sdpMid); 188 jsonPut(json, "candidate", candidate.sdp); 189 190 if (roomState != ConnectionState.CONNECTED) { 191 reportError("Sending ICE candidate in non connected state."); 192 return; 193 } 194 sendMessage(json.toString()); 195 } 196 }); 197 } 198 199 /** Send removed Ice candidates to the other participant. */ 200 @Override sendLocalIceCandidateRemovals(final IceCandidate[] candidates)201 public void sendLocalIceCandidateRemovals(final IceCandidate[] candidates) { 202 executor.execute(new Runnable() { 203 @Override 204 public void run() { 205 JSONObject json = new JSONObject(); 206 jsonPut(json, "type", "remove-candidates"); 207 JSONArray jsonArray = new JSONArray(); 208 for (final IceCandidate candidate : candidates) { 209 jsonArray.put(toJsonCandidate(candidate)); 210 } 211 jsonPut(json, "candidates", jsonArray); 212 213 if (roomState != ConnectionState.CONNECTED) { 214 reportError("Sending ICE candidate removals in non connected state."); 215 return; 216 } 217 sendMessage(json.toString()); 218 } 219 }); 220 } 221 222 // ------------------------------------------------------------------- 223 // TCPChannelClient event handlers 224 225 /** 226 * If the client is the server side, this will trigger onConnectedToRoom. 227 */ 228 @Override onTCPConnected(boolean isServer)229 public void onTCPConnected(boolean isServer) { 230 if (isServer) { 231 roomState = ConnectionState.CONNECTED; 232 233 SignalingParameters parameters = new SignalingParameters( 234 // Ice servers are not needed for direct connections. 235 new ArrayList<>(), 236 isServer, // Server side acts as the initiator on direct connections. 237 null, // clientId 238 null, // wssUrl 239 null, // wwsPostUrl 240 null, // offerSdp 241 null // iceCandidates 242 ); 243 events.onConnectedToRoom(parameters); 244 } 245 } 246 247 @Override onTCPMessage(String msg)248 public void onTCPMessage(String msg) { 249 try { 250 JSONObject json = new JSONObject(msg); 251 String type = json.optString("type"); 252 if (type.equals("candidate")) { 253 events.onRemoteIceCandidate(toJavaCandidate(json)); 254 } else if (type.equals("remove-candidates")) { 255 JSONArray candidateArray = json.getJSONArray("candidates"); 256 IceCandidate[] candidates = new IceCandidate[candidateArray.length()]; 257 for (int i = 0; i < candidateArray.length(); ++i) { 258 candidates[i] = toJavaCandidate(candidateArray.getJSONObject(i)); 259 } 260 events.onRemoteIceCandidatesRemoved(candidates); 261 } else if (type.equals("answer")) { 262 SessionDescription sdp = new SessionDescription( 263 SessionDescription.Type.fromCanonicalForm(type), json.getString("sdp")); 264 events.onRemoteDescription(sdp); 265 } else if (type.equals("offer")) { 266 SessionDescription sdp = new SessionDescription( 267 SessionDescription.Type.fromCanonicalForm(type), json.getString("sdp")); 268 269 SignalingParameters parameters = new SignalingParameters( 270 // Ice servers are not needed for direct connections. 271 new ArrayList<>(), 272 false, // This code will only be run on the client side. So, we are not the initiator. 273 null, // clientId 274 null, // wssUrl 275 null, // wssPostUrl 276 sdp, // offerSdp 277 null // iceCandidates 278 ); 279 roomState = ConnectionState.CONNECTED; 280 events.onConnectedToRoom(parameters); 281 } else { 282 reportError("Unexpected TCP message: " + msg); 283 } 284 } catch (JSONException e) { 285 reportError("TCP message JSON parsing error: " + e.toString()); 286 } 287 } 288 289 @Override onTCPError(String description)290 public void onTCPError(String description) { 291 reportError("TCP connection error: " + description); 292 } 293 294 @Override onTCPClose()295 public void onTCPClose() { 296 events.onChannelClose(); 297 } 298 299 // -------------------------------------------------------------------- 300 // Helper functions. reportError(final String errorMessage)301 private void reportError(final String errorMessage) { 302 Log.e(TAG, errorMessage); 303 executor.execute(new Runnable() { 304 @Override 305 public void run() { 306 if (roomState != ConnectionState.ERROR) { 307 roomState = ConnectionState.ERROR; 308 events.onChannelError(errorMessage); 309 } 310 } 311 }); 312 } 313 sendMessage(final String message)314 private void sendMessage(final String message) { 315 executor.execute(new Runnable() { 316 @Override 317 public void run() { 318 tcpClient.send(message); 319 } 320 }); 321 } 322 323 // Put a |key|->|value| mapping in |json|. jsonPut(JSONObject json, String key, Object value)324 private static void jsonPut(JSONObject json, String key, Object value) { 325 try { 326 json.put(key, value); 327 } catch (JSONException e) { 328 throw new RuntimeException(e); 329 } 330 } 331 332 // Converts a Java candidate to a JSONObject. toJsonCandidate(final IceCandidate candidate)333 private static JSONObject toJsonCandidate(final IceCandidate candidate) { 334 JSONObject json = new JSONObject(); 335 jsonPut(json, "label", candidate.sdpMLineIndex); 336 jsonPut(json, "id", candidate.sdpMid); 337 jsonPut(json, "candidate", candidate.sdp); 338 return json; 339 } 340 341 // Converts a JSON candidate to a Java object. toJavaCandidate(JSONObject json)342 private static IceCandidate toJavaCandidate(JSONObject json) throws JSONException { 343 return new IceCandidate( 344 json.getString("id"), json.getInt("label"), json.getString("candidate")); 345 } 346 } 347