David Liu

Sample app

// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = '1.4.30'
ext.kotlin_version = '1.4.31'
ext.java_version = JavaVersion.VERSION_1_8
repositories {
google()
... ...
... ... @@ -60,8 +60,8 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.2'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.1.0'
implementation 'org.webrtc:google-webrtc:1.0.32006'
implementation "com.squareup.okhttp3:okhttp:4.9.0"
api 'org.webrtc:google-webrtc:1.0.32006'
api "com.squareup.okhttp3:okhttp:4.9.0"
implementation "com.google.protobuf:protobuf-java:${versions.protobuf}"
implementation "com.google.protobuf:protobuf-java-util:${versions.protobuf}"
... ...
... ... @@ -2,5 +2,5 @@ package io.livekit.android
data class ConnectOptions(
val isSecure: Boolean
val isSecure: Boolean = true
)
\ No newline at end of file
... ...
... ... @@ -2,6 +2,7 @@ package io.livekit.android
import android.content.Context
import io.livekit.android.dagger.DaggerLiveKitComponent
import io.livekit.android.room.Room
class LiveKit {
companion object {
... ... @@ -9,8 +10,9 @@ class LiveKit {
appContext: Context,
url: String,
token: String,
options: ConnectOptions
) {
options: ConnectOptions,
listener: Room.Listener?
): Room {
val component = DaggerLiveKitComponent
.factory()
... ... @@ -18,7 +20,10 @@ class LiveKit {
val room = component.roomFactory()
.create(options)
room.connect(url, token, false)
room.listener = listener
room.connect(url, token, options.isSecure)
return room
}
}
}
... ...
... ... @@ -3,22 +3,31 @@ package io.livekit.android.dagger
import com.google.protobuf.util.JsonFormat
import dagger.Module
import dagger.Provides
import dagger.Reusable
import kotlinx.serialization.json.Json
import javax.inject.Named
@Module
class JsonFormatModule {
companion object {
@Provides
fun jsonFormatParser(): JsonFormat.Parser {
fun protobufJsonFormatParser(): JsonFormat.Parser {
return JsonFormat.parser()
}
@Provides
fun jsonFormatPrinter(): JsonFormat.Printer {
fun protobufJsonFormatPrinter(): JsonFormat.Printer {
return JsonFormat.printer()
}
@Provides
@Reusable
fun kotlinSerializationJson(): Json =
Json {
ignoreUnknownKeys = true
}
@Provides
@Named(InjectionNames.SIGNAL_JSON_ENABLED)
fun signalJsonEnabled(): Boolean = false
}
... ...
... ... @@ -4,6 +4,7 @@ import android.content.Context
import com.github.ajalt.timberkt.Timber
import dagger.Module
import dagger.Provides
import org.webrtc.EglBase
import org.webrtc.PeerConnectionFactory
import org.webrtc.audio.AudioDeviceModule
import org.webrtc.audio.JavaAudioDeviceModule
... ... @@ -99,5 +100,12 @@ class RTCModule {
.setAudioDeviceModule(audioDeviceModule)
.createPeerConnectionFactory()
}
@Provides
@Singleton
fun eglBase(): EglBase {
return EglBase.create()
}
}
}
\ No newline at end of file
... ...
... ... @@ -3,4 +3,4 @@ package io.livekit.android.room
import kotlinx.serialization.Serializable
@Serializable
data class IceCandidateJSON(val sdp: String, val sdpMLineIndex: Int, val sdpMid: String?)
\ No newline at end of file
data class IceCandidateJSON(val candidate: String, val sdpMLineIndex: Int, val sdpMid: String?)
\ No newline at end of file
... ...
... ... @@ -25,13 +25,15 @@ class RTCClient
@Inject
constructor(
private val websocketFactory: WebSocket.Factory,
private val fromJson: JsonFormat.Parser,
private val toJson: JsonFormat.Printer,
private val fromJsonProtobuf: JsonFormat.Parser,
private val toJsonProtobuf: JsonFormat.Printer,
private val json: Json,
@Named(InjectionNames.SIGNAL_JSON_ENABLED)
private val useJson: Boolean,
) : WebSocketListener() {
private var isConnected = false
var isConnected = false
private set
private var currentWs: WebSocket? = null
var listener: Listener? = null
... ... @@ -52,12 +54,14 @@ constructor(
}
override fun onOpen(webSocket: WebSocket, response: Response) {
Timber.v { response.message }
super.onOpen(webSocket, response)
}
override fun onMessage(webSocket: WebSocket, text: String) {
Timber.v { text }
val signalResponseBuilder = Rtc.SignalResponse.newBuilder()
fromJson.merge(text, signalResponseBuilder)
fromJsonProtobuf.merge(text, signalResponseBuilder)
val response = signalResponseBuilder.build()
handleSignalResponse(response)
... ... @@ -73,14 +77,18 @@ constructor(
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Timber.v { "websocket closed" }
super.onClosed(webSocket, code, reason)
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
Timber.v { "websocket closing" }
super.onClosing(webSocket, code, reason)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Timber.v(t) { "websocket failure: ${response}" }
super.onFailure(webSocket, t, response)
}
... ... @@ -128,13 +136,13 @@ constructor(
fun sendCandidate(candidate: IceCandidate, target: Rtc.SignalTarget){
val iceCandidateJSON = IceCandidateJSON(
sdp = candidate.sdp,
candidate = candidate.sdp,
sdpMid = candidate.sdpMid,
sdpMLineIndex = candidate.sdpMLineIndex
)
val trickleRequest = Rtc.TrickleRequest.newBuilder()
.setCandidateInit(Json.encodeToString(iceCandidateJSON))
.setCandidateInit(json.encodeToString(iceCandidateJSON))
.setTarget(target)
.build()
... ... @@ -174,12 +182,12 @@ constructor(
fun sendRequest(request: Rtc.SignalRequest) {
Timber.v { "sending request: $request" }
if (!isConnected || currentWs != null) {
if (!isConnected || currentWs == null) {
throw IllegalStateException("not connected!")
}
val sent: Boolean
if (useJson) {
val message = toJson.print(request)
val message = toJsonProtobuf.print(request)
sent = currentWs?.send(message) ?: false
} else {
val message = request.toByteArray().toByteString()
... ... @@ -199,7 +207,7 @@ constructor(
isConnected = true
listener?.onJoin(response.join)
} else {
Timber.e { "Received response while not connected. ${toJson.print(response)}" }
Timber.e { "Received response while not connected. ${toJsonProtobuf.print(response)}" }
}
return
}
... ... @@ -214,11 +222,11 @@ constructor(
}
Rtc.SignalResponse.MessageCase.TRICKLE -> {
val iceCandidateJson =
Json.decodeFromString<IceCandidateJSON>(response.trickle.candidateInit)
json.decodeFromString<IceCandidateJSON>(response.trickle.candidateInit)
val iceCandidate = IceCandidate(
iceCandidateJson.sdpMid,
iceCandidateJson.sdpMLineIndex,
iceCandidateJson.sdp
iceCandidateJson.candidate
)
listener?.onTrickle(iceCandidate, response.trickle.target)
}
... ...
... ... @@ -96,24 +96,26 @@ constructor(
}
fun negotiate() {
if (!client.isConnected) {
return
}
coroutineScope.launch {
val offerObserver = CoroutineSdpObserver()
publisher.peerConnection.createOffer(offerObserver, OFFER_CONSTRAINTS)
val offerOutcome = offerObserver.awaitCreate()
val sdpOffer = when (offerOutcome) {
is Either.Left -> offerOutcome.value
val sdpOffer = when (val outcome = offerObserver.awaitCreate()) {
is Either.Left -> outcome.value
is Either.Right -> {
Timber.d { "error creating offer: ${offerOutcome.value}" }
Timber.d { "error creating offer: ${outcome.value}" }
return@launch
}
}
val setObserver = CoroutineSdpObserver()
publisher.peerConnection.setLocalDescription(setObserver, sdpOffer)
val setOutcome = setObserver.awaitSet()
when (setOutcome) {
when (val outcome = setObserver.awaitSet()) {
is Either.Left -> client.sendOffer(sdpOffer)
is Either.Right -> Timber.d { "error setting local description: ${setOutcome.value}" }
is Either.Right -> Timber.d { "error setting local description: ${outcome.value}" }
}
}
}
... ...
... ... @@ -13,15 +13,14 @@ import io.livekit.android.room.track.Track
import io.livekit.android.room.util.unpackedTrackLabel
import livekit.Model
import livekit.Rtc
import org.webrtc.DataChannel
import org.webrtc.MediaStream
import org.webrtc.MediaStreamTrack
import org.webrtc.*
class Room
@AssistedInject
constructor(
@Assisted private val connectOptions: ConnectOptions,
private val engine: RTCEngine,
private val eglBase: EglBase,
) : RTCEngine.Listener {
init {
engine.listener = this
... ... @@ -74,7 +73,7 @@ constructor(
removedParticipant.unpublishTrack(publication.trackSid)
}
listener?.onparticipantDisconnected(this, removedParticipant)
listener?.onParticipantDisconnected(this, removedParticipant)
}
private fun getOrCreateRemoteParticipant(
... ... @@ -136,7 +135,7 @@ constructor(
fun onConnect(room: Room)
fun onDisconnect(room: Room, error: Exception?)
fun onParticipantConnected(room: Room, participant: RemoteParticipant)
fun onparticipantDisconnected(room: Room, participant: RemoteParticipant)
fun onParticipantDisconnected(room: Room, participant: RemoteParticipant)
fun onFailedToConnect(room: Room, error: Exception)
fun onReconnecting(room: Room, error: Exception)
fun onReconnect(room: Room)
... ... @@ -225,4 +224,10 @@ constructor(
override fun onFailToConnect(error: Exception) {
listener?.onFailedToConnect(this, error)
}
fun setupVideo(viewRenderer: SurfaceViewRenderer) {
viewRenderer.init(eglBase.eglBaseContext, null)
viewRenderer.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT)
viewRenderer.setEnableHardwareScaler(false /* enabled */);
}
}
\ No newline at end of file
... ...
... ... @@ -19,11 +19,11 @@ class LocalVideoTrack(
peerConnectionFactory: PeerConnectionFactory,
context: Context,
enabled: Boolean,
name: String
name: String,
rootEglBase: EglBase,
): LocalVideoTrack {
val source = peerConnectionFactory.createVideoSource(false)
val capturer = createVideoCapturer(context) ?: TODO()
val rootEglBase = EglBase.create()
capturer.initialize(
SurfaceTextureHelper.create("CaptureThread", rootEglBase.eglBaseContext),
context,
... ...
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-parcelize'
android {
compileSdkVersion 29
... ... @@ -19,20 +20,24 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
android {
compileOptions {
sourceCompatibility java_version
targetCompatibility java_version
}
compileOptions {
sourceCompatibility java_version
targetCompatibility java_version
}
buildFeatures {
viewBinding = true
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.4.2'
implementation 'com.google.android.material:material:1.3.0'
implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.core:core-ktx:1.3.2'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.0'
implementation 'com.github.ajalt:timberkt:1.5.1'
implementation project(":livekit-android-sdk")
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
... ...
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.livekit.android">
package="io.livekit.android.sample">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".SampleApplication"
android:networkSecurityConfig="@xml/network_security_config"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme" >
<activity android:name=".sample.MainActivity" />
android:theme="@style/AppTheme">
<activity android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".CallActivity" />
</application>
</manifest>
... ...
package io.livekit.android.sample
import android.os.Bundle
import android.os.Parcelable
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import io.livekit.android.ConnectOptions
import io.livekit.android.LiveKit
import io.livekit.android.room.Room
import io.livekit.android.room.participant.Participant
import io.livekit.android.room.participant.RemoteParticipant
import io.livekit.android.room.track.VideoTrack
import io.livekit.android.sample.databinding.CallActivityBinding
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
class CallActivity : AppCompatActivity() {
lateinit var binding: CallActivityBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = CallActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
val args = intent.getParcelableExtra<BundleArgs>(KEY_ARGS)
if (args == null) {
finish()
return
}
lifecycleScope.launch {
val room = LiveKit.connect(
applicationContext,
args.url,
args.token,
ConnectOptions(false),
object : Room.Listener {
var loadedParticipant = false
override fun onConnect(room: Room) {
}
override fun onDisconnect(room: Room, error: Exception?) {
}
override fun onParticipantConnected(
room: Room,
participant: RemoteParticipant
) {
if (!loadedParticipant) {
room.setupVideo(binding.fullscreenVideoView)
participant.remoteVideoTracks
.first()
.track
.let { it as? VideoTrack }
?.addRenderer(binding.fullscreenVideoView)
}
}
override fun onParticipantDisconnected(
room: Room,
participant: RemoteParticipant
) {
}
override fun onFailedToConnect(room: Room, error: Exception) {
}
override fun onReconnecting(room: Room, error: Exception) {
}
override fun onReconnect(room: Room) {
}
override fun onStartRecording(room: Room) {
}
override fun onStopRecording(room: Room) {
}
override fun onActiveSpeakersChanged(speakers: List<Participant>, room: Room) {
}
}
)
}
}
companion object {
const val KEY_ARGS = "args"
}
@Parcelize
data class BundleArgs(val url: String, val token: String) : Parcelable
}
\ No newline at end of file
... ...
package io.livekit.android.sample
import android.content.Intent
import android.os.Bundle
import android.text.SpannableStringBuilder
import androidx.appcompat.app.AppCompatActivity
import io.livekit.android.sample.databinding.MainActivityBinding
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val binding = MainActivityBinding.inflate(layoutInflater)
binding.run {
url.editText?.text = URL
token.editText?.text = TOKEN
connectButton.setOnClickListener {
val intent = Intent(this@MainActivity, CallActivity::class.java).apply {
putExtra(
CallActivity.KEY_ARGS,
CallActivity.BundleArgs(
url.editText?.text.toString(),
token.editText?.text.toString()
)
)
}
startActivity(intent)
}
}
setContentView(binding.root)
}
companion object {
val URL = SpannableStringBuilder("192.168.11.2:7880")
val TOKEN =
SpannableStringBuilder("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2MTg1NzgxNzMsImlzcyI6IkFQSXdMZWFoN2c0ZnVMWURZQUplYUtzU0UiLCJqdGkiOiJwaG9uZSIsIm5iZiI6MTYxNTk4NjE3MywidmlkZW8iOnsicm9vbSI6Im15cm9vbSIsInJvb21Kb2luIjp0cnVlfX0.O3UedhM9lwdPxsZJQoTfVk0qXc-0ukjV6oZCBIaRTck")
}
}
\ No newline at end of file
... ...
package io.livekit.android.sample
import android.app.Application
import timber.log.Timber
class SampleApplication : Application() {
override fun onCreate() {
super.onCreate()
Timber.plant(Timber.DebugTree())
}
}
\ No newline at end of file
... ...
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.webrtc.SurfaceViewRenderer
android:id="@+id/fullscreen_video_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
<org.webrtc.SurfaceViewRenderer
android:id="@+id/pip_video_view"
android:layout_height="144dp"
android:layout_width="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"/>
<FrameLayout
android:id="@+id/call_fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/hud_fragment_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
... ...
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">example.com</domain>
</domain-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
</trust-anchors>
</base-config>
</network-security-config>
\ No newline at end of file
... ...