- PVSM.RU - https://www.pvsm.ru -

Как я писал клиент PeerJS(WebRTC) под Android

Недавно пришлось писать клиентское приложение на 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/