/* * Copyright 2014 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.os.Handler; import android.os.HandlerThread; import android.util.Log; import androidx.annotation.Nullable; import org.appspot.apprtc.RoomParametersFetcher.RoomParametersFetcherEvents; import org.appspot.apprtc.WebSocketChannelClient.WebSocketChannelEvents; import org.appspot.apprtc.WebSocketChannelClient.WebSocketConnectionState; import org.appspot.apprtc.util.AsyncHttpURLConnection; import org.appspot.apprtc.util.AsyncHttpURLConnection.AsyncHttpEvents; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import org.webrtc.IceCandidate; import org.webrtc.SessionDescription; /** * Negotiates signaling for chatting with https://appr.tc "rooms". * Uses the client<->server specifics of the apprtc AppEngine webapp. * *
To use: create an instance of this object (registering a message handler) and * call connectToRoom(). Once room connection is established * onConnectedToRoom() callback with room parameters is invoked. * Messages to other party (with local Ice candidates and answer SDP) can * be sent after WebSocket connection is established. */ public class WebSocketRTCClient implements AppRTCClient, WebSocketChannelEvents { private static final String TAG = "WSRTCClient"; private static final String ROOM_JOIN = "join"; private static final String ROOM_MESSAGE = "message"; private static final String ROOM_LEAVE = "leave"; private enum ConnectionState { NEW, CONNECTED, CLOSED, ERROR } private enum MessageType { MESSAGE, LEAVE } private final Handler handler; private boolean initiator; private SignalingEvents events; private WebSocketChannelClient wsClient; private ConnectionState roomState; private RoomConnectionParameters connectionParameters; private String messageUrl; private String leaveUrl; public WebSocketRTCClient(SignalingEvents events) { this.events = events; roomState = ConnectionState.NEW; final HandlerThread handlerThread = new HandlerThread(TAG); handlerThread.start(); handler = new Handler(handlerThread.getLooper()); } // -------------------------------------------------------------------- // AppRTCClient interface implementation. // Asynchronously connect to an AppRTC room URL using supplied connection // parameters, retrieves room parameters and connect to WebSocket server. @Override public void connectToRoom(RoomConnectionParameters connectionParameters) { this.connectionParameters = connectionParameters; handler.post(new Runnable() { @Override public void run() { connectToRoomInternal(); } }); } @Override public void disconnectFromRoom() { handler.post(new Runnable() { @Override public void run() { disconnectFromRoomInternal(); handler.getLooper().quit(); } }); } // Connects to room - function runs on a local looper thread. private void connectToRoomInternal() { String connectionUrl = getConnectionUrl(connectionParameters); Log.d(TAG, "Connect to room: " + connectionUrl); roomState = ConnectionState.NEW; wsClient = new WebSocketChannelClient(handler, this); RoomParametersFetcherEvents callbacks = new RoomParametersFetcherEvents() { @Override public void onSignalingParametersReady(final SignalingParameters params) { WebSocketRTCClient.this.handler.post(new Runnable() { @Override public void run() { WebSocketRTCClient.this.signalingParametersReady(params); } }); } @Override public void onSignalingParametersError(String description) { WebSocketRTCClient.this.reportError(description); } }; new RoomParametersFetcher(connectionUrl, null, callbacks).makeRequest(); } // Disconnect from room and send bye messages - runs on a local looper thread. private void disconnectFromRoomInternal() { Log.d(TAG, "Disconnect. Room state: " + roomState); if (roomState == ConnectionState.CONNECTED) { Log.d(TAG, "Closing room."); sendPostMessage(MessageType.LEAVE, leaveUrl, null); } roomState = ConnectionState.CLOSED; if (wsClient != null) { wsClient.disconnect(true); } } // Helper functions to get connection, post message and leave message URLs private String getConnectionUrl(RoomConnectionParameters connectionParameters) { return connectionParameters.roomUrl + "/" + ROOM_JOIN + "/" + connectionParameters.roomId + getQueryString(connectionParameters); } private String getMessageUrl( RoomConnectionParameters connectionParameters, SignalingParameters signalingParameters) { return connectionParameters.roomUrl + "/" + ROOM_MESSAGE + "/" + connectionParameters.roomId + "/" + signalingParameters.clientId + getQueryString(connectionParameters); } private String getLeaveUrl( RoomConnectionParameters connectionParameters, SignalingParameters signalingParameters) { return connectionParameters.roomUrl + "/" + ROOM_LEAVE + "/" + connectionParameters.roomId + "/" + signalingParameters.clientId + getQueryString(connectionParameters); } private String getQueryString(RoomConnectionParameters connectionParameters) { if (connectionParameters.urlParameters != null) { return "?" + connectionParameters.urlParameters; } else { return ""; } } // Callback issued when room parameters are extracted. Runs on local // looper thread. private void signalingParametersReady(final SignalingParameters signalingParameters) { Log.d(TAG, "Room connection completed."); if (connectionParameters.loopback && (!signalingParameters.initiator || signalingParameters.offerSdp != null)) { reportError("Loopback room is busy."); return; } if (!connectionParameters.loopback && !signalingParameters.initiator && signalingParameters.offerSdp == null) { Log.w(TAG, "No offer SDP in room response."); } initiator = signalingParameters.initiator; messageUrl = getMessageUrl(connectionParameters, signalingParameters); leaveUrl = getLeaveUrl(connectionParameters, signalingParameters); Log.d(TAG, "Message URL: " + messageUrl); Log.d(TAG, "Leave URL: " + leaveUrl); roomState = ConnectionState.CONNECTED; // Fire connection and signaling parameters events. events.onConnectedToRoom(signalingParameters); // Connect and register WebSocket client. wsClient.connect(signalingParameters.wssUrl, signalingParameters.wssPostUrl); wsClient.register(connectionParameters.roomId, signalingParameters.clientId); } // Send local offer SDP to the other participant. @Override public void sendOfferSdp(final SessionDescription sdp) { handler.post(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"); sendPostMessage(MessageType.MESSAGE, messageUrl, json.toString()); if (connectionParameters.loopback) { // In loopback mode rename this offer to answer and route it back. SessionDescription sdpAnswer = new SessionDescription( SessionDescription.Type.fromCanonicalForm("answer"), sdp.description); events.onRemoteDescription(sdpAnswer); } } }); } // Send local answer SDP to the other participant. @Override public void sendAnswerSdp(final SessionDescription sdp) { handler.post(new Runnable() { @Override public void run() { if (connectionParameters.loopback) { Log.e(TAG, "Sending answer in loopback mode."); return; } JSONObject json = new JSONObject(); jsonPut(json, "sdp", sdp.description); jsonPut(json, "type", "answer"); wsClient.send(json.toString()); } }); } // Send Ice candidate to the other participant. @Override public void sendLocalIceCandidate(final IceCandidate candidate) { handler.post(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 (initiator) { // Call initiator sends ice candidates to GAE server. if (roomState != ConnectionState.CONNECTED) { reportError("Sending ICE candidate in non connected state."); return; } sendPostMessage(MessageType.MESSAGE, messageUrl, json.toString()); if (connectionParameters.loopback) { events.onRemoteIceCandidate(candidate); } } else { // Call receiver sends ice candidates to websocket server. wsClient.send(json.toString()); } } }); } // Send removed Ice candidates to the other participant. @Override public void sendLocalIceCandidateRemovals(final IceCandidate[] candidates) { handler.post(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 (initiator) { // Call initiator sends ice candidates to GAE server. if (roomState != ConnectionState.CONNECTED) { reportError("Sending ICE candidate removals in non connected state."); return; } sendPostMessage(MessageType.MESSAGE, messageUrl, json.toString()); if (connectionParameters.loopback) { events.onRemoteIceCandidatesRemoved(candidates); } } else { // Call receiver sends ice candidates to websocket server. wsClient.send(json.toString()); } } }); } // -------------------------------------------------------------------- // WebSocketChannelEvents interface implementation. // All events are called by WebSocketChannelClient on a local looper thread // (passed to WebSocket client constructor). @Override public void onWebSocketMessage(final String msg) { if (wsClient.getState() != WebSocketConnectionState.REGISTERED) { Log.e(TAG, "Got WebSocket message in non registered state."); return; } try { JSONObject json = new JSONObject(msg); String msgText = json.getString("msg"); String errorText = json.optString("error"); if (msgText.length() > 0) { json = new JSONObject(msgText); 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")) { if (initiator) { SessionDescription sdp = new SessionDescription( SessionDescription.Type.fromCanonicalForm(type), json.getString("sdp")); events.onRemoteDescription(sdp); } else { reportError("Received answer for call initiator: " + msg); } } else if (type.equals("offer")) { if (!initiator) { SessionDescription sdp = new SessionDescription( SessionDescription.Type.fromCanonicalForm(type), json.getString("sdp")); events.onRemoteDescription(sdp); } else { reportError("Received offer for call receiver: " + msg); } } else if (type.equals("bye")) { events.onChannelClose(); } else { reportError("Unexpected WebSocket message: " + msg); } } else { if (errorText != null && errorText.length() > 0) { reportError("WebSocket error message: " + errorText); } else { reportError("Unexpected WebSocket message: " + msg); } } } catch (JSONException e) { reportError("WebSocket message JSON parsing error: " + e.toString()); } } @Override public void onWebSocketClose() { events.onChannelClose(); } @Override public void onWebSocketError(String description) { reportError("WebSocket error: " + description); } // -------------------------------------------------------------------- // Helper functions. private void reportError(final String errorMessage) { Log.e(TAG, errorMessage); handler.post(new Runnable() { @Override public void run() { if (roomState != ConnectionState.ERROR) { roomState = ConnectionState.ERROR; events.onChannelError(errorMessage); } } }); } // 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); } } // Send SDP or ICE candidate to a room server. private void sendPostMessage( final MessageType messageType, final String url, @Nullable final String message) { String logInfo = url; if (message != null) { logInfo += ". Message: " + message; } Log.d(TAG, "C->GAE: " + logInfo); AsyncHttpURLConnection httpConnection = new AsyncHttpURLConnection("POST", url, message, new AsyncHttpEvents() { @Override public void onHttpError(String errorMessage) { reportError("GAE POST error: " + errorMessage); } @Override public void onHttpComplete(String response) { if (messageType == MessageType.MESSAGE) { try { JSONObject roomJson = new JSONObject(response); String result = roomJson.getString("result"); if (!result.equals("SUCCESS")) { reportError("GAE POST error: " + result); } } catch (JSONException e) { reportError("GAE POST JSON error: " + e.toString()); } } } }); httpConnection.send(); } // Converts a Java candidate to a JSONObject. private 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. IceCandidate toJavaCandidate(JSONObject json) throws JSONException { return new IceCandidate( json.getString("id"), json.getInt("label"), json.getString("candidate")); } }