/* * Copyright 2015 OpenMarket Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.matrix.androidsdk.call; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Color; import android.os.Build; import android.text.TextUtils; import org.matrix.androidsdk.util.Log; import android.view.View; import android.webkit.JavascriptInterface; import android.webkit.PermissionRequest; import android.webkit.WebChromeClient; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import com.google.gson.JsonParser; import org.matrix.androidsdk.MXSession; import org.matrix.androidsdk.rest.callback.ApiCallback; import org.matrix.androidsdk.rest.model.Event; import org.matrix.androidsdk.rest.model.MatrixError; import java.util.Timer; import java.util.TimerTask; public class MXChromeCall extends MXCall { private static final String LOG_TAG = "MXChromeCall"; private WebView mWebView = null; private CallWebAppInterface mCallWebAppInterface = null; private boolean mIsIncomingPrepared = false; private JsonObject mCallInviteParams = null; private JsonArray mPendingCandidates = new JsonArray(); /** * @return true if this stack can perform calls. */ public static boolean isSupported() { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP; } // creator public MXChromeCall(MXSession session, Context context, JsonElement turnServer) { if (!isSupported()) { throw new AssertionError("MXChromeCall : not supported with the current android version"); } if (null == session) { throw new AssertionError("MXChromeCall : session cannot be null"); } if (null == context) { throw new AssertionError("MXChromeCall : context cannot be null"); } mCallId = "c" + System.currentTimeMillis(); mSession = session; mContext = context; mTurnServer = turnServer; } @SuppressLint("NewApi") @Override public void createCallView() { mUIThreadHandler.post(new Runnable() { @Override public void run() { mWebView = new WebView(mContext); mWebView.setBackgroundColor(Color.BLACK); // warn that the webview must be added in an activity/fragment dispatchOnViewLoading(mWebView); mUIThreadHandler.post(new Runnable() { @Override public void run() { mCallWebAppInterface = new CallWebAppInterface(); mWebView.addJavascriptInterface(mCallWebAppInterface, "Android"); WebView.setWebContentsDebuggingEnabled(true); WebSettings settings = mWebView.getSettings(); // Enable Javascript settings.setJavaScriptEnabled(true); // Use WideViewport and Zoom out if there is no viewport defined settings.setUseWideViewPort(true); settings.setLoadWithOverviewMode(true); // Enable pinch to zoom without the zoom buttons settings.setBuiltInZoomControls(true); // Allow use of Local Storage settings.setDomStorageEnabled(true); settings.setAllowFileAccessFromFileURLs(true); settings.setAllowUniversalAccessFromFileURLs(true); settings.setDisplayZoomControls(false); mWebView.setWebViewClient(new WebViewClient()); // AppRTC requires third party cookies to work android.webkit.CookieManager cookieManager = android.webkit.CookieManager.getInstance(); cookieManager.setAcceptThirdPartyCookies(mWebView, true); final String url = "file:///android_asset/www/call.html"; mWebView.loadUrl(url); mWebView.setWebChromeClient(new WebChromeClient() { @Override public void onPermissionRequest(final PermissionRequest request) { mUIThreadHandler.post(new Runnable() { @Override public void run() { request.grant(request.getResources()); } }); } }); } }); } }); } // actions (must be done after onViewReady() /** * Start a call. */ @Override public void placeCall(VideoLayoutConfiguration aLocalVideoPosition) { if (CALL_STATE_FLEDGLING.equals(getCallState())) { mIsIncoming = false; mUIThreadHandler.post(new Runnable() { @Override public void run() { mWebView.loadUrl(mIsVideoCall ? "javascript:placeVideoCall()" : "javascript:placeVoiceCall()"); } }); } } /** * Prepare a call reception. * @param aCallInviteParams the invitation Event content * @param aCallId the call ID * @param aLocalVideoPosition position of the local video attendee */ @Override public void prepareIncomingCall(final JsonObject aCallInviteParams, final String aCallId, VideoLayoutConfiguration aLocalVideoPosition) { mCallId = aCallId; if (CALL_STATE_FLEDGLING.equals(getCallState())) { mIsIncoming = true; mUIThreadHandler.post(new Runnable() { @Override public void run() { mWebView.loadUrl("javascript:initWithInvite('" + aCallId + "'," + aCallInviteParams.toString() + ")"); mIsIncomingPrepared = true; mWebView.post(new Runnable() { @Override public void run() { checkPendingCandidates(); } }); } }); } else if (CALL_STATE_CREATED.equals(getCallState())) { mCallInviteParams = aCallInviteParams; // detect call type from the sdp try { JsonObject offer = mCallInviteParams.get("offer").getAsJsonObject(); JsonElement sdp = offer.get("sdp"); String sdpValue = sdp.getAsString(); setIsVideo(sdpValue.indexOf("m=video") >= 0); } catch (Exception e) { Log.e(LOG_TAG, "## prepareIncomingCall() ; " + e.getMessage()); } } } /** * The call has been detected as an incoming one. * The application launched the dedicated activity and expects to launch the incoming call. * @param aLocalVideoPosition local video position */ @Override public void launchIncomingCall(VideoLayoutConfiguration aLocalVideoPosition) { if (CALL_STATE_FLEDGLING.equals(getCallState())) { prepareIncomingCall(mCallInviteParams, mCallId, null); } } /** * The callee accepts the call. * @param event the event */ private void onCallAnswer(final Event event) { if (!CALL_STATE_CREATED.equals(getCallState()) && (null != mWebView)) { mUIThreadHandler.post(new Runnable() { @Override public void run() { mWebView.loadUrl("javascript:receivedAnswer(" + event.getContent().toString() + ")"); } }); } } /** * The other call member hangs up the call. * @param event the event */ private void onCallHangup(final Event event) { if (!CALL_STATE_CREATED.equals(getCallState()) && (null != mWebView)) { mUIThreadHandler.post(new Runnable() { @Override public void run() { mWebView.loadUrl("javascript:onHangupReceived(" + event.getContent().toString() + ")"); mWebView.post(new Runnable() { @Override public void run() { dispatchOnCallEnd(END_CALL_REASON_PEER_HANG_UP); } }); } }); } } /** * A new Ice candidate is received * @param candidates the ice candidates */ public void onNewCandidates(final JsonElement candidates) { if (!CALL_STATE_CREATED.equals(getCallState()) && (null != mWebView)) { mWebView.post(new Runnable() { @Override public void run() { mWebView.loadUrl("javascript:gotRemoteCandidates(" + candidates.toString() + ")"); } }); } } /** * Add ice candidates * @param candidates ic candidates */ private void addCandidates(JsonArray candidates) { if (mIsIncomingPrepared || !isIncoming()) { onNewCandidates(candidates); } else { synchronized (LOG_TAG) { mPendingCandidates.addAll(candidates); } } } /** * Some Ice candidates could have been received while creating the call view. * Check if some of them have been defined. */ public void checkPendingCandidates() { synchronized (LOG_TAG) { onNewCandidates(mPendingCandidates); mPendingCandidates = new JsonArray(); } } // events thread /** * Manage the call events. * @param event the call event. */ @Override public void handleCallEvent(Event event) { String eventType = event.getType(); if (event.isCallEvent()) { // event from other member if (!TextUtils.equals(event.getSender(), mSession.getMyUserId())) { if (Event.EVENT_TYPE_CALL_ANSWER.equals(eventType) && !mIsIncoming) { onCallAnswer(event); } else if (Event.EVENT_TYPE_CALL_CANDIDATES.equals(eventType)) { JsonArray candidates = event.getContentAsJsonObject().getAsJsonArray("candidates"); addCandidates(candidates); } else if (Event.EVENT_TYPE_CALL_HANGUP.equals(eventType)) { onCallHangup(event); } } else if (Event.EVENT_TYPE_CALL_INVITE.equals(eventType)) { // server echo : assume that the other device is ringing mCallWebAppInterface.mCallState = IMXCall.CALL_STATE_RINGING; // warn in the UI thread mUIThreadHandler.post(new Runnable() { @Override public void run() { dispatchOnStateDidChange(mCallWebAppInterface.mCallState); } }); } else if (Event.EVENT_TYPE_CALL_ANSWER.equals(eventType)) { // check if the call has not been answer in another device mUIThreadHandler.post(new Runnable() { @Override public void run() { // ring on this side if (getCallState().equals(IMXCall.CALL_STATE_RINGING)) { onAnsweredElsewhere(); } } }); } } } // user actions /** * The call is accepted. */ @Override public void answer() { if (!CALL_STATE_CREATED.equals(getCallState()) && (null != mWebView)) { mUIThreadHandler.post(new Runnable() { @Override public void run() { mWebView.loadUrl("javascript:answerCall()"); } }); } } /** * The call is hung up. */ @Override public void hangup(String reason) { if (!CALL_STATE_CREATED.equals(getCallState()) && (null != mWebView)) { mUIThreadHandler.post(new Runnable() { @Override public void run() { mWebView.loadUrl("javascript:hangup()"); } }); } else { sendHangup(reason); } } // getters / setters /** * @return the callstate (must be a CALL_STATE_XX value) */ @Override public String getCallState() { if (null != mCallWebAppInterface) { return mCallWebAppInterface.mCallState; } else { return CALL_STATE_CREATED; } } /** * @return the callView */ @Override public View getCallView() { return mWebView; } /** * @return the callView visibility */ @Override public int getVisibility() { if (null != mWebView) { return mWebView.getVisibility(); } else { return View.GONE; } } /** * Set the callview visibility * @return true if the operation succeeds */ public boolean setVisibility(int visibility) { if (null != mWebView) { mWebView.setVisibility(visibility); return true; } return false; } @Override public void onAnsweredElsewhere() { mUIThreadHandler.post(new Runnable() { @Override public void run() { mWebView.loadUrl("javascript:onAnsweredElsewhere()"); } }); dispatchAnsweredElsewhere(); } // private class private class CallWebAppInterface { public String mCallState = CALL_STATE_CREATING_CALL_VIEW; private Timer mCallTimeoutTimer = null; CallWebAppInterface() { if (null == mCallingRoom) { throw new AssertionError("MXChromeCall : room cannot be null"); } } // JS <-> android calls @JavascriptInterface public String wgetCallId() { return mCallId; } @JavascriptInterface public String wgetRoomId() { return mCallSignalingRoom.getRoomId(); } @JavascriptInterface public String wgetTurnServer() { if (null != mTurnServer) { return mTurnServer.toString(); } else { return null; } } @JavascriptInterface public void wlog(String message) { Log.d(LOG_TAG, "WebView Message : " + message); } @JavascriptInterface public void wCallError(String message) { Log.e(LOG_TAG, "WebView error Message : " + message); if ("ice_failed".equals(message)) { dispatchOnCallError(CALL_ERROR_ICE_FAILED); } else if ("user_media_failed".equals(message)) { dispatchOnCallError(CALL_ERROR_CAMERA_INIT_FAILED); } } @JavascriptInterface public void wOnStateUpdate(String jsstate) { String nextState = null; if ("fledgling".equals(jsstate)) { nextState = CALL_STATE_FLEDGLING; } else if ("wait_local_media".equals(jsstate)) { nextState = CALL_STATE_WAIT_LOCAL_MEDIA; } else if ("create_offer".equals(jsstate)) { nextState = CALL_STATE_WAIT_CREATE_OFFER; } else if ("invite_sent".equals(jsstate)) { nextState = CALL_STATE_INVITE_SENT; } else if ("ringing".equals(jsstate)) { nextState = CALL_STATE_RINGING; } else if ("create_answer".equals(jsstate)) { nextState = CALL_STATE_CREATE_ANSWER; } else if ("connecting".equals(jsstate)) { nextState = CALL_STATE_CONNECTING; } else if ("connected".equals(jsstate)) { nextState = CALL_STATE_CONNECTED; } else if ("ended".equals(jsstate)) { nextState = CALL_STATE_ENDED; } // is there any state update ? if ((null != nextState) && !mCallState.equals(nextState)) { mCallState = nextState; // warn in the UI thread mUIThreadHandler.post(new Runnable() { @Override public void run() { // call timeout management if (CALL_STATE_CONNECTING.equals(mCallState) || CALL_STATE_CONNECTING.equals(mCallState)) { if (null != mCallTimeoutTimer) { mCallTimeoutTimer.cancel(); mCallTimeoutTimer = null; } } dispatchOnStateDidChange(mCallState); } }); } } @JavascriptInterface public void wOnLoaded() { mCallState = CALL_STATE_FLEDGLING; mUIThreadHandler.post(new Runnable() { @Override public void run() { dispatchOnViewReady(); } }); } private void sendHangup(final Event event) { if (null != mCallTimeoutTimer) { mCallTimeoutTimer.cancel(); mCallTimeoutTimer = null; } mUIThreadHandler.post(new Runnable() { @Override public void run() { dispatchOnCallEnd(END_CALL_REASON_UNDEFINED); } }); mPendingEvents.clear(); mCallSignalingRoom.sendEvent(event, new ApiCallback<Void>() { @Override public void onSuccess(Void info) { } @Override public void onNetworkError(Exception e) { // try again sendHangup(event); } @Override public void onMatrixError(MatrixError e) { } @Override public void onUnexpectedError(Exception e) { } }); } @JavascriptInterface public void wSendEvent(final String roomId, final String eventType, final String jsonContent) { mUIThreadHandler.post(new Runnable() { @Override public void run() { try { boolean addIt = true; JsonObject content = (JsonObject) new JsonParser().parse(jsonContent); // merge candidates if (TextUtils.equals(eventType, Event.EVENT_TYPE_CALL_CANDIDATES) && (mPendingEvents.size() > 0)) { try { Event lastEvent = mPendingEvents.get(mPendingEvents.size() - 1); if (TextUtils.equals(lastEvent.getType(), Event.EVENT_TYPE_CALL_CANDIDATES)) { JsonObject lastContent = lastEvent.getContentAsJsonObject(); JsonArray lastContentCandidates = lastContent.get("candidates").getAsJsonArray(); JsonArray newContentCandidates = content.get("candidates").getAsJsonArray(); Log.d(LOG_TAG, "Merge candidates from " + lastContentCandidates.size() + " to " + (lastContentCandidates.size() + newContentCandidates.size() + " items.")); lastContentCandidates.addAll(newContentCandidates); lastContent.remove("candidates"); lastContent.add("candidates", lastContentCandidates); addIt = false; } } catch (Exception e) { Log.e(LOG_TAG, "## wSendEvent() ; " + e.getMessage()); } } if (addIt) { Event event = new Event(eventType, content, mSession.getCredentials().userId, mCallSignalingRoom.getRoomId()); if (null != event) { // receive an hangup -> close the window asap if (TextUtils.equals(eventType, Event.EVENT_TYPE_CALL_HANGUP)) { sendHangup(event); } else { mPendingEvents.add(event); } // the calleee has 30s to answer to call if (TextUtils.equals(eventType, Event.EVENT_TYPE_CALL_INVITE)) { mCallTimeoutTimer = new Timer(); mCallTimeoutTimer.schedule(new TimerTask() { @Override public void run() { try { if (getCallState().equals(IMXCall.CALL_STATE_RINGING) || getCallState().equals(IMXCall.CALL_STATE_INVITE_SENT)) { dispatchOnCallError(CALL_ERROR_USER_NOT_RESPONDING); hangup(null); } // cancel the timer mCallTimeoutTimer.cancel(); mCallTimeoutTimer = null; } catch (Exception e) { Log.e(LOG_TAG, "## wSendEvent() ; " + e.getMessage()); } } }, 30 * 1000); } } } // send events sendNextEvent(); } catch (Exception e) { Log.e(LOG_TAG, "## wSendEvent() ; " + e.getMessage()); } } }); } } }