因为人们都逐渐倾向使用语音和视频通话,所以WebRTC 发展十分迅速并走向大众化,但在 Android 中实现音视频通话却很困难,本教程的内容是希望帮助每个开发者都能轻松了解在Android中开发此功能的过程。
Google 发现有很多 Android 开发者在努力使用 WebRTC(可能这系列文章让他们意识到WebRTC的重要性了?他们已经发布了用于 WebRTC 开发的新gradle 依赖项。注意!这些依赖项仅用于开发 目的!
本文将使用 WebRTC 的最新依赖来开发端到端的 Peer-to-Peer 视频通话APP。
经过一段时间的断更(本人表示抱歉),我来更新这个系列的最后一部分了。(欢呼!)
这是“ Android 版 WebRTC 入门 ”系列的第 4 部分 ,如果你第一次阅读本系列文章,务必要确保已阅读本系列的前几章,然后再继续阅读本部分。
第 1 部分: WebRTC 简介
第 2 部分: PeerConnection 简介
第 3 部分: 点对点视频通话 — 环回
第 4 部分: 使用 socket.io 进行点对点视频通话(本文)
倒叙
还记得那个想向他的女朋友求婚的书呆子吗?在学完上一篇教程后,他已经能够自己创建一个APP并开始自言自语(如果你还没有,请参阅此处了解 loopback 调用)。但太糟糕了,自言自语并不会帮助他收获爱情。他想达到如下图所示的效果。
爱不是简单的事。WebRTC 也是如此。当你终于得到它时,它虽然很单调但很甜蜜!
实际的转移实际上是这样的。
-
用户端必须通过某种方式登录到信令服务器。这有助于服务器识别呼叫的正确接收者。这使用 (1,2,3 和 4) 表示
-
登录后,我们这个书呆子创建指令,存储副本(本地描述)并将其发送到充当快递员的服务器并将其交付给他心仪的女孩。(5,6,7 和 8)
-
女孩现在打开求婚信息,将其存储到她的收藏中(远程描述),生成对求婚指令的响应(答案),将其存储在本地并通过同一个快递员将其发送给男孩 (9-13)
-
男孩现在存储从她那里收到的回复 (14)
-
两人之间进行谈判(Ice Candidates 谈判)(15-20)
-
一旦连接上,只要他们愿意就可以畅所欲言!这可能通过他们之间的直接 P2P 连接或通过 TURN 服务器发生,如果有人在防火墙后面(听起来更像是女生的巨人父亲!)
随时待命
首先,我们需要一个服务器设置并与 TURN 和 STUN 一起运行。如果你是新手,STUN 通常是查找设备的公共 IP而TURN 作为中继服务器,以防两端用户之间无法进行 P2P。如果你想了解更多信息,可以参考本系列的第 2 部分,该部分包含理论!
对于这个演示,我们将使用Xirsys提供的平台来满足我们的 STUN 和 TURN 需求。我免费订阅了他们的网站并使用了他们的免费套餐。如果你想将其扩展到生产,你可能必须选择付费订阅或拥有自己的设置。
我们将使用 Google 的代码实验室文章来设置我们的信令服务器。本文更侧重于 Android 开发人员范围,在服务器端没有做太多工作。因此,我们将使用与代码实验室演示中几乎相同的服务器。
按照此处的链接了解如何为本地开发配置 socket.io。你可能不想使用此服务器代码进行生产,因为它有很多安全漏洞。(那里的代码来自 socket.io 1.2 版——为了演示,我们将使用相同的版本)
'use strict';
var os = require('os');
var nodeStatic = require('node-static');
var https = require('https');
var socketIO = require('socket.io');
var fs = require("fs");
var options = {
key: fs.readFileSync('key.pem'),
cert: fs.readFileSync('cert.pem')
};
var fileServer = new(nodeStatic.Server)();
var app = https.createServer(options,function(req, res) {
fileServer.serve(req, res);
}).listen(1794);
var io = socketIO.listen(app);
io.sockets.on('connection', function(socket) {
// convenience function to log server messages on the client
function log() {
var array = ['Message from server:'];
array.push.apply(array, arguments);
socket.emit('log', array);
}
socket.on('message', function(message) {
log('Client said: ', message);
// for a real app, would be room-only (not broadcast)
socket.broadcast.emit('message', message);
});
socket.on('create or join', function(room) {
log('Received request to create or join room ' + room);
var numClients = io.sockets.sockets.length;
log('Room ' + room + ' now has ' + numClients + ' client(s)');
if (numClients === 1) {
socket.join(room);
log('Client ID ' + socket.id + ' created room ' + room);
socket.emit('created', room, socket.id);
} else if (numClients === 2) {
log('Client ID ' + socket.id + ' joined room ' + room);
io.sockets.in(room).emit('join', room);
socket.join(room);
socket.emit('joined', room, socket.id);
io.sockets.in(room).emit('ready');
} else { // max 5 clients
socket.emit('full', room);
}
});
socket.on('ipaddr', function() {
var ifaces = os.networkInterfaces();
for (var dev in ifaces) {
ifaces[dev].forEach(function(details) {
if (details.family === 'IPv4' && details.address !== '127.0.0.1') {
socket.emit('ipaddr', details.address);
}
});
}
});
socket.on('bye', function(){
console.log('received bye');
});
});
如需完整的节点服务器,可查看GitHub 存储库。
Android
谈到 Android 部分,如果你到目前为止一直在关注该系列,你可能会发现这很容易。在上一部分中,我们在同一个活动类中创建了本地和远程节点,并尝试将音频和视频从一个节点传输到另一个节点。我们从本地两端用户获得了求婚信息,并将其设置为远程对等方实例。
当我们处理远程调用时,我们只关心我们自己的本地对等方。相比于管理远程对等点,我们更需要通过信令通道发送连接所需的数据(SDP 和 ICE 候选)。
让我们来看看呼叫处理和信令的代码。
class MainActivityKotlin : AppCompatActivity(), View.OnClickListener, SignallingClientKotlin.SignalingInterface {
private val rootEglBase by lazy { EglBase.create() }
private val peerConnectionFactory: PeerConnectionFactory by lazy {
//Initialize PeerConnectionFactory globals.
val initializationOptions = PeerConnectionFactory.InitializationOptions.builder(this)
.setEnableVideoHwAcceleration(true)
.createInitializationOptions()
PeerConnectionFactory.initialize(initializationOptions)
//Create a new PeerConnectionFactory instance - using Hardware encoder and decoder.
val options = PeerConnectionFactory.Options()
val defaultVideoEncoderFactory = DefaultVideoEncoderFactory(
rootEglBase.eglBaseContext, /* enableIntelVp8Encoder */true, /* enableH264HighProfile */true)
val defaultVideoDecoderFactory = DefaultVideoDecoderFactory(rootEglBase.eglBaseContext)
PeerConnectionFactory(options, defaultVideoEncoderFactory, defaultVideoDecoderFactory)
}
private var sdpConstraints: MediaConstraints? = null
private var localVideoTrack: VideoTrack? = null
private var localAudioTrack: AudioTrack? = null
private var localVideoView: SurfaceViewRenderer? = null
private var remoteVideoView: SurfaceViewRenderer? = null
private var hangup: Button? = null
private var localPeer: PeerConnection? = null
private var gotUserMedia: Boolean = false
private var peerIceServers: MutableList<PeerConnection.IceServer> = ArrayList()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
initViews()
initVideos()
getIceServers()
SignallingClientKotlin.init(this)
start()
}
private fun initViews() {
hangup = findViewById(R.id.end_call)
localVideoView = findViewById(R.id.local_gl_surface_view)
remoteVideoView = findViewById(R.id.remote_gl_surface_view)
hangup?.setOnClickListener(this)
}
private fun initVideos() {
localVideoView?.init(rootEglBase.eglBaseContext, null)
remoteVideoView?.init(rootEglBase.eglBaseContext, null)
localVideoView?.setZOrderMediaOverlay(true)
remoteVideoView?.setZOrderMediaOverlay(true)
}
private fun getIceServers() {
//get Ice servers using xirsys
Utils.getInstance().retrofitInstance.iceCandidates.enqueue(object : Callback<TurnServerPojo> {
override fun onResponse(call: Call<TurnServerPojo>, response: Response<TurnServerPojo>) {
var iceServers: List<IceServer> = ArrayList()
val body = response.body()
if (body != null) {
iceServers = body.iceServerList.iceServers
}
for (iceServer in iceServers) {
if (iceServer.credential == null) {
val peerIceServer = PeerConnection.IceServer.builder(iceServer.url).createIceServer()
peerIceServers.add(peerIceServer)
} else {
val peerIceServer = PeerConnection.IceServer.builder(iceServer.url)
.setUsername(iceServer.username)
.setPassword(iceServer.credential)
.createIceServer()
peerIceServers.add(peerIceServer)
}
}
Log.d("onApiResponse", "IceServers\n" + iceServers.toString())
}
override fun onFailure(call: Call<TurnServerPojo>, t: Throwable) {
t.printStackTrace()
}
})
}
private fun start() {
//Now create a VideoCapturer instance.
val videoCapturerAndroid: VideoCapturer? = createCameraCapturer(Camera1Enumerator(false))
val audioConstraints = MediaConstraints()
//Create MediaConstraints - Will be useful for specifying video and audio constraints.
val videoSource: VideoSource
//Create a VideoSource instance
if (videoCapturerAndroid != null) {
videoSource = peerConnectionFactory.createVideoSource(videoCapturerAndroid)
localVideoTrack = peerConnectionFactory.createVideoTrack("100", videoSource)
}
//create an AudioSource instance
val audioSource = peerConnectionFactory.createAudioSource(audioConstraints)
localAudioTrack = peerConnectionFactory.createAudioTrack("101", audioSource)
videoCapturerAndroid?.startCapture(1024, 720, 30)
localVideoView?.visibility = View.VISIBLE
//create a videoRenderer based on SurfaceViewRenderer instance
val localRenderer = VideoRenderer(localVideoView)
// And finally, with our VideoRenderer ready, we
// can add our renderer to the VideoTrack.
localVideoTrack?.addRenderer(localRenderer)
localVideoView?.setMirror(true)
remoteVideoView?.setMirror(true)
gotUserMedia = true
if (SignallingClientKotlin.isInitiator) {
onTryToStart()
}
}
/**
* This method will be called directly by the app when it is the initiator and has got the local media
* or when the remote peer sends a message through socket that it is ready to transmit AV data
*/
override fun onTryToStart() {
runOnUiThread {
if (!SignallingClientKotlin.isStarted && localVideoTrack != null && SignallingClientKotlin.isChannelReady) {
createPeerConnection()
SignallingClientKotlin.isStarted = true
if (SignallingClientKotlin.isInitiator) {
doCall()
}
}
}
}
/**
* Creating the local peerconnection instance
*/
private fun createPeerConnection() {
val rtcConfig = PeerConnection.RTCConfiguration(peerIceServers)
// TCP candidates are only useful when connecting to a server that supports
// ICE-TCP.
rtcConfig.tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.DISABLED
rtcConfig.bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE
rtcConfig.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE
rtcConfig.continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY
// Use ECDSA encryption.
rtcConfig.keyType = PeerConnection.KeyType.ECDSA
localPeer = peerConnectionFactory.createPeerConnection(rtcConfig, object : CustomPeerConnectionObserver("localPeerCreation") {
override fun onIceCandidate(iceCandidate: IceCandidate) {
super.onIceCandidate(iceCandidate)
onIceCandidateReceived(iceCandidate)
}
override fun onAddStream(mediaStream: MediaStream) {
showToast("Received Remote stream")
super.onAddStream(mediaStream)
gotRemoteStream(mediaStream)
}
})
addStreamToLocalPeer()
}
/**
* Adding the stream to the localpeer
*/
private fun addStreamToLocalPeer() {
//creating local mediastream
val stream = peerConnectionFactory.createLocalMediaStream("102")
stream.addTrack(localAudioTrack)
stream.addTrack(localVideoTrack)
localPeer!!.addStream(stream)
}
/**
* This method is called when the app is initiator - We generate the offer and send it over through socket
* to remote peer
*/
private fun doCall() {
localPeer!!.createOffer(object : CustomSdpObserver("localCreateOffer") {
override fun onCreateSuccess(sessionDescription: SessionDescription) {
super.onCreateSuccess(sessionDescription)
localPeer!!.setLocalDescription(CustomSdpObserver("localSetLocalDesc"), sessionDescription)
Log.d("onCreateSuccess", "SignallingClient emit ")
SignallingClientKotlin.emitMessage(sessionDescription)
}
}, sdpConstraints)
}
/**
* Received remote peer's media stream. we will get the first video track and render it
*/
private fun gotRemoteStream(stream: MediaStream) {
//we have remote video stream. add to the renderer.
val videoTrack = stream.videoTracks[0]
runOnUiThread {
try {
val remoteRenderer = VideoRenderer(remoteVideoView)
remoteVideoView?.visibility = View.VISIBLE
videoTrack.addRenderer(remoteRenderer)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
/**
* Received local ice candidate. Send it to remote peer through signalling for negotiation
*/
fun onIceCandidateReceived(iceCandidate: IceCandidate) {
//we have received ice candidate. We can set it to the other peer.
SignallingClientKotlin.emitIceCandidate(iceCandidate)
}
/**
* SignallingCallback - called when the room is created - i.e. you are the initiator
*/
override fun onCreatedRoom() {
showToast("You created the room " + gotUserMedia)
if (gotUserMedia) {
SignallingClientKotlin.emitMessage("got user media")
}
}
/**
* SignallingCallback - called when you join the room - you are a participant
*/
override fun onJoinedRoom() {
showToast("You joined the room " + gotUserMedia)
if (gotUserMedia) {
SignallingClientKotlin.emitMessage("got user media")
}
}
override fun onNewPeerJoined() {
showToast("Remote Peer Joined")
}
override fun onRemoteHangUp(msg: String) {
showToast("Remote Peer hungup")
runOnUiThread({ this.hangup() })
}
/**
* SignallingCallback - Called when remote peer sends offer
*/
override fun onOfferReceived(data: JSONObject) {
showToast("Received Offer")
runOnUiThread {
if (!SignallingClientKotlin.isInitiator && !SignallingClientKotlin.isStarted) {
onTryToStart()
}
try {
localPeer!!.setRemoteDescription(CustomSdpObserver("localSetRemote"), SessionDescription(SessionDescription.Type.OFFER, data.getString("sdp")))
doAnswer()
updateVideoViews(true)
} catch (e: JSONException) {
e.printStackTrace()
}
}
}
private fun doAnswer() {
localPeer!!.createAnswer(object : CustomSdpObserver("localCreateAns") {
override fun onCreateSuccess(sessionDescription: SessionDescription) {
super.onCreateSuccess(sessionDescription)
localPeer!!.setLocalDescription(CustomSdpObserver("localSetLocal"), sessionDescription)
SignallingClientKotlin.emitMessage(sessionDescription)
}
}, MediaConstraints())
}
/**
* SignallingCallback - Called when remote peer sends answer to your offer
*/
override fun onAnswerReceived(data: JSONObject) {
showToast("Received Answer")
try {
localPeer!!.setRemoteDescription(CustomSdpObserver("localSetRemote"), SessionDescription(SessionDescription.Type.fromCanonicalForm(data.getString("type").toLowerCase()), data.getString("sdp")))
updateVideoViews(true)
} catch (e: JSONException) {
e.printStackTrace()
}
}
/**
* Remote IceCandidate received
*/
override fun onIceCandidateReceived(data: JSONObject) {
try {
localPeer!!.addIceCandidate(IceCandidate(data.getString("id"), data.getInt("label"), data.getString("candidate")))
} catch (e: JSONException) {
e.printStackTrace()
}
}
private fun updateVideoViews(remoteVisible: Boolean) {
runOnUiThread {
var params = localVideoView?.layoutParams
params?.let {
if (remoteVisible) {
it.height = dpToPx(100)
it.width = dpToPx(100)
} else {
params = FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
localVideoView?.layoutParams = params
}
}
}
/**
* Closing up - normal hangup and app destroye
*/
override fun onClick(v: View) {
when (v.id) {
R.id.end_call -> {
hangup()
}
}
}
private fun hangup() {
try {
localPeer!!.close()
localPeer = null
SignallingClientKotlin.close()
updateVideoViews(false)
} catch (e: Exception) {
e.printStackTrace()
}
}
override fun onDestroy() {
SignallingClientKotlin.close()
super.onDestroy()
}
/**
* Util Methods
*/
private fun dpToPx(dp: Int): Int {
val displayMetrics = resources.displayMetrics
return Math.round(dp * (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT))
}
private fun showToast(msg: String) {
runOnUiThread { Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() }
}
private fun createCameraCapturer(enumerator: CameraEnumerator): VideoCapturer? {
val deviceNames = enumerator.deviceNames
//find the front facing camera and return it.
deviceNames
.filter { enumerator.isFrontFacing(it) }
.mapNotNull { enumerator.createCapturer(it, null) }
.forEach { return it }
return null
}
}
internal class SignallingClientKotlin {
internal interface SignalingInterface {
fun onRemoteHangUp(msg: String)
fun onOfferReceived(data: JSONObject)
fun onAnswerReceived(data: JSONObject)
fun onIceCandidateReceived(data: JSONObject)
fun onTryToStart()
fun onCreatedRoom()
fun onJoinedRoom()
fun onNewPeerJoined()
}
companion object {
private var roomName: String? = null
init {
if (roomName == null) {
roomName = "some_room_name"
}
}
private var socket: Socket? = null
var isChannelReady = false
var isInitiator = false
var isStarted = false
private var callback: SignalingInterface? = null
//This piece of code should not go into production?
//This will help in cases where the node server is running in non-https server and you want to ignore the warnings
private val trustAllCerts = arrayOf<TrustManager>(object : X509TrustManager {
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> {
return arrayOf()
}
@SuppressLint("TrustAllX509TrustManager")
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {
}
@SuppressLint("TrustAllX509TrustManager")
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
}
})
fun init(signalingInterface: SignalingInterface) {
this.callback = signalingInterface
try {
val sslcontext = SSLContext.getInstance("TLS")
sslcontext.init(null, trustAllCerts, null)
IO.setDefaultHostnameVerifier { _, _ -> true }
IO.setDefaultSSLContext(sslcontext)
//set the socket.io url here
socket = IO.socket("your_socket_io_instance_url_with_port")
socket?.connect()
Log.d("SignallingClient", "init() called")
roomName?.let { emitInitStatement(it) }
//room created event.
socket?.on("created") { args ->
Log.d("SignallingClient", "created call() called with: args = [" + Arrays.toString(args) + "]")
isInitiator = true
callback?.onCreatedRoom()
}
//room is full event
socket?.on("full") { args -> Log.d("SignallingClient", "full call() called with: args = [" + Arrays.toString(args) + "]") }
//peer joined event
socket?.on("join") { args ->
Log.d("SignallingClient", "join call() called with: args = [" + Arrays.toString(args) + "]")
isChannelReady = true
callback?.onNewPeerJoined()
}
//when you joined a chat room successfully
socket?.on("joined") { args ->
Log.d("SignallingClient", "joined call() called with: args = [" + Arrays.toString(args) + "]")
isChannelReady = true
callback?.onJoinedRoom()
}
//log event
socket?.on("log") { args -> Log.d("SignallingClient", "log call() called with: args = [" + Arrays.toString(args) + "]") }
//bye event
socket?.on("bye") { args -> callback?.onRemoteHangUp(args[0] as String) }
//messages - SDP and ICE candidates are transferred through this
socket?.on("message") { args ->
Log.d("SignallingClient", "message call() called with: args = [" + Arrays.toString(args) + "]")
when (args[0]) {
is String -> {
Log.d("SignallingClient", "String received :: " + args[0])
val data = args[0] as String
if (data.equals("got user media", ignoreCase = true)) {
callback?.onTryToStart()
}
if (data.equals("bye", ignoreCase = true)) {
callback?.onRemoteHangUp(data)
}
}
is JSONObject -> try {
val data = args[0] as JSONObject
Log.d("SignallingClient", "Json Received :: " + data.toString())
val type = data.getString("type")
if (type.equals("offer", ignoreCase = true)) {
callback?.onOfferReceived(data)
} else if (type.equals("answer", ignoreCase = true) && isStarted) {
callback?.onAnswerReceived(data)
} else if (type.equals("candidate", ignoreCase = true) && isStarted) {
callback?.onIceCandidateReceived(data)
}
} catch (e: JSONException) {
e.printStackTrace()
}
}
}
} catch (e: URISyntaxException) {
e.printStackTrace()
} catch (e: NoSuchAlgorithmException) {
e.printStackTrace()
} catch (e: KeyManagementException) {
e.printStackTrace()
}
}
private fun emitInitStatement(message: String) {
Log.d("SignallingClient", "emitInitStatement() called with: event = [create or join], message = [$message]")
socket?.emit("create or join", message)
}
fun emitMessage(message: String) {
Log.d("SignallingClient", "emitMessage() called with: message = [$message]")
socket?.emit("message", message)
}
fun emitMessage(message: SessionDescription) {
try {
Log.d("SignallingClient", "emitMessage() called with: message = [$message]")
val obj = JSONObject()
obj.put("type", message.type.canonicalForm())
obj.put("sdp", message.description)
Log.d("emitMessage", obj.toString())
socket?.emit("message", obj)
Log.d("vivek1794", obj.toString())
} catch (e: JSONException) {
e.printStackTrace()
}
}
fun emitIceCandidate(iceCandidate: IceCandidate) {
try {
val jsonObject = JSONObject()
jsonObject.put("type", "candidate")
jsonObject.put("label", iceCandidate.sdpMLineIndex)
jsonObject.put("id", iceCandidate.sdpMid)
jsonObject.put("candidate", iceCandidate.sdp)
socket?.emit("message", jsonObject)
} catch (e: Exception) {
e.printStackTrace()
}
}
fun close() {
socket?.emit("bye", roomName)
socket?.disconnect()
socket?.close()
}
}
}
这有很多代码。但如果你把它分成几部分,并用第 2 部分的代码来引用它,就会很有意义。注释应该可以帮你找到通过代码的方式。(如果你仍然使用 JAVA,可查看 Github 存储库 — MainActivity.java和SignallingClient.java文件)
简而言之,我们所做的就是尝试建立一个到socket.io web socket的连接,并且当连接被创建时,我们向服务器发送一个房间名称。如果尚未创建新房间,服务器将创建一个新房间,如果该房间已经存在并且有空余空间,则会把你添加到该房间。(为了演示目的,房间容量设置为 2)
如果你是创建者,则需要在其他参与者连接到会话后将信息发送给他们。然后第二个参与者将通过套接字接收它、创建他们的答案并通过套接字将其发送给发起者。
关闭
现在 SDP 和 ICE 候选对象通过套接字从一个参与者传输到另一个参与者,WebRTC 将发挥其魔力并在参与者之间传输音频和视频流。
我们的男孩现在有他的小周末项目,现在可以与他的女孩取得联系并说出他的想法(一切都很顺利,这甚至可能是这篇文章被推迟的原因)
这一步的代码是 在Github上。这使用了 WebRTC 的最新 gradle 依赖项,如果你使用的是旧版本的 WebRTC,它可能会对你的代码进行一些重大更改。要确保在你的APP中启用 Java 8 兼容性,并再次查看我们创建 PeerConnectionFactory 和 PeerConnection 实例的位置,因为那里发生了一些变化,它可能会破坏您的旧代码。
如果遇到任何问题,可以与我联系!
原文作者 Vivek Chanddru
原文链接 https://vivekc.xyz/getting-started-with-webrtc-part-4-de72b58ab31e