davidliu
Committed by GitHub

Browserstack tests (#68)

* e2e test app

* instrumented video encode decode test

* browserstack in github actions

* fix yaml

* more action fixes

* more yml fixes

* test more codecs and control tests

* Optimize test some more

* fix supported codecs logging

* don't try simulcast encodings if using default

* clean as a part of build process

* Directly test if VP8/H264 is available for device
正在显示 60 个修改的文件 包含 2004 行增加13 行删除
... ... @@ -29,7 +29,64 @@ jobs:
run: chmod +x gradlew
- name: Build with Gradle
run: ./gradlew livekit-android-sdk:assembleRelease livekit-android-sdk:testRelease
run: ./gradlew clean livekit-android-sdk:assembleRelease livekit-android-sdk:testRelease
- name: Import video test keys into gradle properties
run: |
sed -i -e "s,livekitUrl=,livekitUrl=$LIVEKIT_URL,g" gradle.properties
sed -i -e "s,livekitApiKey=,livekitApiKey=$LIVEKIT_API_KEY,g" gradle.properties
sed -i -e "s,livekitApiSecret=,livekitApiSecret=$LIVEKIT_API_SECRET,g" gradle.properties
env:
LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }}
LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }}
LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }}
- name: Build video test app
run: ./gradlew video-encode-decode-test:assembleDebug video-encode-decode-test:assembleDebugAndroidTest
- name: Video Encode Decode App upload and Set app id in environment variable.
run: |
APP_UPLOAD_RESPONSE=$(curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" -X POST https://api-cloud.browserstack.com/app-automate/upload -F "file=@video-encode-decode-test/build/outputs/apk/debug/video-encode-decode-test-debug.apk")
echo "APP_UPLOAD_RESPONSE: $APP_UPLOAD_RESPONSE"
APP_ID=$(echo $APP_UPLOAD_RESPONSE | jq -r ".app_url")
if [ $APP_ID != null ]; then
echo "Apk uploaded to BrowserStack with app id : $APP_ID";
echo "BROWSERSTACK_APP_ID=$APP_ID" >> $GITHUB_ENV;
echo "Setting value of BROWSERSTACK_APP_ID in environment variables to $APP_ID";
else
UPLOAD_ERROR_MESSAGE=$(echo $APP_UPLOAD_RESPONSE | jq -r ".error")
echo "App upload failed, reason : ",$UPLOAD_ERROR_MESSAGE
exit 1;
fi
env:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
- name: Video Encode Decode Test Suite upload and Set test suite id in environment variable.
run: |
APP_UPLOAD_RESPONSE=$(curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" -X POST https://api-cloud.browserstack.com/app-automate/espresso/v2/test-suite -F "file=@video-encode-decode-test/build/outputs/apk/androidTest/debug/video-encode-decode-test-debug-androidTest.apk")
echo "APP_UPLOAD_RESPONSE: $APP_UPLOAD_RESPONSE"
APP_ID=$(echo $APP_UPLOAD_RESPONSE | jq -r ".test_suite_url")
if [ $APP_ID != null ]; then
echo "Test Suite Apk uploaded to BrowserStack with id: $APP_ID";
echo "BROWSERSTACK_TEST_ID=$APP_ID" >> $GITHUB_ENV;
echo "Setting value of BROWSERSTACK_TEST_ID in environment variables to $APP_ID";
else
UPLOAD_ERROR_MESSAGE=$(echo $APP_UPLOAD_RESPONSE | jq -r ".error")
echo "App upload failed, reason : ",$UPLOAD_ERROR_MESSAGE
exit 1;
fi
env:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
- name: Trigger BrowserStack tests
run: |
TEST_RESPONSE=$(curl -u "$BROWSERSTACK_USERNAME:$BROWSERSTACK_ACCESS_KEY" -X POST "https://api-cloud.browserstack.com/app-automate/espresso/v2/build" -d '{"deviceLogs": true, "app": "'"$BROWSERSTACK_APP_ID"'", "testSuite": "'"$BROWSERSTACK_TEST_ID"'", "devices": ["Samsung Galaxy Tab S7-10.0","Samsung Galaxy S22-12.0", "Samsung Galaxy S21-12.0","Samsung Galaxy S20-10.0", "Google Pixel 6-12.0", "Google Pixel 5-11.0", "Google Pixel 3-10.0", "OnePlus 7-9.0","Xiaomi Redmi Note 8-9.0", "Huawei P30-9.0"]}' -H "Content-Type: application/json")
echo "TEST_RESPONSE: $TEST_RESPONSE"
env:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
- name: get version name
if: github.event_name == 'push'
... ...
... ... @@ -50,3 +50,10 @@ nexusPassword=
signing.keyId=
signing.password=
signing.secretKeyRingFile=
# For instrumented tests.
# WARNING: Do not edit this and potentially commit to repo.
# Instead, override in ~/.gradle/gradle.properties
livekitUrl=
livekitApiKey=
livekitApiSecret=
\ No newline at end of file
... ...
... ... @@ -26,4 +26,5 @@ object InjectionNames {
// Overrides
internal const val OVERRIDE_OKHTTP = "override_okhttp"
internal const val OVERRIDE_VIDEO_ENCODER_FACTORY = "override_video_encoder_factory"
}
\ No newline at end of file
... ...
... ... @@ -8,6 +8,7 @@ import io.livekit.android.room.Room
import okhttp3.OkHttpClient
import org.webrtc.EglBase
import org.webrtc.PeerConnectionFactory
import org.webrtc.VideoEncoderFactory
import javax.inject.Named
import javax.inject.Singleton
... ... @@ -36,7 +37,12 @@ internal interface LiveKitComponent {
@BindsInstance
@Named(InjectionNames.OVERRIDE_OKHTTP)
@Nullable
okHttpClientOverride: OkHttpClient?
okHttpClientOverride: OkHttpClient?,
@BindsInstance
@Named(InjectionNames.OVERRIDE_VIDEO_ENCODER_FACTORY)
@Nullable
videoEncoderFactory: VideoEncoderFactory?,
): LiveKitComponent
}
}
... ... @@ -47,7 +53,8 @@ internal fun LiveKitComponent.Factory.create(
): LiveKitComponent {
return create(
appContext = context,
okHttpClientOverride = overrides.okHttpClient
okHttpClientOverride = overrides.okHttpClient,
videoEncoderFactory = overrides.videoEncoderFactory,
)
}
... ... @@ -55,5 +62,6 @@ internal fun LiveKitComponent.Factory.create(
* Overrides to replace LiveKit internally used component with custom implementations.
*/
data class LiveKitOverrides(
val okHttpClient: OkHttpClient? = null
val okHttpClient: OkHttpClient? = null,
val videoEncoderFactory: VideoEncoderFactory? = null,
)
\ No newline at end of file
... ...
package io.livekit.android.dagger
import android.content.Context
import androidx.annotation.Nullable
import dagger.Module
import dagger.Provides
import io.livekit.android.util.LKLog
... ... @@ -99,10 +100,12 @@ object RTCModule {
fun videoEncoderFactory(
@Named(InjectionNames.OPTIONS_VIDEO_HW_ACCEL)
videoHwAccel: Boolean,
eglContext: EglBase.Context
eglContext: EglBase.Context,
@Named(InjectionNames.OVERRIDE_VIDEO_ENCODER_FACTORY)
@Nullable
videoEncoderFactoryOverride: VideoEncoderFactory?
): VideoEncoderFactory {
return if (videoHwAccel) {
return videoEncoderFactoryOverride ?: if (videoHwAccel) {
SimulcastVideoEncoderFactoryWrapper(
eglContext,
enableIntelVp8Encoder = true,
... ...
... ... @@ -19,10 +19,7 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.cancel
import livekit.LivekitModels
import livekit.LivekitRtc
import org.webrtc.EglBase
import org.webrtc.PeerConnectionFactory
import org.webrtc.RtpParameters
import org.webrtc.RtpTransceiver
import org.webrtc.*
import javax.inject.Named
import kotlin.math.max
... ... @@ -78,6 +75,25 @@ internal constructor(
}
/**
* Creates a video track, recording video through the supplied [capturer]
*/
fun createVideoTrack(
name: String = "",
capturer: VideoCapturer,
options: LocalVideoTrackOptions = videoTrackCaptureDefaults.copy(),
): LocalVideoTrack {
return LocalVideoTrack.createTrack(
peerConnectionFactory = peerConnectionFactory,
context = context,
name = name,
capturer = capturer,
options = LocalVideoTrackOptions(),
rootEglBase = eglBase,
trackFactory = videoTrackFactory,
)
}
/**
* Creates a video track, recording video through the camera with the given [options].
*
* @exception SecurityException will be thrown if [Manifest.permission.CAMERA] permission is missing.
... ...
... ... @@ -125,6 +125,31 @@ constructor(
peerConnectionFactory: PeerConnectionFactory,
context: Context,
name: String,
capturer: VideoCapturer,
options: LocalVideoTrackOptions = LocalVideoTrackOptions(),
rootEglBase: EglBase,
trackFactory: Factory
): LocalVideoTrack {
val source = peerConnectionFactory.createVideoSource(false)
capturer.initialize(
SurfaceTextureHelper.create("VideoCaptureThread", rootEglBase.eglBaseContext),
context,
source.capturerObserver
)
val track = peerConnectionFactory.createVideoTrack(UUID.randomUUID().toString(), source)
return trackFactory.create(
capturer = capturer,
source = source,
options = options,
name = name,
rtcTrack = track
)
}
internal fun createTrack(
peerConnectionFactory: PeerConnectionFactory,
context: Context,
name: String,
options: LocalVideoTrackOptions,
rootEglBase: EglBase,
trackFactory: Factory
... ...
... ... @@ -22,7 +22,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
internal class SimulcastVideoEncoderFactoryWrapper(
open class SimulcastVideoEncoderFactoryWrapper(
sharedContext: EglBase.Context?,
enableIntelVp8Encoder: Boolean,
enableH264HighProfile: Boolean
... ...
... ... @@ -43,7 +43,7 @@ dependencies {
api "androidx.lifecycle:lifecycle-common-java8:${versions.androidx_lifecycle}"
api "com.google.protobuf:protobuf-javalite:${versions.protobuf}"
api project(":livekit-android-sdk")
implementation 'androidx.preference:preference-ktx:1.1.1'
api 'androidx.preference:preference-ktx:1.1.1'
// debugImplementation because LeakCanary should only run in debug builds.
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1'
testImplementation 'junit:junit:4.+'
... ...
... ... @@ -8,3 +8,4 @@ include ':sample-app', ':sample-app-compose', ':livekit-android-sdk'
rootProject.name='livekit-android'
include ':sample-app-common'
include ':livekit-lint'
include ':video-encode-decode-test'
... ...
/build
\ No newline at end of file
... ...
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-parcelize'
}
def getServerUrl() {
return hasProperty('livekitUrl') ? livekitUrl : ""
}
def getApiKey() {
return hasProperty('livekitApiKey') ? livekitApiKey : ""
}
def getApiSecret() {
return hasProperty('livekitApiSecret') ? livekitApiSecret : ""
}
final serverUrl = getServerUrl()
final apiKey = getApiKey()
final apiSecret = getApiSecret()
android {
compileSdkVersion androidSdk.compileVersion
defaultConfig {
applicationId "io.livekit.android.videoencodedecodetest"
minSdkVersion androidSdk.minVersion
targetSdkVersion androidSdk.targetVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary true
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
buildConfigField "String", "SERVER_URL", "\"$serverUrl\""
buildConfigField "String", "API_KEY", "\"$apiKey\""
buildConfigField "String", "API_SECRET", "\"$apiSecret\""
}
debug {
buildConfigField "String", "SERVER_URL", "\"$serverUrl\""
buildConfigField "String", "API_KEY", "\"$apiKey\""
buildConfigField "String", "API_SECRET", "\"$apiSecret\""
}
}
compileOptions {
sourceCompatibility java_version
targetCompatibility java_version
}
kotlinOptions {
jvmTarget = java_version
freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn'
}
buildFeatures {
compose true
}
composeOptions {
kotlinCompilerExtensionVersion compose_compiler_version
}
packagingOptions {
resources {
excludes += '/META-INF/{AL2.0,LGPL2.1}'
}
}
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation deps.coroutines.lib
implementation "androidx.core:core-ktx:${versions.androidx_core}"
implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'com.google.android.material:material:1.4.0'
implementation "androidx.compose.ui:ui:$compose_version"
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc01"
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 'androidx.activity:activity-compose:1.3.1'
implementation 'com.google.accompanist:accompanist-pager:0.19.0'
implementation 'com.google.accompanist:accompanist-pager-indicators:0.19.0'
implementation deps.timber
implementation project(":sample-app-common")
implementation project(":livekit-android-sdk")
testImplementation 'junit:junit:4.+'
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
androidTestImplementation 'io.jsonwebtoken:jjwt-api:0.11.2'
androidTestImplementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
androidTestImplementation "io.jsonwebtoken:jjwt-jackson:0.11.2"
debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
}
\ 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.videoencodedecode
import android.content.Context
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.rules.ActivityScenarioRule
/**
* Factory method to provide android specific implementation of createComposeRule, for a given
* activity class type A that needs to be launched via an intent.
*
* @param intentFactory A lambda that provides a Context that can used to create an intent. A intent needs to be returned.
*/
inline fun <A: ComponentActivity> createAndroidIntentComposeRule(intentFactory: (context: Context) -> Intent) : AndroidComposeTestRule<ActivityScenarioRule<A>, A> {
val context = ApplicationProvider.getApplicationContext<Context>()
val intent = intentFactory(context)
return AndroidComposeTestRule(
activityRule = ActivityScenarioRule(intent),
activityProvider = { scenarioRule -> scenarioRule.getActivity() }
)
}
/**
* Gets the activity from a scenarioRule.
*
* https://androidx.tech/artifacts/compose.ui/ui-test-junit4/1.0.0-alpha11-source/androidx/compose/ui/test/junit4/AndroidIntentComposeTestRule.kt.html
*/
fun <A : ComponentActivity> ActivityScenarioRule<A>.getActivity(): A {
var activity: A? = null
scenario.onActivity { activity = it }
return activity ?: throw IllegalStateException("Activity was not set in the ActivityScenarioRule!")
}
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode
fun randomAlphanumericString(length: Int): String {
val builder = StringBuilder(length)
for (i in 0 until length) {
val value = (0..61).random()
builder.append(value.toAlphaNumeric())
}
return builder.toString()
}
fun Int.toAlphaNumeric(): Char {
if (this < 0 || this > 62) {
throw IllegalArgumentException()
}
var offset = this
if (offset < 10) {
return '0' + offset
}
offset -= 10
if (offset < 26) {
return 'a' + offset
}
offset -= 26
if (offset < 26) {
return 'A' + offset
}
throw IllegalArgumentException()
}
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.util.*
import javax.crypto.spec.SecretKeySpec
@RunWith(AndroidJUnit4::class)
abstract class VideoTest {
val roomName = "browserStackTestRoom-${randomAlphanumericString(12)}"
companion object {
val SERVER_URL = BuildConfig.SERVER_URL
val API_KEY = BuildConfig.API_KEY
val API_SECRET = BuildConfig.API_SECRET
}
private fun createToken(name: String) = Jwts.builder()
.setIssuer(API_KEY)
.setExpiration(Date(System.currentTimeMillis() + 1000 * 60 * 60 /* 1hour */))
.setNotBefore(Date(0))
.setSubject(name)
.setId(name)
.addClaims(
mapOf(
"video" to mapOf(
"roomJoin" to true,
"room" to roomName
),
"name" to name
)
)
.signWith(
SecretKeySpec(API_SECRET.toByteArray(), "HmacSHA256"),
SignatureAlgorithm.HS256
)
.compact()
protected val token1 = createToken("phone1")
protected val token2 = createToken("phone2")
@get:Rule
abstract val composeTestRule: ComposeTestRule
@Test
fun videoReceivedTest() {
composeTestRule.waitForIdle()
composeTestRule.waitUntil(20 * 1000L) {
composeTestRule.onAllNodesWithTag(VIDEO_FRAME_INDICATOR).fetchSemanticsNodes(false, "").isNotEmpty()
}
}
}
... ...
package io.livekit.android.videoencodedecode.test
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.webrtc.DefaultVideoEncoderFactory
import org.webrtc.EglBase
import org.webrtc.PeerConnectionFactory
class OfficialCodecSupportTest {
val defaultVideoEncoderFactory = DefaultVideoEncoderFactory(EglBase.create().eglBaseContext, true, true)
@Before
fun setup() {
PeerConnectionFactory.initialize(
PeerConnectionFactory.InitializationOptions
.builder(InstrumentationRegistry.getInstrumentation().targetContext)
.createInitializationOptions()
)
}
@Test
fun isVP8Supported() {
Assert.assertTrue(defaultVideoEncoderFactory.supportedCodecs.any { it.name == "VP8" })
}
@Test
fun isH264Supported() {
Assert.assertTrue(defaultVideoEncoderFactory.supportedCodecs.any { it.name == "H264" })
}
}
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode.test.control
import android.content.Intent
import androidx.compose.ui.test.junit4.ComposeTestRule
import io.livekit.android.videoencodedecode.CallActivity
import io.livekit.android.videoencodedecode.VideoTest
import io.livekit.android.videoencodedecode.createAndroidIntentComposeRule
class H264DefaultVideoTest : VideoTest() {
override val composeTestRule: ComposeTestRule = createAndroidIntentComposeRule<CallActivity> { context ->
Intent(context, CallActivity::class.java).apply {
putExtra(
CallActivity.KEY_ARGS,
CallActivity.BundleArgs(
SERVER_URL,
token1,
token2,
useDefaultVideoEncoderFactory = true,
codecWhiteList = listOf("H264")
)
)
}
}
}
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode.test.control
import android.content.Intent
import androidx.compose.ui.test.junit4.ComposeTestRule
import io.livekit.android.videoencodedecode.CallActivity
import io.livekit.android.videoencodedecode.VideoTest
import io.livekit.android.videoencodedecode.createAndroidIntentComposeRule
class NoWhitelistDefaultVideoTest : VideoTest() {
override val composeTestRule: ComposeTestRule = createAndroidIntentComposeRule<CallActivity> { context ->
Intent(context, CallActivity::class.java).apply {
putExtra(
CallActivity.KEY_ARGS,
CallActivity.BundleArgs(
SERVER_URL,
token1,
token2,
useDefaultVideoEncoderFactory = true
)
)
}
}
}
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode.test.control
import android.content.Intent
import androidx.compose.ui.test.junit4.ComposeTestRule
import io.livekit.android.videoencodedecode.CallActivity
import io.livekit.android.videoencodedecode.VideoTest
import io.livekit.android.videoencodedecode.createAndroidIntentComposeRule
class VP8DefaultVideoTest : VideoTest() {
override val composeTestRule: ComposeTestRule = createAndroidIntentComposeRule<CallActivity> { context ->
Intent(context, CallActivity::class.java).apply {
putExtra(
CallActivity.KEY_ARGS,
CallActivity.BundleArgs(
SERVER_URL,
token1,
token2,
useDefaultVideoEncoderFactory = true,
codecWhiteList = listOf("VP8")
)
)
}
}
}
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode.test.test
import android.content.Intent
import androidx.compose.ui.test.junit4.ComposeTestRule
import io.livekit.android.videoencodedecode.CallActivity
import io.livekit.android.videoencodedecode.VideoTest
import io.livekit.android.videoencodedecode.createAndroidIntentComposeRule
class H264SimulcastVideoTest : VideoTest() {
override val composeTestRule: ComposeTestRule = createAndroidIntentComposeRule<CallActivity> { context ->
Intent(context, CallActivity::class.java).apply {
putExtra(
CallActivity.KEY_ARGS,
CallActivity.BundleArgs(
SERVER_URL,
token1,
token2,
useDefaultVideoEncoderFactory = false,
codecWhiteList = listOf("H264")
)
)
}
}
}
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode.test.test
import android.content.Intent
import androidx.compose.ui.test.junit4.ComposeTestRule
import io.livekit.android.videoencodedecode.CallActivity
import io.livekit.android.videoencodedecode.VideoTest
import io.livekit.android.videoencodedecode.createAndroidIntentComposeRule
class NoWhitelistSimulcastVideoTest : VideoTest() {
override val composeTestRule: ComposeTestRule = createAndroidIntentComposeRule<CallActivity> { context ->
Intent(context, CallActivity::class.java).apply {
putExtra(
CallActivity.KEY_ARGS,
CallActivity.BundleArgs(
SERVER_URL,
token1,
token2,
useDefaultVideoEncoderFactory = false
)
)
}
}
}
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode.test.test
import android.content.Intent
import androidx.compose.ui.test.junit4.ComposeTestRule
import io.livekit.android.videoencodedecode.CallActivity
import io.livekit.android.videoencodedecode.VideoTest
import io.livekit.android.videoencodedecode.createAndroidIntentComposeRule
class VP8DefaultVideoTest : VideoTest() {
override val composeTestRule: ComposeTestRule = createAndroidIntentComposeRule<CallActivity> { context ->
Intent(context, CallActivity::class.java).apply {
putExtra(
CallActivity.KEY_ARGS,
CallActivity.BundleArgs(
SERVER_URL,
token1,
token2,
useDefaultVideoEncoderFactory = false,
codecWhiteList = listOf("VP8")
)
)
}
}
}
\ 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.videoencodedecode">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application
android:allowBackup="true"
android:name="io.livekit.android.videoencodedecode.SampleApplication"
android:fullBackupContent="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.Livekitandroid">
<activity
android:name="io.livekit.android.videoencodedecode.MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.Livekitandroid.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name="io.livekit.android.videoencodedecode.CallActivity"
android:theme="@style/Theme.Livekitandroid.NoActionBar" />
</application>
</manifest>
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode
import android.os.Bundle
import android.os.Parcelable
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
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
private lateinit var viewModel2: CallViewModel
@OptIn(ExperimentalMaterialApi::class)
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 args = intent.getParcelableExtra<BundleArgs>(KEY_ARGS)
?: throw NullPointerException("args is null!")
val token = if (key == VIEWMODEL_KEY1) args.token1 else args.token2
val showVideo = key == VIEWMODEL_KEY1
@Suppress("UNCHECKED_CAST")
return CallViewModel(
args.url,
token,
args.useDefaultVideoEncoderFactory,
args.codecWhiteList,
showVideo,
application
) as T
}
})
viewModel1 = viewModelProvider.get(VIEWMODEL_KEY1, CallViewModel::class.java)
viewModel2 = viewModelProvider.get(VIEWMODEL_KEY2, CallViewModel::class.java)
// Setup compose view.
setContent {
Content(
viewModel1,
viewModel2
)
}
}
@ExperimentalMaterialApi
@Composable
fun Content(viewModel1: CallViewModel, viewModel2: CallViewModel) {
AppTheme(darkTheme = true) {
ConstraintLayout(
modifier = Modifier
.fillMaxSize()
.background(Color.Red)
) {
val (topConnectionItem, bottomConnectionItem) = createRefs()
Surface(
color = Color.Cyan,
modifier = Modifier
.semantics {
testTag = TEST_TAG1
}
.constrainAs(topConnectionItem) {
top.linkTo(parent.top)
start.linkTo(parent.start)
end.linkTo(parent.end)
bottom.linkTo(bottomConnectionItem.top)
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
}) {
ConnectionItem(viewModel = viewModel1)
}
Surface(
color = Color.Magenta,
modifier = Modifier
.semantics {
testTag = TEST_TAG2
}
.constrainAs(bottomConnectionItem) {
top.linkTo(topConnectionItem.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
bottom.linkTo(parent.bottom)
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
}) {
ConnectionItem(viewModel = viewModel2)
}
}
}
}
companion object {
const val KEY_ARGS = "args"
const val VIEWMODEL_KEY1 = "key1"
const val VIEWMODEL_KEY2 = "key2"
const val TEST_TAG1 = "tag1"
const val TEST_TAG2 = "tag2"
}
@Parcelize
data class BundleArgs(
val url: String,
val token1: String,
val token2: String,
val useDefaultVideoEncoderFactory: Boolean = false,
val codecWhiteList: List<String>? = null,
) : Parcelable
}
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode
import android.app.Application
import android.graphics.Color
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.viewModelScope
import com.github.ajalt.timberkt.Timber
import io.livekit.android.LiveKit
import io.livekit.android.RoomOptions
import io.livekit.android.dagger.LiveKitOverrides
import io.livekit.android.room.Room
import io.livekit.android.room.participant.Participant
import io.livekit.android.room.participant.VideoTrackPublishDefaults
import io.livekit.android.room.track.LocalVideoTrackOptions
import io.livekit.android.room.track.VideoCaptureParameter
import io.livekit.android.util.flow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import org.webrtc.EglBase
@OptIn(ExperimentalCoroutinesApi::class)
class CallViewModel(
private val url: String,
private val token: String,
private val useDefaultVideoEncoder: Boolean = false,
private val codecWhiteList: List<String>? = null,
private val showVideo: Boolean,
application: Application
) : AndroidViewModel(application) {
private val mutableRoom = MutableStateFlow<Room?>(null)
val room: MutableStateFlow<Room?> = mutableRoom
val participants = mutableRoom.flatMapLatest { room ->
if (room != null) {
room::remoteParticipants.flow
.map { remoteParticipants ->
listOf<Participant>(room.localParticipant) +
remoteParticipants
.keys
.sortedBy { it }
.mapNotNull { remoteParticipants[it] }
}
} else {
flowOf(emptyList())
}
}
private val mutableError = MutableStateFlow<Throwable?>(null)
val error = mutableError.hide()
init {
viewModelScope.launch {
launch {
error.collect { Timber.e(it) }
}
try {
val videoEncoderFactory = if (useDefaultVideoEncoder || codecWhiteList != null) {
val factory = if (useDefaultVideoEncoder) {
WhitelistDefaultVideoEncoderFactory(
EglBase.create().eglBaseContext,
enableIntelVp8Encoder = true,
enableH264HighProfile = true
)
} else {
WhitelistSimulcastVideoEncoderFactory(
EglBase.create().eglBaseContext,
enableIntelVp8Encoder = true,
enableH264HighProfile = true,
)
}
factory.apply { codecWhitelist = this@CallViewModel.codecWhiteList }
} else {
null
}
val overrides = LiveKitOverrides(videoEncoderFactory = videoEncoderFactory)
val room = LiveKit.connect(
application,
url,
token,
roomOptions = RoomOptions(videoTrackPublishDefaults = VideoTrackPublishDefaults(simulcast = !useDefaultVideoEncoder)),
overrides = overrides
)
// Create and publish audio/video tracks
val localParticipant = room.localParticipant
if (showVideo) {
val capturer = DummyVideoCapturer(Color.RED)
val videoTrack = localParticipant.createVideoTrack(
capturer = capturer,
options = LocalVideoTrackOptions(captureParams = VideoCaptureParameter(128, 128, 30))
)
videoTrack.startCapture()
localParticipant.publishVideoTrack(videoTrack)
}
mutableRoom.value = room
} catch (e: Throwable) {
mutableError.value = e
}
}
}
override fun onCleared() {
super.onCleared()
mutableRoom.value?.disconnect()
}
}
private fun <T> LiveData<T>.hide(): LiveData<T> = this
private fun <T> MutableStateFlow<T>.hide(): StateFlow<T> = this
private fun <T> Flow<T>.hide(): Flow<T> = this
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import io.livekit.android.room.Room
import io.livekit.android.room.participant.Participant
/**
* Widget for showing the other participant in a connection.
*/
@Composable
fun ConnectionItem(viewModel: CallViewModel) {
val room by viewModel.room.collectAsState()
val participants by viewModel.participants.collectAsState(initial = emptyList())
if (room != null) {
RoomItem(room = room!!, participants)
}
}
@Composable
fun RoomItem(room: Room, participants: List<Participant>) {
val remoteParticipant = participants.filterNot { it == room.localParticipant }.firstOrNull()
if (remoteParticipant != null) {
ParticipantItem(room = room, participant = remoteParticipant, isSpeaking = false)
}
}
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode
import android.content.Context
import android.os.SystemClock
import androidx.annotation.ColorInt
import org.webrtc.*
import java.nio.ByteBuffer
import java.util.*
import java.util.concurrent.TimeUnit
class DummyVideoCapturer(@ColorInt var color: Int) : VideoCapturer {
var capturerObserver: CapturerObserver? = null
val timer = Timer()
var frameWidth = 0
var frameHeight = 0
private val tickTask: TimerTask = object : TimerTask() {
override fun run() {
tick()
}
}
fun tick() {
val videoFrame: VideoFrame = createFrame()
this.capturerObserver?.onFrameCaptured(videoFrame)
videoFrame.release()
}
override fun initialize(
surfaceTextureHelper: SurfaceTextureHelper,
applicationContext: Context,
capturerObserver: CapturerObserver
) {
this.capturerObserver = capturerObserver
}
override fun startCapture(width: Int, height: Int, framerate: Int) {
frameWidth = width
frameHeight = height
this.timer.schedule(this.tickTask, 0L, (1000 / framerate).toLong())
}
@Throws(InterruptedException::class)
override fun stopCapture() {
this.timer.cancel()
}
override fun changeCaptureFormat(width: Int, height: Int, framerate: Int) {}
override fun dispose() {
this.timer.cancel()
}
private fun createFrame(): VideoFrame {
val captureTimeNs = TimeUnit.MILLISECONDS.toNanos(SystemClock.elapsedRealtime())
val buffer = JavaI420Buffer.allocate(this.frameWidth, this.frameHeight)
encodeYUV420SP(buffer, this.color, frameWidth, frameHeight)
this.color = (color + 1) % 0xFFFFFF
return VideoFrame(buffer, 0, captureTimeNs)
}
override fun isScreencast(): Boolean {
return false
}
companion object {
// adapted from https://stackoverflow.com/a/13055615/295675
fun encodeYUV420SP(yuvBuffer: JavaI420Buffer, color: Int, width: Int, height: Int) {
var yIndex = 0
var uvIndex = 0
var R: Int
var G: Int
var B: Int
var Y: Int
var U: Int
var V: Int
val dataY: ByteBuffer = yuvBuffer.dataY
val dataU = yuvBuffer.dataU
val dataV = yuvBuffer.dataV
var index = 0
for (j in 0 until height) {
for (i in 0 until width) {
R = color and 0xff0000 shr 16
G = color and 0xff00 shr 8
B = color and 0xff shr 0
// well known RGB to YUV algorithm
Y = (66 * R + 129 * G + 25 * B + 128 shr 8) + 16
U = (-38 * R - 74 * G + 112 * B + 128 shr 8) + 128
V = (112 * R - 94 * G - 18 * B + 128 shr 8) + 128
// NV21 has a plane of Y and interleaved planes of VU each sampled by a factor of 2
// meaning for every 4 Y pixels there are 1 V and 1 U. Note the sampling is every other
// pixel AND every other scanline.
dataY[yIndex++] = (if (Y < 0) 0 else if (Y > 255) 255 else Y).toByte()
if (j % 2 == 0 && index % 2 == 0) {
dataV[uvIndex] = (if (V < 0) 0 else if (V > 255) 255 else V).toByte()
dataU[uvIndex] = (if (U < 0) 0 else if (U > 255) 255 else U).toByte()
uvIndex++
}
index++
}
}
}
}
}
private operator fun ByteBuffer.set(index: Int, value: Byte) {
put(index, value)
}
... ...
package io.livekit.android.videoencodedecode
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.google.accompanist.pager.ExperimentalPagerApi
import io.livekit.android.composesample.ui.theme.AppTheme
@ExperimentalPagerApi
class MainActivity : ComponentActivity() {
val viewModel by viewModels<MainViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestPermissions()
setContent {
MainContent(
defaultUrl = viewModel.getSavedUrl(),
defaultToken1 = viewModel.getSavedToken1(),
defaultToken2 = viewModel.getSavedToken2(),
onConnect = { url, token1, token2, ->
val intent = Intent(this@MainActivity, CallActivity::class.java).apply {
putExtra(
CallActivity.KEY_ARGS,
CallActivity.BundleArgs(
url,
token1,
token2,
)
)
}
startActivity(intent)
},
onSave = { url, token1, token2 ->
viewModel.setSavedUrl(url)
viewModel.setSavedToken1(token1)
viewModel.setSavedToken2(token2)
Toast.makeText(
this@MainActivity,
"Values saved.",
Toast.LENGTH_SHORT
).show()
},
onReset = {
viewModel.reset()
Toast.makeText(
this@MainActivity,
"Values reset.",
Toast.LENGTH_SHORT
).show()
}
)
}
}
@Preview(
showBackground = true,
showSystemUi = true,
)
@Composable
fun MainContent(
defaultUrl: String = MainViewModel.URL,
defaultToken1: String = MainViewModel.TOKEN1,
defaultToken2: String = MainViewModel.TOKEN2,
onConnect: (url: String, token1: String, token2: String) -> Unit = { _, _, _ -> },
onSave: (url: String, token1: String, token2: String) -> Unit = { _, _, _ -> },
onReset: () -> Unit = {},
) {
AppTheme {
var url by remember { mutableStateOf(defaultUrl) }
var token1 by remember { mutableStateOf(defaultToken1) }
var token2 by remember { mutableStateOf(defaultToken2) }
// A surface container using the 'background' color from the theme
Surface(
color = MaterialTheme.colors.background,
modifier = Modifier.fillMaxSize()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(10.dp)
) {
Spacer(modifier = Modifier.height(50.dp))
Image(
painter = painterResource(id = R.drawable.banner_dark),
contentDescription = "",
)
Spacer(modifier = Modifier.height(20.dp))
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("URL") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(20.dp))
OutlinedTextField(
value = token1,
onValueChange = { token1 = it },
label = { Text("Token1") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(20.dp))
OutlinedTextField(
value = token2,
onValueChange = { token2 = it },
label = { Text("Token2") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(20.dp))
Button(onClick = { onConnect(url, token1, token2) }) {
Text("Connect")
}
Spacer(modifier = Modifier.height(20.dp))
Button(onClick = { onSave(url, token1, token2) }) {
Text("Save Values")
}
Spacer(modifier = Modifier.height(20.dp))
Button(onClick = {
onReset()
url = MainViewModel.URL
token1 = MainViewModel.TOKEN1
token2 = MainViewModel.TOKEN2
}) {
Text("Reset Values")
}
}
}
}
}
private fun requestPermissions() {
val requestPermissionLauncher =
registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { grants ->
for (grant in grants.entries) {
if (!grant.value) {
Toast.makeText(
this,
"Missing permission: ${grant.key}",
Toast.LENGTH_SHORT
)
.show()
}
}
}
val neededPermissions = listOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
.filter {
ContextCompat.checkSelfPermission(
this,
it
) == PackageManager.PERMISSION_DENIED
}
.toTypedArray()
if (neededPermissions.isNotEmpty()) {
requestPermissionLauncher.launch(neededPermissions)
}
}
}
... ...
package io.livekit.android.videoencodedecode
import android.app.Application
import androidx.core.content.edit
import androidx.lifecycle.AndroidViewModel
import androidx.preference.PreferenceManager
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val preferences = PreferenceManager.getDefaultSharedPreferences(application)
fun getSavedUrl() = preferences.getString(PREFERENCES_KEY_URL, URL) as String
fun getSavedToken1() = preferences.getString(PREFERENCES_KEY_TOKEN1, TOKEN1) as String
fun getSavedToken2() = preferences.getString(PREFERENCES_KEY_TOKEN2, TOKEN2) as String
fun setSavedUrl(url: String) {
preferences.edit {
putString(PREFERENCES_KEY_URL, url)
}
}
fun setSavedToken1(token: String) {
preferences.edit {
putString(PREFERENCES_KEY_TOKEN1, token)
}
}
fun setSavedToken2(token: String) {
preferences.edit {
putString(PREFERENCES_KEY_TOKEN2, token)
}
}
fun reset() {
preferences.edit { clear() }
}
companion object {
private const val PREFERENCES_KEY_URL = "url"
private const val PREFERENCES_KEY_TOKEN1 = "token1"
private const val PREFERENCES_KEY_TOKEN2 = "token2"
const val URL = ""
const val TOKEN1 =
""
const val TOKEN2 =
""
}
}
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.material.Icon
import androidx.compose.material.Surface
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
import androidx.constraintlayout.compose.Dimension
import io.livekit.android.composesample.ui.theme.BlueMain
import io.livekit.android.composesample.ui.theme.NoVideoBackground
import io.livekit.android.room.Room
import io.livekit.android.room.participant.ConnectionQuality
import io.livekit.android.room.participant.Participant
import io.livekit.android.util.flow
/**
* Widget for displaying a participant.
*/
@Composable
fun ParticipantItem(
room: Room,
participant: Participant,
modifier: Modifier = Modifier,
isSpeaking: Boolean,
) {
val identity by participant::identity.flow.collectAsState()
val videoTracks by participant::videoTracks.flow.collectAsState()
val audioTracks by participant::audioTracks.flow.collectAsState()
val identityBarPadding = 4.dp
ConstraintLayout(
modifier = modifier.background(NoVideoBackground)
.run {
if (isSpeaking) {
border(2.dp, BlueMain)
} else {
this
}
}
) {
val (videoItem, identityBar, identityText, muteIndicator, connectionIndicator) = createRefs()
VideoItemTrackSelector(
room = room,
participant = participant,
modifier = Modifier.constrainAs(videoItem) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
height = Dimension.fillToConstraints
}
)
Surface(
color = Color(0x80000000),
modifier = Modifier.constrainAs(identityBar) {
bottom.linkTo(parent.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
width = Dimension.fillToConstraints
height = Dimension.value(30.dp)
}
) {}
Text(
text = identity ?: "",
color = Color.White,
modifier = Modifier.constrainAs(identityText) {
top.linkTo(identityBar.top)
bottom.linkTo(identityBar.bottom)
start.linkTo(identityBar.start, margin = identityBarPadding)
end.linkTo(muteIndicator.end, margin = 10.dp)
width = Dimension.fillToConstraints
height = Dimension.wrapContent
},
)
val isMuted = audioTracks.values.none { it.track != null && !it.muted }
if (isMuted) {
Icon(
painter = painterResource(id = R.drawable.outline_mic_off_24),
contentDescription = "",
tint = Color.Red,
modifier = Modifier.constrainAs(muteIndicator) {
top.linkTo(identityBar.top)
bottom.linkTo(identityBar.bottom)
end.linkTo(identityBar.end, margin = identityBarPadding)
}
)
}
val connectionQuality by participant::connectionQuality.flow.collectAsState()
val connectionIcon = when (connectionQuality) {
ConnectionQuality.EXCELLENT -> R.drawable.wifi_strength_4
ConnectionQuality.GOOD -> R.drawable.wifi_strength_3
ConnectionQuality.POOR -> R.drawable.wifi_strength_alert_outline
ConnectionQuality.UNKNOWN -> R.drawable.wifi_strength_alert_outline
}
if (connectionQuality == ConnectionQuality.POOR) {
Icon(
painter = painterResource(id = connectionIcon),
contentDescription = "",
tint = Color.Red,
modifier = Modifier
.constrainAs(connectionIndicator) {
top.linkTo(parent.top, margin = identityBarPadding)
end.linkTo(parent.end, margin = identityBarPadding)
}
.alpha(0.5f)
)
}
}
}
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode
import android.app.Application
import io.livekit.android.LiveKit
import io.livekit.android.util.LoggingLevel
class SampleApplication : Application() {
override fun onCreate() {
super.onCreate()
LiveKit.loggingLevel = LoggingLevel.VERBOSE
}
}
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material.Icon
import androidx.compose.material.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import io.livekit.android.renderer.TextureViewRenderer
import io.livekit.android.room.Room
import io.livekit.android.room.participant.Participant
import io.livekit.android.room.track.RemoteVideoTrack
import io.livekit.android.room.track.Track
import io.livekit.android.room.track.VideoTrack
import io.livekit.android.room.track.video.ComposeVisibility
import io.livekit.android.util.flow
import kotlinx.coroutines.flow.*
/**
* Widget for displaying a VideoTrack. Handles the Compose <-> AndroidView interop needed to use
* [TextureViewRenderer].
*/
@Composable
fun VideoItem(
room: Room,
videoTrack: VideoTrack,
modifier: Modifier = Modifier
) {
val videoSinkVisibility = remember(room, videoTrack) { ComposeVisibility() }
var boundVideoTrack by remember { mutableStateOf<VideoTrack?>(null) }
var videoView: TextureViewRenderer? by remember { mutableStateOf(null) }
var receivedVideoFrames by remember { mutableStateOf(false) }
fun cleanupVideoTrack() {
videoView?.let { boundVideoTrack?.removeRenderer(it) }
boundVideoTrack = null
}
fun setupVideoIfNeeded(videoTrack: VideoTrack, view: TextureViewRenderer) {
if (boundVideoTrack == videoTrack) {
return
}
cleanupVideoTrack()
boundVideoTrack = videoTrack
if (videoTrack is RemoteVideoTrack) {
videoTrack.addRenderer(view, videoSinkVisibility)
} else {
videoTrack.addRenderer(view)
}
}
DisposableEffect(room, videoTrack) {
onDispose {
videoSinkVisibility.onDispose()
cleanupVideoTrack()
}
}
DisposableEffect(currentCompositeKeyHash.toString()) {
onDispose {
videoView?.release()
}
}
Box(
modifier = modifier
) {
AndroidView(
factory = { context ->
TextureViewRenderer(context).apply {
room.initVideoRenderer(this)
setupVideoIfNeeded(videoTrack, this)
this.addFrameListener({
if (!receivedVideoFrames) {
receivedVideoFrames = true
}
}, 0f)
videoView = this
}
},
update = { view ->
setupVideoIfNeeded(videoTrack, view)
},
modifier = Modifier
.fillMaxSize()
.onGloballyPositioned { videoSinkVisibility.onGloballyPositioned(it) },
)
// Frame Indicator
if (receivedVideoFrames) {
val frameIndicatorColor = Color.Green
Surface(
color = frameIndicatorColor,
modifier = Modifier
.size(40.dp)
.semantics { testTag = VIDEO_FRAME_INDICATOR }
) {}
}
}
}
const val VIDEO_FRAME_INDICATOR = "frame_indicator"
/**
* This widget primarily serves as a way to observe changes in [videoTracks].
*/
@Composable
fun VideoItemTrackSelector(
room: Room,
participant: Participant,
modifier: Modifier = Modifier
) {
val subscribedVideoTracksFlow by remember(participant) {
mutableStateOf(
participant::videoTracks.flow
.flatMapLatest { videoTracks ->
combine(
videoTracks.values
.map { trackPublication ->
// Re-emit when track changes
trackPublication::track.flow
.map { trackPublication }
}
) { trackPubs ->
trackPubs.toList().filter { trackPublication -> trackPublication.track != null }
}
}
)
}
val videoTracks by subscribedVideoTracksFlow.collectAsState(initial = emptyList())
val videoTrack = videoTracks.firstOrNull { pub -> pub.source == Track.Source.SCREEN_SHARE }?.track as? VideoTrack
?: videoTracks.firstOrNull { pub -> pub.source == Track.Source.CAMERA }?.track as? VideoTrack
?: videoTracks.firstOrNull()?.track as? VideoTrack
if (videoTrack != null) {
VideoItem(
room = room,
videoTrack = videoTrack,
modifier = modifier
)
} else {
Box(modifier = modifier) {
Icon(
painter = painterResource(id = R.drawable.outline_videocam_off_24),
contentDescription = null,
tint = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}
}
}
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode
import androidx.activity.viewModels
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
typealias CreateViewModel<VM> = () -> VM
inline fun <reified VM : ViewModel> FragmentActivity.viewModelByFactory(
noinline create: CreateViewModel<VM>
): Lazy<VM> {
return viewModels {
createViewModelFactoryFactory(create)
}
}
fun <VM> createViewModelFactoryFactory(
create: CreateViewModel<VM>
): ViewModelProvider.Factory {
return object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return create() as? T
?: throw IllegalArgumentException("Unknown viewmodel class!")
}
}
}
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode
import com.github.ajalt.timberkt.Timber
import org.webrtc.DefaultVideoEncoderFactory
import org.webrtc.EglBase
import org.webrtc.VideoCodecInfo
class WhitelistDefaultVideoEncoderFactory(
eglContext: EglBase.Context?,
enableIntelVp8Encoder: Boolean,
enableH264HighProfile: Boolean
) : DefaultVideoEncoderFactory(eglContext, enableIntelVp8Encoder, enableH264HighProfile), WhitelistEncoderFactory {
override var codecWhitelist: List<String>? = null
override fun getSupportedCodecs(): Array<VideoCodecInfo> {
val supportedCodecs = super.getSupportedCodecs()
Timber.v { "actual supported codecs: ${supportedCodecs.map { it.name }}" }
val filteredCodecs = supportedCodecs.filter { codecWhitelist?.contains(it.name) ?: true }.toTypedArray()
Timber.v { "filtered supported codecs: ${filteredCodecs.map { it.name }}" }
return filteredCodecs
}
}
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode
interface WhitelistEncoderFactory {
var codecWhitelist: List<String>?
}
\ No newline at end of file
... ...
package io.livekit.android.videoencodedecode
import com.github.ajalt.timberkt.Timber
import io.livekit.android.webrtc.SimulcastVideoEncoderFactoryWrapper
import org.webrtc.EglBase
import org.webrtc.VideoCodecInfo
class WhitelistSimulcastVideoEncoderFactory(
sharedContext: EglBase.Context?,
enableIntelVp8Encoder: Boolean,
enableH264HighProfile: Boolean
) : SimulcastVideoEncoderFactoryWrapper(sharedContext, enableIntelVp8Encoder, enableH264HighProfile),
WhitelistEncoderFactory {
override var codecWhitelist: List<String>? = null
override fun getSupportedCodecs(): Array<VideoCodecInfo> {
val supportedCodecs = super.getSupportedCodecs()
Timber.v { "actual supported codecs: ${supportedCodecs.map { it.name }}" }
val filteredCodecs = supportedCodecs.filter { codecWhitelist?.contains(it.name) ?: true }.toTypedArray()
Timber.v { "filtered supported codecs: ${filteredCodecs.map { it.name }}" }
return filteredCodecs
}
}
\ No newline at end of file
... ...
package io.livekit.android.composesample.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
@Preview
@Composable
fun DebugMenuDialog(
onDismissRequest: () -> Unit = {},
simulateMigration: () -> Unit = {},
fullReconnect: () -> Unit = {},
) {
Dialog(onDismissRequest = onDismissRequest) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.background(Color.DarkGray, shape = RoundedCornerShape(3.dp))
.fillMaxWidth()
.padding(10.dp)
) {
Text("Debug Menu", color = Color.White)
Spacer(modifier = Modifier.height(10.dp))
Button(onClick = {
simulateMigration()
onDismissRequest()
}) {
Text("Simulate Migration")
}
Button(onClick = {
fullReconnect()
onDismissRequest()
}) {
Text("Reconnect to room")
}
}
}
}
\ No newline at end of file
... ...
package io.livekit.android.composesample.ui.theme
import androidx.compose.ui.graphics.Color
val BlueMain = Color(0xFF007DFF)
val BlueDark = Color(0xFF0058B3)
val BlueLight = Color(0xFF66B1FF)
val NoVideoIconTint = Color(0xFF5A8BFF)
val NoVideoBackground = Color(0xFF00153C)
\ No newline at end of file
... ...
package io.livekit.android.composesample.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Shapes
import androidx.compose.ui.unit.dp
val Shapes = Shapes(
small = RoundedCornerShape(4.dp),
medium = RoundedCornerShape(4.dp),
large = RoundedCornerShape(0.dp)
)
\ No newline at end of file
... ...
package io.livekit.android.composesample.ui.theme
import androidx.compose.material.MaterialTheme
import androidx.compose.material.darkColors
import androidx.compose.material.lightColors
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val DarkColorPalette = darkColors(
primary = BlueMain,
primaryVariant = BlueDark,
secondary = BlueMain,
background = Color.Black,
surface = Color.Transparent,
onPrimary = Color.White,
onSecondary = Color.White,
onBackground = Color.White,
onSurface = Color.White,
)
private val LightColorPalette = lightColors(
primary = BlueMain,
primaryVariant = BlueDark,
secondary = BlueMain,
/* Other default colors to override
background = Color.White,
surface = Color.White,
onPrimary = Color.White,
onSecondary = Color.Black,
onBackground = Color.Black,
onSurface = Color.Black,
*/
)
@Composable
fun AppTheme(
darkTheme: Boolean = true,
content: @Composable() () -> Unit
) {
val colors = if (darkTheme) {
DarkColorPalette
} else {
LightColorPalette
}
MaterialTheme(
colors = colors,
typography = Typography,
shapes = Shapes,
content = content
)
}
\ No newline at end of file
... ...
package io.livekit.android.composesample.ui.theme
import androidx.compose.material.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
body1 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp
)
/* Other default text styles to override
button = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.W500,
fontSize = 14.sp
),
caption = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp
)
*/
)
\ 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"?>
<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
... ...
<?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">Video Encode Decode Tester</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/colorPrimary</item>
<item name="colorPrimaryVariant">@color/colorPrimaryDark</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
<style name="Theme.Livekitandroid.NoActionBar">
<item name="windowActionBar">false</item>
<item name="windowNoTitle">true</item>
</style>
<style name="Theme.Livekitandroid.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar" />
<style name="Theme.Livekitandroid.PopupOverlay" parent="ThemeOverlay.AppCompat.Light" />
</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
... ...