From 6f6ef72950b9bda79392e83d7b1495d4ff07b4a2 Mon Sep 17 00:00:00 2001 From: "henrik.lundin@webrtc.org" Date: Wed, 19 Nov 2014 13:02:24 +0000 Subject: [PATCH] Add DCHECK to ensure that NetEq's packet buffer is not empty This DCHECK ensures that one packet was inserted after the buffer was flushed. R=kwiberg@webrtc.org Review URL: https://webrtc-codereview.appspot.com/30169004 git-svn-id: http://webrtc.googlecode.com/svn/trunk@7719 4adac7df-926f-26a2-2b94-8c16560cd09d --- talk/examples/android/res/values/strings.xml | 5 - talk/examples/android/res/xml/preferences.xml | 6 - .../src/org/appspot/apprtc/AppRTCClient.java | 44 ++- .../appspot/apprtc/AppRTCDemoActivity.java | 36 +- .../org/appspot/apprtc/ConnectActivity.java | 14 +- .../org/appspot/apprtc/GAEChannelClient.java | 2 + .../src/org/appspot/apprtc/GAERTCClient.java | 326 ++++-------------- .../appspot/apprtc/PeerConnectionClient.java | 65 ++-- .../appspot/apprtc/RoomParametersFetcher.java | 296 ++++++++++++++++ .../org/appspot/apprtc/SettingsActivity.java | 6 +- .../apprtc/WebSocketChannelClient.java | 216 ++++++++++++ .../appspot/apprtc/WebSocketRTCClient.java | 313 +++++++++++++++++ .../android/third_party/autobanh/LICENSE | 177 ++++++++++ .../android/third_party/autobanh/LICENSE.md | 21 ++ .../android/third_party/autobanh/autobanh.jar | Bin 0 -> 45472 bytes talk/libjingle_examples.gyp | 5 + .../modules/audio_coding/neteq/neteq_impl.cc | 3 + 17 files changed, 1211 insertions(+), 324 deletions(-) create mode 100644 talk/examples/android/src/org/appspot/apprtc/RoomParametersFetcher.java create mode 100644 talk/examples/android/src/org/appspot/apprtc/WebSocketChannelClient.java create mode 100644 talk/examples/android/src/org/appspot/apprtc/WebSocketRTCClient.java create mode 100644 talk/examples/android/third_party/autobanh/LICENSE create mode 100644 talk/examples/android/third_party/autobanh/LICENSE.md create mode 100644 talk/examples/android/third_party/autobanh/autobanh.jar diff --git a/talk/examples/android/res/values/strings.xml b/talk/examples/android/res/values/strings.xml index 774eee1c94..e96d6bfe0e 100644 --- a/talk/examples/android/res/values/strings.xml +++ b/talk/examples/android/res/values/strings.xml @@ -27,11 +27,6 @@ room_preference room_list_preference - url_preference - Connection URL: - Enter AppRTC connection server URL. - https://apprtc.appspot.com - resolution_preference Video resolution. Enter AppRTC local video resolution. diff --git a/talk/examples/android/res/xml/preferences.xml b/talk/examples/android/res/xml/preferences.xml index b8c08bbecf..c5c3a1e9e8 100644 --- a/talk/examples/android/res/xml/preferences.xml +++ b/talk/examples/android/res/xml/preferences.xml @@ -1,11 +1,5 @@ - iceServers; public final boolean initiator; public final MediaConstraints pcConstraints; public final MediaConstraints videoConstraints; public final MediaConstraints audioConstraints; + public final String postMessageUrl; + public final String roomId; + public final String clientId; + public final String channelToken; + public final String offerSdp; - public AppRTCSignalingParameters( + public SignalingParameters( List iceServers, boolean initiator, MediaConstraints pcConstraints, - MediaConstraints videoConstraints, MediaConstraints audioConstraints) { + MediaConstraints videoConstraints, MediaConstraints audioConstraints, + String postMessageUrl, 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.roomId = roomId; + this.clientId = clientId; + this.channelToken = channelToken; + this.offerSdp = offerSdp; + if (channelToken == null || channelToken.length() == 0) { + this.websocketSignaling = true; + } else { + this.websocketSignaling = false; + } } } /** - * Callback interface for messages delivered on signalling channel. + * Callback interface for messages delivered on signaling channel. * * Methods are guaranteed to be invoked on the UI thread of |activity|. */ - public static interface AppRTCSignalingEvents { + public static interface SignalingEvents { /** * Callback fired once the room's signaling parameters - * AppRTCSignalingParameters are extracted. + * SignalingParameters are extracted. */ - public void onConnectedToRoom(final AppRTCSignalingParameters params); + public void onConnectedToRoom(final SignalingParameters params); /** * Callback fired once channel for signaling messages is opened and @@ -115,6 +137,6 @@ public interface AppRTCClient { /** * Callback fired once channel error happened. */ - public void onChannelError(int code, String description); + public void onChannelError(final String description); } } diff --git a/talk/examples/android/src/org/appspot/apprtc/AppRTCDemoActivity.java b/talk/examples/android/src/org/appspot/apprtc/AppRTCDemoActivity.java index 3ad26afcae..7facd3c852 100644 --- a/talk/examples/android/src/org/appspot/apprtc/AppRTCDemoActivity.java +++ b/talk/examples/android/src/org/appspot/apprtc/AppRTCDemoActivity.java @@ -49,7 +49,7 @@ import android.widget.ImageButton; import android.widget.TextView; import android.widget.Toast; -import org.appspot.apprtc.AppRTCClient.AppRTCSignalingParameters; +import org.appspot.apprtc.AppRTCClient.SignalingParameters; import org.webrtc.IceCandidate; import org.webrtc.PeerConnectionFactory; import org.webrtc.SessionDescription; @@ -60,17 +60,18 @@ import org.webrtc.VideoRendererGui; import org.webrtc.VideoRendererGui.ScalingType; /** - * Main Activity of the AppRTCDemo Android app demonstrating interoperability + * Activity of the AppRTCDemo Android app demonstrating interoperability * between the Android/Java implementation of PeerConnection and the * apprtc.appspot.com demo webapp. */ public class AppRTCDemoActivity extends Activity - implements AppRTCClient.AppRTCSignalingEvents, + implements AppRTCClient.SignalingEvents, PeerConnectionClient.PeerConnectionEvents { private static final String TAG = "AppRTCClient"; + private final boolean USE_WEBSOCKETS = false; private PeerConnectionClient pc; - private AppRTCClient appRtcClient = new GAERTCClient(this, this); - private AppRTCSignalingParameters appRtcParameters; + private AppRTCClient appRtcClient; + private SignalingParameters signalingParameters; private AppRTCAudioManager audioManager = null; private View rootView; private View menuBar; @@ -199,6 +200,11 @@ public class AppRTCDemoActivity extends Activity if ((room != null && !room.equals("")) || (loopback != null && loopback.equals("loopback"))) { 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); @@ -324,7 +330,7 @@ public class AppRTCDemoActivity extends Activity finish(); } - private void disconnectWithMessage(String errorMessage) { + private void disconnectWithMessage(final String errorMessage) { new AlertDialog.Builder(this) .setTitle(getText(R.string.channel_error_title)) .setMessage(errorMessage) @@ -357,20 +363,20 @@ public class AppRTCDemoActivity extends Activity // -----Implementation of AppRTCClient.AppRTCSignalingEvents --------------- // All events are called from UI thread. @Override - public void onConnectedToRoom(final AppRTCSignalingParameters params) { + public void onConnectedToRoom(final SignalingParameters params) { if (audioManager != null) { // Store existing audio settings and change audio mode to // MODE_IN_COMMUNICATION for best possible VoIP performance. logAndToast("Initializing the audio manager..."); audioManager.init(); } - appRtcParameters = params; + signalingParameters = params; abortUnless(PeerConnectionFactory.initializeAndroidGlobals( this, true, true, VideoRendererGui.getEGLContext()), "Failed to initializeAndroidGlobals"); logAndToast("Creating peer connection..."); pc = new PeerConnectionClient( - this, localRender, remoteRender, appRtcParameters, this); + this, localRender, remoteRender, signalingParameters, this); if (pc.isHDVideo()) { setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); } else { @@ -417,7 +423,7 @@ public class AppRTCDemoActivity extends Activity if (pc == null) { return; } - if (appRtcParameters.initiator) { + if (signalingParameters.initiator) { logAndToast("Creating OFFER..."); // Create offer. Offer SDP will be sent to answering client in // PeerConnectionEvents.onLocalDescription event. @@ -432,7 +438,7 @@ public class AppRTCDemoActivity extends Activity } logAndToast("Received remote " + sdp.type + " ..."); pc.setRemoteDescription(sdp); - if (!appRtcParameters.initiator) { + if (!signalingParameters.initiator) { logAndToast("Creating ANSWER..."); // Create answer. Answer SDP will be sent to offering client in // PeerConnectionEvents.onLocalDescription event. @@ -454,7 +460,7 @@ public class AppRTCDemoActivity extends Activity } @Override - public void onChannelError(int code, String description) { + public void onChannelError(final String description) { disconnectWithMessage(description); } @@ -465,7 +471,11 @@ public class AppRTCDemoActivity extends Activity public void onLocalDescription(final SessionDescription sdp) { if (appRtcClient != null) { logAndToast("Sending " + sdp.type + " ..."); - appRtcClient.sendLocalDescription(sdp); + if (signalingParameters.initiator) { + appRtcClient.sendOfferSdp(sdp); + } else { + appRtcClient.sendAnswerSdp(sdp); + } } } diff --git a/talk/examples/android/src/org/appspot/apprtc/ConnectActivity.java b/talk/examples/android/src/org/appspot/apprtc/ConnectActivity.java index ce99bbf33e..ff78ebce30 100644 --- a/talk/examples/android/src/org/appspot/apprtc/ConnectActivity.java +++ b/talk/examples/android/src/org/appspot/apprtc/ConnectActivity.java @@ -63,6 +63,10 @@ import org.webrtc.MediaCodecVideoEncoder; public class ConnectActivity extends Activity { 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 ImageButton addRoomButton; private ImageButton removeRoomButton; private ImageButton connectButton; @@ -70,7 +74,6 @@ public class ConnectActivity extends Activity { private EditText roomEditText; private ListView roomListView; private SharedPreferences sharedPref; - private String keyprefUrl; private String keyprefResolution; private String keyprefFps; private String keyprefCpuUsageDetection; @@ -86,7 +89,6 @@ public class ConnectActivity extends Activity { // Get setting keys. PreferenceManager.setDefaultValues(this, R.xml.preferences, false); sharedPref = PreferenceManager.getDefaultSharedPreferences(this); - keyprefUrl = getString(R.string.pref_url_key); keyprefResolution = getString(R.string.pref_resolution_key); keyprefFps = getString(R.string.pref_fps_key); keyprefCpuUsageDetection = getString(R.string.pref_cpu_usage_detection_key); @@ -193,9 +195,11 @@ public class ConnectActivity extends Activity { if (view.getId() == R.id.connect_loopback_button) { loopback = true; } - String url = sharedPref.getString(keyprefUrl, - getString(R.string.pref_url_default)); - if (loopback) { + String url = APPRTC_SERVER; + if (USE_WEBSOCKETS) { + url = APPRTC_WS_SERVER; + } + if (loopback && !USE_WEBSOCKETS) { url += "/?debug=loopback"; } else { String roomName = getSelectedItem(); diff --git a/talk/examples/android/src/org/appspot/apprtc/GAEChannelClient.java b/talk/examples/android/src/org/appspot/apprtc/GAEChannelClient.java index 5fd0a54ed4..d44975b7b6 100644 --- a/talk/examples/android/src/org/appspot/apprtc/GAEChannelClient.java +++ b/talk/examples/android/src/org/appspot/apprtc/GAEChannelClient.java @@ -141,6 +141,8 @@ public class GAEChannelClient { @JavascriptInterface public void onError(final int code, final String description) { + Log.e(TAG, "Channel error. Code: " + code + + ". Description: " + description); if (!disconnected) { handler.onError(code, description); } diff --git a/talk/examples/android/src/org/appspot/apprtc/GAERTCClient.java b/talk/examples/android/src/org/appspot/apprtc/GAERTCClient.java index c3d95641da..2a1e8a1fd5 100644 --- a/talk/examples/android/src/org/appspot/apprtc/GAERTCClient.java +++ b/talk/examples/android/src/org/appspot/apprtc/GAERTCClient.java @@ -30,20 +30,16 @@ import android.app.Activity; import android.os.AsyncTask; import android.util.Log; -import org.json.JSONArray; +import org.appspot.apprtc.RoomParametersFetcher.RoomParametersFetcherEvents; import org.json.JSONException; import org.json.JSONObject; import org.webrtc.IceCandidate; -import org.webrtc.MediaConstraints; -import org.webrtc.PeerConnection; import org.webrtc.SessionDescription; import java.io.IOException; -import java.io.InputStream; import java.net.URL; import java.net.URLConnection; import java.util.LinkedList; -import java.util.Scanner; /** * Negotiates signaling for chatting with apprtc.appspot.com "rooms". @@ -55,25 +51,23 @@ import java.util.Scanner; * Messages to other party (with local Ice candidates and SDP) can * be sent after GAE channel is opened and onChannelOpen() callback is invoked. */ -public class GAERTCClient implements AppRTCClient { +public class GAERTCClient implements AppRTCClient, RoomParametersFetcherEvents { private static final String TAG = "GAERTCClient"; private GAEChannelClient channelClient; private final Activity activity; - private AppRTCClient.AppRTCSignalingEvents events; - private final GAEChannelClient.GAEMessageHandler gaeHandler = - new GAEHandler(); - private AppRTCClient.AppRTCSignalingParameters appRTCSignalingParameters; - private String gaeBaseHref; - private String channelToken; - private String postMessageUrl; + private SignalingEvents events; + private GAEChannelClient.GAEMessageHandler gaeHandler; + private SignalingParameters signalingParameters; + private RoomParametersFetcher fetcher; private LinkedList sendQueue = new LinkedList(); - public GAERTCClient(Activity activity, - AppRTCClient.AppRTCSignalingEvents events) { + public GAERTCClient(Activity activity, SignalingEvents events) { this.activity = activity; this.events = events; } + // -------------------------------------------------------------------- + // AppRTCClient interface implementation. /** * Asynchronously connect to an AppRTC room URL, e.g. * https://apprtc.appspot.com/?r=NNN and register message-handling callbacks @@ -81,7 +75,8 @@ public class GAERTCClient implements AppRTCClient { */ @Override public void connectToRoom(String url) { - (new RoomParameterGetter()).execute(url); + fetcher = new RoomParametersFetcher(this); + fetcher.execute(url); } /** @@ -94,6 +89,7 @@ public class GAERTCClient implements AppRTCClient { sendMessage("{\"type\": \"bye\"}"); channelClient.close(); channelClient = null; + gaeHandler = null; } } @@ -105,9 +101,17 @@ public class GAERTCClient implements AppRTCClient { * we might want to filter elsewhere. */ @Override - public void sendLocalDescription(final SessionDescription sdp) { + public void sendOfferSdp(final SessionDescription sdp) { JSONObject json = new JSONObject(); - jsonPut(json, "type", sdp.type.canonicalForm()); + jsonPut(json, "type", "offer"); + jsonPut(json, "sdp", sdp.description); + sendMessage(json.toString()); + } + + @Override + public void sendAnswerSdp(final SessionDescription sdp) { + JSONObject json = new JSONObject(); + jsonPut(json, "type", "answer"); jsonPut(json, "sdp", sdp.description); sendMessage(json.toString()); } @@ -125,7 +129,6 @@ public class GAERTCClient implements AppRTCClient { sendMessage(json.toString()); } - // Queue a message for sending to the room's channel and send it if already // connected (other wise queued messages are drained when the channel is // eventually established). @@ -145,223 +148,6 @@ public class GAERTCClient implements AppRTCClient { } } - // AsyncTask that converts an AppRTC room URL into the set of signaling - // parameters to use with that room. - private class RoomParameterGetter - extends AsyncTask { - private Exception exception = null; - - @Override - protected AppRTCSignalingParameters doInBackground(String... urls) { - if (urls.length != 1) { - exception = new RuntimeException("Must be called with a single URL"); - return null; - } - try { - exception = null; - return getParametersForRoomUrl(urls[0]); - } catch (JSONException e) { - exception = e; - } catch (IOException e) { - exception = e; - } - return null; - } - - @Override - protected void onPostExecute(AppRTCSignalingParameters params) { - if (exception != null) { - Log.e(TAG, "Room connection error: " + exception.toString()); - events.onChannelError(0, exception.getMessage()); - return; - } - channelClient = - new GAEChannelClient(activity, channelToken, gaeHandler); - synchronized (sendQueue) { - appRTCSignalingParameters = params; - } - requestQueueDrainInBackground(); - events.onConnectedToRoom(appRTCSignalingParameters); - } - - // Fetches |url| and fishes the signaling parameters out of the JSON. - private AppRTCSignalingParameters getParametersForRoomUrl(String url) - throws IOException, JSONException { - url = url + "&t=json"; - String response = drainStream((new URL(url)).openConnection().getInputStream()); - Log.d(TAG, "Room response: " + response); - JSONObject roomJson = new JSONObject(response); - - if (roomJson.has("error")) { - JSONArray errors = roomJson.getJSONArray("error_messages"); - throw new IOException(errors.toString()); - } - - gaeBaseHref = url.substring(0, url.indexOf('?')); - channelToken = roomJson.getString("token"); - postMessageUrl = "/message?r=" + - roomJson.getString("room_key") + "&u=" + - roomJson.getString("me"); - boolean initiator = roomJson.getInt("initiator") == 1; - LinkedList iceServers = - iceServersFromPCConfigJSON(roomJson.getString("pc_config")); - - boolean isTurnPresent = false; - for (PeerConnection.IceServer server : iceServers) { - Log.d(TAG, "IceServer: " + server); - if (server.uri.startsWith("turn:")) { - isTurnPresent = true; - break; - } - } - if (!isTurnPresent) { - PeerConnection.IceServer server = - requestTurnServer(roomJson.getString("turn_url")); - Log.d(TAG, "TurnServer: " + server); - iceServers.add(server); - } - - MediaConstraints pcConstraints = constraintsFromJSON( - roomJson.getString("pc_constraints")); - addDTLSConstraintIfMissing(pcConstraints); - Log.d(TAG, "pcConstraints: " + pcConstraints); - MediaConstraints videoConstraints = constraintsFromJSON( - getAVConstraints("video", - roomJson.getString("media_constraints"))); - Log.d(TAG, "videoConstraints: " + videoConstraints); - MediaConstraints audioConstraints = constraintsFromJSON( - getAVConstraints("audio", - roomJson.getString("media_constraints"))); - Log.d(TAG, "audioConstraints: " + audioConstraints); - - return new AppRTCSignalingParameters( - iceServers, initiator, - pcConstraints, videoConstraints, audioConstraints); - } - - // Mimic Chrome and set DtlsSrtpKeyAgreement to true if not set to false by - // the web-app. - private void addDTLSConstraintIfMissing( - MediaConstraints pcConstraints) { - for (MediaConstraints.KeyValuePair pair : pcConstraints.mandatory) { - if (pair.getKey().equals("DtlsSrtpKeyAgreement")) { - return; - } - } - for (MediaConstraints.KeyValuePair pair : pcConstraints.optional) { - if (pair.getKey().equals("DtlsSrtpKeyAgreement")) { - 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")); - } - - // Return the constraints specified for |type| of "audio" or "video" in - // |mediaConstraintsString|. - private String getAVConstraints( - String type, String mediaConstraintsString) { - try { - JSONObject json = new JSONObject(mediaConstraintsString); - // Tricksy handling of values that are allowed to be (boolean or - // MediaTrackConstraints) by the getUserMedia() spec. There are three - // cases below. - if (!json.has(type) || !json.optBoolean(type, true)) { - // Case 1: "audio"/"video" is not present, or is an explicit "false" - // boolean. - return null; - } - if (json.optBoolean(type, false)) { - // Case 2: "audio"/"video" is an explicit "true" boolean. - return "{\"mandatory\": {}, \"optional\": []}"; - } - // Case 3: "audio"/"video" is an object. - return json.getJSONObject(type).toString(); - } catch (JSONException e) { - throw new RuntimeException(e); - } - } - - private MediaConstraints constraintsFromJSON(String jsonString) { - if (jsonString == null) { - return null; - } - try { - MediaConstraints constraints = new MediaConstraints(); - JSONObject json = new JSONObject(jsonString); - JSONObject mandatoryJSON = json.optJSONObject("mandatory"); - if (mandatoryJSON != null) { - JSONArray mandatoryKeys = mandatoryJSON.names(); - if (mandatoryKeys != null) { - for (int i = 0; i < mandatoryKeys.length(); ++i) { - String key = mandatoryKeys.getString(i); - String value = mandatoryJSON.getString(key); - constraints.mandatory.add( - new MediaConstraints.KeyValuePair(key, value)); - } - } - } - JSONArray optionalJSON = json.optJSONArray("optional"); - if (optionalJSON != null) { - for (int i = 0; i < optionalJSON.length(); ++i) { - JSONObject keyValueDict = optionalJSON.getJSONObject(i); - String key = keyValueDict.names().getString(0); - String value = keyValueDict.getString(key); - constraints.optional.add( - new MediaConstraints.KeyValuePair(key, value)); - } - } - return constraints; - } catch (JSONException e) { - throw new RuntimeException(e); - } - } - - // Requests & returns a TURN ICE Server based on a request URL. Must be run - // off the main thread! - private PeerConnection.IceServer requestTurnServer(String url) { - try { - 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()); - 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); - } catch (JSONException e) { - throw new RuntimeException(e); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - } - - // Return the list of ICE servers described by a WebRTCPeerConnection - // configuration string. - private LinkedList iceServersFromPCConfigJSON( - String pcConfig) { - try { - JSONObject json = new JSONObject(pcConfig); - JSONArray servers = json.getJSONArray("iceServers"); - LinkedList ret = - new LinkedList(); - for (int i = 0; i < servers.length(); ++i) { - JSONObject server = servers.getJSONObject(i); - String url = server.getString("urls"); - String credential = - server.has("credential") ? server.getString("credential") : ""; - ret.add(new PeerConnection.IceServer(url, "", credential)); - } - return ret; - } catch (JSONException e) { - throw new RuntimeException(e); - } - } - // Request an attempt to drain the send queue, on a background thread. private void requestQueueDrainInBackground() { (new AsyncTask() { @@ -375,37 +161,68 @@ public class GAERTCClient implements AppRTCClient { // Send all queued messages if connected to the room. private void maybeDrainQueue() { synchronized (sendQueue) { - if (appRTCSignalingParameters == null) { + if (signalingParameters == null) { return; } try { for (String msg : sendQueue) { Log.d(TAG, "SEND: " + msg); - URLConnection connection = - new URL(gaeBaseHref + postMessageUrl).openConnection(); + URLConnection connection = new URL( + signalingParameters.postMessageUrl).openConnection(); connection.setDoOutput(true); connection.getOutputStream().write(msg.getBytes("UTF-8")); if (!connection.getHeaderField(null).startsWith("HTTP/1.1 200 ")) { - throw new IOException( - "Non-200 response to POST: " + connection.getHeaderField(null) + - " for msg: " + msg); + String errorMessage = "Non-200 response to POST: " + + connection.getHeaderField(null) + " for msg: " + msg; + reportChannelError(errorMessage); } } } catch (IOException e) { - throw new RuntimeException(e); + reportChannelError("GAE Post error: " + e.getMessage()); } sendQueue.clear(); } } - // Return the contents of an InputStream as a String. - private static String drainStream(InputStream in) { - Scanner s = new Scanner(in).useDelimiter("\\A"); - return s.hasNext() ? s.next() : ""; + private void reportChannelError(final String errorMessage) { + Log.e(TAG, errorMessage); + activity.runOnUiThread(new Runnable() { + public void run() { + events.onChannelError(errorMessage); + } + }); } + // -------------------------------------------------------------------- + // RoomConnectionEvents interface implementation. + // All events are called on UI thread. + @Override + public void onSignalingParametersReady(final SignalingParameters params) { + Log.d(TAG, "Room signaling parameters ready."); + if (params.websocketSignaling) { + reportChannelError("Room does not support GAE channel signaling."); + return; + } + gaeHandler = new GAEHandler(); + channelClient = + new GAEChannelClient(activity, params.channelToken, gaeHandler); + synchronized (sendQueue) { + signalingParameters = params; + } + requestQueueDrainInBackground(); + events.onConnectedToRoom(signalingParameters); + } + + @Override + public void onSignalingParametersError(final String description) { + reportChannelError("Room connection error: " + description); + } + + + // -------------------------------------------------------------------- + // GAEMessageHandler interface implementation. // Implementation detail: handler for receiving GAE messages and dispatching - // them appropriately. + // them appropriately. All dispatched messages are called from UI thread. private class GAEHandler implements GAEChannelClient.GAEMessageHandler { private boolean channelOpen = false; @@ -442,10 +259,10 @@ public class GAERTCClient implements AppRTCClient { } else if (type.equals("bye")) { events.onChannelClose(); } else { - events.onChannelError(1, "Unexpected channel message: " + msg); + reportChannelError("Unexpected channel message: " + msg); } } catch (JSONException e) { - events.onChannelError(1, "Channel message JSON parsing error: " + + reportChannelError("Channel message JSON parsing error: " + e.toString()); } } @@ -462,12 +279,9 @@ public class GAERTCClient implements AppRTCClient { } public void onError(final int code, final String description) { - activity.runOnUiThread(new Runnable() { - public void run() { - events.onChannelError(code, description); - channelOpen = false; - } - }); + channelOpen = false; + reportChannelError("GAE Handler error. Code: " + code + + ". " + description); } } diff --git a/talk/examples/android/src/org/appspot/apprtc/PeerConnectionClient.java b/talk/examples/android/src/org/appspot/apprtc/PeerConnectionClient.java index 9c917bbbb7..b005de7b37 100644 --- a/talk/examples/android/src/org/appspot/apprtc/PeerConnectionClient.java +++ b/talk/examples/android/src/org/appspot/apprtc/PeerConnectionClient.java @@ -30,7 +30,7 @@ package org.appspot.apprtc; import android.app.Activity; import android.util.Log; -import org.appspot.apprtc.AppRTCClient.AppRTCSignalingParameters; +import org.appspot.apprtc.AppRTCClient.SignalingParameters; import org.webrtc.DataChannel; import org.webrtc.IceCandidate; import org.webrtc.MediaConstraints; @@ -53,7 +53,7 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; public class PeerConnectionClient { - private static final String TAG = "RTCClient"; + private static final String TAG = "PCRTCClient"; private final Activity activity; private PeerConnectionFactory factory; private PeerConnection pc; @@ -63,8 +63,11 @@ public class PeerConnectionClient { private final SDPObserver sdpObserver = new SDPObserver(); private final VideoRenderer.Callbacks localRender; private final VideoRenderer.Callbacks remoteRender; - private LinkedList queuedRemoteCandidates = - new LinkedList(); + // Queued remote ICE candidates are consumed only after both local and + // 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; @@ -77,26 +80,28 @@ public class PeerConnectionClient { Activity activity, VideoRenderer.Callbacks localRender, VideoRenderer.Callbacks remoteRender, - AppRTCSignalingParameters appRtcParameters, + SignalingParameters signalingParameters, PeerConnectionEvents events) { this.activity = activity; this.localRender = localRender; this.remoteRender = remoteRender; this.events = events; - isInitiator = appRtcParameters.initiator; + isInitiator = signalingParameters.initiator; + queuedRemoteCandidates = new LinkedList(); + queuedLocalCandidates = new LinkedList(); sdpMediaConstraints = new MediaConstraints(); sdpMediaConstraints.mandatory.add(new MediaConstraints.KeyValuePair( "OfferToReceiveAudio", "true")); sdpMediaConstraints.mandatory.add(new MediaConstraints.KeyValuePair( "OfferToReceiveVideo", "true")); - videoConstraints = appRtcParameters.videoConstraints; + videoConstraints = signalingParameters.videoConstraints; factory = new PeerConnectionFactory(); - MediaConstraints pcConstraints = appRtcParameters.pcConstraints; + MediaConstraints pcConstraints = signalingParameters.pcConstraints; pcConstraints.optional.add( new MediaConstraints.KeyValuePair("RtpDataChannels", "true")); - pc = factory.createPeerConnection(appRtcParameters.iceServers, + pc = factory.createPeerConnection(signalingParameters.iceServers, pcConstraints, pcObserver); isInitiator = false; @@ -113,11 +118,11 @@ public class PeerConnectionClient { pc.addStream(videoMediaStream); } - if (appRtcParameters.audioConstraints != null) { + if (signalingParameters.audioConstraints != null) { MediaStream lMS = factory.createLocalMediaStream("ARDAMSAudio"); lMS.addTrack(factory.createAudioTrack( "ARDAMSa0", - factory.createAudioSource(appRtcParameters.audioConstraints))); + factory.createAudioSource(signalingParameters.audioConstraints))); pc.addStream(lMS); } } @@ -176,7 +181,6 @@ public class PeerConnectionClient { }); } - public void addRemoteIceCandidate(final IceCandidate candidate) { activity.runOnUiThread(new Runnable() { public void run() { @@ -379,11 +383,21 @@ public class PeerConnectionClient { return newSdpDescription.toString(); } - private void drainRemoteCandidates() { - for (IceCandidate candidate : queuedRemoteCandidates) { - pc.addIceCandidate(candidate); + 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) { + pc.addIceCandidate(candidate); + } + queuedRemoteCandidates = null; } - queuedRemoteCandidates = null; } public void switchCamera() { @@ -435,7 +449,11 @@ public class PeerConnectionClient { public void onIceCandidate(final IceCandidate candidate){ activity.runOnUiThread(new Runnable() { public void run() { - events.onIceCandidate(candidate); + if (queuedLocalCandidates != null) { + queuedLocalCandidates.add(candidate); + } else { + events.onIceCandidate(candidate); + } } }); } @@ -470,6 +488,7 @@ public class PeerConnectionClient { @Override public void onIceGatheringChange( PeerConnection.IceGatheringState newState) { + Log.d(TAG, "IceGatheringState: " + newState); } @Override @@ -543,20 +562,20 @@ public class PeerConnectionClient { Log.d(TAG, "Local SDP set succesfully"); events.onLocalDescription(localSdp); } else { - // We've just set remote description, - // so drain remote ICE candidates. + // We've just set remote description, so drain remote + // and send local ICE candidates. Log.d(TAG, "Remote SDP set succesfully"); - drainRemoteCandidates(); + drainCandidates(); } } else { // For answering peer connection we set remote SDP and then // create answer and set local SDP. if (pc.getLocalDescription() != null) { - // We've just set our local SDP so time to send it and drain - // remote ICE candidates. + // We've just set our local SDP so time to send it, drain + // remote and send local ICE candidates. Log.d(TAG, "Local SDP set succesfully"); events.onLocalDescription(localSdp); - drainRemoteCandidates(); + drainCandidates(); } else { // We've just set remote SDP - do nothing for now - // answer will be created soon. diff --git a/talk/examples/android/src/org/appspot/apprtc/RoomParametersFetcher.java b/talk/examples/android/src/org/appspot/apprtc/RoomParametersFetcher.java new file mode 100644 index 0000000000..99981f38d3 --- /dev/null +++ b/talk/examples/android/src/org/appspot/apprtc/RoomParametersFetcher.java @@ -0,0 +1,296 @@ +/* + * libjingle + * Copyright 2014, Google Inc. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. The name of the author may not be used to endorse or promote products + * derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.appspot.apprtc; + +import android.os.AsyncTask; +import android.util.Log; + +import org.appspot.apprtc.AppRTCClient.SignalingParameters; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.webrtc.MediaConstraints; +import org.webrtc.PeerConnection; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; +import java.util.LinkedList; +import java.util.Scanner; + +// AsyncTask that converts an AppRTC room URL into the set of signaling +// parameters to use with that room. +public class RoomParametersFetcher + extends AsyncTask { + private static final String TAG = "RoomRTCClient"; + private Exception exception = null; + private RoomParametersFetcherEvents events = null; + + /** + * Room parameters fetcher callbacks. + */ + public static interface RoomParametersFetcherEvents { + /** + * Callback fired once the room's signaling parameters + * SignalingParameters are extracted. + */ + public void onSignalingParametersReady(final SignalingParameters params); + + /** + * Callback for room parameters extraction error. + */ + public void onSignalingParametersError(final String description); + } + + public RoomParametersFetcher(RoomParametersFetcherEvents events) { + super(); + this.events = events; + } + + @Override + protected SignalingParameters doInBackground(String... urls) { + if (events == null) { + exception = new RuntimeException("Room conenction events should be set"); + return null; + } + if (urls.length != 1) { + exception = new RuntimeException("Must be called with a single URL"); + return null; + } + try { + exception = null; + return getParametersForRoomUrl(urls[0]); + } catch (JSONException e) { + exception = e; + } catch (IOException e) { + exception = e; + } + return null; + } + + @Override + protected void onPostExecute(SignalingParameters params) { + if (exception != null) { + Log.e(TAG, "Room connection error: " + exception.toString()); + events.onSignalingParametersError(exception.getMessage()); + return; + } + if (params == null) { + Log.e(TAG, "Can not extract room parameters"); + events.onSignalingParametersError("Can not extract room parameters"); + return; + } + events.onSignalingParametersReady(params); + } + + // Fetches |url| and fishes the signaling parameters out of the JSON. + private SignalingParameters getParametersForRoomUrl(String url) + throws IOException, JSONException { + url = url + "&t=json"; + Log.d(TAG, "Connecting to room: " + url); + InputStream responseStream = new BufferedInputStream( + (new URL(url)).openConnection().getInputStream()); + String response = drainStream(responseStream); + Log.d(TAG, "Room response: " + response); + JSONObject roomJson = new JSONObject(response); + + if (roomJson.has("error")) { + JSONArray errors = roomJson.getJSONArray("error_messages"); + throw new IOException(errors.toString()); + } + + String roomId = roomJson.getString("room_key"); + String clientId = roomJson.getString("me"); + Log.d(TAG, "RoomId: " + roomId + ". ClientId: " + clientId); + String channelToken = roomJson.optString("token"); + String offerSdp = roomJson.optString("offer"); + if (offerSdp != null && offerSdp.length() > 0) { + JSONObject offerJson = new JSONObject(offerSdp); + offerSdp = offerJson.getString("sdp"); + Log.d(TAG, "SDP type: " + offerJson.getString("type")); + } else { + offerSdp = null; + } + + String postMessageUrl = url.substring(0, url.indexOf('?')); + postMessageUrl += "/message?r=" + roomId + "&u=" + clientId; + Log.d(TAG, "Post url: " + postMessageUrl); + + boolean initiator = roomJson.getInt("initiator") == 1; + Log.d(TAG, "Initiator: " + initiator); + + LinkedList iceServers = + iceServersFromPCConfigJSON(roomJson.getString("pc_config")); + boolean isTurnPresent = false; + for (PeerConnection.IceServer server : iceServers) { + Log.d(TAG, "IceServer: " + server); + if (server.uri.startsWith("turn:")) { + isTurnPresent = true; + break; + } + } + if (!isTurnPresent) { + PeerConnection.IceServer server = + requestTurnServer(roomJson.getString("turn_url")); + Log.d(TAG, "TurnServer: " + server); + iceServers.add(server); + } + + MediaConstraints pcConstraints = constraintsFromJSON( + roomJson.getString("pc_constraints")); + addDTLSConstraintIfMissing(pcConstraints); + Log.d(TAG, "pcConstraints: " + pcConstraints); + MediaConstraints videoConstraints = constraintsFromJSON( + getAVConstraints("video", + roomJson.getString("media_constraints"))); + Log.d(TAG, "videoConstraints: " + videoConstraints); + MediaConstraints audioConstraints = constraintsFromJSON( + getAVConstraints("audio", + roomJson.getString("media_constraints"))); + Log.d(TAG, "audioConstraints: " + audioConstraints); + + return new SignalingParameters( + iceServers, initiator, + pcConstraints, videoConstraints, audioConstraints, + postMessageUrl, 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) { + for (MediaConstraints.KeyValuePair pair : pcConstraints.mandatory) { + if (pair.getKey().equals("DtlsSrtpKeyAgreement")) { + return; + } + } + for (MediaConstraints.KeyValuePair pair : pcConstraints.optional) { + if (pair.getKey().equals("DtlsSrtpKeyAgreement")) { + 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")); + } + + // Return the constraints specified for |type| of "audio" or "video" in + // |mediaConstraintsString|. + private String getAVConstraints ( + String type, String mediaConstraintsString) throws JSONException { + JSONObject json = new JSONObject(mediaConstraintsString); + // Tricksy handling of values that are allowed to be (boolean or + // MediaTrackConstraints) by the getUserMedia() spec. There are three + // cases below. + if (!json.has(type) || !json.optBoolean(type, true)) { + // Case 1: "audio"/"video" is not present, or is an explicit "false" + // boolean. + return null; + } + if (json.optBoolean(type, false)) { + // Case 2: "audio"/"video" is an explicit "true" boolean. + return "{\"mandatory\": {}, \"optional\": []}"; + } + // Case 3: "audio"/"video" is an object. + return json.getJSONObject(type).toString(); + } + + private MediaConstraints constraintsFromJSON(String jsonString) + throws JSONException { + if (jsonString == null) { + return null; + } + MediaConstraints constraints = new MediaConstraints(); + JSONObject json = new JSONObject(jsonString); + JSONObject mandatoryJSON = json.optJSONObject("mandatory"); + if (mandatoryJSON != null) { + JSONArray mandatoryKeys = mandatoryJSON.names(); + if (mandatoryKeys != null) { + for (int i = 0; i < mandatoryKeys.length(); ++i) { + String key = mandatoryKeys.getString(i); + String value = mandatoryJSON.getString(key); + constraints.mandatory.add( + new MediaConstraints.KeyValuePair(key, value)); + } + } + } + JSONArray optionalJSON = json.optJSONArray("optional"); + if (optionalJSON != null) { + for (int i = 0; i < optionalJSON.length(); ++i) { + JSONObject keyValueDict = optionalJSON.getJSONObject(i); + String key = keyValueDict.names().getString(0); + String value = keyValueDict.getString(key); + constraints.optional.add( + new MediaConstraints.KeyValuePair(key, value)); + } + } + return constraints; + } + + // Requests & returns a TURN ICE Server based on a request URL. Must be run + // off the main thread! + private PeerConnection.IceServer requestTurnServer(String url) + throws IOException, JSONException { + 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()); + 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); + } + + // Return the list of ICE servers described by a WebRTCPeerConnection + // configuration string. + private LinkedList iceServersFromPCConfigJSON( + String pcConfig) throws JSONException { + JSONObject json = new JSONObject(pcConfig); + JSONArray servers = json.getJSONArray("iceServers"); + LinkedList ret = + new LinkedList(); + for (int i = 0; i < servers.length(); ++i) { + JSONObject server = servers.getJSONObject(i); + String url = server.getString("urls"); + String credential = + server.has("credential") ? server.getString("credential") : ""; + ret.add(new PeerConnection.IceServer(url, "", credential)); + } + return ret; + } + + // Return the contents of an InputStream as a String. + private String drainStream(InputStream in) { + Scanner s = new Scanner(in).useDelimiter("\\A"); + return s.hasNext() ? s.next() : ""; + } + +} diff --git a/talk/examples/android/src/org/appspot/apprtc/SettingsActivity.java b/talk/examples/android/src/org/appspot/apprtc/SettingsActivity.java index eccb67ed2d..367c834b9a 100644 --- a/talk/examples/android/src/org/appspot/apprtc/SettingsActivity.java +++ b/talk/examples/android/src/org/appspot/apprtc/SettingsActivity.java @@ -36,7 +36,6 @@ import android.preference.Preference; public class SettingsActivity extends Activity implements OnSharedPreferenceChangeListener{ private SettingsFragment settingsFragment; - private String keyprefUrl; private String keyprefResolution; private String keyprefFps; private String keyprefCpuUsageDetection; @@ -44,7 +43,6 @@ public class SettingsActivity extends Activity @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - keyprefUrl = getString(R.string.pref_url_key); keyprefResolution = getString(R.string.pref_resolution_key); keyprefFps = getString(R.string.pref_fps_key); keyprefCpuUsageDetection = getString(R.string.pref_cpu_usage_detection_key); @@ -63,7 +61,6 @@ public class SettingsActivity extends Activity SharedPreferences sharedPreferences = settingsFragment.getPreferenceScreen().getSharedPreferences(); sharedPreferences.registerOnSharedPreferenceChangeListener(this); - updateSummary(sharedPreferences, keyprefUrl); updateSummary(sharedPreferences, keyprefResolution); updateSummary(sharedPreferences, keyprefFps); updateSummaryB(sharedPreferences, keyprefCpuUsageDetection); @@ -80,8 +77,7 @@ public class SettingsActivity extends Activity @Override public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { - if (key.equals(keyprefUrl) || key.equals(keyprefResolution) || - key.equals(keyprefFps)) { + if (key.equals(keyprefResolution) || key.equals(keyprefFps)) { updateSummary(sharedPreferences, key); } else if (key.equals(keyprefCpuUsageDetection)) { updateSummaryB(sharedPreferences, key); diff --git a/talk/examples/android/src/org/appspot/apprtc/WebSocketChannelClient.java b/talk/examples/android/src/org/appspot/apprtc/WebSocketChannelClient.java new file mode 100644 index 0000000000..373480d156 --- /dev/null +++ b/talk/examples/android/src/org/appspot/apprtc/WebSocketChannelClient.java @@ -0,0 +1,216 @@ +/* + * libjingle + * Copyright 2014, Google Inc. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. The name of the author may not be used to endorse or promote products + * derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.appspot.apprtc; + +import android.os.AsyncTask; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import de.tavendo.autobahn.WebSocketConnection; +import de.tavendo.autobahn.WebSocketException; +import de.tavendo.autobahn.WebSocket.WebSocketConnectionObserver; + +import java.net.URI; +import java.net.URISyntaxException; + +import org.json.JSONException; +import org.json.JSONObject; + +/** + * WebSocket client implementation. + * For proper synchronization all methods should be called from UI thread + * and all WebSocket events are delivered on UI thread as well. + */ + +public class WebSocketChannelClient { + private final String TAG = "WSChannelRTCClient"; + private final WebSocketChannelEvents events; + private final Handler uiHandler; + private WebSocketConnection ws; + private WebSocketObserver wsObserver; + private URI serverURI; + private WebSocketConnectionState state; + + public enum WebSocketConnectionState { + NEW, CONNECTED, REGISTERED, CLOSED, ERROR + }; + + /** + * Callback interface for messages delivered on WebSocket. + * All events are invoked from UI thread. + */ + public interface WebSocketChannelEvents { + public void onWebSocketOpen(); + public void onWebSocketMessage(final String message); + public void onWebSocketClose(); + public void onWebSocketError(final String description); + } + + public WebSocketChannelClient(WebSocketChannelEvents events) { + this.events = events; + uiHandler = new Handler(Looper.getMainLooper()); + state = WebSocketConnectionState.NEW; + } + + public WebSocketConnectionState getState() { + return state; + } + + public void connect(String url) { + if (state != WebSocketConnectionState.NEW) { + Log.e(TAG, "WebSocket is already connected."); + return; + } + Log.d(TAG, "Connecting WebSocket to: " + url); + + ws = new WebSocketConnection(); + wsObserver = new WebSocketObserver(); + try { + serverURI = new URI(url); + ws.connect(serverURI, wsObserver); + } catch (URISyntaxException e) { + reportError("URI error: " + e.getMessage()); + } catch (WebSocketException e) { + reportError("WebSocket connection error: " + e.getMessage()); + } + } + + public void register(String roomId, String clientId) { + if (state != WebSocketConnectionState.CONNECTED) { + Log.w(TAG, "WebSocket register() in state " + state); + return; + } + JSONObject json = new JSONObject(); + try { + json.put("cmd", "register"); + json.put("roomid", roomId); + json.put("clientid", clientId); + Log.d(TAG, "WS SEND: " + json.toString()); + ws.sendTextMessage(json.toString()); + state = WebSocketConnectionState.REGISTERED; + } 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; + } + 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()); + } + } + + public void disconnect() { + Log.d(TAG, "Disonnect WebSocket. State: " + state); + if (state == WebSocketConnectionState.REGISTERED) { + 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(); + } + } + + private void reportError(final String errorMessage) { + Log.e(TAG, errorMessage); + uiHandler.post(new Runnable() { + public void run() { + if (state != WebSocketConnectionState.ERROR) { + state = WebSocketConnectionState.ERROR; + events.onWebSocketError(errorMessage); + } + } + }); + } + + private class WebSocketObserver implements WebSocketConnectionObserver { + @Override + public void onOpen() { + Log.d(TAG, "WebSocket connection opened to: " + serverURI.toString()); + uiHandler.post(new Runnable() { + public void run() { + state = WebSocketConnectionState.CONNECTED; + events.onWebSocketOpen(); + } + }); + } + + @Override + public void onClose(WebSocketCloseNotification code, String reason) { + Log.d(TAG, "WebSocket connection closed. Code: " + code + + ". Reason: " + reason); + uiHandler.post(new Runnable() { + public void run() { + if (state != WebSocketConnectionState.CLOSED) { + state = WebSocketConnectionState.CLOSED; + events.onWebSocketClose(); + } + } + }); + } + + @Override + public void onTextMessage(String payload) { + Log.d(TAG, "WS GET: " + payload); + final String message = payload; + uiHandler.post(new Runnable() { + public void run() { + if (state == WebSocketConnectionState.CONNECTED || + state == WebSocketConnectionState.REGISTERED) { + events.onWebSocketMessage(message); + } + } + }); + } + + @Override + public void onRawTextMessage(byte[] payload) { + } + + @Override + public void onBinaryMessage(byte[] payload) { + } + } + +} diff --git a/talk/examples/android/src/org/appspot/apprtc/WebSocketRTCClient.java b/talk/examples/android/src/org/appspot/apprtc/WebSocketRTCClient.java new file mode 100644 index 0000000000..aaef09b981 --- /dev/null +++ b/talk/examples/android/src/org/appspot/apprtc/WebSocketRTCClient.java @@ -0,0 +1,313 @@ +/* + * libjingle + * Copyright 2014, Google Inc. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. The name of the author may not be used to endorse or promote products + * derived from this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED + * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF + * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO + * EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, + * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; + * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, + * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR + * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF + * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.appspot.apprtc; + +import android.os.AsyncTask; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import java.io.IOException; +import java.net.URL; +import java.net.URLConnection; +import java.util.LinkedList; + +import org.appspot.apprtc.RoomParametersFetcher.RoomParametersFetcherEvents; +import org.appspot.apprtc.WebSocketChannelClient.WebSocketChannelEvents; +import org.appspot.apprtc.WebSocketChannelClient.WebSocketConnectionState; +import org.json.JSONException; +import org.json.JSONObject; +import org.webrtc.IceCandidate; +import org.webrtc.SessionDescription; + +/** + * Negotiates signaling for chatting with apprtc.appspot.com "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, + RoomParametersFetcherEvents, WebSocketChannelEvents { + private static final String TAG = "WSRTCClient"; + private static final String WSS_SERVER = "wss://apprtc-ws.webrtc.org:8089/ws"; + + private enum ConnectionState { + NEW, CONNECTED, CLOSED, ERROR + }; + private final Handler uiHandler; + private SignalingEvents events; + private SignalingParameters signalingParameters; + private WebSocketChannelClient wsClient; + private RoomParametersFetcher fetcher; + private ConnectionState roomState; + private LinkedList gaePostQueue; + + public WebSocketRTCClient(SignalingEvents events) { + this.events = events; + uiHandler = new Handler(Looper.getMainLooper()); + gaePostQueue = new LinkedList(); + } + + // -------------------------------------------------------------------- + // RoomConnectionEvents interface implementation. + // All events are called on UI thread. + @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"); + return; + } + signalingParameters = params; + roomState = ConnectionState.CONNECTED; + events.onConnectedToRoom(signalingParameters); + wsClient.register(signalingParameters.roomId, signalingParameters.clientId); + events.onChannelOpen(); + if (!signalingParameters.initiator) { + // For call receiver get sdp offer from room parameters. + SessionDescription sdp = new SessionDescription( + SessionDescription.Type.fromCanonicalForm("offer"), + signalingParameters.offerSdp); + events.onRemoteDescription(sdp); + } + } + + @Override + public void onSignalingParametersError(final String description) { + reportError("Room connection error: " + description); + } + + // -------------------------------------------------------------------- + // WebSocketChannelEvents interface implementation. + // All events are called on UI thread. + @Override + public void onWebSocketOpen() { + Log.d(TAG, "Websocket connection completed."); + if (roomState == ConnectionState.CONNECTED) { + wsClient.register( + signalingParameters.roomId, signalingParameters.clientId); + } + } + + @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")) { + IceCandidate candidate = new IceCandidate( + (String) json.get("id"), + json.getInt("label"), + (String) json.get("candidate")); + events.onRemoteIceCandidate(candidate); + } else if (type.equals("answer")) { + SessionDescription sdp = new SessionDescription( + SessionDescription.Type.fromCanonicalForm(type), + (String)json.get("sdp")); + events.onRemoteDescription(sdp); + } 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); + } + + // -------------------------------------------------------------------- + // AppRTCClient interface implementation. + // Asynchronously connect to an AppRTC room URL, e.g. + // https://apprtc.appspot.com/?r=NNN, retrieve room parameters + // and connect to WebSocket server. + @Override + public void connectToRoom(String url) { + // Get room parameters. + roomState = ConnectionState.NEW; + fetcher = new RoomParametersFetcher(this); + fetcher.execute(url); + // Connect to WebSocket server. + wsClient = new WebSocketChannelClient(this); + wsClient.connect(WSS_SERVER); + } + + @Override + public void disconnect() { + Log.d(TAG, "Disconnect. Room state: " + roomState); + if (roomState == ConnectionState.CONNECTED) { + Log.d(TAG, "Closing room."); + sendGAEMessage("{\"type\": \"bye\"}"); + } + wsClient.disconnect(); + } + + // Send local SDP (offer or answer, depending on role) to the + // other participant. Note that it is important to send the output of + // create{Offer,Answer} and not merely the current value of + // getLocalDescription() because the latter may include ICE candidates that + // 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()); + } + + @Override + public void sendAnswerSdp(final SessionDescription sdp) { + if (wsClient.getState() != WebSocketConnectionState.REGISTERED) { + reportError("Sending answer SDP in non registered state."); + 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) { + 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()); + } + + // -------------------------------------------------------------------- + // Helper functions. + private void reportError(final String errorMessage) { + Log.e(TAG, errorMessage); + uiHandler.post(new Runnable() { + 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); + } + } + + // Queue a message for sending to the room and send it if already connected. + private synchronized void sendGAEMessage(String msg) { + synchronized (gaePostQueue) { + gaePostQueue.add(msg); + } + (new AsyncTask() { + public Void doInBackground(Void... unused) { + maybeDrainGAEPostQueue(); + return null; + } + }).execute(); + } + + // Send all queued messages if connected to the room. + private void maybeDrainGAEPostQueue() { + synchronized (gaePostQueue) { + if (roomState != ConnectionState.CONNECTED) { + return; + } + try { + for (String msg : gaePostQueue) { + Log.d(TAG, "ROOM SEND: " + msg); + // 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); + String type = json.optString("type"); + if (type != null && type.equals("bye")) { + roomState = ConnectionState.CLOSED; + } + // Send POST request. + URLConnection connection = new URL( + signalingParameters.postMessageUrl).openConnection(); + connection.setDoOutput(true); + connection.setRequestProperty( + "content-type", "text/plain; charset=utf-8"); + connection.getOutputStream().write(msg.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); + } + } + } catch (IOException e) { + reportError("GAE POST error: " + e.getMessage()); + } catch (JSONException e) { + reportError("GAE POST JSON error: " + e.getMessage()); + } + gaePostQueue.clear(); + } + } + +} diff --git a/talk/examples/android/third_party/autobanh/LICENSE b/talk/examples/android/third_party/autobanh/LICENSE new file mode 100644 index 0000000000..f433b1a53f --- /dev/null +++ b/talk/examples/android/third_party/autobanh/LICENSE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/talk/examples/android/third_party/autobanh/LICENSE.md b/talk/examples/android/third_party/autobanh/LICENSE.md new file mode 100644 index 0000000000..2079e90d6b --- /dev/null +++ b/talk/examples/android/third_party/autobanh/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Cameron Lowell Palmer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/talk/examples/android/third_party/autobanh/autobanh.jar b/talk/examples/android/third_party/autobanh/autobanh.jar new file mode 100644 index 0000000000000000000000000000000000000000..5a10b7f3f185b0db8e60f4db2ed175923c12228c GIT binary patch literal 45472 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 literal 0 HcmV?d00001 diff --git a/talk/libjingle_examples.gyp b/talk/libjingle_examples.gyp index f7ce53bdde..740452d183 100755 --- a/talk/libjingle_examples.gyp +++ b/talk/libjingle_examples.gyp @@ -314,6 +314,7 @@ 'examples/android/README', 'examples/android/ant.properties', 'examples/android/assets/channel.html', + 'examples/android/third_party/autobanh/autobanh.jar', 'examples/android/build.xml', 'examples/android/jni/Android.mk', 'examples/android/project.properties', @@ -351,9 +352,12 @@ 'examples/android/src/org/appspot/apprtc/GAEChannelClient.java', 'examples/android/src/org/appspot/apprtc/GAERTCClient.java', 'examples/android/src/org/appspot/apprtc/PeerConnectionClient.java', + 'examples/android/src/org/appspot/apprtc/RoomParametersFetcher.java', 'examples/android/src/org/appspot/apprtc/SettingsActivity.java', 'examples/android/src/org/appspot/apprtc/SettingsFragment.java', 'examples/android/src/org/appspot/apprtc/UnhandledExceptionHandler.java', + 'examples/android/src/org/appspot/apprtc/WebSocketChannelClient.java', + 'examples/android/src/org/appspot/apprtc/WebSocketRTCClient.java', ], 'outputs': [ '<(PRODUCT_DIR)/AppRTCDemo-debug.apk', @@ -367,6 +371,7 @@ 'mkdir -p <(INTERMEDIATE_DIR) && ' # Must happen _before_ the cd below 'mkdir -p examples/android/libs/<(android_app_abi) && ' 'cp <(PRODUCT_DIR)/libjingle_peerconnection.jar examples/android/libs/ &&' + 'cp examples/android/third_party/autobanh/autobanh.jar examples/android/libs/ &&' '<(android_strip) -o examples/android/libs/<(android_app_abi)/libjingle_peerconnection_so.so <(PRODUCT_DIR)/libjingle_peerconnection_so.so &&' 'cd examples/android && ' '{ ANDROID_SDK_ROOT=<(android_sdk_root) ' diff --git a/webrtc/modules/audio_coding/neteq/neteq_impl.cc b/webrtc/modules/audio_coding/neteq/neteq_impl.cc index f3d1a4f6b8..5ec38d4349 100644 --- a/webrtc/modules/audio_coding/neteq/neteq_impl.cc +++ b/webrtc/modules/audio_coding/neteq/neteq_impl.cc @@ -15,6 +15,7 @@ #include +#include "webrtc/base/checks.h" #include "webrtc/common_audio/signal_processing/include/signal_processing_library.h" #include "webrtc/modules/audio_coding/neteq/accelerate.h" #include "webrtc/modules/audio_coding/neteq/background_noise.h" @@ -607,6 +608,8 @@ int NetEqImpl::InsertPacketInternal(const WebRtcRTPHeader& rtp_header, new_codec_ = true; update_sample_rate_and_channels = true; LOG_F(LS_WARNING) << "Packet buffer flushed"; + DCHECK(!packet_buffer_->Empty()) + << "One packet must have been inserted after the flush."; } else if (ret != PacketBuffer::kOK) { LOG_FERR1(LS_WARNING, InsertPacketList, packet_list.size()); PacketBuffer::DeleteAllPackets(&packet_list);