- PVSM.RU - https://www.pvsm.ru -
Недавно пришлось писать клиентское приложение на Android для сервера, который организовал видеосвязь между пользователями с помощью библиотеки PeerJS [1]. Эта библиотека является надстройкой над WebRTC, ну или что-то типо того.
Подошел к делу с энтузиазмом, так как до этого ничего такого сложного не делал.
Естественно, первым шагом был поиск библиотек, проектов, которые реализуют такой функционал.
Нашел sample [2] WebRTC, но потом обнаружил проект [3], который попроще все это реализовывал.
Начал переделывать под себя, потому что брокер, который использую я — peerjs.
Изменил JavaScript код при подключении и начал ловить сообщения об ошибке. Не сразу, но допер, что все дело в том, что стандартный WebView (через который выполняем JavaScript-код) не поддерживает WebRTC.
«Как обойти эту проблему» — задался я таким вопросом. Долго гуглил и ничего толкового не нашел.
Решил, что полезно будет покопаться в API PeerJS и посмотреть, как они все реализуют.
Нашел несколько запросов, которые посылает библиотека, и которые легко будет воспроизвести, а также понял, как peerjs подключает клиентов. Через WebSocket!
После этого натолкнулся на проект [4] и это решило все!
Дальше приведу код, который умещается в одну Activity.
Ну во-первых, нужно взять из последнего упомянутого проекта папочку libs и добавить себе в проект.
Дальше создаем Activity и добавим ссылки на объекты, которые создадим позже:
private static boolean factoryStaticInitialized;
private GLSurfaceView surfaceView;
private VideoRenderer.Callbacks localRender;
private VideoRenderer.Callbacks remoteRender;
private VideoRenderer localRenderer;
private VideoSource videoSource;
private VideoTrack videoTrack;
private AudioTrack audioTrack;
private MediaStream localMediaStream;
private boolean videoSourceStopped;
private boolean initiator = false;
private boolean video = true;
private boolean audio = true;
private WebSocketClient client;
private PeerConnectionFactory factory;
private PeerConnection peerConnection;
private final PCObserver pcObserver = new PCObserver();
private final SDPObserver sdpObserver = new SDPObserver();
private MediaConstraints sdpMediaConstraints;
private LinkedList<PeerConnection.IceServer> iceServers = new LinkedList<PeerConnection.IceServer>();
private LinkedList<IceCandidate> queuedRemoteCandidates = new LinkedList<IceCandidate>();
private Toast logToast;
private final Boolean[] quit = new Boolean[] { false };
private String id;
private String token = "имяпакета"; // здесь указываем набор символов, я использую имя пакета без точек
private String connectionId = "mc_имяпакета"; // также случайный набор символов, только в начале "mc_"
В onCreate создадим GLSurfaceView, в котором будем выводить видео, и добавляем его в какой-нибудь контейнер:
surfaceView = new GLSurfaceView(this);
surfaceView.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
LinearLayout content = (LinearLayout)findViewById(R.id.activity_webrtc_content);
content.addView(surfaceView);
Дальше настроим места отображения видео:
VideoRendererGui.setView(surfaceView);
remoteRender = VideoRendererGui.create(0, 0, 100, 100);
localRender = VideoRendererGui.create(1, 74, 25, 25);
Здесь мы указываем, что получаемое видео будет отображаться на 100% высоты и ширины surfaceView, а видео с нашей камеры будет отображаться в левом нижнем углу, с отступом слева 1% ширины, 74 % высоты — справа, и размером 25%.
if (!factoryStaticInitialized) {
PeerConnectionFactory.initializeAndroidGlobals(this, true, true);
factoryStaticInitialized = true;
}
audioManager = ((AudioManager) getSystemService(AUDIO_SERVICE));
@SuppressWarnings("deprecation")
boolean isWiredHeadsetOn = audioManager.isWiredHeadsetOn();
audioManager.setMode(isWiredHeadsetOn ? AudioManager.MODE_IN_CALL : AudioManager.MODE_IN_COMMUNICATION);
audioManager.setSpeakerphoneOn(!isWiredHeadsetOn);
sdpMediaConstraints = new MediaConstraints();
sdpMediaConstraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"));
sdpMediaConstraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"));
iceServers.add(new PeerConnection.IceServer("stun:stun.l.google.com:19302"));
createPC();
initializeAndroidGlobals — и не спрашивайте, зачем этот метод. Знаю лишь, что без него не создать соединение.
iceServers — почитайте подробнее в интернете. Иногда видео не передается, потому что нужно добавить еще серверов аналогичным способом. И на стороне сервера, если Вы планируете с сайта тоже общаться, нужно добавить iceServer-ы.
Далее реализуем метод createPC():
void createPC(){
factory = new PeerConnectionFactory();
MediaConstraints pcConstraints = new MediaConstraints();
pcConstraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"));
pcConstraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"));
pcConstraints.optional.add(new MediaConstraints.KeyValuePair("RtpDataChannels", "true"));
pcConstraints.optional.add(new MediaConstraints.KeyValuePair("DtlsSrtpKeyAgreement", "true"));
peerConnection = factory.createPeerConnection(iceServers, pcConstraints, pcObserver);
// а это и есть наше подключение
createDataChannelToRegressionTestBug2302(peerConnection);
// проводим какую-то проверку подключения
logAndToast("Creating local video source...");
MediaConstraints videoConstraints = new MediaConstraints();
videoConstraints.mandatory.add(new MediaConstraints.KeyValuePair("maxHeight", "240"));
videoConstraints.mandatory.add(new MediaConstraints.KeyValuePair("maxWidth", "320"));
// можно и не указывать размер видео
localMediaStream = factory.createLocalMediaStream("ARDAMS");
VideoCapturer capturer = getVideoCapturer();
videoSource = factory.createVideoSource(capturer, videoConstraints);
videoTrack = factory.createVideoTrack("ARDAMSv0", videoSource);
localRenderer = new VideoRenderer(localRender);
videoTrack.addRenderer(localRenderer); // наше видео, которое можно будет отключать
localMediaStream.addTrack(videoTrack);
audioTrack = factory.createAudioTrack("ARDAMSa0", factory.createAudioSource(new MediaConstraints())); // наше аудио с микрофона
localMediaStream.addTrack(audioTrack);
peerConnection.addStream(localMediaStream, new MediaConstraints());
GetID getId = new GetID();
try {
getId.execute(); //запускаем асинхронную задачу, которая выполнит http-запрос на получение id от брокера
}catch (Exception e) {
logAndToast("No Internet connection");
disconnectAndExit();
}
}
private class GetID extends AsyncTask<Void, Void, String>{
@Override
protected String doInBackground(Void... params) {
NetHelper a = NetHelper.getInstance(RTCActivity.this);
// выполняем http запрос, который вернет нам id
String result = a.executeHttpGet("http://0.peerjs.com:9000/" + roomKey + "/id?ts=" + Calendar.getInstance().getTimeInMillis() + ".7330598266421392");
// roomKey - номер комнаты. его можно получить на сайте PeerJS или использовать стандартный "lwjd5qra8257b9"
// 7330598266421392 - случайный набор цифр, который обеспечит уникальность id
if (result==null)
return null;
result = result.replace("n", ""); // id возвращается с переносом строки в конце, так что удаляем его
return result;
}
@Override
protected void onPostExecute(String result) {
super.onPostExecute(result);
if (result==null)
return;
id = result;
// создаем слушатель, который будет отлавливать получаемые события для сокета
WebSocketClient.Listener listener = new WebSocketClient.Listener() {
@Override
public void onMessage(byte[] arg0) {
}
@Override
public void onMessage(final String data) {
runOnUiThread(new Runnable() {
public void run() {
try {
JSONObject json = new JSONObject(data);
String type = (String) json.get("type");
if (type.equalsIgnoreCase("candidate")) {
JSONObject jsonCandidate = json.getJSONObject("payload").getJSONObject("candidate");
IceCandidate candidate = new IceCandidate(
(String) jsonCandidate.get("sdpMid"),
jsonCandidate.getInt("sdpMLineIndex"),
(String) jsonCandidate.get("candidate"));
if (queuedRemoteCandidates != null) {
queuedRemoteCandidates.add(candidate);
} else {
peerConnection.addIceCandidate(candidate);
}
} else if (type.equalsIgnoreCase("answer") || type.equalsIgnoreCase("offer")) {
connectionId = json.getJSONObject("payload").getString("connectionId");
friendId = json.getString("src");
JSONObject jsonSdp = json.getJSONObject("payload").getJSONObject("sdp");
SessionDescription sdp = new SessionDescription(
SessionDescription.Type.fromCanonicalForm(type),
preferISAC((String) jsonSdp.get("sdp")));
peerConnection.setRemoteDescription(sdpObserver, sdp);
} else if (type.equalsIgnoreCase("bye")) {
logAndToast("Remote end hung up; dropping PeerConnection");
disconnectAndExit();
} else {
//throw new RuntimeException("Unexpected message: " + data);
}
} catch (JSONException e) {
//throw new RuntimeException(e);
}
}
});
}
@Override
public void onError(Exception arg0) {
runOnUiThread(new Runnable() {
public void run() {
disconnectAndExit();
}
});
}
@Override
public void onDisconnect(int arg0, String arg1) {
runOnUiThread(new Runnable() {
public void run() {
disconnectAndExit();
}
});
}
@Override
public void onConnect() {
// когда сокет подключился
runOnUiThread(new Runnable() {
public void run() {
if (initiator){
logAndToast("Creating offer...");
peerConnection.createOffer(sdpObserver, sdpMediaConstraints);
}
}
});
}
};
URI uri = null;
try {
// создадим URI для сокета. для брокера peerjs он должен иметь такой вид
uri = new URI("ws", "", "0.peerjs.com", 9000, "/peerjs", "key=" + roomKey + "&id=" + id + "&token=" + token, "");
// roomKey - уже описывал, указываем тот же
// id - только что полученный от брокера id
// token - случайный набор символов (я использую имя пакета без точек)
} catch (URISyntaxException e) {
disconnectAndExit();
}
client = new WebSocketClient(uri, listener, null); // непосредственно создаем сокет
client.connect();
}
}
Если Вы знаете id (выданный брокером) пользователя к которому хотите подключиться, то в методе onConnect сокета нужно создать offer.
Если — нет, то ничего не выполняйте.
код из слушателя сокета нужно обязательно выполнять в таком методе
runOnUiThread(new Runnable() {
public void run() {
}
});
В методе onMessage(final String data) мы получаем сообщение. Это может быть:
— offer, который отправил другой пользователь, чтобы подключиться к нам:
— answer, который отправил пользователь, которому мы отправили offer, чтобы подключиться к нему:
— candidate — в них содержится информация по iceServer-ам. Их мы добавляем в свое подключение.
Обращу внимание, что сообщения имеют структуру, определенную peerjs, так что для других брокеров придется разбирать их по-другому.
Реализуем такой класс:
private class PCObserver implements PeerConnection.Observer {
@Override
public void onIceCandidate(final IceCandidate candidate){
runOnUiThread(new Runnable() {
public void run() {
JSONObject json = new JSONObject();
JSONObject payload = new JSONObject();
JSONObject jsonCandidate = new JSONObject();
jsonPut(json, "type", "CANDIDATE");
jsonPut(jsonCandidate, "sdpMid", candidate.sdpMid);
jsonPut(jsonCandidate, "sdpMLineIndex", candidate.sdpMLineIndex);
jsonPut(jsonCandidate, "candidate", candidate.sdp);
jsonPut(payload, "candidate", jsonCandidate);
jsonPut(payload, "type", "media");
jsonPut(payload, "connectionId", connectionId);
jsonPut(json, "payload", payload);
jsonPut(json, "dst", friendId);
jsonPut(json, "src", id);
sendMessage(json);
}
});
}
@Override
public void onError(){
runOnUiThread(new Runnable() {
public void run() {
disconnectAndExit();
}
});
}
@Override
public void onSignalingChange(PeerConnection.SignalingState newState) {
}
@Override
public void onIceConnectionChange(PeerConnection.IceConnectionState newState) {
}
@Override
public void onIceGatheringChange(PeerConnection.IceGatheringState newState) {
}
@Override
public void onAddStream(final MediaStream stream){
runOnUiThread(new Runnable() {
public void run() {
if (stream.videoTracks.size() == 1) {
stream.videoTracks.get(0).addRenderer(new VideoRenderer(remoteRender));
}
}
});
}
@Override
public void onRemoveStream(final MediaStream stream){
runOnUiThread(new Runnable() {
public void run() {
stream.videoTracks.get(0).dispose();
}
});
}
@Override
public void onDataChannel(final DataChannel dc) {
}
@Override
public void onRenegotiationNeeded() {
}
}
Этот класс отправляет второму пользователю кандидатов и получает входящий поток с видео и аудио.
И еще один класс:
private class SDPObserver implements SdpObserver {
private SessionDescription localSdp;
@Override
public void onCreateSuccess(final SessionDescription origSdp) {
final SessionDescription sdp = new SessionDescription(origSdp.type, preferISAC(origSdp.description));
localSdp = sdp;
runOnUiThread(new Runnable() {
public void run() {
peerConnection.setLocalDescription(sdpObserver, sdp);
}
});
}
private void sendLocalDescription() {
logAndToast("Sending " + localSdp.type);
JSONObject json = new JSONObject();
JSONObject payload = new JSONObject();
JSONObject sdp = new JSONObject();
jsonPut(json, "type", localSdp.type.canonicalForm().toUpperCase());
jsonPut(sdp, "sdp", localSdp.description);
jsonPut(sdp, "type", localSdp.type.canonicalForm().toLowerCase());
jsonPut(payload, "sdp", sdp);
jsonPut(payload, "type", "media");
jsonPut(payload, "connectionId", connectionId);
jsonPut(payload, "browser", "Chrome");
jsonPut(json, "payload", payload);
jsonPut(json, "dst", friendId);
sendMessage(json);
}
@Override
public void onSetSuccess() {
runOnUiThread(new Runnable() {
public void run() {
if (initiator) {
if (peerConnection.getRemoteDescription() != null) {
drainRemoteCandidates();
} else {
sendLocalDescription();
}
} else {
if (peerConnection.getLocalDescription() == null) {
logAndToast("Creating answer");
peerConnection.createAnswer(SDPObserver.this, sdpMediaConstraints);
} else {
sendLocalDescription();
drainRemoteCandidates();
}
}
}
});
}
@Override
public void onCreateFailure(final String error) {
}
@Override
public void onSetFailure(final String error) {
}
private void drainRemoteCandidates() {
for (IceCandidate candidate : queuedRemoteCandidates) {
peerConnection.addIceCandidate(candidate);
}
queuedRemoteCandidates = null;
}
}
Этот класс определяет настройки SDP — протокола, по которому отправляется информация о iceServer-ах и offer/answer.
Offer/Answer тоже отправляет этот класс в методе sendLocalDescription(). Offer/Answer также имеют определенную peerjs структуру.
Также добавим второстепенные методы:
// здесь мы получаем локальное видео с камеры
private VideoCapturer getVideoCapturer() {
String[] cameraFacing = { "front", "back" };
int[] cameraIndex = { 0, 1 };
int[] cameraOrientation = { 0, 90, 180, 270 };
for (String facing : cameraFacing) {
for (int index : cameraIndex) {
for (int orientation : cameraOrientation) {
String name = "Camera " + index + ", Facing " + facing + ", Orientation " + orientation;
VideoCapturer capturer = VideoCapturer.create(name);
if (capturer != null) {
logAndToast("Using camera: " + name);
return capturer;
}
}
}
}
return null;
}
@Override
protected void onDestroy() {
disconnectAndExit();
super.onDestroy();
}
private void logAndToast(String msg) {
Log.d(TAG, msg);
if (logToast != null) {
logToast.cancel();
}
logToast = Toast.makeText(this, msg, Toast.LENGTH_SHORT);
logToast.show();
}
private void sendMessage(JSONObject json) {
client.send(json.toString()); // отправляем сообщение
}
private static void jsonPut(JSONObject json, String key, Object value) {
try {
json.put(key, value);
} catch (JSONException e) {
}
}
// сложный метод, который я не трогал руками. Он разбирает sdp-параметры
private static String preferISAC(String sdpDescription) {
String[] lines = sdpDescription.split("rn");
int mLineIndex = -1;
String isac16kRtpMap = null;
Pattern isac16kPattern = Pattern.compile("^a=rtpmap:(\d+) ISAC/16000[r]?$");
for (int i = 0; (i < lines.length) && (mLineIndex == -1 || isac16kRtpMap == null); ++i) {
if (lines[i].startsWith("m=audio ")) {
mLineIndex = i;
continue;
}
Matcher isac16kMatcher = isac16kPattern.matcher(lines[i]);
if (isac16kMatcher.matches()) {
isac16kRtpMap = isac16kMatcher.group(1);
continue;
}
}
if (mLineIndex == -1) {
Log.d(TAG, "No m=audio line, so can't prefer iSAC");
return sdpDescription;
}
if (isac16kRtpMap == null) {
Log.d(TAG, "No ISAC/16000 line, so can't prefer iSAC");
return sdpDescription;
}
String[] origMLineParts = lines[mLineIndex].split(" ");
StringBuilder newMLine = new StringBuilder();
int origPartIndex = 0;
newMLine.append(origMLineParts[origPartIndex++]).append(" ");
newMLine.append(origMLineParts[origPartIndex++]).append(" ");
newMLine.append(origMLineParts[origPartIndex++]).append(" ");
newMLine.append(isac16kRtpMap);
for (; origPartIndex < origMLineParts.length; ++origPartIndex) {
if (!origMLineParts[origPartIndex].equals(isac16kRtpMap)) {
newMLine.append(" ").append(origMLineParts[origPartIndex]);
}
}
lines[mLineIndex] = newMLine.toString();
StringBuilder newSdpDescription = new StringBuilder();
for (String line : lines) {
newSdpDescription.append(line).append("rn");
}
return newSdpDescription.toString();
}
// освобождаем все ресурсы и выходим
private void disconnectAndExit() {
synchronized (quit[0]) {
if (quit[0]) {
return;
}
quit[0] = true;
if (peerConnection != null) {
peerConnection.dispose();
peerConnection = null;
}
if (client != null) {
client.send("{"type": "bye"}");
client.disconnect();
client = null;
}
if (videoSource != null) {
videoSource.dispose();
videoSource = null;
}
if (factory != null) {
factory.dispose();
factory = null;
}
if (audioManager!=null)
audioManager.abandonAudioFocus(audioFocusListener);
finish();
}
}
@Override
public void onStop() {
disconnectAndExit();
super.onStop();
}
@Override
public void onPause() {
super.onPause();
surfaceView.onPause();
if (videoSource != null) {
videoSource.stop(); // останавливаем трансляцию видео
videoSourceStopped = true;
}
}
@Override
public void onResume() {
super.onResume();
surfaceView.onResume();
if (videoSource != null && videoSourceStopped) {
videoSource.restart(); // возобновляем трансляцию видео
}
}
// проверка на какую-то ошибку
private static void createDataChannelToRegressionTestBug2302(PeerConnection pc) {
DataChannel dc = pc.createDataChannel("dcLabel", new DataChannel.Init());
dc.close();
dc.dispose();
}
У нас есть переменная initiator. По-умолчанию она false. Она означает, звоните ли вы кому-то или ждете звонка.
Я проверяю в асинхронной задаче наличие в базе на сервере id пользователя, которому звоню. Если нашел, значит он подключен к брокеру. Ставлю initiator=true. При подключении сокета сразу создастся offer и мы подключимся к второму пользователю.
Если нет id пользователя, то мы просто ждем. Когда кто-то захочет нам позвонить, он должен узнать наш id и отправить offer.
Если хотите сделать кнопки для отключения передачи видео, аудио, то код Вам в помощь:
final ImageView noVideo = (ImageView)findViewById(R.id.activity_webrtc_video);
noVideo.setOnClickListener(new OnClickListener(){
@Override
public void onClick(View v) {
if (video){
noVideo.setImageResource(R.drawable.video_off);
video = false;
videoTrack.setEnabled(false);
}else{
noVideo.setImageResource(R.drawable.video_on);
video = true;
videoTrack.setEnabled(true);
}
}
});
final ImageView noAudio = (ImageView)findViewById(R.id.activity_webrtc_voice);
noAudio.setOnClickListener(new OnClickListener(){
@Override
public void onClick(View v) {
if (audio){
noAudio.setImageResource(R.drawable.voice_off);
audio = false;
audioTrack.setEnabled(false);
}else{
noAudio.setImageResource(R.drawable.voice_on);
audio = true;
audioTrack.setEnabled(true);
}
}
});
Ну и естественно добавим активность в манифест и разрешения:
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature android:name="android.hardware.camera"/>
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
Ну, вот вроде и все. На сумбурное повествование прошу не обижаться. И качество кода не стремится к эталону.
Надеюсь, кому-нибудь пригодится.
Автор: mefi100fell
Источник [5]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/android/78433
Ссылки в тексте:
[1] PeerJS: http://peerjs.com
[2] sample: https://code.google.com/p/webrtc/source/browse/#svn%2Ftrunk%2Ftalk%2Fexamples%2Fandroid%253Fstate%253Dclosed
[3] проект: https://github.com/SDkie/Webrtc-for-Android
[4] проект: https://github.com/pchab/AndroidRTC
[5] Источник: http://habrahabr.ru/post/247079/
Нажмите здесь для печати.