diff --git a/talk/examples/android/src/org/appspot/apprtc/AppRTCClient.java b/talk/examples/android/src/org/appspot/apprtc/AppRTCClient.java index ddabcd61d1..96816a77c2 100644 --- a/talk/examples/android/src/org/appspot/apprtc/AppRTCClient.java +++ b/talk/examples/android/src/org/appspot/apprtc/AppRTCClient.java @@ -39,7 +39,7 @@ public interface AppRTCClient { * https://apprtc.appspot.com/?r=NNN. Once connection is established * onConnectedToRoom() callback with room parameters is invoked. */ - public void connectToRoom(String url); + public void connectToRoom(String url, boolean loopback); /** * Send offer SDP to the other participant. @@ -71,7 +71,7 @@ public interface AppRTCClient { public final MediaConstraints pcConstraints; public final MediaConstraints videoConstraints; public final MediaConstraints audioConstraints; - public final String postMessageUrl; + public final String roomUrl; public final String roomId; public final String clientId; public final String channelToken; @@ -81,14 +81,14 @@ public interface AppRTCClient { List iceServers, boolean initiator, MediaConstraints pcConstraints, MediaConstraints videoConstraints, MediaConstraints audioConstraints, - String postMessageUrl, String roomId, String clientId, + String roomUrl, String roomId, String clientId, String channelToken, String offerSdp ) { this.iceServers = iceServers; this.initiator = initiator; this.pcConstraints = pcConstraints; this.videoConstraints = videoConstraints; this.audioConstraints = audioConstraints; - this.postMessageUrl = postMessageUrl; + this.roomUrl = roomUrl; this.roomId = roomId; this.clientId = clientId; this.channelToken = channelToken; diff --git a/talk/examples/android/src/org/appspot/apprtc/AppRTCDemoActivity.java b/talk/examples/android/src/org/appspot/apprtc/AppRTCDemoActivity.java index 7facd3c852..9093b238f6 100644 --- a/talk/examples/android/src/org/appspot/apprtc/AppRTCDemoActivity.java +++ b/talk/examples/android/src/org/appspot/apprtc/AppRTCDemoActivity.java @@ -85,7 +85,10 @@ public class AppRTCDemoActivity extends Activity private TextView hudView; private TextView roomName; private ImageButton videoScalingButton; + private boolean commandLineRun; + private int runTimeMs; private boolean iceConnected; + private boolean isError; @Override public void onCreate(Bundle savedInstanceState) { @@ -194,30 +197,44 @@ public class AppRTCDemoActivity extends Activity final Intent intent = getIntent(); Uri url = intent.getData(); + boolean loopback = intent.getBooleanExtra( + ConnectActivity.EXTRA_LOOPBACK, false); + commandLineRun = intent.getBooleanExtra( + ConnectActivity.EXTRA_CMDLINE, false); + runTimeMs = intent.getIntExtra( + ConnectActivity.EXTRA_RUNTIME, 0); if (url != null) { String room = url.getQueryParameter("r"); - String loopback = url.getQueryParameter("debug"); - if ((room != null && !room.equals("")) || - (loopback != null && loopback.equals("loopback"))) { + if (loopback || (room != null && !room.equals(""))) { logAndToast(getString(R.string.connecting_to, url)); if (USE_WEBSOCKETS) { appRtcClient = new WebSocketRTCClient(this); } else { appRtcClient = new GAERTCClient(this, this); } - appRtcClient.connectToRoom(url.toString()); - if (room != null && !room.equals("")) { - roomName.setText(room); - } else { + appRtcClient.connectToRoom(url.toString(), loopback); + if (loopback) { roomName.setText("loopback"); + } else { + roomName.setText(room); + } + if (commandLineRun && runTimeMs > 0) { + // For command line execution run connection for and exit. + videoView.postDelayed(new Runnable() { + public void run() { + disconnect(); + } + }, runTimeMs); } } else { logAndToast("Empty or missing room name!"); + setResult(RESULT_CANCELED); finish(); } } else { logAndToast(getString(R.string.missing_url)); - Log.wtf(TAG, "Didn't get any URL in intent!"); + Log.e(TAG, "Didn't get any URL in intent!"); + setResult(RESULT_CANCELED); finish(); } } @@ -253,10 +270,6 @@ public class AppRTCDemoActivity extends Activity @Override protected void onDestroy() { disconnect(); - if (audioManager != null) { - audioManager.close(); - audioManager = null; - } super.onDestroy(); } @@ -327,20 +340,34 @@ public class AppRTCDemoActivity extends Activity pc.close(); pc = null; } + if (audioManager != null) { + audioManager.close(); + audioManager = null; + } + if (iceConnected && !isError) { + setResult(RESULT_OK); + } else { + setResult(RESULT_CANCELED); + } finish(); } - private void disconnectWithMessage(final String errorMessage) { - new AlertDialog.Builder(this) - .setTitle(getText(R.string.channel_error_title)) - .setMessage(errorMessage) - .setCancelable(false) - .setNeutralButton(R.string.ok, new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, int id) { - dialog.cancel(); - disconnect(); - } - }).create().show(); + private void disconnectWithErrorMessage(final String errorMessage) { + if (commandLineRun) { + Log.e(TAG, "Critical error: " + errorMessage); + disconnect(); + } else { + new AlertDialog.Builder(this) + .setTitle(getText(R.string.channel_error_title)) + .setMessage(errorMessage) + .setCancelable(false) + .setNeutralButton(R.string.ok, new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, int id) { + dialog.cancel(); + disconnect(); + } + }).create().show(); + } } // Poor-man's assert(): die with |msg| unless |condition| is true. @@ -461,7 +488,10 @@ public class AppRTCDemoActivity extends Activity @Override public void onChannelError(final String description) { - disconnectWithMessage(description); + if (!isError) { + isError = true; + disconnectWithErrorMessage(description); + } } // -----Implementation of PeerConnectionClient.PeerConnectionEvents.--------- @@ -501,7 +531,10 @@ public class AppRTCDemoActivity extends Activity @Override public void onPeerConnectionError(String description) { - disconnectWithMessage(description); + if (!isError) { + isError = true; + disconnectWithErrorMessage(description); + } } } diff --git a/talk/examples/android/src/org/appspot/apprtc/ConnectActivity.java b/talk/examples/android/src/org/appspot/apprtc/ConnectActivity.java index ff78ebce30..903ecf7243 100644 --- a/talk/examples/android/src/org/appspot/apprtc/ConnectActivity.java +++ b/talk/examples/android/src/org/appspot/apprtc/ConnectActivity.java @@ -62,10 +62,14 @@ import org.webrtc.MediaCodecVideoEncoder; */ public class ConnectActivity extends Activity { + public static final String EXTRA_LOOPBACK = "org.appspot.apprtc.LOOPBACK"; + public static final String EXTRA_CMDLINE = "org.appspot.apprtc.CMDLINE"; + public static final String EXTRA_RUNTIME = "org.appspot.apprtc.RUNTIME"; private static final String TAG = "ConnectActivity"; private final boolean USE_WEBSOCKETS = false; private final String APPRTC_SERVER = "https://apprtc.appspot.com"; private final String APPRTC_WS_SERVER = "https://8-dot-apprtc.appspot.com"; + private final int CONNECTION_REQUEST = 1; private ImageButton addRoomButton; private ImageButton removeRoomButton; @@ -81,6 +85,8 @@ public class ConnectActivity extends Activity { private String keyprefRoomList; private ArrayList roomList; private ArrayAdapter adapter; + private boolean commandLineRun; + private int runTimeMs; @Override public void onCreate(Bundle savedInstanceState) { @@ -95,27 +101,20 @@ public class ConnectActivity extends Activity { keyprefRoom = getString(R.string.pref_room_key); keyprefRoomList = getString(R.string.pref_room_list_key); - // If an implicit VIEW intent is launching the app, go directly to that URL. - final Intent intent = getIntent(); - if ("android.intent.action.VIEW".equals(intent.getAction())) { - connectToRoom(intent.getData().toString()); - return; - } - setContentView(R.layout.activity_connect); roomEditText = (EditText) findViewById(R.id.room_edittext); roomEditText.setOnEditorActionListener( - new TextView.OnEditorActionListener() { - @Override - public boolean onEditorAction( - TextView textView, int i, KeyEvent keyEvent) { - if (i == EditorInfo.IME_ACTION_DONE) { - addRoomButton.performClick(); - return true; - } - return false; + new TextView.OnEditorActionListener() { + @Override + public boolean onEditorAction( + TextView textView, int i, KeyEvent keyEvent) { + if (i == EditorInfo.IME_ACTION_DONE) { + addRoomButton.performClick(); + return true; } + return false; + } }); roomEditText.requestFocus(); @@ -131,6 +130,21 @@ public class ConnectActivity extends Activity { connectLoopbackButton = (ImageButton) findViewById(R.id.connect_loopback_button); connectLoopbackButton.setOnClickListener(connectListener); + + // If an implicit VIEW intent is launching the app, go directly to that URL. + commandLineRun = false; + final Intent intent = getIntent(); + if ("android.intent.action.VIEW".equals(intent.getAction())) { + commandLineRun = true; + boolean loopback = intent.getBooleanExtra(EXTRA_LOOPBACK, false); + runTimeMs = intent.getIntExtra(EXTRA_RUNTIME, 0); + String url = intent.getData().toString(); + if (loopback && !url.contains("debug=loopback")) { + url += "/?debug=loopback"; + } + connectToRoom(url, loopback); + return; + } } @Override @@ -188,6 +202,16 @@ public class ConnectActivity extends Activity { } } + @Override + protected void onActivityResult( + int requestCode, int resultCode, Intent data) { + if (requestCode == CONNECTION_REQUEST && commandLineRun) { + Log.d(TAG, "Return: " + resultCode); + setResult(resultCode); + finish(); + } + } + private final OnClickListener connectListener = new OnClickListener() { @Override public void onClick(View view) { @@ -195,11 +219,13 @@ public class ConnectActivity extends Activity { if (view.getId() == R.id.connect_loopback_button) { loopback = true; } - String url = APPRTC_SERVER; + String url; if (USE_WEBSOCKETS) { url = APPRTC_WS_SERVER; + } else { + url = APPRTC_SERVER; } - if (loopback && !USE_WEBSOCKETS) { + if (loopback) { url += "/?debug=loopback"; } else { String roomName = getSelectedItem(); @@ -267,16 +293,19 @@ public class ConnectActivity extends Activity { url += "&googCpuOveruseDetection=false"; } // TODO(kjellander): Add support for custom parameters to the URL. - connectToRoom(url); + connectToRoom(url, loopback); } }; - private void connectToRoom(String roomUrl) { + private void connectToRoom(String roomUrl, boolean loopback) { if (validateUrl(roomUrl)) { Uri url = Uri.parse(roomUrl); Intent intent = new Intent(this, AppRTCDemoActivity.class); intent.setData(url); - startActivity(intent); + intent.putExtra(EXTRA_LOOPBACK, loopback); + intent.putExtra(EXTRA_CMDLINE, commandLineRun); + intent.putExtra(EXTRA_RUNTIME, runTimeMs); + startActivityForResult(intent, CONNECTION_REQUEST); } } diff --git a/talk/examples/android/src/org/appspot/apprtc/GAERTCClient.java b/talk/examples/android/src/org/appspot/apprtc/GAERTCClient.java index 2a1e8a1fd5..fb0f9f07da 100644 --- a/talk/examples/android/src/org/appspot/apprtc/GAERTCClient.java +++ b/talk/examples/android/src/org/appspot/apprtc/GAERTCClient.java @@ -60,6 +60,7 @@ public class GAERTCClient implements AppRTCClient, RoomParametersFetcherEvents { private SignalingParameters signalingParameters; private RoomParametersFetcher fetcher; private LinkedList sendQueue = new LinkedList(); + private String postMessageUrl; public GAERTCClient(Activity activity, SignalingEvents events) { this.activity = activity; @@ -74,8 +75,8 @@ public class GAERTCClient implements AppRTCClient, RoomParametersFetcherEvents { * on its GAE Channel. */ @Override - public void connectToRoom(String url) { - fetcher = new RoomParametersFetcher(this); + public void connectToRoom(String url, boolean loopback) { + fetcher = new RoomParametersFetcher(this, loopback); fetcher.execute(url); } @@ -167,8 +168,7 @@ public class GAERTCClient implements AppRTCClient, RoomParametersFetcherEvents { try { for (String msg : sendQueue) { Log.d(TAG, "SEND: " + msg); - URLConnection connection = new URL( - signalingParameters.postMessageUrl).openConnection(); + URLConnection connection = new URL(postMessageUrl).openConnection(); connection.setDoOutput(true); connection.getOutputStream().write(msg.getBytes("UTF-8")); if (!connection.getHeaderField(null).startsWith("HTTP/1.1 200 ")) { @@ -203,6 +203,8 @@ public class GAERTCClient implements AppRTCClient, RoomParametersFetcherEvents { reportChannelError("Room does not support GAE channel signaling."); return; } + postMessageUrl = params.roomUrl + "/message?r=" + + params.roomId + "&u=" + params.clientId; gaeHandler = new GAEHandler(); channelClient = new GAEChannelClient(activity, params.channelToken, gaeHandler); diff --git a/talk/examples/android/src/org/appspot/apprtc/PeerConnectionClient.java b/talk/examples/android/src/org/appspot/apprtc/PeerConnectionClient.java index b005de7b37..247b0aa663 100644 --- a/talk/examples/android/src/org/appspot/apprtc/PeerConnectionClient.java +++ b/talk/examples/android/src/org/appspot/apprtc/PeerConnectionClient.java @@ -67,7 +67,6 @@ public class PeerConnectionClient { // remote descriptions are set. Similarly local ICE candidates are sent to // remote peer after both local and remote description are set. private LinkedList queuedRemoteCandidates = null; - private LinkedList queuedLocalCandidates = null; private MediaConstraints sdpMediaConstraints; private MediaConstraints videoConstraints; private PeerConnectionEvents events; @@ -88,7 +87,6 @@ public class PeerConnectionClient { this.events = events; isInitiator = signalingParameters.initiator; queuedRemoteCandidates = new LinkedList(); - queuedLocalCandidates = new LinkedList(); sdpMediaConstraints = new MediaConstraints(); sdpMediaConstraints.mandatory.add(new MediaConstraints.KeyValuePair( @@ -384,13 +382,6 @@ public class PeerConnectionClient { } private void drainCandidates() { - if (queuedLocalCandidates != null) { - Log.d(TAG, "Send " + queuedLocalCandidates.size() + " local candidates"); - for (IceCandidate candidate : queuedLocalCandidates) { - events.onIceCandidate(candidate); - } - queuedLocalCandidates = null; - } if (queuedRemoteCandidates != null) { Log.d(TAG, "Add " + queuedRemoteCandidates.size() + " remote candidates"); for (IceCandidate candidate : queuedRemoteCandidates) { @@ -449,11 +440,7 @@ public class PeerConnectionClient { public void onIceCandidate(final IceCandidate candidate){ activity.runOnUiThread(new Runnable() { public void run() { - if (queuedLocalCandidates != null) { - queuedLocalCandidates.add(candidate); - } else { - events.onIceCandidate(candidate); - } + events.onIceCandidate(candidate); } }); } diff --git a/talk/examples/android/src/org/appspot/apprtc/RoomParametersFetcher.java b/talk/examples/android/src/org/appspot/apprtc/RoomParametersFetcher.java index 99981f38d3..f49a873868 100644 --- a/talk/examples/android/src/org/appspot/apprtc/RoomParametersFetcher.java +++ b/talk/examples/android/src/org/appspot/apprtc/RoomParametersFetcher.java @@ -51,6 +51,7 @@ public class RoomParametersFetcher private static final String TAG = "RoomRTCClient"; private Exception exception = null; private RoomParametersFetcherEvents events = null; + private boolean loopback; /** * Room parameters fetcher callbacks. @@ -68,9 +69,11 @@ public class RoomParametersFetcher public void onSignalingParametersError(final String description); } - public RoomParametersFetcher(RoomParametersFetcherEvents events) { + public RoomParametersFetcher( + RoomParametersFetcherEvents events, boolean loopback) { super(); this.events = events; + this.loopback = loopback; } @Override @@ -138,11 +141,18 @@ public class RoomParametersFetcher offerSdp = null; } - String postMessageUrl = url.substring(0, url.indexOf('?')); - postMessageUrl += "/message?r=" + roomId + "&u=" + clientId; - Log.d(TAG, "Post url: " + postMessageUrl); + String roomUrl = url.substring(0, url.indexOf('?')); + Log.d(TAG, "Room url: " + roomUrl); - boolean initiator = roomJson.getInt("initiator") == 1; + boolean initiator; + if (loopback) { + // In loopback mode caller should always be call initiator. + // TODO(glaznev): remove this once 8-dot-apprtc server will set initiator + // flag to true for loopback calls. + initiator = true; + } else { + initiator = roomJson.getInt("initiator") == 1; + } Log.d(TAG, "Initiator: " + initiator); LinkedList iceServers = @@ -156,15 +166,17 @@ public class RoomParametersFetcher } } if (!isTurnPresent) { - PeerConnection.IceServer server = - requestTurnServer(roomJson.getString("turn_url")); - Log.d(TAG, "TurnServer: " + server); - iceServers.add(server); + LinkedList turnServers = + requestTurnServers(roomJson.getString("turn_url")); + for (PeerConnection.IceServer turnServer : turnServers) { + Log.d(TAG, "TurnServer: " + turnServer); + iceServers.add(turnServer); + } } MediaConstraints pcConstraints = constraintsFromJSON( roomJson.getString("pc_constraints")); - addDTLSConstraintIfMissing(pcConstraints); + addDTLSConstraintIfMissing(pcConstraints, loopback); Log.d(TAG, "pcConstraints: " + pcConstraints); MediaConstraints videoConstraints = constraintsFromJSON( getAVConstraints("video", @@ -178,13 +190,14 @@ public class RoomParametersFetcher return new SignalingParameters( iceServers, initiator, pcConstraints, videoConstraints, audioConstraints, - postMessageUrl, roomId, clientId, + roomUrl, roomId, clientId, channelToken, offerSdp); } // Mimic Chrome and set DtlsSrtpKeyAgreement to true if not set to false by // the web-app. - private void addDTLSConstraintIfMissing(MediaConstraints pcConstraints) { + private void addDTLSConstraintIfMissing( + MediaConstraints pcConstraints, boolean loopback) { for (MediaConstraints.KeyValuePair pair : pcConstraints.mandatory) { if (pair.getKey().equals("DtlsSrtpKeyAgreement")) { return; @@ -195,10 +208,15 @@ public class RoomParametersFetcher return; } } - // DTLS isn't being suppressed (e.g. for debug=loopback calls), so enable - // it by default. - pcConstraints.optional.add( - new MediaConstraints.KeyValuePair("DtlsSrtpKeyAgreement", "true")); + // DTLS isn't being specified (e.g. for debug=loopback calls), so enable + // it for normal calls and disable for loopback calls. + if (loopback) { + pcConstraints.optional.add( + new MediaConstraints.KeyValuePair("DtlsSrtpKeyAgreement", "false")); + } else { + pcConstraints.optional.add( + new MediaConstraints.KeyValuePair("DtlsSrtpKeyAgreement", "true")); + } } // Return the constraints specified for |type| of "audio" or "video" in @@ -256,17 +274,25 @@ public class RoomParametersFetcher // Requests & returns a TURN ICE Server based on a request URL. Must be run // off the main thread! - private PeerConnection.IceServer requestTurnServer(String url) + private LinkedList requestTurnServers(String url) throws IOException, JSONException { + LinkedList turnServers = + new LinkedList(); + Log.d(TAG, "Request TURN from: " + url); URLConnection connection = (new URL(url)).openConnection(); connection.addRequestProperty("user-agent", "Mozilla/5.0"); connection.addRequestProperty("origin", "https://apprtc.appspot.com"); String response = drainStream(connection.getInputStream()); + Log.d(TAG, "TURN response: " + response); JSONObject responseJSON = new JSONObject(response); - String uri = responseJSON.getJSONArray("uris").getString(0); String username = responseJSON.getString("username"); String password = responseJSON.getString("password"); - return new PeerConnection.IceServer(uri, username, password); + JSONArray turnUris = responseJSON.getJSONArray("uris"); + for (int i = 0; i < turnUris.length(); i++) { + String uri = turnUris.getString(i); + turnServers.add(new PeerConnection.IceServer(uri, username, password)); + } + return turnServers; } // Return the list of ICE servers described by a WebRTCPeerConnection diff --git a/talk/examples/android/src/org/appspot/apprtc/WebSocketChannelClient.java b/talk/examples/android/src/org/appspot/apprtc/WebSocketChannelClient.java index 373480d156..170c807005 100644 --- a/talk/examples/android/src/org/appspot/apprtc/WebSocketChannelClient.java +++ b/talk/examples/android/src/org/appspot/apprtc/WebSocketChannelClient.java @@ -35,8 +35,14 @@ import de.tavendo.autobahn.WebSocketConnection; import de.tavendo.autobahn.WebSocketException; import de.tavendo.autobahn.WebSocket.WebSocketConnectionObserver; +import java.io.IOException; +import java.io.UnsupportedEncodingException; +import java.net.HttpURLConnection; import java.net.URI; import java.net.URISyntaxException; +import java.net.URL; +import java.net.URLEncoder; +import java.util.LinkedList; import org.json.JSONException; import org.json.JSONObject; @@ -53,8 +59,18 @@ public class WebSocketChannelClient { private final Handler uiHandler; private WebSocketConnection ws; private WebSocketObserver wsObserver; - private URI serverURI; + private String wsServerUrl; + private String postServerUrl; + private String roomID; + private String clientID; private WebSocketConnectionState state; + // Http post/delete message queue. Messages are added from UI thread in + // post() and disconnect() calls. Messages are consumed by AsyncTask's + // background thread. + private LinkedList wsHttpQueue; + // WebSocket send queue. Messages are added to the queue when WebSocket + // client is not registered and are consumed in register() call. + private LinkedList wsSendQueue; public enum WebSocketConnectionState { NEW, CONNECTED, REGISTERED, CLOSED, ERROR @@ -74,6 +90,10 @@ public class WebSocketChannelClient { public WebSocketChannelClient(WebSocketChannelEvents events) { this.events = events; uiHandler = new Handler(Looper.getMainLooper()); + roomID = null; + clientID = null; + wsHttpQueue = new LinkedList(); + wsSendQueue = new LinkedList(); state = WebSocketConnectionState.NEW; } @@ -81,18 +101,19 @@ public class WebSocketChannelClient { return state; } - public void connect(String url) { + public void connect(String wsUrl, String postUrl) { if (state != WebSocketConnectionState.NEW) { Log.e(TAG, "WebSocket is already connected."); return; } - Log.d(TAG, "Connecting WebSocket to: " + url); + Log.d(TAG, "Connecting WebSocket to: " + wsUrl + ". Post URL: " + postUrl); ws = new WebSocketConnection(); wsObserver = new WebSocketObserver(); try { - serverURI = new URI(url); - ws.connect(serverURI, wsObserver); + wsServerUrl = wsUrl; + postServerUrl = postUrl; + ws.connect(new URI(wsServerUrl), wsObserver); } catch (URISyntaxException e) { reportError("URI error: " + e.getMessage()); } catch (WebSocketException e) { @@ -100,39 +121,81 @@ public class WebSocketChannelClient { } } - public void register(String roomId, String clientId) { + public void setClientParameters(String roomID, String clientID) { + this.roomID = roomID; + this.clientID = clientID; + } + + public void register() { if (state != WebSocketConnectionState.CONNECTED) { Log.w(TAG, "WebSocket register() in state " + state); return; } + if (roomID == null || clientID == null) { + Log.w(TAG, "Call WebSocket register() without setting client ID"); + return; + } JSONObject json = new JSONObject(); try { json.put("cmd", "register"); - json.put("roomid", roomId); - json.put("clientid", clientId); + json.put("roomid", roomID); + json.put("clientid", clientID); Log.d(TAG, "WS SEND: " + json.toString()); ws.sendTextMessage(json.toString()); state = WebSocketConnectionState.REGISTERED; + // Send any previously accumulated messages. + synchronized(wsSendQueue) { + for (String sendMessage : wsSendQueue) { + send(sendMessage); + } + wsSendQueue.clear(); + } } catch (JSONException e) { reportError("WebSocket register JSON error: " + e.getMessage()); } } public void send(String message) { - if (state != WebSocketConnectionState.REGISTERED) { - Log.e(TAG, "WebSocket send() in non registered state : " + message); - return; + switch (state) { + case NEW: + case CONNECTED: + // Store outgoing messages and send them after websocket client + // is registered. + Log.d(TAG, "WS ACC: " + message); + synchronized(wsSendQueue) { + wsSendQueue.add(message); + return; + } + case ERROR: + case CLOSED: + Log.e(TAG, "WebSocket send() in error or closed state : " + message); + return; + case REGISTERED: + JSONObject json = new JSONObject(); + try { + json.put("cmd", "send"); + json.put("msg", message); + message = json.toString(); + Log.d(TAG, "WS SEND: " + message); + ws.sendTextMessage(message); + } catch (JSONException e) { + reportError("WebSocket send JSON error: " + e.getMessage()); + } + break; } - JSONObject json = new JSONObject(); - try { - json.put("cmd", "send"); - json.put("msg", message); - message = json.toString(); - Log.d(TAG, "WS SEND: " + message); - ws.sendTextMessage(message); - } catch (JSONException e) { - reportError("WebSocket send JSON error: " + e.getMessage()); + return; + } + + // This call can be used to send WebSocket messages before WebSocket + // connection is opened. However for now this way of sending messages + // is not used until possible race condition of arriving ice candidates + // send through websocket before SDP answer sent through http post will be + // resolved. + public void post(String message) { + synchronized (wsHttpQueue) { + wsHttpQueue.add(new WsHttpMessage("POST", message)); } + requestHttpQueueDrainInBackground(); } public void disconnect() { @@ -141,14 +204,19 @@ public class WebSocketChannelClient { send("{\"type\": \"bye\"}"); state = WebSocketConnectionState.CONNECTED; } - // TODO(glaznev): send DELETE to http WebSocket server once send() - // will switch to http POST. - // Close WebSocket in CONNECTED or ERROR states only. if (state == WebSocketConnectionState.CONNECTED || state == WebSocketConnectionState.ERROR) { - state = WebSocketConnectionState.CLOSED; ws.disconnect(); + + // Send DELETE to http WebSocket server. + synchronized (wsHttpQueue) { + wsHttpQueue.clear(); + wsHttpQueue.add(new WsHttpMessage("DELETE", "")); + } + requestHttpQueueDrainInBackground(); + + state = WebSocketConnectionState.CLOSED; } } @@ -164,10 +232,83 @@ public class WebSocketChannelClient { }); } + private class WsHttpMessage { + WsHttpMessage(String method, String message) { + this.method = method; + this.message = message; + } + public final String method; + public final String message; + } + + // TODO(glaznev): This is not good implementation due to discrepancy + // between JS encodeURIComponent() and Java URLEncoder.encode(). + // Remove this once WebSocket server will switch to a different encoding. + private String encodeURIComponent(String s) { + String result = null; + try { + result = URLEncoder.encode(s, "UTF-8") + .replaceAll("\\+", "%20") + .replaceAll("\\%21", "!") + .replaceAll("\\%27", "'") + .replaceAll("\\%28", "(") + .replaceAll("\\%29", ")") + .replaceAll("\\%7E", "~"); + } catch (UnsupportedEncodingException e) { + result = s; + } + return result; + } + + // Request an attempt to drain the send queue, on a background thread. + private void requestHttpQueueDrainInBackground() { + (new AsyncTask() { + public Void doInBackground(Void... unused) { + maybeDrainWsHttpQueue(); + return null; + } + }).execute(); + } + + // Send all queued websocket messages. + private void maybeDrainWsHttpQueue() { + synchronized (wsHttpQueue) { + if (roomID == null || clientID == null) { + return; + } + try { + for (WsHttpMessage wsHttpMessage : wsHttpQueue) { + // Send POST request. + Log.d(TAG, "WS " + wsHttpMessage.method + " : " + + wsHttpMessage.message); + String postUrl = postServerUrl + roomID + "/" + clientID; + HttpURLConnection connection = + (HttpURLConnection) new URL(postUrl).openConnection(); + connection.setDoOutput(true); + connection.setRequestProperty( + "Content-type", "application/x-www-form-urlencoded"); + connection.setRequestMethod(wsHttpMessage.method); + if (wsHttpMessage.message.length() > 0) { + String message = "msg=" + encodeURIComponent(wsHttpMessage.message); + connection.getOutputStream().write(message.getBytes("UTF-8")); + } + String replyHeader = connection.getHeaderField(null); + if (!replyHeader.startsWith("HTTP/1.1 200 ")) { + reportError("Non-200 response to " + wsHttpMessage.method + " : " + + connection.getHeaderField(null)); + } + } + } catch (IOException e) { + reportError("WS POST error: " + e.getMessage()); + } + wsHttpQueue.clear(); + } + } + private class WebSocketObserver implements WebSocketConnectionObserver { @Override public void onOpen() { - Log.d(TAG, "WebSocket connection opened to: " + serverURI.toString()); + Log.d(TAG, "WebSocket connection opened to: " + wsServerUrl); uiHandler.post(new Runnable() { public void run() { state = WebSocketConnectionState.CONNECTED; diff --git a/talk/examples/android/src/org/appspot/apprtc/WebSocketRTCClient.java b/talk/examples/android/src/org/appspot/apprtc/WebSocketRTCClient.java index aaef09b981..ef6ef288cb 100644 --- a/talk/examples/android/src/org/appspot/apprtc/WebSocketRTCClient.java +++ b/talk/examples/android/src/org/appspot/apprtc/WebSocketRTCClient.java @@ -32,8 +32,8 @@ import android.os.Looper; import android.util.Log; import java.io.IOException; +import java.net.HttpURLConnection; import java.net.URL; -import java.net.URLConnection; import java.util.LinkedList; import org.appspot.apprtc.RoomParametersFetcher.RoomParametersFetcherEvents; @@ -57,23 +57,31 @@ import org.webrtc.SessionDescription; public class WebSocketRTCClient implements AppRTCClient, RoomParametersFetcherEvents, WebSocketChannelEvents { private static final String TAG = "WSRTCClient"; - private static final String WSS_SERVER = "wss://apprtc-ws.webrtc.org:8089/ws"; + private static final String WSS_SERVER = + "wss://apprtc-ws.webrtc.org:8089/ws"; + // TODO(glaznev): remove this hard-coded URL and instead get WebSocket http + // server URL from room response once it will be supported by 8-dot-apprtc. + private static final String WSS_POST_URL = + "https://apprtc-ws.webrtc.org:8089/"; private enum ConnectionState { NEW, CONNECTED, CLOSED, ERROR }; private final Handler uiHandler; + private boolean loopback; private SignalingEvents events; private SignalingParameters signalingParameters; private WebSocketChannelClient wsClient; private RoomParametersFetcher fetcher; private ConnectionState roomState; - private LinkedList gaePostQueue; + private LinkedList gaePostQueue; + private String postMessageUrl; + private String byeMessageUrl; public WebSocketRTCClient(SignalingEvents events) { this.events = events; uiHandler = new Handler(Looper.getMainLooper()); - gaePostQueue = new LinkedList(); + gaePostQueue = new LinkedList(); } // -------------------------------------------------------------------- @@ -82,14 +90,24 @@ public class WebSocketRTCClient implements AppRTCClient, @Override public void onSignalingParametersReady(final SignalingParameters params) { Log.d(TAG, "Room connection completed."); - if (!params.initiator && params.offerSdp == null) { - reportError("Offer SDP is not available"); + if (!loopback && !params.initiator && params.offerSdp == null) { + reportError("Offer SDP is not available."); + return; + } + if (loopback && params.offerSdp != null) { + reportError("Loopback room is busy."); return; } signalingParameters = params; + postMessageUrl = params.roomUrl + "message?r=" + + params.roomId + "&u=" + params.clientId; + byeMessageUrl = params.roomUrl + "bye/" + + params.roomId + "/" + params.clientId; roomState = ConnectionState.CONNECTED; + wsClient.setClientParameters( + signalingParameters.roomId, signalingParameters.clientId); + wsClient.register(); events.onConnectedToRoom(signalingParameters); - wsClient.register(signalingParameters.roomId, signalingParameters.clientId); events.onChannelOpen(); if (!signalingParameters.initiator) { // For call receiver get sdp offer from room parameters. @@ -112,8 +130,7 @@ public class WebSocketRTCClient implements AppRTCClient, public void onWebSocketOpen() { Log.d(TAG, "Websocket connection completed."); if (roomState == ConnectionState.CONNECTED) { - wsClient.register( - signalingParameters.roomId, signalingParameters.clientId); + wsClient.register(); } } @@ -175,24 +192,30 @@ public class WebSocketRTCClient implements AppRTCClient, // https://apprtc.appspot.com/?r=NNN, retrieve room parameters // and connect to WebSocket server. @Override - public void connectToRoom(String url) { + public void connectToRoom(String url, boolean loopback) { + this.loopback = loopback; // Get room parameters. roomState = ConnectionState.NEW; - fetcher = new RoomParametersFetcher(this); + fetcher = new RoomParametersFetcher(this, loopback); fetcher.execute(url); // Connect to WebSocket server. wsClient = new WebSocketChannelClient(this); - wsClient.connect(WSS_SERVER); + if (!loopback) { + wsClient.connect(WSS_SERVER, WSS_POST_URL); + } } @Override public void disconnect() { Log.d(TAG, "Disconnect. Room state: " + roomState); + wsClient.disconnect(); if (roomState == ConnectionState.CONNECTED) { Log.d(TAG, "Closing room."); - sendGAEMessage("{\"type\": \"bye\"}"); + // TODO(glaznev): Remove json bye message sending once new bye will + // be supported on 8-dot. + //sendGAEMessage(byeMessageUrl, ""); + sendGAEMessage(postMessageUrl, "{\"type\": \"bye\"}"); } - wsClient.disconnect(); } // Send local SDP (offer or answer, depending on role) to the @@ -202,14 +225,26 @@ public class WebSocketRTCClient implements AppRTCClient, // we might want to filter elsewhere. @Override public void sendOfferSdp(final SessionDescription sdp) { - JSONObject json = new JSONObject(); - jsonPut(json, "sdp", sdp.description); - jsonPut(json, "type", "offer"); - sendGAEMessage(json.toString()); + if (loopback) { + // In loopback mode rename this offer to answer and send it back. + SessionDescription sdpAnswer = new SessionDescription( + SessionDescription.Type.fromCanonicalForm("answer"), + sdp.description); + events.onRemoteDescription(sdpAnswer); + } else { + JSONObject json = new JSONObject(); + jsonPut(json, "sdp", sdp.description); + jsonPut(json, "type", "offer"); + sendGAEMessage(postMessageUrl, json.toString()); + } } @Override public void sendAnswerSdp(final SessionDescription sdp) { + if (loopback) { + Log.e(TAG, "Sending answer in loopback mode."); + return; + } if (wsClient.getState() != WebSocketConnectionState.REGISTERED) { reportError("Sending answer SDP in non registered state."); return; @@ -223,16 +258,20 @@ public class WebSocketRTCClient implements AppRTCClient, // Send Ice candidate to the other participant. @Override public void sendLocalIceCandidate(final IceCandidate candidate) { - if (wsClient.getState() != WebSocketConnectionState.REGISTERED) { - reportError("Sending ICE candidate in non registered state."); - return; + if (loopback) { + events.onRemoteIceCandidate(candidate); + } else { + if (wsClient.getState() != WebSocketConnectionState.REGISTERED) { + reportError("Sending ICE candidate in non registered state."); + return; + } + JSONObject json = new JSONObject(); + jsonPut(json, "type", "candidate"); + jsonPut(json, "label", candidate.sdpMLineIndex); + jsonPut(json, "id", candidate.sdpMid); + jsonPut(json, "candidate", candidate.sdp); + wsClient.send(json.toString()); } - JSONObject json = new JSONObject(); - jsonPut(json, "type", "candidate"); - jsonPut(json, "label", candidate.sdpMLineIndex); - jsonPut(json, "id", candidate.sdpMid); - jsonPut(json, "candidate", candidate.sdp); - wsClient.send(json.toString()); } // -------------------------------------------------------------------- @@ -258,10 +297,19 @@ public class WebSocketRTCClient implements AppRTCClient, } } + private class GAEMessage { + GAEMessage(String postUrl, String message) { + this.postUrl = postUrl; + this.message = message; + } + public final String postUrl; + public final String message; + } + // Queue a message for sending to the room and send it if already connected. - private synchronized void sendGAEMessage(String msg) { + private synchronized void sendGAEMessage(String url, String message) { synchronized (gaePostQueue) { - gaePostQueue.add(msg); + gaePostQueue.add(new GAEMessage(url, message)); } (new AsyncTask() { public Void doInBackground(Void... unused) { @@ -278,27 +326,32 @@ public class WebSocketRTCClient implements AppRTCClient, return; } try { - for (String msg : gaePostQueue) { - Log.d(TAG, "ROOM SEND: " + msg); + for (GAEMessage gaeMessage : gaePostQueue) { + Log.d(TAG, "ROOM SEND to " + gaeMessage.postUrl + + ". Message: " + gaeMessage.message); // Check if this is 'bye' message and update room connection state. - // TODO(glaznev): change this to new bye message format: - // https://apprtc.appspot.com/bye/{roomid}/{clientid} - JSONObject json = new JSONObject(msg); + // TODO(glaznev): Uncomment this check and remove check below + // once new bye message will be supported by 8-dot. + //if (gaeMessage.postUrl.contains("bye")) { + // roomState = ConnectionState.CLOSED; + //} + JSONObject json = new JSONObject(gaeMessage.message); String type = json.optString("type"); if (type != null && type.equals("bye")) { roomState = ConnectionState.CLOSED; } // Send POST request. - URLConnection connection = new URL( - signalingParameters.postMessageUrl).openConnection(); + HttpURLConnection connection = + (HttpURLConnection) new URL(gaeMessage.postUrl).openConnection(); connection.setDoOutput(true); connection.setRequestProperty( "content-type", "text/plain; charset=utf-8"); - connection.getOutputStream().write(msg.getBytes("UTF-8")); + connection.getOutputStream().write( + gaeMessage.message.getBytes("UTF-8")); String replyHeader = connection.getHeaderField(null); if (!replyHeader.startsWith("HTTP/1.1 200 ")) { reportError("Non-200 response to POST: " + - connection.getHeaderField(null) + " for msg: " + msg); + connection.getHeaderField(null)); } } } catch (IOException e) { @@ -306,6 +359,7 @@ public class WebSocketRTCClient implements AppRTCClient, } catch (JSONException e) { reportError("GAE POST JSON error: " + e.getMessage()); } + gaePostQueue.clear(); } } diff --git a/talk/examples/android/third_party/autobanh/NOTICE b/talk/examples/android/third_party/autobanh/NOTICE new file mode 100644 index 0000000000..91ed7dfe0e --- /dev/null +++ b/talk/examples/android/third_party/autobanh/NOTICE @@ -0,0 +1,3 @@ +AutobahnAndroid +Copyright 2011,2012 Tavendo GmbH. Licensed under Apache 2.0 +This product includes software developed at Tavendo GmbH http://www.tavendo.de