First call on Android device

This commit is contained in:
Pierre HUBERT 2019-02-26 15:31:32 +01:00
parent 797b0ae09b
commit f08f1940fc
23 changed files with 3209 additions and 14 deletions

2
.idea/misc.xml generated
View File

@ -29,7 +29,7 @@
</value>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_7" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">

View File

@ -3,6 +3,11 @@ apply plugin: 'com.android.application'
android {
compileSdkVersion 28
compileOptions {
sourceCompatibility 1.8
targetCompatibility 1.8
}
defaultConfig {
applicationId "org.communiquons.android.comunic.client"
minSdkVersion 21
@ -59,6 +64,8 @@ dependencies {
implementation 'com.android.support:design:28.0.0-rc02'
implementation 'com.android.support:preference-v7:28.0.0-rc02'
implementation 'com.android.support:support-v4:28.0.0-rc02'
implementation 'com.squareup.okhttp3:okhttp:3.12.1'
implementation 'org.whispersystems:webrtc-android:M71'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'

View File

@ -7,6 +7,12 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Video calls require camera and microphone -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:allowBackup="true"
android:fullBackupContent="@xml/backup_scheme"

View File

@ -0,0 +1,137 @@
/*
* Copyright 2013 The WebRTC Project Authors. All rights reserved.
*
* Use of this source code is governed by a BSD-style license
* that can be found in the LICENSE file in the root of the source
* tree. An additional intellectual property rights grant can be found
* in the file PATENTS. All contributing project authors may
* be found in the AUTHORS file in the root of the source tree.
*/
package org.appspot.apprtc;
import org.webrtc.IceCandidate;
import org.webrtc.PeerConnection;
import org.webrtc.SessionDescription;
import java.util.List;
/**
* AppRTCClient is the interface representing an AppRTC client.
*/
public interface AppRTCClient {
/**
* Struct holding the connection parameters of an AppRTC room.
*/
class RoomConnectionParameters {
public final String roomUrl;
public final String roomId;
public final boolean loopback;
public final String urlParameters;
public RoomConnectionParameters(
String roomUrl, String roomId, boolean loopback, String urlParameters) {
this.roomUrl = roomUrl;
this.roomId = roomId;
this.loopback = loopback;
this.urlParameters = urlParameters;
}
public RoomConnectionParameters(String roomUrl, String roomId, boolean loopback) {
this(roomUrl, roomId, loopback, null /* urlParameters */);
}
}
/**
* Asynchronously connect to an AppRTC room URL using supplied connection
* parameters. Once connection is established onConnectedToRoom()
* callback with room parameters is invoked.
*/
void connectToRoom(RoomConnectionParameters connectionParameters);
/**
* Send offer SDP to the other participant.
*/
void sendOfferSdp(final SessionDescription sdp);
/**
* Send answer SDP to the other participant.
*/
void sendAnswerSdp(final SessionDescription sdp);
/**
* Send Ice candidate to the other participant.
*/
void sendLocalIceCandidate(final IceCandidate candidate);
/**
* Send removed ICE candidates to the other participant.
*/
void sendLocalIceCandidateRemovals(final IceCandidate[] candidates);
/**
* Disconnect from room.
*/
void disconnectFromRoom();
/**
* Struct holding the signaling parameters of an AppRTC room.
*/
class SignalingParameters {
public final List<PeerConnection.IceServer> iceServers;
public final boolean initiator;
public final String clientId;
public final String wssUrl;
public final String wssPostUrl;
public final SessionDescription offerSdp;
public final List<IceCandidate> iceCandidates;
public SignalingParameters(List<PeerConnection.IceServer> iceServers, boolean initiator,
String clientId, String wssUrl, String wssPostUrl, SessionDescription offerSdp,
List<IceCandidate> iceCandidates) {
this.iceServers = iceServers;
this.initiator = initiator;
this.clientId = clientId;
this.wssUrl = wssUrl;
this.wssPostUrl = wssPostUrl;
this.offerSdp = offerSdp;
this.iceCandidates = iceCandidates;
}
}
/**
* Callback interface for messages delivered on signaling channel.
*
* <p>Methods are guaranteed to be invoked on the UI thread of |activity|.
*/
interface SignalingEvents {
/**
* Callback fired once the room's signaling parameters
* SignalingParameters are extracted.
*/
void onConnectedToRoom(final SignalingParameters params);
/**
* Callback fired once remote SDP is received.
*/
void onRemoteDescription(final SessionDescription sdp);
/**
* Callback fired once remote Ice candidate is received.
*/
void onRemoteIceCandidate(final IceCandidate candidate);
/**
* Callback fired once remote Ice candidate removals are received.
*/
void onRemoteIceCandidatesRemoved(final IceCandidate[] candidates);
/**
* Callback fired once channel is closed.
*/
void onChannelClose();
/**
* Callback fired once channel error happened.
*/
void onChannelError(final String description);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,153 @@
/*
* Copyright 2018 The WebRTC project authors. All Rights Reserved.
*
* Use of this source code is governed by a BSD-style license
* that can be found in the LICENSE file in the root of the source
* tree. An additional intellectual property rights grant can be found
* in the file PATENTS. All contributing project authors may
* be found in the AUTHORS file in the root of the source tree.
*/
package org.appspot.apprtc;
import android.media.AudioFormat;
import android.os.Environment;
import android.support.annotation.Nullable;
import android.util.Log;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.concurrent.ExecutorService;
import org.webrtc.audio.JavaAudioDeviceModule;
import org.webrtc.audio.JavaAudioDeviceModule.SamplesReadyCallback;
import org.webrtc.voiceengine.WebRtcAudioRecord;
import org.webrtc.voiceengine.WebRtcAudioRecord.WebRtcAudioRecordSamplesReadyCallback;
/**
* Implements the AudioRecordSamplesReadyCallback interface and writes
* recorded raw audio samples to an output file.
*/
public class RecordedAudioToFileController
implements SamplesReadyCallback, WebRtcAudioRecordSamplesReadyCallback {
private static final String TAG = "RecordedAudioToFile";
private static final long MAX_FILE_SIZE_IN_BYTES = 58348800L;
private final Object lock = new Object();
private final ExecutorService executor;
@Nullable private OutputStream rawAudioFileOutputStream;
private boolean isRunning;
private long fileSizeInBytes;
public RecordedAudioToFileController(ExecutorService executor) {
Log.d(TAG, "ctor");
this.executor = executor;
}
/**
* Should be called on the same executor thread as the one provided at
* construction.
*/
public boolean start() {
Log.d(TAG, "start");
if (!isExternalStorageWritable()) {
Log.e(TAG, "Writing to external media is not possible");
return false;
}
synchronized (lock) {
isRunning = true;
}
return true;
}
/**
* Should be called on the same executor thread as the one provided at
* construction.
*/
public void stop() {
Log.d(TAG, "stop");
synchronized (lock) {
isRunning = false;
if (rawAudioFileOutputStream != null) {
try {
rawAudioFileOutputStream.close();
} catch (IOException e) {
Log.e(TAG, "Failed to close file with saved input audio: " + e);
}
rawAudioFileOutputStream = null;
}
fileSizeInBytes = 0;
}
}
// Checks if external storage is available for read and write.
private boolean isExternalStorageWritable() {
String state = Environment.getExternalStorageState();
if (Environment.MEDIA_MOUNTED.equals(state)) {
return true;
}
return false;
}
// Utilizes audio parameters to create a file name which contains sufficient
// information so that the file can be played using an external file player.
// Example: /sdcard/recorded_audio_16bits_48000Hz_mono.pcm.
private void openRawAudioOutputFile(int sampleRate, int channelCount) {
final String fileName = Environment.getExternalStorageDirectory().getPath() + File.separator
+ "recorded_audio_16bits_" + String.valueOf(sampleRate) + "Hz"
+ ((channelCount == 1) ? "_mono" : "_stereo") + ".pcm";
final File outputFile = new File(fileName);
try {
rawAudioFileOutputStream = new FileOutputStream(outputFile);
} catch (FileNotFoundException e) {
Log.e(TAG, "Failed to open audio output file: " + e.getMessage());
}
Log.d(TAG, "Opened file for recording: " + fileName);
}
// Called when new audio samples are ready.
@Override
public void onWebRtcAudioRecordSamplesReady(WebRtcAudioRecord.AudioSamples samples) {
onWebRtcAudioRecordSamplesReady(new JavaAudioDeviceModule.AudioSamples(samples.getAudioFormat(),
samples.getChannelCount(), samples.getSampleRate(), samples.getData()));
}
// Called when new audio samples are ready.
@Override
public void onWebRtcAudioRecordSamplesReady(JavaAudioDeviceModule.AudioSamples samples) {
// The native audio layer on Android should use 16-bit PCM format.
if (samples.getAudioFormat() != AudioFormat.ENCODING_PCM_16BIT) {
Log.e(TAG, "Invalid audio format");
return;
}
synchronized (lock) {
// Abort early if stop() has been called.
if (!isRunning) {
return;
}
// Open a new file for the first callback only since it allows us to add audio parameters to
// the file name.
if (rawAudioFileOutputStream == null) {
openRawAudioOutputFile(samples.getSampleRate(), samples.getChannelCount());
fileSizeInBytes = 0;
}
}
// Append the recorded 16-bit audio samples to the open output file.
executor.execute(() -> {
if (rawAudioFileOutputStream != null) {
try {
// Set a limit on max file size. 58348800 bytes corresponds to
// approximately 10 minutes of recording in mono at 48kHz.
if (fileSizeInBytes < MAX_FILE_SIZE_IN_BYTES) {
// Writes samples.getData().length bytes to output stream.
rawAudioFileOutputStream.write(samples.getData());
fileSizeInBytes += samples.getData().length;
}
} catch (IOException e) {
Log.e(TAG, "Failed to write audio to file: " + e.getMessage());
}
}
});
}
}

View File

@ -0,0 +1,74 @@
/*
* Copyright 2018 The WebRTC project authors. All Rights Reserved.
*
* Use of this source code is governed by a BSD-style license
* that can be found in the LICENSE file in the root of the source
* tree. An additional intellectual property rights grant can be found
* in the file PATENTS. All contributing project authors may
* be found in the AUTHORS file in the root of the source tree.
*/
package org.appspot.apprtc;
import android.content.Context;
import android.os.ParcelFileDescriptor;
import android.util.Log;
import java.io.File;
import java.io.IOException;
import org.webrtc.PeerConnection;
public class RtcEventLog {
private static final String TAG = "RtcEventLog";
private static final int OUTPUT_FILE_MAX_BYTES = 10_000_000;
private final PeerConnection peerConnection;
private RtcEventLogState state = RtcEventLogState.INACTIVE;
enum RtcEventLogState {
INACTIVE,
STARTED,
STOPPED,
}
public RtcEventLog(PeerConnection peerConnection) {
if (peerConnection == null) {
throw new NullPointerException("The peer connection is null.");
}
this.peerConnection = peerConnection;
}
public void start(final File outputFile) {
if (state == RtcEventLogState.STARTED) {
Log.e(TAG, "RtcEventLog has already started.");
return;
}
final ParcelFileDescriptor fileDescriptor;
try {
fileDescriptor = ParcelFileDescriptor.open(outputFile,
ParcelFileDescriptor.MODE_READ_WRITE | ParcelFileDescriptor.MODE_CREATE
| ParcelFileDescriptor.MODE_TRUNCATE);
} catch (IOException e) {
Log.e(TAG, "Failed to create a new file", e);
return;
}
// Passes ownership of the file to WebRTC.
boolean success =
peerConnection.startRtcEventLog(fileDescriptor.detachFd(), OUTPUT_FILE_MAX_BYTES);
if (!success) {
Log.e(TAG, "Failed to start RTC event log.");
return;
}
state = RtcEventLogState.STARTED;
Log.d(TAG, "RtcEventLog started.");
}
public void stop() {
if (state != RtcEventLogState.STARTED) {
Log.e(TAG, "RtcEventLog was not started.");
return;
}
peerConnection.stopRtcEventLog();
state = RtcEventLogState.STOPPED;
Log.d(TAG, "RtcEventLog stopped.");
}
}

View File

@ -15,6 +15,9 @@ import org.communiquons.android.comunic.client.data.models.NextPendingCallInform
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.webrtc.PeerConnection;
import java.util.ArrayList;
/**
* Calls helper
@ -90,6 +93,30 @@ public class CallsHelper extends BaseHelper {
return mCallsConfiguration != null && mCallsConfiguration.isEnabled();
}
/**
* Get the list of STUN and TURN servers
*
* @return List of STUN and TURN servers available for Comunic
*/
public static ArrayList<PeerConnection.IceServer> GetPeerServers(){
ArrayList<PeerConnection.IceServer> servers = new ArrayList<>();
//Stun server
servers.add(PeerConnection.IceServer.builder(
mCallsConfiguration.getStunServer()).createIceServer());
//TURN server
servers.add(PeerConnection.IceServer
.builder(mCallsConfiguration.getTurnServer())
.setUsername(mCallsConfiguration.getTurnUsername())
.setPassword(mCallsConfiguration.getTurnPassword())
.createIceServer()
);
return servers;
}
/**
* Create a call for a conversation, returns information about this call then
*
@ -151,6 +178,26 @@ public class CallsHelper extends BaseHelper {
}
/**
* Get information about a call
*
* @param callID Target call ID
* @return Information about the call / null in case of failure
*/
public CallInformation getInfo(int callID){
APIRequest request = new APIRequest(getContext(), "calls/getInfo");
request.addInt("call_id", callID);
try {
return JSONObjectToCallInformation(request.exec().getJSONObject(), null);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
/**
* Try to get and return call information

View File

@ -1,6 +1,7 @@
package org.communiquons.android.comunic.client.data.models;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import java.util.ArrayList;
@ -75,6 +76,36 @@ public class CallInformation {
this.members = members;
}
/**
* Find a member by user ID
*
* @param userID The ID of the user to search
* @return Information about the target user
*/
public CallMember findMember(int userID){
for(CallMember member : members)
if(member.getUserID() == userID)
return member;
throw new RuntimeException("Specified user was not found in the conversation!");
}
/**
* Find a member by call ID
*
* @param userCallID The ID of the target user
* @return Information about the user / null in case of failure
*/
@Nullable
public CallMember findMember(String userCallID){
for(CallMember member : members)
if(member.getUserCallID().equals(userCallID))
return member;
throw new RuntimeException("Specified user was not found in the conversation!");
}
public String getCallName() {
return callName;
}

View File

@ -50,7 +50,7 @@ public class CallsConfiguration {
this.signalServerPort = signalServerPort;
}
public boolean isSignalSererSecure() {
public boolean isSignalServerSecure() {
return isSignalSererSecure;
}

View File

@ -1,10 +1,46 @@
package org.communiquons.android.comunic.client.ui.activities;
import android.Manifest;
import android.content.pm.PackageManager;
import android.os.AsyncTask;
import android.os.Bundle;
import android.widget.TextView;
import android.support.annotation.Nullable;
import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat;
import android.util.Log;
import android.widget.ProgressBar;
import android.widget.Toast;
import org.appspot.apprtc.AppRTCClient;
import org.appspot.apprtc.PeerConnectionClient;
import org.communiquons.android.comunic.client.R;
import org.communiquons.android.comunic.client.data.enums.MemberCallStatus;
import org.communiquons.android.comunic.client.data.helpers.CallsHelper;
import org.communiquons.android.comunic.client.data.models.CallInformation;
import org.communiquons.android.comunic.client.data.models.CallMember;
import org.communiquons.android.comunic.client.data.models.CallResponse;
import org.communiquons.android.comunic.client.data.models.CallsConfiguration;
import org.communiquons.android.comunic.client.data.utils.AccountUtils;
import org.communiquons.android.comunic.client.ui.arrays.CallPeersConnectionsList;
import org.communiquons.android.comunic.client.ui.asynctasks.GetCallInformationTask;
import org.communiquons.android.comunic.client.ui.asynctasks.RespondToCallTask;
import org.communiquons.android.comunic.client.ui.models.CallPeerConnection;
import org.communiquons.android.comunic.client.ui.receivers.PendingCallsBroadcastReceiver;
import org.communiquons.signalexchangerclient.SignalExchangerCallback;
import org.communiquons.signalexchangerclient.SignalExchangerClient;
import org.communiquons.signalexchangerclient.SignalExchangerInitConfig;
import org.webrtc.Camera1Enumerator;
import org.webrtc.CameraEnumerator;
import org.webrtc.EglBase;
import org.webrtc.IceCandidate;
import org.webrtc.Logging;
import org.webrtc.PeerConnectionFactory;
import org.webrtc.SessionDescription;
import org.webrtc.StatsReport;
import org.webrtc.SurfaceViewRenderer;
import org.webrtc.VideoCapturer;
import org.webrtc.VideoFrame;
import org.webrtc.VideoSink;
import java.util.Objects;
@ -13,21 +49,89 @@ import java.util.Objects;
*
* @author Pierre HUBERT
*/
public class CallActivity extends BaseActivity {
public class CallActivity extends BaseActivity implements SignalExchangerCallback {
/**
* Debug tag
*/
private static final String TAG = CallActivity.class.getSimpleName();
/**
* Mandatory argument that includes call id
*/
public static final String ARGUMENT_CALL_ID = "call_id";
/**
* Permissions requests codes
*/
private static final int MY_PERMISSIONS_REQUEST_CAMERA = 100;
private static final int MY_PERMISSIONS_REQUEST_RECORD_AUDIO = 101;
private static final int MY_PERMISSIONS_REQUEST = 102;
/**
* Refresh call information thread
*/
private RefreshCallInformation mRefreshCallInformation = null;
/**
* Current call ID and information
*/
private int mCallID = -1;
private CallInformation mCallInformation = null;
/**
* Signal exchanger client
*/
private SignalExchangerClient mSignalExchangerClient = null;
/**
* Connections list
*/
private CallPeersConnectionsList mList = new CallPeersConnectionsList();
/**
* WebRTC attributes
*/
private EglBase rootEglBase;
/**
* Views
*/
private ProgressBar mProgressBar;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_call);
//Hide call bar
Objects.requireNonNull(getSupportActionBar()).hide();
mCallID = getIntent().getIntExtra(ARGUMENT_CALL_ID, 0);
//Get views
initViews();
initVideos();
//Mark the call as accepted
RespondToCallTask respondToCallTask = new RespondToCallTask(this);
respondToCallTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR,
new CallResponse(mCallID, true));
getTasksManager().addTask(respondToCallTask);
}
@Override
protected void onStart() {
super.onStart();
//Refresh at a regular interval information about the call
mRefreshCallInformation = new RefreshCallInformation();
mRefreshCallInformation.start();
}
@Override
@ -37,7 +141,507 @@ public class CallActivity extends BaseActivity {
//Hide call notifications
PendingCallsBroadcastReceiver.RemoveCallNotification(this);
((TextView)findViewById(R.id.call_id)).setText(
"Call " + getIntent().getExtras().getInt(ARGUMENT_CALL_ID));
//Make sure we have access to user camera and microphone
askForPermissions();
}
@Override
protected void onStop() {
super.onStop();
mRefreshCallInformation.interrupt();
}
/**
* Request access to user camera and microphone devices
*
* Based on https://github.com/sergiopaniego/WebRTCAndroidExample
*/
private void askForPermissions() {
if ((ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED) &&
(ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
!= PackageManager.PERMISSION_GRANTED)) {
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO},
MY_PERMISSIONS_REQUEST);
} else if (ContextCompat.checkSelfPermission(this,
Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.RECORD_AUDIO},
MY_PERMISSIONS_REQUEST_RECORD_AUDIO);
} else if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.CAMERA},
MY_PERMISSIONS_REQUEST_CAMERA);
}
}
/**
* Get views
*/
private void initViews(){
mProgressBar = findViewById(R.id.progressBar);
}
private void initVideos(){
rootEglBase = EglBase.create();
}
/**
* Refresh call information
*/
private void getCallInformation(){
GetCallInformationTask getCallInformationTask = new GetCallInformationTask(this);
getCallInformationTask.setOnPostExecuteListener(this::onGotCallInformation);
getCallInformationTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, mCallID);
}
/**
* Once we have got information about the call
*
* @param info Information about the call
*/
private void onGotCallInformation(@Nullable CallInformation info){
if(info == null){
Toast.makeText(this, R.string.err_get_call_info, Toast.LENGTH_SHORT).show();
return;
}
setTitle(info.getCallName());
mCallInformation = info;
//Check if everyone left the conversation
if(mCallInformation.hasAllMembersLeftCallExcept(AccountUtils.getID(this))){
Toast.makeText(this, R.string.notice_call_terminated, Toast.LENGTH_SHORT).show();
finish();
return;
}
//Connect to signaling server
if(mSignalExchangerClient == null){
initializeSignalClient();
return;
}
//Check connection is establish
if(!mSignalExchangerClient.isConnected())
return;
processClientsConnections();
}
private void initializeSignalClient(){
CallsConfiguration callsConfiguration = CallsHelper.GetCallsConfiguration();
assert callsConfiguration != null;
mSignalExchangerClient = new SignalExchangerClient(new SignalExchangerInitConfig(
callsConfiguration.getSignalServerName(),
callsConfiguration.getSignalServerPort(),
mCallInformation.findMember(AccountUtils.getID(this)).getUserCallID(),
callsConfiguration.isSignalServerSecure()
), this);
}
private void processClientsConnections(){
//Process each peer connection
for(CallMember member : mCallInformation.getMembers())
processClientConnection(member);
}
private void processClientConnection(CallMember member){
//Skip current user
if(member.getUserID() == AccountUtils.getID(this))
return;
//Check if the member left the conversation
if(member.getStatus() != MemberCallStatus.ACCEPTED){
disconnectFromPeer(member);
return;
}
if(mList.find(member) == null && member.getUserID() > AccountUtils.getID(this)) {
createPeerConnection(member, false);
mSignalExchangerClient.sendReadyMessage(member.getUserCallID());
}
if(mList.find(member) != null)
Objects.requireNonNull(mList.find(member)).setMember(member);
}
/**
* Create the peer connection for a specific call member
*
* @param member Target member
* @param isInitiator Specify whether if we should send the offer or not to this user
*/
private void createPeerConnection(CallMember member, boolean isInitiator){
Log.v(TAG, "Create peer connection for connection with user " + member.getUserID());
CallPeerConnection callPeer = new CallPeerConnection(member);
mList.add(callPeer);
//Create peer connection
PeerConnectionClient peerConnectionClient = new PeerConnectionClient(
getApplicationContext(),
rootEglBase,
new PeerConnectionClient.PeerConnectionParameters(
true,
false,
false,
0,
0,
0,
0,
"",
true,
false,
0,
null,
false,
false,
false,
false,
false,
false,
false,
false,
false,
false,
null
),
new PeerConnectionEvents(callPeer)
);
callPeer.setPeerConnectionClient(peerConnectionClient);
PeerConnectionFactory.Options options = new PeerConnectionFactory.Options();
peerConnectionClient.createPeerConnectionFactory(options);
//Signaling parameters
AppRTCClient.SignalingParameters parameters = new AppRTCClient.SignalingParameters(
CallsHelper.GetPeerServers(), isInitiator, null,
null, null, null, null
);
//Initialize video view
SurfaceViewRenderer localView = new SurfaceViewRenderer(this);
localView.init(rootEglBase.getEglBaseContext(), null);
localView.setZOrderMediaOverlay(true);
callPeer.setLocalVideoView(localView);
SurfaceViewRenderer remoteView = new SurfaceViewRenderer(this);
remoteView.init(rootEglBase.getEglBaseContext(), null);
remoteView.setZOrderMediaOverlay(false);
callPeer.setRemoteViewView(remoteView);
ProxyVideoSink localProxyVideoSink = new ProxyVideoSink();
localProxyVideoSink.setTarget(callPeer.getLocalVideoView());
ProxyVideoSink remoteProxyRenderer = new ProxyVideoSink();
remoteProxyRenderer.setTarget(callPeer.getRemoteViewView());
callPeer.getRemoteSinks().add(remoteProxyRenderer);
//Start connection
peerConnectionClient.createPeerConnection(
localProxyVideoSink,
callPeer.getRemoteSinks(),
createCameraCapturer(new Camera1Enumerator(false)),
parameters
);
if(isInitiator)
peerConnectionClient.createOffer();
}
/**
* Disconnect from a specific peer
*
* @param member Information about related call member
*/
private void disconnectFromPeer(CallMember member){
CallPeerConnection callPeer = mList.find(member);
if(callPeer == null)
return;
callPeer.getPeerConnectionClient().close();
mList.remove(callPeer);
}
//Based on https://github.com/vivek1794/webrtc-android-codelab
@Nullable
private VideoCapturer createCameraCapturer(CameraEnumerator enumerator){
final String[] deviceNames = enumerator.getDeviceNames();
// First, try to find front facing camera
Logging.d(TAG, "Looking for front facing cameras.");
for (String deviceName : deviceNames) {
if (enumerator.isFrontFacing(deviceName)) {
Logging.d(TAG, "Creating front facing camera capturer.");
VideoCapturer videoCapturer = enumerator.createCapturer(deviceName, null);
if (videoCapturer != null) {
return videoCapturer;
}
}
}
// Front facing camera not found, try something else
Logging.d(TAG, "Looking for other cameras.");
for (String deviceName : deviceNames) {
if (!enumerator.isFrontFacing(deviceName)) {
Logging.d(TAG, "Creating other camera capturer.");
VideoCapturer videoCapturer = enumerator.createCapturer(deviceName, null);
if (videoCapturer != null) {
return videoCapturer;
}
}
}
return null;
}
@Override
public void onSignalServerError(String msg, @Nullable Throwable t) {
runOnUiThread(() -> Toast.makeText(this,
R.string.err_connect_signaling_server, Toast.LENGTH_SHORT).show());
}
@Override
public void onConnectedToSignalingServer() {
runOnUiThread(this::processClientsConnections);
}
@Override
public void onReadyMessageCallback(String target_id, int number_targets) {
Log.e(TAG, "Send ready message callback");
}
@Override
public void onReadyMessage(String source_id) {
runOnUiThread(() -> {
//Ignore message if a connection has already been established
if (mList.findByCallID(source_id) != null) {
Log.e(TAG, "Ignored ready message from " + source_id + " because a connection has already be made!");
return;
}
CallMember member = mCallInformation.findMember(source_id);
if (member == null) {
Log.e(TAG, source_id + " sent a ready message but it does not belong to the conversation!");
return;
}
Log.v(TAG, source_id + " informed it is ready to establish connection.");
createPeerConnection(member, true);
});
}
@Override
public void onSignal(String source_id, String signal) {
Log.e(TAG, "Received new signal from " + source_id);
}
@Override
public void onSendSignalCallback(int number_targets) {
Log.e(TAG, "Send signal callback, number of targets: " + number_targets);
}
@Override
public void gotRemoteIceCandidate(String source_id, IceCandidate iceCandidate) {
runOnUiThread(() -> {
CallPeerConnection connection = mList.findByCallID(source_id);
if(connection == null) {
Log.e(TAG, "Dropped ICE candidate from " + source_id + " no peer connection was ready to receive it!");
return;
}
connection.getPeerConnectionClient().addRemoteIceCandidate(iceCandidate);
});
}
@Override
public void gotRemoteSessionDescription(String source_id, SessionDescription sessionDescription) {
runOnUiThread(() -> {
CallPeerConnection connection = mList.findByCallID(source_id);
if(connection == null) {
Log.e(TAG, "Dropped session description from " + source_id + " no peer connection was ready to receive it!");
return;
}
connection.getPeerConnectionClient().setRemoteDescription(sessionDescription);
connection.getPeerConnectionClient().createAnswer();
});
}
/**
* Class used to received events that comes from a connection
*/
private class PeerConnectionEvents implements PeerConnectionClient.PeerConnectionEvents {
private CallPeerConnection connection;
PeerConnectionEvents(CallPeerConnection connection) {
this.connection = connection;
}
@Override
public void onLocalDescription(SessionDescription sdp) {
Log.v(TAG, "Got a new local description");
runOnUiThread(() ->
mSignalExchangerClient.sendSessionDescription(
connection.getMember().getUserCallID(), sdp));
}
@Override
public void onIceCandidate(IceCandidate candidate) {
Log.v(TAG, "Got a new ICE candidate");
runOnUiThread(() -> mSignalExchangerClient.sendIceCandidate(
connection.getMember().getUserCallID(),
candidate));
}
@Override
public void onIceCandidatesRemoved(IceCandidate[] candidates) {
Log.v(TAG, "Some ice candidates removed with peer " +
connection.getMember().getUserID());
}
@Override
public void onIceConnected() {
Log.v(TAG, "Ice connected with peer " +
connection.getMember().getUserID());
}
@Override
public void onIceDisconnected() {
Log.v(TAG, "Ice disconnected from peer " +
connection.getMember().getUserID());
}
@Override
public void onConnected() {
Log.v(TAG, "Connected to peer " +
connection.getMember().getUserID());
}
@Override
public void onDisconnected() {
Log.v(TAG, "Disconnected from peer " +
connection.getMember().getUserID());
}
@Override
public void onPeerConnectionClosed() {
Log.v(TAG, "Connection close from user " +
connection.getMember().getUserID());
runOnUiThread(() -> disconnectFromPeer(connection.getMember()));
}
@Override
public void onPeerConnectionStatsReady(StatsReport[] reports) {
Log.v(TAG, "Stats ready for peer connection with " +
connection.getMember().getUserID());
}
@Override
public void onPeerConnectionError(String description) {
Log.e(TAG, "Peer connection error with " +
connection.getMember().getUserID() + " " + description);
}
}
/**
* Refresh call information thread
*/
private class RefreshCallInformation extends Thread {
private final Object o = new Object();
private boolean stop = false;
@Override
public void run() {
super.run();
synchronized (o){
while(!stop) {
runOnUiThread(CallActivity.this::getCallInformation);
try {
o.wait((long) (1.5 * 1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public void interrupt(){
stop = true;
}
}
/**
* I don't know why, but this is an absolute requirement ! (to show videos)
*/
private static class ProxyVideoSink implements VideoSink {
private VideoSink target;
@Override
synchronized public void onFrame(VideoFrame frame) {
if (target == null) {
Logging.d(TAG, "Dropping frame in proxy because target is null.");
return;
}
target.onFrame(frame);
}
synchronized public void setTarget(VideoSink target) {
this.target = target;
}
}
}

View File

@ -25,7 +25,7 @@ import android.widget.Toast;
import org.communiquons.android.comunic.client.BuildConfig;
import org.communiquons.android.comunic.client.R;
import org.communiquons.android.comunic.client.crashreporter.CrashReporter;
import org.communiquons.crashreporter.CrashReporter;
import org.communiquons.android.comunic.client.data.enums.VirtualDirectoryType;
import org.communiquons.android.comunic.client.data.helpers.APIRequestHelper;
import org.communiquons.android.comunic.client.data.helpers.AccountHelper;

View File

@ -0,0 +1,48 @@
package org.communiquons.android.comunic.client.ui.arrays;
import android.support.annotation.Nullable;
import org.communiquons.android.comunic.client.data.models.CallMember;
import org.communiquons.android.comunic.client.ui.models.CallPeerConnection;
import java.util.ArrayList;
/**
* List of clients connections
*
* @author Pierre HUBERT
*/
public class CallPeersConnectionsList extends ArrayList<CallPeerConnection> {
/**
* Find the connection matching a specific call member
*
*
* @param member Information about the target member
* @return Full client connection
*/
@Nullable
public CallPeerConnection find(CallMember member){
for(CallPeerConnection connection : this)
if(connection.getMember().getUserID() == member.getUserID())
return connection;
return null;
}
/**
* Try to find a peer connection using call ID
*
* @param id The ID of the user call ID
* @return Information about the peer connection / null object in case of failure
*/
@Nullable
public CallPeerConnection findByCallID(String id){
for(CallPeerConnection connection : this)
if(connection.getMember().getUserCallID().equals(id))
return connection;
return null;
}
}

View File

@ -0,0 +1,32 @@
package org.communiquons.android.comunic.client.ui.asynctasks;
import android.content.Context;
import org.communiquons.android.comunic.client.data.helpers.CallsHelper;
import org.communiquons.android.comunic.client.data.models.CallInformation;
/**
* Task to get information about a call
*
* @author Pierre HUBERT
*/
public class GetCallInformationTask extends SafeAsyncTask<Integer, Void, CallInformation> {
public GetCallInformationTask(Context context) {
super(context);
}
@Override
protected CallInformation doInBackground(Integer... integers) {
CallsHelper callsHelper = new CallsHelper(getContext());
CallInformation callInformation = callsHelper.getInfo(integers[0]);
//Try to get call name
if(callInformation == null || callsHelper.getCallName(callInformation) == null)
return null;
return callInformation;
}
}

View File

@ -0,0 +1,70 @@
package org.communiquons.android.comunic.client.ui.models;
import org.appspot.apprtc.PeerConnectionClient;
import org.communiquons.android.comunic.client.data.models.CallMember;
import org.webrtc.SurfaceViewRenderer;
import org.webrtc.VideoSink;
import java.util.ArrayList;
/**
* Single remote connection information
*
* @author Pierre HUBERT
*/
public class CallPeerConnection {
//Private fields
private CallMember member;
private PeerConnectionClient peerConnectionClient;
private ArrayList<VideoSink> remoteSinks = new ArrayList<>();
//Views
private SurfaceViewRenderer mLocalVideoView;
private SurfaceViewRenderer mRemoteViewView;
public CallPeerConnection(CallMember member) {
this.member = member;
}
public CallMember getMember() {
return member;
}
public void setMember(CallMember member) {
this.member = member;
}
public PeerConnectionClient getPeerConnectionClient() {
return peerConnectionClient;
}
public void setPeerConnectionClient(PeerConnectionClient peerConnectionClient) {
this.peerConnectionClient = peerConnectionClient;
}
public ArrayList<VideoSink> getRemoteSinks() {
return remoteSinks;
}
public void setRemoteSinks(ArrayList<VideoSink> remoteSinks) {
this.remoteSinks = remoteSinks;
}
public SurfaceViewRenderer getRemoteViewView() {
return mRemoteViewView;
}
public void setRemoteViewView(SurfaceViewRenderer mRemoteViewView) {
this.mRemoteViewView = mRemoteViewView;
}
public SurfaceViewRenderer getLocalVideoView() {
return mLocalVideoView;
}
public void setLocalVideoView(SurfaceViewRenderer mLocalVideoView) {
this.mLocalVideoView = mLocalVideoView;
}
}

View File

@ -1,4 +1,4 @@
package org.communiquons.android.comunic.client.crashreporter;
package org.communiquons.crashreporter;
import android.content.Context;
import android.os.AsyncTask;

View File

@ -0,0 +1,84 @@
package org.communiquons.signalexchangerclient;
import org.json.JSONException;
import org.json.JSONObject;
/**
* Signal exchanger client request
*
* @author Pierre HUBERT
*/
class ClientRequest {
/**
* Contains request information
*/
private JSONObject mList;
/**
* Initialize object
*/
ClientRequest(){
this.mList = new JSONObject();
}
/**
* Add a string to the request
*
* @param name The name of the string to add
* @param value The value of the string to add
* @return This object to help to concatenate requests
*/
ClientRequest addString(String name, String value){
try {
mList.put(name, value);
} catch (JSONException e) {
e.printStackTrace();
throw new RuntimeException("Could not add a string to a JSON object!");
}
return this;
}
/**
* Add a boolean to the request
*
* @param name The name of the string to add
* @param value Boolean value
* @return This object
*/
ClientRequest addBoolean(String name, boolean value){
try {
mList.put(name, value);
} catch (JSONException e) {
e.printStackTrace();
}
return this;
}
/**
* Add a JSON object to the request
*
* @param name The name of the field to add
* @param value The object
* @return This object
*/
ClientRequest addJSONObject(String name, JSONObject value){
try {
mList.put(name, value);
} catch (JSONException e) {
e.printStackTrace();
}
return this;
}
/**
* Get resulting JSON object
*
* @return Get the resulting JSON object
*/
JSONObject get(){
return mList;
}
}

View File

@ -0,0 +1,76 @@
package org.communiquons.signalexchangerclient;
import android.support.annotation.Nullable;
import org.webrtc.IceCandidate;
import org.webrtc.SessionDescription;
/**
* This interface should be implemented by the classes
* that makes use of the {@link SignalExchangerClient}
* in order to get updated about new information
* availability
*
* @author Pierre HUBERT
*/
public interface SignalExchangerCallback {
/**
* Method called when an error occur
*
* @param msg Message associated to the error
* @param t Optional associated throwable
*/
void onSignalServerError(String msg, @Nullable Throwable t);
/**
* Method called once we are connected to the server
*/
void onConnectedToSignalingServer();
/**
* Method called on ready message callback
*
* @param target_id The ID of the target
* @param number_targets The number of peers who received the message
*/
void onReadyMessageCallback(String target_id, int number_targets);
/**
* Method called when this client receive a new ready message signal
*
* @param source_id The source of the message
*/
void onReadyMessage(String source_id);
/**
* Method called when the client received a signal
*
* @param source_id The source of the signal
* @param signal The signal
*/
void onSignal(String source_id, String signal);
/**
* Send signals callback
*
* @param number_targets The number of targets for the signal
*/
void onSendSignalCallback(int number_targets);
/**
* This method is called once we received a remote Ice Candidate
*
* @param source_id The source of the signal
* @param iceCandidate The candidate itself
*/
void gotRemoteIceCandidate(String source_id, IceCandidate iceCandidate);
/**
* This method is called when we have got a new remote session description
*
* @param source_id The source of the signal
* @param sessionDescription The session description
*/
void gotRemoteSessionDescription(String source_id, SessionDescription sessionDescription);
}

View File

@ -0,0 +1,333 @@
package org.communiquons.signalexchangerclient;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.util.Log;
import org.json.JSONException;
import org.json.JSONObject;
import org.webrtc.IceCandidate;
import org.webrtc.SessionDescription;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.WebSocket;
import okhttp3.WebSocketListener;
/**
* Signal exchanger client
*
* @author Pierre HUBERT
*/
public class SignalExchangerClient extends WebSocketListener {
/**
* Debug log
*/
private static final String TAG = SignalExchangerClient.class.getSimpleName();
/**
* Instance configuration
*/
private SignalExchangerInitConfig mConfig;
/**
* Signal exchanger callback
*/
@Nullable
private SignalExchangerCallback mCallback;
/**
* Http Client
*/
private OkHttpClient mClient;
/**
* Current WebSocket connection
*/
private WebSocket mWebSocket;
/**
* Initialize a SignalExchanger client
*
* @param config Configuration of the client
* @param cb Callback function to call when we got information update
*/
public SignalExchangerClient(@NonNull SignalExchangerInitConfig config,
@Nullable SignalExchangerCallback cb){
//Save configuration
this.mConfig = config;
this.mCallback = cb;
//Connect to the WebSocket
String url = (config.isSecure() ? "wss" : "ws")
+ "://" + config.getDomain() + ":" + config.getPort() + "/socket";
mClient = new OkHttpClient();
Request request = new Request.Builder().url(url).build();
mWebSocket = mClient.newWebSocket(request, this);
}
/**
* Get current client configuration
*
* @return Configuration of the client
*/
public SignalExchangerInitConfig getConfig() {
return mConfig;
}
/**
* Set the callback to use on new updates
*
* @param mCallback Callback to use
*/
public void setCallback(@Nullable SignalExchangerCallback mCallback) {
this.mCallback = mCallback;
}
/**
* Check out whether the current client is connected to a server or not
*
* @return true if the client is connected to a server / false else
*/
public boolean isConnected(){
return mWebSocket != null;
}
/**
* Send ready message to a client
*
* @param target_client_id The ID of the target client
*/
public void sendReadyMessage(String target_client_id){
sendData(new ClientRequest()
.addBoolean("ready_msg", true)
.addString("target_id", target_client_id));
}
/**
* Send a signal to a target
*
* @param target_id The ID of the target
* @param signal The signal to send
*/
public void sendSignal(String target_id, String signal){
sendData(new ClientRequest()
.addString("target_id", target_id)
.addString("signal", signal));
}
/**
* Send a session description to a target
*
* @param target_id The ID of the target
* @param description The description
*/
public void sendSessionDescription(String target_id, SessionDescription description){
try {
JSONObject object = new JSONObject();
object.put("type", description.type.canonicalForm());
object.put("sdp", description.description);
sendSignal(target_id, object.toString());
} catch (JSONException e) {
e.printStackTrace();
}
}
/**
* Send an Ice Candidate to a remote peer
*
* @param target_id The ID of the target
* @param candidate The candidate to send
*/
public void sendIceCandidate(String target_id, IceCandidate candidate){
try {
JSONObject candidateObj = new JSONObject();
candidateObj.put("sdpMid", candidate.sdpMid);
candidateObj.put("sdpMLineIndex", candidate.sdpMLineIndex);
candidateObj.put("candidate", candidate.sdp);
JSONObject object = new JSONObject();
object.put("candidate", candidateObj);
sendSignal(target_id, object.toString());
} catch (JSONException e) {
e.printStackTrace();
}
}
/**
* Send data to the server
*
* @param request The data to send to the server
*/
private void sendData(@NonNull ClientRequest request){
//Continues only in case of active connection
if(!isConnected()) {
return;
}
//Send data to the server
Log.v(TAG, "Sending " + request.get().toString());
mWebSocket.send(request.get().toString());
}
/**
* Invoked when a web socket has been accepted by the remote peer and may begin transmitting
* messages.
*/
public void onOpen(WebSocket webSocket, Response response) {
//Save WebSocket object
this.mWebSocket = webSocket;
//Send the ID of current client to the server
sendData(new ClientRequest()
.addString("client_id", mConfig.getClientID()));
//Inform we are connected
if(mCallback != null)
mCallback.onConnectedToSignalingServer();
}
/** Invoked when a text (type {@code 0x1}) message has been received. */
@Override
public void onMessage(WebSocket webSocket, String text) {
Log.v(TAG, "Received new message from server: " + text);
//Decode message
try {
JSONObject message = new JSONObject(text);
//Ready message callback
if(message.has("ready_message_sent")){
if(mCallback != null)
mCallback.onReadyMessageCallback(
message.getString("target_id"),
message.getInt("number_of_targets")
);
}
//Ready message
else if(message.has("ready_msg")){
if(mCallback != null)
mCallback.onReadyMessage(
message.getString("source_id")
);
}
//Signal
else if(message.has("signal")) {
if(mCallback != null)
mCallback.onSignal(
message.getString("source_id"),
message.getString("signal")
);
processReceivedSignal(message.getString("source_id"),
message.getString("signal"));
}
//Send signal callback
else if(message.has("signal_sent")){
if(mCallback != null)
mCallback.onSendSignalCallback(
message.getInt("number_of_targets")
);
}
//Success message
else if(message.has("success"))
Log.v(TAG, "Success: " + message.getString("success"));
//Unrecognized message
else
Log.e(TAG, "Message from server not understood!");
} catch (JSONException e) {
e.printStackTrace();
if(mCallback != null)
mCallback.onSignalServerError("Could not parse response from server!", e);
}
}
/**
* Process a received signal
*
* @param source_id The source of the signal
* @param signal The signal to process
*/
private void processReceivedSignal(String source_id, String signal) throws JSONException {
JSONObject object = new JSONObject(signal);
//Ice candidate
if(object.has("candidate")) {
JSONObject candidate = object.getJSONObject("candidate");
if (mCallback != null)
mCallback.gotRemoteIceCandidate(
source_id, new IceCandidate(
candidate.getString("sdpMid"),
candidate.getInt("sdpMLineIndex"),
candidate.getString("candidate")
)
);
}
//Sdp signal
else if(object.has("sdp") && object.has("type")){
SessionDescription.Type type = SessionDescription.Type.fromCanonicalForm(
object.getString("type"));
String sdp = object.getString("sdp");
if(mCallback != null)
mCallback.gotRemoteSessionDescription(source_id,
new SessionDescription(type, sdp));
}
else
Log.e(TAG, "Could not understand received signal!");
}
/**
* Invoked when both peers have indicated that no more messages will be transmitted and the
* connection has been successfully released. No further calls to this listener will be made.
*/
public void onClosed(WebSocket webSocket, int code, String reason) {
mWebSocket = null;
}
/**
* Invoked when a web socket has been closed due to an error reading from or writing to the
* network. Both outgoing and incoming messages may have been lost. No further calls to this
* listener will be made.
*/
public void onFailure(WebSocket webSocket, Throwable t, @Nullable Response response) {
if(mCallback != null)
mCallback.onSignalServerError(t.getMessage(), t);
}
}

View File

@ -0,0 +1,58 @@
package org.communiquons.signalexchangerclient;
/**
* Signal exchanger configuration intialization
*
* @author Pierre HUBERT
*/
public class SignalExchangerInitConfig {
//Private fields
private String domain;
private int port;
private String clientID;
private boolean isSecure;
public SignalExchangerInitConfig() {
}
public SignalExchangerInitConfig(String domain, int port, String clientID, boolean isSecure) {
this.domain = domain;
this.port = port;
this.clientID = clientID;
this.isSecure = isSecure;
}
public String getDomain() {
return domain;
}
public void setDomain(String domain) {
this.domain = domain;
}
public int getPort() {
return port;
}
public void setPort(int port) {
this.port = port;
}
public String getClientID() {
return clientID;
}
public void setClientID(String clientID) {
this.clientID = clientID;
}
public boolean isSecure() {
return isSecure;
}
public void setSecure(boolean secure) {
isSecure = secure;
}
}

View File

@ -6,17 +6,18 @@
android:layout_height="match_parent"
tools:context=".ui.activities.CallActivity">
<TextView
android:id="@+id/call_id"
<ProgressBar
android:id="@+id/progressBar"
style="?android:attr/progressBarStyle"
android:layout_width="wrap_content"
android:layout_height="21dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:text="TextView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</android.support.constraint.ConstraintLayout>

View File

@ -329,4 +329,7 @@
<string name="button_accept_call">Répondre</string>
<string name="title_incoming_call">Appel entrant</string>
<string name="err_get_next_pending_call_info">Impossible de récupérer les informations de l\'appel en cours !</string>
<string name="err_get_call_info">Impossible de récupérer les infoamtions sur l\'appel !</string>
<string name="err_connect_signaling_server">Impossible de connecter au signaling server !</string>
<string name="notice_call_terminated">Appel terminé.</string>
</resources>

View File

@ -328,4 +328,7 @@
<string name="button_accept_call">Accept call</string>
<string name="title_incoming_call">Incoming call</string>
<string name="err_get_next_pending_call_info">Could not get pending call information!</string>
<string name="err_get_call_info">Could not get call information!</string>
<string name="err_connect_signaling_server">Could not connect to signaling server!</string>
<string name="notice_call_terminated">Call terminated</string>
</resources>