Pull to refresh

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

Reading time 13 min
Views 14K
Недавно пришлось писать клиентское приложение на Android для сервера, который организовал видеосвязь между пользователями с помощью библиотеки PeerJS. Эта библиотека является надстройкой над WebRTC, ну или что-то типо того.

Подошел к делу с энтузиазмом, так как до этого ничего такого сложного не делал.
Естественно, первым шагом был поиск библиотек, проектов, которые реализуют такой функционал.
Нашел sample WebRTC, но потом обнаружил проект, который попроще все это реализовывал.

Начал переделывать под себя, потому что брокер, который использую я — peerjs.
Изменил JavaScript код при подключении и начал ловить сообщения об ошибке. Не сразу, но допер, что все дело в том, что стандартный WebView (через который выполняем JavaScript-код) не поддерживает WebRTC.

«Как обойти эту проблему» — задался я таким вопросом. Долго гуглил и ничего толкового не нашел.
Решил, что полезно будет покопаться в API PeerJS и посмотреть, как они все реализуют.

Нашел несколько запросов, которые посылает библиотека, и которые легко будет воспроизвести, а также понял, как peerjs подключает клиентов. Через WebSocket!

После этого натолкнулся на проект и это решило все!
Дальше приведу код, который умещается в одну 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("\r\n");
		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("\r\n");
		}
		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" />


Ну, вот вроде и все. На сумбурное повествование прошу не обижаться. И качество кода не стремится к эталону.

Надеюсь, кому-нибудь пригодится.
Tags:
Hubs:
+2
Comments 4
Comments Comments 4

Articles