davidliu
Committed by GitHub

Update documentation and add a basic quickstart sample app. (#124)

* update readme and docs

* clean up interface methods leaking into docs

* AudioSwitchHandler documentation

* Update package descriptions

* Include link to API reference in README

* update dependencies

* Add in basic one participant sample app

* clean up debug keys

* update readme sample

* Update README and add readmes for each project folder

* Assemble all modules for compile test safety

* Disconnect on destroy

* update compile/target version

* fix build errors

* fix tests
正在显示 53 个修改的文件 包含 792 行增加107 行删除
... ... @@ -36,7 +36,7 @@ jobs:
run: chmod +x gradlew
- name: Build with Gradle
run: ./gradlew clean livekit-android-sdk:assembleRelease livekit-android-sdk:testRelease
run: ./gradlew clean assembleRelease livekit-android-sdk:testRelease
- name: Import video test keys into gradle properties
if: github.event_name == 'push'
... ...
# Android Kotlin SDK for LiveKit
Official Android Client SDK for [LiveKit](https://github.com/livekit/livekit-server). Easily add video & audio capabilities to your Android apps.
Official Android Client SDK for [LiveKit](https://github.com/livekit/livekit-server). Easily add
video & audio capabilities to your Android apps.
## Docs
Docs and guides at [https://docs.livekit.io](https://docs.livekit.io)
Docs and guides at [https://docs.livekit.io](https://docs.livekit.io).
API reference can be found
at [https://docs.livekit.io/client-sdk-android/index.html](https://docs.livekit.io/client-sdk-android/index.html)
.
## Installation
... ... @@ -35,13 +40,6 @@ subprojects {
}
```
## Sample App
There are two sample apps with similar functionality:
* [Compose app](https://github.com/livekit/client-sdk-android/tree/master/sample-app-compose/src/main/java/io/livekit/android/composesample)
* [Standard app](https://github.com/livekit/client-sdk-android/tree/master/sample-app)
## Usage
### Permissions
... ... @@ -86,36 +84,47 @@ LiveKit uses `SurfaceViewRenderer` to render video tracks. A `TextureView` imple
provided through `TextureViewRenderer`. Subscribed audio tracks are automatically played.
```kt
class MainActivity : AppCompatActivity(), RoomListener {
class MainActivity : AppCompatActivity() {
lateinit var room: Room
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
...
val url = "wss://your_host";
setContentView(R.layout.activity_main)
// Create Room object.
room = LiveKit.create(applicationContext)
// Setup the video renderer
room.initVideoRenderer(findViewById<SurfaceViewRenderer>(R.id.renderer))
connectToRoom()
}
private fun connectToRoom() {
val url = "wss://your_host"
val token = "your_token"
lifecycleScope.launch {
// Create Room object.
val room = LiveKit.create(
applicationContext,
RoomOptions(),
)
// Setup event handling.
launch {
room.events.collect { event ->
when(event){
when (event) {
is RoomEvent.TrackSubscribed -> onTrackSubscribed(event)
else -> {}
}
}
}
// Connect to server.
room.connect(
url,
token,
ConnectOptions()
)
// Turn on audio/video recording.
val localParticipant = room.localParticipant
localParticipant.setMicrophoneEnabled(true)
... ... @@ -124,22 +133,27 @@ class MainActivity : AppCompatActivity(), RoomListener {
}
private fun onTrackSubscribed(event: RoomEvent.TrackSubscribed) {
if (event.track is VideoTrack) {
val track = event.track
if (track is VideoTrack) {
attachVideo(track)
}
}
private fun attachVideo(videoTrack: VideoTrack) {
// viewBinding.renderer is a `io.livekit.android.renderer.SurfaceViewRenderer` in your
// layout
videoTrack.addRenderer(viewBinding.renderer)
videoTrack.addRenderer(findViewById<SurfaceViewRenderer>(R.id.renderer))
findViewById<View>(R.id.progress).visibility = View.GONE
}
}
```
See
the [basic sample app](https://github.com/livekit/client-sdk-android/blob/main/sample-app-basic/src/main/java/io/livekit/android/sample/basic/MainActivity.kt)
for the full implementation.
### `@FlowObservable`
Properties marked with `@FlowObservable` can be accessed as a Kotlin Flow to observe changes directly:
Properties marked with `@FlowObservable` can be accessed as a Kotlin Flow to observe changes
directly:
```kt
coroutineScope.launch {
... ... @@ -149,12 +163,33 @@ coroutineScope.launch {
}
```
## Sample App
We have a basic quickstart sample
app [here](https://github.com/livekit/client-sdk-android/blob/main/sample-app-basic), showing how to
connect to a room, publish your device's audio/video, and display the video of one remote participant.
There are two more full featured video conferencing sample apps:
* [Compose app](https://github.com/livekit/client-sdk-android/tree/main/sample-app-compose/src/main/java/io/livekit/android/composesample)
* [Standard app](https://github.com/livekit/client-sdk-android/tree/main/sample-app/src/main/java/io/livekit/android/sample)
They both use
the [`CallViewModel`](https://github.com/livekit/client-sdk-android/blob/main/sample-app-common/src/main/java/io/livekit/android/sample/CallViewModel.kt)
, which handles the `Room` connection and exposes the data needed for a basic video conferencing
app.
The respective `ParticipantItem` class in each app is responsible for the displaying of each
participant's UI.
* [Compose `ParticipantItem`](https://github.com/livekit/client-sdk-android/blob/main/sample-app-compose/src/main/java/io/livekit/android/composesample/ParticipantItem.kt)
* [Standard `ParticipantItem`](https://github.com/livekit/client-sdk-android/blob/main/sample-app/src/main/java/io/livekit/android/sample/ParticipantItem.kt)
## Dev Environment
To develop the Android SDK or running the sample app, you'll need:
To develop the Android SDK or running the sample app directly from this repo, you'll need:
- Ensure the protocol submodule repo is initialized and updated with `git submodule update --init`
- Install [Android Studio Arctic Fox 2020.3.1+](https://developer.android.com/studio)
For those developing on Apple M1 Macs, please add below to $HOME/.gradle/gradle.properties
... ...
... ... @@ -2,9 +2,9 @@
buildscript {
ext {
compose_version = '1.1.1'
compose_compiler_version = '1.1.1'
kotlin_version = '1.6.10'
compose_version = '1.2.0'
compose_compiler_version = '1.2.0'
kotlin_version = '1.7.0'
java_version = JavaVersion.VERSION_1_8
dokka_version = '1.5.0'
}
... ... @@ -13,8 +13,8 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.android.tools.build:gradle:7.1.3'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.0"
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
classpath "org.jetbrains.dokka:dokka-gradle-plugin:$dokka_version"
classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.18'
... ... @@ -45,15 +45,15 @@ nexusStaging {
ext {
androidSdk = [
compileVersion: 31,
targetVersion : 31,
compileVersion: 32,
targetVersion : 32,
minVersion : 21,
]
versions = [
androidx_core : "1.7.0",
androidx_lifecycle: "2.4.0",
androidx_core : "1.8.0",
androidx_lifecycle: "2.5.1",
autoService : '1.0.1',
dagger : "2.27",
dagger : "2.43",
groupie : "2.9.0",
junit : "4.13.2",
junitJupiter : "5.5.0",
... ...
... ... @@ -49,7 +49,7 @@ android {
kotlinCompilerExtensionVersion compose_compiler_version
}
kotlinOptions {
freeCompilerArgs = ["-Xinline-classes", "-Xopt-in=kotlin.RequiresOptIn"]
freeCompilerArgs = ["-Xinline-classes", "-opt-in=kotlin.RequiresOptIn"]
jvmTarget = java_version
}
}
... ... @@ -114,13 +114,13 @@ dependencies {
api 'com.github.webrtc-sdk:android:104.5112.01'
api "com.squareup.okhttp3:okhttp:4.10.0"
api "com.twilio:audioswitch:1.1.5"
implementation "androidx.annotation:annotation:1.3.0"
implementation "androidx.annotation:annotation:1.4.0"
implementation "androidx.core:core:${versions.androidx_core}"
implementation "com.google.protobuf:protobuf-javalite:${versions.protobuf}"
implementation "androidx.compose.ui:ui:$compose_version"
implementation 'com.google.dagger:dagger:2.38'
kapt 'com.google.dagger:dagger-compiler:2.38'
implementation "com.google.dagger:dagger:${versions.dagger}"
kapt "com.google.dagger:dagger-compiler:${versions.dagger}"
implementation deps.timber
implementation 'com.vdurmont:semver4j:3.1.0'
... ...
... ... @@ -6,6 +6,15 @@ Android Client SDK to [LiveKit](https://github.com/livekit/livekit-server).
This package contains the initial `connect` function.
# Package io.livekit.android.compose
Utilities and composables for use with Jetpack Compose.
# Package io.livekit.android.room
Room is the primary class that manages the connection to the LiveKit Room. It exposes listeners that lets you hook into room events
Room is the primary class that manages the connection to the LiveKit Room. It exposes listeners that lets you hook into room events.
# Package io.livekit.android.room.track
`AudioTrack` and `VideoTrack` are the classes that represent the types of media streams that can be
subscribed and published.
\ No newline at end of file
... ...
... ... @@ -10,13 +10,43 @@ import com.twilio.audioswitch.AudioSwitch
import javax.inject.Inject
import javax.inject.Singleton
/**
* An [AudioHandler] built on top of [AudioSwitch].
*
* The various settings should be set before connecting to a [Room] and [start] is called.
*/
@Singleton
class AudioSwitchHandler
@Inject
constructor(private val context: Context) : AudioHandler {
/**
* Toggle whether logging is enabled for [AudioSwitch]. By default, this is set to false.
*/
var loggingEnabled = false
/**
* Listen to changes in the available and active audio devices.
*
* @see AudioDeviceChangeListener
*/
var audioDeviceChangeListener: AudioDeviceChangeListener? = null
/**
* Listen to changes in audio focus.
*
* @see AudioManager.OnAudioFocusChangeListener
*/
var onAudioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null
/**
* The preferred priority of audio devices to use. The first available audio device will be used.
*
* By default, the preferred order is set to:
* 1. BluetoothHeadset
* 2. WiredHeadset
* 3. Earpiece
* 4. Speakerphone
*/
var preferredDeviceList: List<Class<out AudioDevice>>? = null
private var audioSwitch: AudioSwitch? = null
... ...
... ... @@ -602,6 +602,7 @@ internal constructor(
LKLog.e { "error setting remote description for answer: ${outcome.value} " }
return@launch
}
else -> {}
}
}
... ... @@ -621,6 +622,7 @@ internal constructor(
LKLog.e { "error setting local description for answer: ${outcome.value}" }
return@launch
}
else -> {}
}
}
... ...
... ... @@ -45,7 +45,7 @@ constructor(
@Named(InjectionNames.DISPATCHER_IO)
private val ioDispatcher: CoroutineDispatcher,
val audioHandler: AudioHandler,
) : RTCEngine.Listener, ParticipantListener, ConnectivityManager.NetworkCallback() {
) : RTCEngine.Listener, ParticipantListener {
private lateinit var coroutineScope: CoroutineScope
private val eventBus = BroadcastEventBus<RoomEvent>()
... ... @@ -217,7 +217,7 @@ constructor(
val networkRequest = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
cm.registerNetworkCallback(networkRequest, this)
cm.registerNetworkCallback(networkRequest, networkCallback)
if (options.audio) {
val audioTrack = localParticipant.createAudioTrack()
... ... @@ -237,6 +237,9 @@ constructor(
handleDisconnect()
}
/**
* @suppress
*/
override fun onJoinResponse(response: LivekitRtc.JoinResponse) {
LKLog.i { "Connected to server, server version: ${response.serverVersion}, client version: ${Version.CLIENT_VERSION}" }
... ... @@ -441,7 +444,7 @@ constructor(
try {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
cm.unregisterNetworkCallback(this)
cm.unregisterNetworkCallback(networkCallback)
} catch (e: IllegalArgumentException) {
// do nothing, may happen on older versions if attempting to unregister twice.
}
... ... @@ -512,29 +515,29 @@ constructor(
}
//------------------------------------- NetworkCallback -------------------------------------//
/**
* @suppress
*/
override fun onLost(network: Network) {
// lost connection, flip to reconnecting
hasLostConnectivity = true
}
/**
* @suppress
*/
override fun onAvailable(network: Network) {
// only actually reconnect after connection is re-established
if (!hasLostConnectivity) {
return
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
/**
* @suppress
*/
override fun onLost(network: Network) {
// lost connection, flip to reconnecting
hasLostConnectivity = true
}
/**
* @suppress
*/
override fun onAvailable(network: Network) {
// only actually reconnect after connection is re-established
if (!hasLostConnectivity) {
return
}
LKLog.i { "network connection available, reconnecting" }
reconnect()
hasLostConnectivity = false
}
LKLog.i { "network connection available, reconnecting" }
reconnect()
hasLostConnectivity = false
}
//----------------------------------- RTCEngine.Listener ------------------------------------//
/**
... ... @@ -820,7 +823,6 @@ constructor(
}
/**
* @suppress
* // TODO(@dl): can this be moved out of Room/SDK?
*/
fun initVideoRenderer(viewRenderer: SurfaceViewRenderer) {
... ... @@ -830,7 +832,6 @@ constructor(
}
/**
* @suppress
* // TODO(@dl): can this be moved out of Room/SDK?
*/
fun initVideoRenderer(viewRenderer: TextureViewRenderer) {
... ...
... ... @@ -180,6 +180,9 @@ internal constructor(
createScreencastTrack(mediaProjectionPermissionResultData = mediaProjectionPermissionResultData)
publishVideoTrack(track)
}
else -> {
LKLog.w { "Attempting to enable an unknown source, ignoring." }
}
}
}
} else {
... ...
package io.livekit.android.room
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import androidx.test.platform.app.InstrumentationRegistry
import io.livekit.android.MockE2ETest
import io.livekit.android.events.EventCollector
import io.livekit.android.events.FlowCollector
... ... @@ -23,6 +26,9 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows.shadowOf
import org.robolectric.shadows.ShadowConnectivityManager
import org.robolectric.shadows.ShadowNetworkInfo
@ExperimentalCoroutinesApi
@RunWith(RobolectricTestRunner::class)
... ... @@ -259,8 +265,19 @@ class RoomMockE2ETest : MockE2ETest() {
connect()
val eventCollector = EventCollector(room.events, coroutineRule.scope)
val network = Mockito.mock(Network::class.java)
room.onLost(network)
room.onAvailable(network)
val connectivityManager = InstrumentationRegistry.getInstrumentation()
.context
.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val shadowConnectivityManager: ShadowConnectivityManager = shadowOf(connectivityManager)
shadowConnectivityManager.networkCallbacks.forEach { callback ->
callback.onLost(network)
}
shadowConnectivityManager.networkCallbacks.forEach { callback ->
callback.onAvailable(network)
}
val events = eventCollector.stopCollecting()
Assert.assertEquals(1, events.size)
... ...
package io.livekit.android.room
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import androidx.test.core.app.ApplicationProvider
import androidx.test.platform.app.InstrumentationRegistry
import io.livekit.android.audio.NoAudioHandler
import io.livekit.android.coroutines.TestCoroutineRule
import io.livekit.android.events.EventCollector
... ... @@ -26,6 +28,8 @@ import org.mockito.Mockito
import org.mockito.junit.MockitoJUnit
import org.mockito.kotlin.*
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.shadows.ShadowConnectivityManager
import org.webrtc.EglBase
@ExperimentalCoroutinesApi
... ... @@ -108,8 +112,19 @@ class RoomTest {
connect()
val network = Mockito.mock(Network::class.java)
room.onLost(network)
room.onAvailable(network)
val connectivityManager = InstrumentationRegistry.getInstrumentation()
.context
.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val shadowConnectivityManager: ShadowConnectivityManager = Shadows.shadowOf(connectivityManager)
shadowConnectivityManager.networkCallbacks.forEach { callback ->
callback.onLost(network)
}
shadowConnectivityManager.networkCallbacks.forEach { callback ->
callback.onAvailable(network)
}
Mockito.verify(rtcEngine).reconnect()
}
... ...
/build
\ No newline at end of file
... ...
# Sample-app-basic
A quickstart app showing how to:
1. Connect to a room.
2. Publish your device's audio/video.
3. Display a remote participant's video.
This app only handles the video of one remote participant. For a more fully featured video
conferencing app, check out the other sample apps in this repo.
... ...
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
compileSdk 32
defaultConfig {
applicationId "io.livekit.android.sample.basic"
minSdk 21
targetSdk 32
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
}
dependencies {
implementation "androidx.core:core-ktx:${versions.androidx_core}"
implementation 'androidx.appcompat:appcompat:1.4.2'
implementation "androidx.activity:activity-ktx:1.5.1"
implementation 'androidx.fragment:fragment-ktx:1.5.1'
implementation "androidx.viewpager2:viewpager2:1.0.0"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:${versions.androidx_lifecycle}"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${versions.androidx_lifecycle}"
implementation "androidx.lifecycle:lifecycle-common-java8:${versions.androidx_lifecycle}"
implementation 'com.google.android.material:material:1.6.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation project(":livekit-android-sdk")
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}
\ No newline at end of file
... ...
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
\ No newline at end of file
... ...
package io.livekit.android.sample.basic
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("io.livekit.android.sample.basic", appContext.packageName)
}
}
\ No newline at end of file
... ...
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.livekit.android.sample.basic">
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Livekitandroid">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
\ No newline at end of file
... ...
package io.livekit.android.sample.basic
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import io.livekit.android.LiveKit
import io.livekit.android.events.RoomEvent
import io.livekit.android.events.collect
import io.livekit.android.renderer.SurfaceViewRenderer
import io.livekit.android.room.Room
import io.livekit.android.room.track.VideoTrack
import kotlinx.coroutines.launch
class MainActivity : AppCompatActivity() {
lateinit var room: Room
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// Create Room object.
room = LiveKit.create(applicationContext)
// Setup the video renderer
room.initVideoRenderer(findViewById<SurfaceViewRenderer>(R.id.renderer))
requestNeededPermissions { connectToRoom() }
}
private fun connectToRoom() {
val url = "wss://your_host"
val token = "your_token"
lifecycleScope.launch {
// Setup event handling.
launch {
room.events.collect { event ->
when (event) {
is RoomEvent.TrackSubscribed -> onTrackSubscribed(event)
else -> {}
}
}
}
// Connect to server.
room.connect(
url,
token,
)
// Turn on audio/video recording.
val localParticipant = room.localParticipant
localParticipant.setMicrophoneEnabled(true)
localParticipant.setCameraEnabled(true)
}
}
private fun onTrackSubscribed(event: RoomEvent.TrackSubscribed) {
val track = event.track
if (track is VideoTrack) {
attachVideo(track)
}
}
private fun attachVideo(videoTrack: VideoTrack) {
videoTrack.addRenderer(findViewById<SurfaceViewRenderer>(R.id.renderer))
findViewById<View>(R.id.progress).visibility = View.GONE
}
private fun requestNeededPermissions(onHasPermissions: () -> Unit) {
val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { grants ->
var hasDenied = false
// Check if any permissions weren't granted.
for (grant in grants.entries) {
if (!grant.value) {
Toast.makeText(this, "Missing permission: ${grant.key}", Toast.LENGTH_SHORT).show()
hasDenied = true
}
}
if (!hasDenied) {
onHasPermissions()
}
}
// Assemble the needed permissions to request
val neededPermissions = listOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
.let { perms ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Need BLUETOOTH_CONNECT permission on API S+ to output to bluetooth devices.
perms + listOf(Manifest.permission.BLUETOOTH_CONNECT)
} else {
perms
}
}
.filter { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_DENIED }
.toTypedArray()
if (neededPermissions.isNotEmpty()) {
requestPermissionLauncher.launch(neededPermissions)
} else {
onHasPermissions()
}
}
override fun onDestroy() {
super.onDestroy()
room.disconnect()
}
}
\ No newline at end of file
... ...
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
\ No newline at end of file
... ...
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
... ...
<?xml version="1.0" encoding="utf-8"?>
<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"
tools:context=".MainActivity">
<io.livekit.android.renderer.SurfaceViewRenderer
android:id="@+id/renderer"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<ProgressBar
android:id="@+id/progress"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>
\ No newline at end of file
... ...
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
\ No newline at end of file
... ...
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
\ No newline at end of file
... ...
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Livekitandroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>
\ No newline at end of file
... ...
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
\ No newline at end of file
... ...
<resources>
<string name="app_name">sample-app-basic</string>
</resources>
\ No newline at end of file
... ...
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Livekitandroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>
\ No newline at end of file
... ...
<?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
... ...
package io.livekit.android.sample.basic
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
\ No newline at end of file
... ...
# sample-app-common
Contains code common to `sample-app` and `sample-app-compose`.
\ No newline at end of file
... ...
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="io.livekit.android.sample">
package="io.livekit.android.sample.common">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
... ...
... ... @@ -2,6 +2,7 @@ package io.livekit.android.sample
import android.app.Application
import android.content.Intent
import android.media.projection.MediaProjectionManager
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
... ... @@ -57,6 +58,7 @@ class CallViewModel(
private var localScreencastTrack: LocalScreencastVideoTrack? = null
// Controls
private val mutableMicEnabled = MutableLiveData(true)
val micEnabled = mutableMicEnabled.hide()
... ... @@ -69,18 +71,22 @@ class CallViewModel(
private val mutableScreencastEnabled = MutableLiveData(false)
val screenshareEnabled = mutableScreencastEnabled.hide()
// Emits a string whenever a data message is received.
private val mutableDataReceived = MutableSharedFlow<String>()
val dataReceived = mutableDataReceived
// Whether other participants are allowed to subscribe to this participant's tracks.
private val mutablePermissionAllowed = MutableStateFlow(true)
val permissionAllowed = mutablePermissionAllowed.hide()
init {
viewModelScope.launch {
// Collect any errors.
launch {
error.collect { Timber.e(it) }
}
// Handle any changes in speakers.
launch {
combine(participants, activeSpeakers) { participants, speakers -> participants to speakers }
.collect { (participantsList, speakers) ->
... ... @@ -93,6 +99,7 @@ class CallViewModel(
}
launch {
// Handle room events.
room.events.collect {
when (it) {
is RoomEvent.FailedToConnect -> mutableError.value = it.error
... ... @@ -124,6 +131,7 @@ class CallViewModel(
localParticipant.setCameraEnabled(true)
mutableCameraEnabled.postValue(localParticipant.isCameraEnabled())
// Update the speaker
handlePrimarySpeaker(emptyList(), emptyList(), room)
} catch (e: Throwable) {
mutableError.value = e
... ... @@ -167,6 +175,10 @@ class CallViewModel(
mutablePrimarySpeaker.value = speaker
}
/**
* Start a screen capture with the result intent from
* [MediaProjectionManager.createScreenCaptureIntent]
*/
fun startScreenCapture(mediaProjectionPermissionResultData: Intent) {
val localParticipant = room.localParticipant
viewModelScope.launch {
... ... @@ -243,6 +255,7 @@ class CallViewModel(
room.localParticipant.setTrackSubscriptionPermissions(mutablePermissionAllowed.value)
}
// Debug functions
fun simulateMigration() {
room.sendSimulateScenario(
LivekitRtc.SimulateScenario.newBuilder()
... ...
... ... @@ -4,6 +4,7 @@ import android.app.Application
import androidx.core.content.edit
import androidx.lifecycle.AndroidViewModel
import androidx.preference.PreferenceManager
import io.livekit.android.sample.common.BuildConfig
class MainViewModel(application: Application) : AndroidViewModel(application) {
... ...
... ... @@ -13,6 +13,7 @@ fun ComponentActivity.requestNeededPermissions() {
registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { grants ->
// Check if any permissions weren't granted.
for (grant in grants.entries) {
if (!grant.value) {
Toast.makeText(
... ... @@ -24,6 +25,7 @@ fun ComponentActivity.requestNeededPermissions() {
}
}
}
val neededPermissions = listOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
.let { perms ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
... ... @@ -32,12 +34,7 @@ fun ComponentActivity.requestNeededPermissions() {
perms
}
}
.filter {
ContextCompat.checkSelfPermission(
this,
it
) == PackageManager.PERMISSION_DENIED
}
.filter { ContextCompat.checkSelfPermission(this, it) == PackageManager.PERMISSION_DENIED }
.toTypedArray()
if (neededPermissions.isNotEmpty()) {
... ...
# sample-app-compose
A sample video conferencing app for LiveKit made using Jetpack Compose.
\ No newline at end of file
... ...
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'org.jetbrains.kotlin.android'
id 'kotlin-parcelize'
}
... ... @@ -32,7 +32,7 @@ android {
}
kotlinOptions {
jvmTarget = java_version
freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn'
freeCompilerArgs += '-opt-in=kotlin.RequiresOptIn'
}
buildFeatures {
compose true
... ...
... ... @@ -13,7 +13,6 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
... ... @@ -217,7 +216,6 @@ class CallActivity : AppCompatActivity() {
) {
Surface(
onClick = { viewModel.setMicEnabled(!micEnabled) },
indication = rememberRipple(false),
modifier = Modifier
.size(controlSize)
.padding(controlPadding)
... ... @@ -232,7 +230,6 @@ class CallActivity : AppCompatActivity() {
}
Surface(
onClick = { viewModel.setCameraEnabled(!videoEnabled) },
indication = rememberRipple(false),
modifier = Modifier
.size(controlSize)
.padding(controlPadding)
... ... @@ -247,7 +244,6 @@ class CallActivity : AppCompatActivity() {
}
Surface(
onClick = { viewModel.flipCamera() },
indication = rememberRipple(false),
modifier = Modifier
.size(controlSize)
.padding(controlPadding)
... ... @@ -266,7 +262,6 @@ class CallActivity : AppCompatActivity() {
viewModel.stopScreenCapture()
}
},
indication = rememberRipple(false),
modifier = Modifier
.size(controlSize)
.padding(controlPadding)
... ... @@ -284,7 +279,6 @@ class CallActivity : AppCompatActivity() {
var messageToSend by remember { mutableStateOf("") }
Surface(
onClick = { showMessageDialog = true },
indication = rememberRipple(false),
modifier = Modifier
.size(controlSize)
.padding(controlPadding)
... ... @@ -335,7 +329,6 @@ class CallActivity : AppCompatActivity() {
}
Surface(
onClick = { onExitClick() },
indication = rememberRipple(false),
modifier = Modifier
.size(controlSize)
.padding(controlPadding)
... ... @@ -358,7 +351,6 @@ class CallActivity : AppCompatActivity() {
var showAudioDeviceDialog by remember { mutableStateOf(false) }
Surface(
onClick = { showAudioDeviceDialog = true },
indication = rememberRipple(false),
modifier = Modifier
.size(controlSize)
.padding(controlPadding)
... ... @@ -380,7 +372,6 @@ class CallActivity : AppCompatActivity() {
}
Surface(
onClick = { viewModel.toggleSubscriptionPermissions() },
indication = rememberRipple(false),
modifier = Modifier
.size(controlSize)
.padding(controlPadding)
... ... @@ -397,7 +388,6 @@ class CallActivity : AppCompatActivity() {
var showDebugDialog by remember { mutableStateOf(false) }
Surface(
onClick = { showDebugDialog = true },
indication = rememberRipple(false),
modifier = Modifier
.size(controlSize)
.padding(controlPadding)
... ...
# sample-app
A sample video conferencing app for LiveKit.
\ No newline at end of file
... ...
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-parcelize'
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-parcelize'
}
android {
compileSdkVersion androidSdk.compileVersion
... ... @@ -36,16 +37,16 @@ dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation deps.coroutines.lib
implementation 'com.google.android.material:material:1.4.0'
implementation 'androidx.appcompat:appcompat:1.4.0'
implementation 'com.google.android.material:material:1.6.1'
implementation 'androidx.appcompat:appcompat:1.4.2'
implementation "androidx.core:core-ktx:${versions.androidx_core}"
implementation "androidx.activity:activity-ktx:1.4.0"
implementation 'androidx.fragment:fragment-ktx:1.3.6'
implementation "androidx.activity:activity-ktx:1.5.1"
implementation 'androidx.fragment:fragment-ktx:1.5.1'
implementation "androidx.viewpager2:viewpager2:1.0.0"
implementation "androidx.lifecycle:lifecycle-runtime-ktx:${versions.androidx_lifecycle}"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${versions.androidx_lifecycle}"
implementation "androidx.lifecycle:lifecycle-common-java8:${versions.androidx_lifecycle}"
implementation 'com.google.android.material:material:1.3.0'
implementation 'com.google.android.material:material:1.6.1'
implementation "com.github.lisawray.groupie:groupie:${versions.groupie}"
implementation "com.github.lisawray.groupie:groupie-viewbinding:${versions.groupie}"
implementation deps.timber
... ...
... ... @@ -8,3 +8,4 @@ rootProject.name='livekit-android'
include ':sample-app-common'
include ':livekit-lint'
include ':video-encode-decode-test'
include ':sample-app-basic'
... ...
# video-encode-decode-test
Tests for checking various video codec capabilities in simulcast.
\ No newline at end of file
... ...
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'org.jetbrains.kotlin.android'
id 'kotlin-parcelize'
}
... ... @@ -57,7 +57,7 @@ android {
}
kotlinOptions {
jvmTarget = java_version
freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn'
freeCompilerArgs += '-opt-in=kotlin.RequiresOptIn'
}
buildFeatures {
compose true
... ...
... ... @@ -17,11 +17,11 @@ import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import com.google.accompanist.pager.ExperimentalPagerApi
import io.livekit.android.composesample.ui.theme.AppTheme
import kotlinx.parcelize.Parcelize
@OptIn(ExperimentalPagerApi::class)
class CallActivity : AppCompatActivity() {
private lateinit var viewModel1: CallViewModel
... ... @@ -31,11 +31,13 @@ class CallActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val viewModelProvider = ViewModelProvider(this, object : ViewModelProvider.KeyedFactory() {
override fun <T : ViewModel> create(key: String, modelClass: Class<T>): T {
val viewModelProvider = ViewModelProvider(this, object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
val args = intent.getParcelableExtra<BundleArgs>(KEY_ARGS)
?: throw NullPointerException("args is null!")
val key = extras[ViewModelProvider.NewInstanceFactory.VIEW_MODEL_KEY]
val token = if (key == VIEWMODEL_KEY1) args.token1 else args.token2
val showVideo = key == VIEWMODEL_KEY1
@Suppress("UNCHECKED_CAST")
... ...