Об особенностях технологии разработки видео чатов и конференций WebRTC на Android.

Поможет разработчикам, начинающим интересоваться темой. Полезно управляющим и владельцам бизнеса, задумавшимся о разработке Android приложения с видеосвязью. В коде не разберетесь, но узнаете варианты разработки с плюсами и минусами, получите представление о задачах для программиста.

Вкратце о WebRTC

WebRTC — технология разработки видео чатов и конференций. Она позволяет создавать peer-to-peer соединение между мобильными устройствами и браузерами для передачи медиа потоков. Более подробную информацию, как она устроена и об общих принципах работы можно найти в нашей статье про WebRTC простым языком.

2 способа реализации видеосвязи с WebRTC на Android

  1. Самый простой и быстрый вариант — воспользоваться одним из множества коммерческих проектов, например, Twilio или LiveSwitch. Они предоставляют свои SDK для различных платформ и реализуют функциональность из коробки, но имеют минусы. Они платные и функционал ограничен: вы сможете сделать только те функции, которые в них есть, а не любые, какие придумаете.
  2. Другой вариант — использовать одну из существующих библиотек. Этот подход требует большего количества кода, но сэкономит деньги и даст большую гибкость в реализации функциональности. В этой статье мы рассмотрим второй вариант, а в качестве библиотеки используем https://webrtc.github.io/webrtc-org/native-code/android/

Создание соединения

Как создать WebRTC-соединение

Создание WebRTC-соединения состоит из двух этапов:

  1. Установка логического соединения — устройства должны договориться о формате данных, кодеках и т.п.
  2. Установка физического соединения — устройства должны знать адреса друг друга

Для начала отметим, что на этапе инициирования соединения, для обмена данными между устройствами, используется сигнальный механизм. В качестве сигнального механизма может выступать любой канал передачи данных — например, сокеты.

Предположим, мы хотим установить видеосвязь между двумя устройствами. Для этого нужно установить логическое соединение между ними.

Логическое соединение

Логическое соединение устанавливается с использованием Session Description Protocol (SDP), для этого один пир:

  1. Создает объект PeerConnection
  2. Формирует на SDP-offer — объект, содержащий данные о предстоящей сессии, и при помощи сигнального механизма отправляет его собеседнику.
 
 val peerConnectionFactory: PeerConnectionFactory 
lateinit var peerConnection: PeerConnection 
fun createPeerConnection(iceServers: List) { 
  val rtcConfig = PeerConnection.RTCConfiguration(iceServers) 
  peerConnection = peerConnectionFactory.createPeerConnection( 
      rtcConfig, 
      object : PeerConnection.Observer { 
          ... 
      } 
  )!! 
} 
fun sendSdpOffer() { 
  peerConnection.createOffer( 
      object : SdpObserver { 
          override fun onCreateSuccess(sdpOffer: SessionDescription) { 
              peerConnection.setLocalDescription(sdpObserver, sdpOffer) 
              signaling.sendSdpOffer(sdpOffer) 
          } 
          ... 

      }, MediaConstraints() 
  ) 
}
 

В свою очередь, другой пир:

  1. Также создает объект PeerConnection
  2. При помощи сигнального механизма получает SDP-offer, отравленный первым пиром, и сохраняет его у себя
  3. Формирует SDP-answer и отправляет его обратно, также при помощи сигнального механизма
 
 fun onSdpOfferReceive(sdpOffer: SessionDescription) {// Saving the received SDP-offer 
  peerConnection.setRemoteDescription(sdpObserver, sdpOffer) 
  sendSdpAnswer() 
} 
// Формирование и отправка SDP-answer 
fun sendSdpAnswer() { 
  peerConnection.createAnswer( 
      object : SdpObserver { 
          override fun onCreateSuccess(sdpOffer: SessionDescription) { 
              peerConnection.setLocalDescription(sdpObserver, sdpOffer) 
              signaling.sendSdpAnswer(sdpOffer) 
          } 
           … 
      }, MediaConstraints() 
  ) 
} 
 

Первый пир, получив SDP-answer, сохраняет его у себя

 
fun onSdpAnswerReceive(sdpAnswer: SessionDescription) { 
  peerConnection.setRemoteDescription(sdpObserver, sdpAnswer) 
  sendSdpAnswer() 
}
 

После успешного обмена объектами SessionDescription, логическое соединение считается установленным.

Физическое соединение

Теперь необходимо установить физическое соединение между устройствами, что чаще всего является нетривиальной задачей. Обычно устройства в интернете не имеют публичных адресов, поскольку располагаются за роутерами и межсетевыми экранами. Для решения этой проблемы в WebRTC используется технология ICE (Interactive Connectivity Establishment).

Важной составляющей ICE являются Stun и Turn сервера. Они служат одной цели - установке связи между устройствами, которые не имеют публичных адресов.

Stun-сервер

Устройство делает запрос к Stun-серверу и в ответ получает свой публичный адрес. Далее, при помощи сигнального механизма отправляет его собеседнику. После того, как собеседник проделает то же самое, устройства узнают расположение друг друга в сети и будут готовы к передаче данных друг другу.

Turn-сервер

В некоторых случаях роутер может иметь ограничение “Symmetric NAT”. Это ограничение не позволит установить прямое соединение между устройствами. В этом случае используется Turn-сервер. Он выступает в качестве посредника и все данные идут через него. Подробнее можно почитать в документации по WebRTC от Mozilla.

Как мы увидели, STUN и TURN сервера играют важную роль в установке физического соединения между устройствами. Именно для этой цели мы, при создании объекта PeerConnection, передаем список с доступными ICE-серверами.

Для установки физического соединения один пир генерирует ICE-кандидаты - объекты, содержащие информацию о том, как устройство может быть найдено в сети, и отправляет их через сигнальный механизм собеседнику

 
lateinit var peerConnection: PeerConnection  
fun createPeerConnection(iceServers: List) { 
  val rtcConfig = PeerConnection.RTCConfiguration(iceServers) 
  peerConnection = peerConnectionFactory.createPeerConnection( 
      rtcConfig, 
      object : PeerConnection.Observer { 
          override fun onIceCandidate(iceCandidate: IceCandidate) { 
              signaling.sendIceCandidate(iceCandidate) 
          }           … 
      } 
  )!! 
}
 

Далее второй пир, через сигнальный механизм получая ICE-кандидаты первого пира, сохраняет их у себя. При этом он также генерирует свои ICE-кандидаты и отправляет их обратно

 
fun onIceCandidateReceive(iceCandidate: IceCandidate) { 
  peerConnection.addIceCandidate(iceCandidate) 
}
 

Теперь, когда пиры обменялись своими адресами, можно приступать к передаче и приему данных.

Получение данных

Библиотека, после установки логического и физического соединений с собеседником, вызовет колбэк onAddTrack и передаст в него объект MediaStream, содержащий VideoTrack и AudioTrack собеседника

 
fun createPeerConnection(iceServers: List) { 
   val rtcConfig = PeerConnection.RTCConfiguration(iceServers) 
   peerConnection = peerConnectionFactory.createPeerConnection( 
       rtcConfig, 
       object : PeerConnection.Observer { 
           override fun onIceCandidate(iceCandidate: IceCandidate) { … } 
           override fun onAddTrack( 
               rtpReceiver: RtpReceiver?, 
               mediaStreams: Array 
           ) { 
               onTrackAdded(mediaStreams) 
           } 
           …  
       } 
   )!! 
}
 

Дальше мы должны получить VideoTrack из MediaStream и отобразить его на экране.

 
private fun onTrackAdded(mediaStreams: Array) { 
   val videoTrack: VideoTrack? = mediaStreams.mapNotNull {                                                    
       it.videoTracks.firstOrNull()  
   }.firstOrNull() 
   displayVideoTrack(videoTrack) 
   …  
}
 

Для отображения VideoTrack, необходимо передать ему объект, реализующий интерфейс VideoSink. Для этих целей библиотека предоставляет класс SurfaceViewRenderer.

 
 fun displayVideoTrack(videoTrack: VideoTrack?) { 
   videoTrack?.addSink(binding.surfaceViewRenderer) 
} 
 

Для получения звука собеседника нам не нужно делать ничего дополнительного — библиотека все сделает за нас. Но все же, если мы хотим более тонкой настройки звука, можно получить объект AudioTrack и через него менять настройки аудио

 
 var audioTrack: AudioTrack? = null 
private fun onTrackAdded(mediaStreams: Array) { 
   …  
   audioTrack = mediaStreams.mapNotNull {  
       it.audioTracks.firstOrNull()  
   }.firstOrNull() 
} 
 

Например, следующим образом мы можем замьютить собеседника

 
 fun muteAudioTrack() { 
   audioTrack.setEnabled(false) 
} 
 

Отправка данных

Отправка видео и аудио со своего устройства также начинается с создания объекта PeerConnection и отправки IceCandidate-ов. Но в отличие от создания SDPOffer при получении видеопотока собеседника, в данном случае мы должны сначала создать объект MediaStream, который включает в себя AudioTrack и VideoTrack.

Для отправки своих аудио и видеопотоков, нужно создать объект PeerConnection, после чего при помощи сигнального механизма обменяться IceCandidate и SDP пакетами. Но вместо того, чтобы получить от библиотеки медиапоток собеседника, мы должны получить медиапоток нашего устройства и передать его в библиотеку, чтобы она передала его нашему собеседнику

 
 fun createLocalConnection() {  
   localPeerConnection = peerConnectionFactory.createPeerConnection( 
       rtcConfig, 
       object : PeerConnection.Observer { 
            ... 
       } 
   )!! 
   val localMediaStream = getLocalMediaStream() 
   localPeerConnection.addStream(localMediaStream) 
   localPeerConnection.createOffer( 
       object : SdpObserver { 
            ... 
       }, MediaConstraints() 
   ) 
}
 

Теперь нам нужно создать объект MediaStream и передать в него объекты AudioTrack и VideoTrack

 
 val context: Context 
private fun getLocalMediaStream(): MediaStream? { 
   val stream = peerConnectionFactory.createLocalMediaStream("user") 
   val audioTrack = getLocalAudioTrack() 
   stream.addTrack(audioTrack) 
   val videoTrack = getLocalVideoTrack(context) 
   stream.addTrack(videoTrack) 
   return stream 
} 
 

Получить AudioTrack можно следующим образом

 
 private fun getLocalAudioTrack(): AudioTrack { 
   val audioConstraints = MediaConstraints() 
   val audioSource = peerConnectionFactory.createAudioSource(audioConstraints) 
   return peerConnectionFactory.createAudioTrack("user_audio", audioSource) 
} 
 

Получение VideoTrack происходит чуть сложнее. Сначала получаем список всех камер устройства:

 
lateinit var capturer: CameraVideoCapturer 
private fun getLocalVideoTrack(context: Context): VideoTrack { 
   val cameraEnumerator = Camera2Enumerator(context) 
   val camera = cameraEnumerator.deviceNames.firstOrNull { 
       cameraEnumerator.isFrontFacing(it) 
   } ?: cameraEnumerator.deviceNames.first() 
   ... 
}
 

Далее нужно создать объект CameraVideoCapturer, который и будет захватывать изображение

 
private fun getLocalVideoTrack(context: Context): VideoTrack { 
   ... 

   capturer = cameraEnumerator.createCapturer(camera, null) 
   val surfaceTextureHelper = SurfaceTextureHelper.create( 
       "CaptureThread", 
       EglBase.create().eglBaseContext 
   ) 
   val videoSource = 
       peerConnectionFactory.createVideoSource(capturer.isScreencast ?: false) 
   capturer.initialize(surfaceTextureHelper, context, videoSource.capturerObserver) 
   
   ... 
} 
 

Теперь, после получения CameraVideoCapturer начинаем захватывать изображение и добавляем его в MediaStream

 
 private fun getLocalMediaStream(): MediaStream? { 
  ... 
  val videoTrack = getLocalVideoTrack(context) 
  stream.addTrack(videoTrack) 
  return stream 
} 
private fun getLocalVideoTrack(context: Context): VideoTrack { 
    ... 
  capturer.startCapture(1024, 720, 30) 
  return peerConnectionFactory.createVideoTrack("user0_video", videoSource) 
} 
 

После создания MediaStream и добавления его в PeerConnection, библиотека формирует SDP-offer и происходит описанный выше обмен SDP-пакетами через сигнальный механизм. По завершению этого процесса собеседник начнет получать наш видео поток. C этого момента соединение установлено.

  • Разработка