본문 바로가기
안드로이드 자바

[Android][Java] WebRTC library 사용해서 원격 피어와 연결하기

by teamnova 2023. 8. 21.
728x90

아래의 예제의 안드로이드 버전을 만들어봤습니다.

아래의 예제에서 broadcast를 사용해서 탭간 통신하는 부분을 서버와의 소켓 통신으로 변경했습니다!

https://webrtc.github.io/samples/src/content/peerconnection/channel/

 

Peer connection between two tabs

This sample shows how to setup a connection between two peers in different tabs using RTCPeerConnection and Broadcast Channel Start Hang Up Click the start button in two tabs (of the same browser) to make a call View source on GitHub

webrtc.github.io

 

실행결과입니다.

 

 

 

아래의 글을 참고하시면서 현재 글을 보시면 좋을 것 같습니다.

 

2023.05.22 - [안드로이드 자바] - [Android][Java] WebRTC library 사용해서 피어 간 연결하기

2023.04.01 - [Nodejs] - [ Nodejs ] socket.io 사용해서 서버와 데이터 주고받기

 

 

먼저 webrtc 라이브러리를 다운받아서 app>libs 폴더에 추가해주신 뒤, (위의 글 참고)

build.gradle > dependencies 에 코드를 추가해주세요.

    implementation files('libs/libwebrtc.aar')
    implementation ('io.socket:socket.io-client:2.0.0') {
        exclude group: 'org.json', module: 'json'
    }

 

전체 코드입니다. 

위의 글에서 코드가 추가, 수정되었기 때문에 위의 글 예제를 바탕으로 예제를 만드시면 됩니다.

 

수정 서버코드입니다.

const express = require("express");
const http = require("http");
const app = express();
const server = http.Server(app);

const PORT = 5000;

server.listen(PORT, () => {

    console.log(`Server running on http://localhost:5000`);

    app.get('/', (req, res) => {
    	res.sendFile('test.html', { root: (__dirname+'/view') });
     });
});
    

var io = require('socket.io')(server);
let users = {};
let socketToRoom = {};

io.sockets.on('connection', function(socket) {

	console.log('socket connection');
    socket.join("webrtc");


    socket.on("message",data=>{

        let type = data.type;

        switch(type){
            case "ready": case "offer" : case "candidate" : case "answer" : case "bye" : 
                socket.broadcast.to("webrtc").emit("onmessage",data);
                console.log("type : "+type)
                break;
            
        }
        

    });


})

 

수정 PeerConnectionActivity.java 전체코드입니다.

public class PeerConnectionActivity extends AppCompatActivity {

//    https://webrtc.github.io/samples/src/content/peerconnection/pc1/
//    https://webrtc.github.io/samples/src/content/peerconnection/channel/

    PeerConnection PC;

    PeerConnection.RTCConfiguration rtcConfig;
    List<PeerConnection.IceServer> iceServers;

    Button 시작,종료;
    Application myApp;
    PeerConnectionFactory connectionFactory;
    MediaConstraints mediaConstraints;

    PeerConnection.Observer PCObserver;
    SdpObserver sdpObserver;

    VideoCapturer videoCapturer;

    EglBase rootEglBase;
    EglBase.Context eglBaseContext;
    SurfaceTextureHelper surfaceTextureHelper;

    final String VIDEO_TRACK_ID = "ARDAMSv0";

    VideoTrack localTrack;
    VideoTrack remoteTrack;

    SurfaceViewRenderer localView,remoteView;

    Socket socket;

    Emitter.Listener onConnect,onMessage;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        setContentView(R.layout.activity_peer_connection);

        myApp = (Application) getApplicationContext();
        connectionFactory = myApp.getPeerConnectionFactory();

        시작 = findViewById(R.id.button3);
        종료 = findViewById(R.id.button5);
        localView = findViewById(R.id.localview);
        remoteView = findViewById(R.id.remoteview);

        SurfaceViewRendererInit(localView);
        SurfaceViewRendererInit(remoteView);

        iceServers = new ArrayList<>();
        PeerConnection.IceServer stunserver =
                PeerConnection.IceServer.builder("stun:stun.l.google.com:19302")
                        .createIceServer();
        iceServers.add(stunserver);

        rtcConfig = new PeerConnection.RTCConfiguration(iceServers);
        rootEglBase = EglBase.create();
        eglBaseContext= rootEglBase.getEglBaseContext();
        surfaceTextureHelper = SurfaceTextureHelper.create(Thread.currentThread().getName(), eglBaseContext);


        mediaConstraints = new MediaConstraints();
        mediaConstraints.mandatory.add(
                new MediaConstraints.KeyValuePair("OfferToReceiveAudio",  Boolean.toString(true)));
        mediaConstraints.mandatory.add(new MediaConstraints.KeyValuePair(
                "OfferToReceiveVideo", Boolean.toString(true)));//isVideoCallEnabled()

        PCObserver = new PeerConnection.Observer() {
            @Override
            public void onSignalingChange(PeerConnection.SignalingState signalingState) {

            }

            @Override
            public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) {

            }

            @Override
            public void onIceConnectionReceivingChange(boolean b) {

            }

            @Override
            public void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) {

            }

            @Override
            public void onIceCandidate(IceCandidate iceCandidate) {
// const message = {
//                        type: 'candidate',
//                        candidate: null,
//    };
//                if (e.candidate) {
//                    message.candidate = e.candidate.candidate;
//                    message.sdpMid = e.candidate.sdpMid;
//                    message.sdpMLineIndex = e.candidate.sdpMLineIndex;
//                }
//                signaling.postMessage(message)
                JSONObject message = new JSONObject();
                try {
                    message.put("type","candidate");
                    message.put("candidate",toJsonCandidate(iceCandidate));
                } catch (JSONException e) {
                    e.printStackTrace();
                    return;
                }


                socket.emit("message",message);
            }

            @Override
            public void onTrack(RtpTransceiver transceiver) {
                PeerConnection.Observer.super.onTrack(transceiver);
                PeerConnection.Observer.super.onTrack(transceiver);
                PeerConnection.Observer.super.onTrack(transceiver);
                Log.i("onTrack","");
                PeerConnection.Observer.super.onTrack(transceiver);

                RtpReceiver receiver = transceiver.getReceiver();

                if(receiver.track().kind().equals(MediaStreamTrack.VIDEO_TRACK_KIND)){
                    VideoTrack videoTrack = (VideoTrack) receiver.track();
                    Log.i("onTrack","videoTrack");
                    videoTrack.addSink(remoteView);
                }
            }

            @Override
            public void onConnectionChange(PeerConnection.PeerConnectionState newState) {

                PeerConnection.Observer.super.onConnectionChange(newState);

                Log.i("onConnectionChange",newState.toString());

            }

            @Override
            public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {

            }

            @Override
            public void onAddStream(MediaStream mediaStream) {

            }

            @Override
            public void onRemoveStream(MediaStream mediaStream) {

            }

            @Override
            public void onDataChannel(DataChannel dataChannel) {

            }

            @Override
            public void onRenegotiationNeeded() {

            }
        };
        onConnect = new Emitter.Listener() {
            @Override
            public void call(Object... args) {

                Log.i("연결 성공","connect");

            }
        };
        onMessage = new Emitter.Listener() {
            @Override
            public void call(Object... args) {


                JSONObject message = (JSONObject) args[0];
                String type;
                try {
                    type = message.getString("type");
                } catch (JSONException e) {
                    e.printStackTrace();
                    return;
                }

                switch (type){
                    case "ready":
                        if(PC != null){
                            Log.i("ready : ","already in call, ignoring");
                            break;
                        }
                        makeCall();
                        break;
                    case "offer" :

                        SessionDescription offer;
                        try {

                            String sdp_description = message.getString("sdp");
                            offer = new SessionDescription(SessionDescription.Type.OFFER,sdp_description);
                            handleOffer(offer);

                        } catch (JSONException e) {
                            e.printStackTrace();
                            return;
                        }

                        break;
                    case "answer" :
                        SessionDescription answer;
                        try {

                            String sdp_description = message.getString("sdp");
                            answer = new SessionDescription(SessionDescription.Type.ANSWER,sdp_description);

                        } catch (JSONException e) {
                            e.printStackTrace();
                            return;
                        }
                        handleAnswer(answer);
                        break;
                    case "candidate" :
                        IceCandidate candidate;
                        try {
                            JSONObject msg = (JSONObject) message.get("candidate");
                            candidate = new IceCandidate(  msg.getString("sdpMid"),msg.getInt("sdpMLineIndex"), msg.getString("candidate"));

                        } catch (JSONException e) {
                            e.printStackTrace();
                            return;
                        }
                        handleCandidate(candidate);
                        break;
                    case "bye" :
                        if(PC != null)
                            hangup();
                        break;
                }

            }
        };


        시작.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {

                // 비디오 트랙 가져오기
                localTrack = getLocalVideo(true);
                localTrack.addSink(localView);

//                signaling.postMessage({type: 'ready'});

                JSONObject message = new JSONObject();
                try {
                    message.put("type","ready");
                } catch (JSONException e) {
                    e.printStackTrace();
                }
                socket.emit("message",message);

            }
        });



        종료.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {

                hangup();
                JSONObject message = new JSONObject();
                try {
                    message.put("type","bye");
                } catch (JSONException e) {
                    e.printStackTrace();
                }
                socket.emit("message",message);

            }
        });

        socket = IO.socket(URI.create(서버 uri));//"http://chat.socket.io"); //

        socket.on(Socket.EVENT_CONNECT, onConnect);

        socket.on("onmessage",onMessage);
        socket.connect();

    }


    public void makeCall(){
        createPeerConnection();

        PC.createOffer(new SdpObserver() {
            @Override
            public void onCreateSuccess(SessionDescription sessionDescription) {
                JSONObject message = new JSONObject();
                try {
                    message.put("type","offer");
                    message.put("sdp",sessionDescription.description);

                } catch (JSONException e) {
                    e.printStackTrace();
                    return;
                }
                socket.emit("message",message);

                PC.setLocalDescription(new SdpObserver() {
                    @Override
                    public void onCreateSuccess(SessionDescription sessionDescription) {

                    }

                    @Override
                    public void onSetSuccess() {

                    }

                    @Override
                    public void onCreateFailure(String s) {

                    }

                    @Override
                    public void onSetFailure(String s) {

                    }
                }, sessionDescription);
            }

            @Override
            public void onSetSuccess() {

            }

            @Override
            public void onCreateFailure(String s) {

            }

            @Override
            public void onSetFailure(String s) {

            }
        },mediaConstraints);


    }

    public void createPeerConnection(){

        PC = connectionFactory.createPeerConnection(rtcConfig,PCObserver);
        PC.addTrack(localTrack);

    }

    // icecandidate를 json형식으로 변환한다.
    // Converts a Java candidate to a JSONObject.
    private JSONObject toJsonCandidate(final IceCandidate candidate) {
        JSONObject json = new JSONObject();
        try {
            json.put("sdpMLineIndex", candidate.sdpMLineIndex);
            json.put("sdpMid", candidate.sdpMid);
            json.put("candidate", candidate.sdp);
        } catch (JSONException e) {
            e.printStackTrace();
        }
//        jsonPut(json, "id", candidate.sdpMid);
//        jsonPut(json, "candidate", candidate.sdp);
        return json;
    }

    /**내 기기의(카메라 소스로 얻은) video track을 반환한다.
     *
     *@paramstatus
     *@return:내 기기의(카메라 소스로 얻은) video track
     */
    public VideoTrack getLocalVideo(boolean status){

        // localVideo 변수 선언
        VideoTrack localVideo;

        // videoCapturer : 비디오 소스에서 비디오 프레임을 캡처하고 VideoSource 객체에 전달하는 데 사용됩니다.
        videoCapturer = createCameraCapturer(status);
        // createCameraCapturer {videoCapturer} 출력
        Log.w("createCameraCapturer",videoCapturer.toString());

        // VideoSource 객체를 생성합니다. 이 VideoSource 객체는 로컬 미디어 스트림에서 비디오 소스로 사용됩니다.
        // 1번째 매개변수는 비디오 소스가 카메라 스트림(실시간 비디오)을 사용하는지 또는 비디오 파일 스트림(미리 녹화된 비디오)을 사용하는지를 나타냅니다.
        // videoCapturer.isScreencast() :비디오 캡처기가 화면 녹화 모드에서 동작하는 경우, 즉 사용자가 화면 공유를 수행하는 경우에 true 값을 반환합니다. 반면에 일반적인 카메라 캡처기의 경우, false 값을 반환합니다.
        // 즉, VideoCapturer 객체가 현재 어떤 모드로 동작하는지를 나타내는
        VideoSource videoSource = connectionFactory.createVideoSource(videoCapturer.isScreencast());


        //  비디오 캡처기를 초기화하고 비디오 프레임을 캡처하기 시작하는 데 사용
        // 비디오 캡처기의 콜백 함수를 설정하고, 캡처할 비디오 해상도, 비율 및 프레임 속도 등의 속성을 설정
        //  videoSource.getCapturerObserver() : 비디오 프레임을 전달할 CapturerObserver 객체
        videoCapturer.initialize(surfaceTextureHelper, getApplicationContext(), videoSource.getCapturerObserver());
        Log.i("videoSource.getObserver",videoSource.getCapturerObserver().toString());

        // 비디오 캡쳐 : getUserMedia 로 스트림 받아오기 시작?
        // 비디오 캡처를 시작하고, 캡처된 비디오 프레임을 VideoSink로 전달하기 위해 호출
        // 캡처할 비디오 프레임의 너비, 높이, 및 프레임 속도
        // 240, 320,30
        videoCapturer.startCapture(240, 320, 30);

        //        이 메서드는 로컬 비디오 트랙을 생성하는 데 사용됩니다.
        //  VideoSource 객체와 연결된 VideoTrack을 만듭니다.
        // VIDEO_TRACK_ID은 비디오 트랙 고유 식별자로 사용.
        localVideo = connectionFactory.createVideoTrack(VIDEO_TRACK_ID, videoSource);

        // 내 기기의 (카메라 소스로 얻은) video track
        return localVideo;
        // getLocalVideo 함수의 끝.

    }

    /**
     *
     *@paramisFront
     *@return:생성한videoCapturer반환,생성 실패시null반환.
     */
    private VideoCapturer createCameraCapturer(boolean isFront) {


        // Camera1Enumerator : Android 디바이스의 카메라 목록을 가져오고 선택한 카메라를 열기 위한 클래스
        // 매개변수 : true를 전달하면 전면 카메라만 사용하며, false를 전달하거나 이 매개변수를 생략하면 전면 카메라와 후면 카메라 모두 사용
        Camera1Enumerator enumerator = new Camera1Enumerator(false);

        // Android 디바이스에서 사용 가능한 카메라 디바이스를 열거할 수 있습니다.
        // 카메라의 ID와 이름을 갖는 CameraEnumerationAndroid.CaptureDeviceInfo 객체의 목록을 반환합니다.
        final String[] deviceNames = enumerator.getDeviceNames();

        // First, try to find front facing camera
        // deviceNames 요소의수만큼 반복
        for (String deviceName : deviceNames) {

            Log.i("deviceName : ",deviceName);

            // A ? B : C => 조건 연산자, A가 참이면 B를 반환, 거짓이면 C를 반환
            // Camera1Enumerator.isFrontFacing() : 전면카메라면 true, 후면 카메라라면 false를 반환
            // Camera1Enumerator.isBackFacing() : 후면카메라면 true, 전면 카메라라면 false를 반환
            if (isFront ? enumerator.isFrontFacing(deviceName) : enumerator.isBackFacing(deviceName)) {

                // 매개변수로는 CameraEnumerationAndroid.CaptureDeviceInfo 객체와 CapturerObserver 객체, CameraEventsHandler 객체를 받습니다.
                // deviceName 카메라로 VideoCapturer 객체 생성
                //  선택한 카메라를 열기 위한 메소드
                VideoCapturer videoCapturer = enumerator.createCapturer(deviceName, new CameraVideoCapturer.CameraEventsHandler() {
                    // CameraVideoCapturer에서 발생하는 각 이벤트를 처리하는 기본 구현을 제공하며,
                    // 이벤트를 수신하려면 CameraVideoCapturer.setCameraEventsHandler() 메서드를 사용하여 CameraEventsHandler 객체를 등록해야 합니다.

                    // 오버라이딩, 함수 재정의
                    @Override
                    // 카메라에서 오류가 발생할 때 호출됩니다. 오류 메시지를 매개변수로 받습니다.
                    public void onCameraError(String s) {
                        // onCameraError 메세지 출력.
                        Log.w("onCameraError",s);
                        // onCameraError 함수의 끝.
                    }

                    // 오버라이딩, 함수 재정의
                    @Override
                    // 카메라 연결이 끊어졌을 때 호출됩니다.
                    public void onCameraDisconnected() {
                        // onCameraDisconnected 메세지 출력.
                        Log.w("onCameraDisconnected","");
                        // onCameraDisconnected 함수의 끝.
                    }

                    // 오버라이딩, 함수 재정의
                    @Override
                    // 카메라가 정지되거나 동결될 때 호출됩니다. 오류 메시지를 매개변수로 받습니다.
                    public void onCameraFreezed(String s) {
                        // onCameraFreezed 메세지 출력.
                        Log.w("onCameraFreezed",s);
                        // onCameraFreezed 함수의 끝.
                    }

                    // 오버라이딩, 함수 재정의
                    @Override
                    // 카메라를 열고 있는 동안 호출됩니다. 열려고 하는 카메라의 이름을 매개변수로 받습니다.
                    public void onCameraOpening(String s) {
                        // onCameraOpening 메세지 출력.
                        Log.w("onCameraOpening",s);
                        // onCameraOpening 함수의 끝.
                    }

                    // 오버라이딩, 함수 재정의
                    @Override
                    // 첫 번째 비디오 프레임이 사용 가능할 때 호출됩니다.
                    public void onFirstFrameAvailable() {
                        // onFirstFrameAvailable 메세지 출력.
                        Log.w("onFirstFrameAvailable","");
                        // onFirstFrameAvailable 함수의 끝.
                    }

                    // 오버라이딩, 함수 재정의
                    @Override
                    // 카메라가 닫혔을 때 호출됩니다.
                    public void onCameraClosed() {
                        // onCameraClosed 메세지 출력.
                        Log.w("onCameraClosed","");
                        // onCameraClosed 함수의 끝.
                    }
                    // createCapturer () 끝.
                });

                // 생성성공시 {} 안의 코드 실행.
                if (videoCapturer != null) {
                    // 생성한 videoCapturer 반환
                    return videoCapturer;
                    // 생성성공시 실행할 코드 끝.
                }

                // isFront가 true일 때 전면카메라, false일 때 후면 카메라면 실행할 코드 끝.
            }
            /// 반복하는 코드의 끝.
        }

        // null 반환
        return null;

        /// createCameraCapturer 함수 끝.
    }

    void SurfaceViewRendererInit(SurfaceViewRenderer view){

        view.setMirror(false);

//        rootEglBase = EglBase.create();
//        eglBaseContext = rootEglBase.getEglBaseContext();
//        Log.i("eglBaseContext : ",eglBaseContext.toString());

//        view.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_BALANCED);
        view.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT);
        view.removeFrameListener(new EglRenderer.FrameListener() {
            @Override
            public void onFrame(Bitmap bitmap) {
                Log.i("removeFrameListener :","");
            }
        });
        view.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
            @Override
            public void onViewAttachedToWindow(View v) {
                Log.i("onViewAttached","ToWindow :");

            }

            @Override
            public void onViewDetachedFromWindow(View v) {
                Log.i("onViewDetached","FromWindow :");

            }
        });
        view.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
            @Override
            public void onLayoutChange(View v, int left, int top, int right, int bottom, int oldLeft, int oldTop, int oldRight, int oldBottom) {

            }
        });
        view.init(eglBaseContext,  new RendererCommon.RendererEvents() {
            //            첫 번째 프레임이 렌더링되면 콜백이 실행됩니다.
            @Override
            public void onFirstFrameRendered() {
                Log.i("RendererEvents","onFirstFrameRendered");
//                box.setVisiProfile(false);

            }
            //            렌더링된 프레임 해상도 또는 회전이 변경되면 콜백이 실행됩니다.
            @Override
            public void onFrameResolutionChanged(int i, int i1, int i2) {
                Log.i("RendererEvents","onFrameResolutionChanged");


            }

        });

    }

    public void hangup(){
        // 종료
        PC.close();
        PC = null;

    }
    public void handleOffer(SessionDescription offer){
//        if (pc) {
//            console.error('existing peerconnection');
//            return;
//        }
        if (PC != null) {
            Log.i("","existing peerconnection");
            return;
        }

//        await createPeerConnection();
        createPeerConnection();
//     0   await pc.setRemoteDescription(offer);
//
        Log.i("handleOffer","");
        PC.setRemoteDescription(new SdpObserver() {
            @Override
            public void onCreateSuccess(SessionDescription sessionDescription) {

            }

            @Override
            public void onSetSuccess() {
                Log.i("handleOffer","onSetSuccess");

                PC.createAnswer(new SdpObserver() {
                    @Override
                    public void onCreateSuccess(SessionDescription sessionDescription) {
                        Log.i("createAnswer","onCreateSuccess");

                        JSONObject message = new JSONObject();
                        try {
                            message.put("type","answer");
                            message.put("sdp",sessionDescription.description);
                        } catch (JSONException e) {
                            e.printStackTrace();
                            return;
                        }
                        socket.emit("message",message);

                        PC.setLocalDescription(new SdpObserver() {
                            @Override
                            public void onCreateSuccess(SessionDescription sessionDescription) {

                            }

                            @Override
                            public void onSetSuccess() {

                            }

                            @Override
                            public void onCreateFailure(String s) {

                            }

                            @Override
                            public void onSetFailure(String s) {

                            }
                        },sessionDescription);
                    }

                    @Override
                    public void onSetSuccess() {

                    }

                    @Override
                    public void onCreateFailure(String s) {

                    }

                    @Override
                    public void onSetFailure(String s) {

                    }
                },mediaConstraints);


            }

            @Override
            public void onCreateFailure(String s) {

            }

            @Override
            public void onSetFailure(String s) {

            Log.i("handleoffer","onSetFailure");
                Log.i("handleoffer",s);

            }
        },offer);

//  const answer = await pc.createAnswer();
//        signaling.postMessage({type: 'answer', sdp: answer.sdp});
//        await pc.setLocalDescription(answer);
    }
//
//    async function handleAnswer(answer) {
//        if (!pc) {
//            console.error('no peerconnection');
//            return;
//        }
//        await pc.setRemoteDescription(answer);
//    }

    public void handleAnswer(SessionDescription answer){
        //        if (!pc) {
//            console.error('no peerconnection');
//            return;
//        }
        if(PC == null){
            Log.i("Error","no peerconnection");
            return;
        }

        PC.setRemoteDescription(new SdpObserver() {
            @Override
            public void onCreateSuccess(SessionDescription sessionDescription) {

            }

            @Override
            public void onSetSuccess() {

            }

            @Override
            public void onCreateFailure(String s) {

            }

            @Override
            public void onSetFailure(String s) {

            }
        },answer);
    }

    public void handleCandidate(IceCandidate candidate){
//        if (!pc) {
//            console.error('no peerconnection');
//            return;
//        }
        if(PC == null){
            Log.i("Error","no peerconnection");
            return;
        }


        if(candidate.sdp == null){
            PC.addIceCandidate(null);
            Log.i("candidate","null");
        }
        else{
            PC.addIceCandidate(candidate);
            Log.i("candidate","not null");

        }

//        if (!candidate.candidate) {
//            await pc.addIceCandidate(null);
//        } else {
//            await pc.addIceCandidate(candidate);
//        }
    }

}

수정 activity_peer_connection.xml 파일 코드입니다.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".PeerConnectionActivity">


    <org.webrtc.SurfaceViewRenderer
        android:id="@+id/localview"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toTopOf="@+id/remoteview"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />


    <org.webrtc.SurfaceViewRenderer
        android:id="@+id/remoteview"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="@+id/localview"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/localview" />


    <Button
        android:id="@+id/button3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="start"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

    <Button
        android:id="@+id/button5"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="hangup"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toEndOf="@+id/button3" />

</androidx.constraintlayout.widget.ConstraintLayout>