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,8 +29,65 @@ jobs: @@ -29,8 +29,65 @@ jobs:
29 run: chmod +x gradlew 29 run: chmod +x gradlew
30 30
31 - name: Build with Gradle 31 - name: Build with Gradle
32 - run: ./gradlew livekit-android-sdk:assembleRelease livekit-android-sdk:testRelease 32 + run: ./gradlew clean livekit-android-sdk:assembleRelease livekit-android-sdk:testRelease
33 33
  34 + - name: Import video test keys into gradle properties
  35 + run: |
  36 + sed -i -e "s,livekitUrl=,livekitUrl=$LIVEKIT_URL,g" gradle.properties
  37 + sed -i -e "s,livekitApiKey=,livekitApiKey=$LIVEKIT_API_KEY,g" gradle.properties
  38 + sed -i -e "s,livekitApiSecret=,livekitApiSecret=$LIVEKIT_API_SECRET,g" gradle.properties
  39 + env:
  40 + LIVEKIT_URL: ${{ secrets.LIVEKIT_URL }}
  41 + LIVEKIT_API_KEY: ${{ secrets.LIVEKIT_API_KEY }}
  42 + LIVEKIT_API_SECRET: ${{ secrets.LIVEKIT_API_SECRET }}
  43 +
  44 + - name: Build video test app
  45 + run: ./gradlew video-encode-decode-test:assembleDebug video-encode-decode-test:assembleDebugAndroidTest
  46 +
  47 + - name: Video Encode Decode App upload and Set app id in environment variable.
  48 + run: |
  49 + 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")
  50 + echo "APP_UPLOAD_RESPONSE: $APP_UPLOAD_RESPONSE"
  51 + APP_ID=$(echo $APP_UPLOAD_RESPONSE | jq -r ".app_url")
  52 + if [ $APP_ID != null ]; then
  53 + echo "Apk uploaded to BrowserStack with app id : $APP_ID";
  54 + echo "BROWSERSTACK_APP_ID=$APP_ID" >> $GITHUB_ENV;
  55 + echo "Setting value of BROWSERSTACK_APP_ID in environment variables to $APP_ID";
  56 + else
  57 + UPLOAD_ERROR_MESSAGE=$(echo $APP_UPLOAD_RESPONSE | jq -r ".error")
  58 + echo "App upload failed, reason : ",$UPLOAD_ERROR_MESSAGE
  59 + exit 1;
  60 + fi
  61 + env:
  62 + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
  63 + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
  64 +
  65 + - name: Video Encode Decode Test Suite upload and Set test suite id in environment variable.
  66 + run: |
  67 + 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")
  68 + echo "APP_UPLOAD_RESPONSE: $APP_UPLOAD_RESPONSE"
  69 + APP_ID=$(echo $APP_UPLOAD_RESPONSE | jq -r ".test_suite_url")
  70 + if [ $APP_ID != null ]; then
  71 + echo "Test Suite Apk uploaded to BrowserStack with id: $APP_ID";
  72 + echo "BROWSERSTACK_TEST_ID=$APP_ID" >> $GITHUB_ENV;
  73 + echo "Setting value of BROWSERSTACK_TEST_ID in environment variables to $APP_ID";
  74 + else
  75 + UPLOAD_ERROR_MESSAGE=$(echo $APP_UPLOAD_RESPONSE | jq -r ".error")
  76 + echo "App upload failed, reason : ",$UPLOAD_ERROR_MESSAGE
  77 + exit 1;
  78 + fi
  79 + env:
  80 + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
  81 + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
  82 +
  83 + - name: Trigger BrowserStack tests
  84 + run: |
  85 + 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")
  86 + echo "TEST_RESPONSE: $TEST_RESPONSE"
  87 + env:
  88 + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
  89 + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
  90 +
34 - name: get version name 91 - name: get version name
35 if: github.event_name == 'push' 92 if: github.event_name == 'push'
36 run: echo "::set-output name=version_name::$(cat gradle.properties | grep VERSION_NAME | cut -d "=" -f2)" 93 run: echo "::set-output name=version_name::$(cat gradle.properties | grep VERSION_NAME | cut -d "=" -f2)"
@@ -50,3 +50,10 @@ nexusPassword= @@ -50,3 +50,10 @@ nexusPassword=
50 signing.keyId= 50 signing.keyId=
51 signing.password= 51 signing.password=
52 signing.secretKeyRingFile= 52 signing.secretKeyRingFile=
  53 +
  54 +# For instrumented tests.
  55 +# WARNING: Do not edit this and potentially commit to repo.
  56 +# Instead, override in ~/.gradle/gradle.properties
  57 +livekitUrl=
  58 +livekitApiKey=
  59 +livekitApiSecret=
@@ -26,4 +26,5 @@ object InjectionNames { @@ -26,4 +26,5 @@ object InjectionNames {
26 26
27 // Overrides 27 // Overrides
28 internal const val OVERRIDE_OKHTTP = "override_okhttp" 28 internal const val OVERRIDE_OKHTTP = "override_okhttp"
  29 + internal const val OVERRIDE_VIDEO_ENCODER_FACTORY = "override_video_encoder_factory"
29 } 30 }
@@ -8,6 +8,7 @@ import io.livekit.android.room.Room @@ -8,6 +8,7 @@ import io.livekit.android.room.Room
8 import okhttp3.OkHttpClient 8 import okhttp3.OkHttpClient
9 import org.webrtc.EglBase 9 import org.webrtc.EglBase
10 import org.webrtc.PeerConnectionFactory 10 import org.webrtc.PeerConnectionFactory
  11 +import org.webrtc.VideoEncoderFactory
11 import javax.inject.Named 12 import javax.inject.Named
12 import javax.inject.Singleton 13 import javax.inject.Singleton
13 14
@@ -36,7 +37,12 @@ internal interface LiveKitComponent { @@ -36,7 +37,12 @@ internal interface LiveKitComponent {
36 @BindsInstance 37 @BindsInstance
37 @Named(InjectionNames.OVERRIDE_OKHTTP) 38 @Named(InjectionNames.OVERRIDE_OKHTTP)
38 @Nullable 39 @Nullable
39 - okHttpClientOverride: OkHttpClient? 40 + okHttpClientOverride: OkHttpClient?,
  41 +
  42 + @BindsInstance
  43 + @Named(InjectionNames.OVERRIDE_VIDEO_ENCODER_FACTORY)
  44 + @Nullable
  45 + videoEncoderFactory: VideoEncoderFactory?,
40 ): LiveKitComponent 46 ): LiveKitComponent
41 } 47 }
42 } 48 }
@@ -47,7 +53,8 @@ internal fun LiveKitComponent.Factory.create( @@ -47,7 +53,8 @@ internal fun LiveKitComponent.Factory.create(
47 ): LiveKitComponent { 53 ): LiveKitComponent {
48 return create( 54 return create(
49 appContext = context, 55 appContext = context,
50 - okHttpClientOverride = overrides.okHttpClient 56 + okHttpClientOverride = overrides.okHttpClient,
  57 + videoEncoderFactory = overrides.videoEncoderFactory,
51 ) 58 )
52 } 59 }
53 60
@@ -55,5 +62,6 @@ internal fun LiveKitComponent.Factory.create( @@ -55,5 +62,6 @@ internal fun LiveKitComponent.Factory.create(
55 * Overrides to replace LiveKit internally used component with custom implementations. 62 * Overrides to replace LiveKit internally used component with custom implementations.
56 */ 63 */
57 data class LiveKitOverrides( 64 data class LiveKitOverrides(
58 - val okHttpClient: OkHttpClient? = null 65 + val okHttpClient: OkHttpClient? = null,
  66 + val videoEncoderFactory: VideoEncoderFactory? = null,
59 ) 67 )
1 package io.livekit.android.dagger 1 package io.livekit.android.dagger
2 2
3 import android.content.Context 3 import android.content.Context
  4 +import androidx.annotation.Nullable
4 import dagger.Module 5 import dagger.Module
5 import dagger.Provides 6 import dagger.Provides
6 import io.livekit.android.util.LKLog 7 import io.livekit.android.util.LKLog
@@ -99,10 +100,12 @@ object RTCModule { @@ -99,10 +100,12 @@ object RTCModule {
99 fun videoEncoderFactory( 100 fun videoEncoderFactory(
100 @Named(InjectionNames.OPTIONS_VIDEO_HW_ACCEL) 101 @Named(InjectionNames.OPTIONS_VIDEO_HW_ACCEL)
101 videoHwAccel: Boolean, 102 videoHwAccel: Boolean,
102 - eglContext: EglBase.Context 103 + eglContext: EglBase.Context,
  104 + @Named(InjectionNames.OVERRIDE_VIDEO_ENCODER_FACTORY)
  105 + @Nullable
  106 + videoEncoderFactoryOverride: VideoEncoderFactory?
103 ): VideoEncoderFactory { 107 ): VideoEncoderFactory {
104 -  
105 - return if (videoHwAccel) { 108 + return videoEncoderFactoryOverride ?: if (videoHwAccel) {
106 SimulcastVideoEncoderFactoryWrapper( 109 SimulcastVideoEncoderFactoryWrapper(
107 eglContext, 110 eglContext,
108 enableIntelVp8Encoder = true, 111 enableIntelVp8Encoder = true,
@@ -19,10 +19,7 @@ import kotlinx.coroutines.CoroutineDispatcher @@ -19,10 +19,7 @@ import kotlinx.coroutines.CoroutineDispatcher
19 import kotlinx.coroutines.cancel 19 import kotlinx.coroutines.cancel
20 import livekit.LivekitModels 20 import livekit.LivekitModels
21 import livekit.LivekitRtc 21 import livekit.LivekitRtc
22 -import org.webrtc.EglBase  
23 -import org.webrtc.PeerConnectionFactory  
24 -import org.webrtc.RtpParameters  
25 -import org.webrtc.RtpTransceiver 22 +import org.webrtc.*
26 import javax.inject.Named 23 import javax.inject.Named
27 import kotlin.math.max 24 import kotlin.math.max
28 25
@@ -78,6 +75,25 @@ internal constructor( @@ -78,6 +75,25 @@ internal constructor(
78 } 75 }
79 76
80 /** 77 /**
  78 + * Creates a video track, recording video through the supplied [capturer]
  79 + */
  80 + fun createVideoTrack(
  81 + name: String = "",
  82 + capturer: VideoCapturer,
  83 + options: LocalVideoTrackOptions = videoTrackCaptureDefaults.copy(),
  84 + ): LocalVideoTrack {
  85 + return LocalVideoTrack.createTrack(
  86 + peerConnectionFactory = peerConnectionFactory,
  87 + context = context,
  88 + name = name,
  89 + capturer = capturer,
  90 + options = LocalVideoTrackOptions(),
  91 + rootEglBase = eglBase,
  92 + trackFactory = videoTrackFactory,
  93 + )
  94 + }
  95 +
  96 + /**
81 * Creates a video track, recording video through the camera with the given [options]. 97 * Creates a video track, recording video through the camera with the given [options].
82 * 98 *
83 * @exception SecurityException will be thrown if [Manifest.permission.CAMERA] permission is missing. 99 * @exception SecurityException will be thrown if [Manifest.permission.CAMERA] permission is missing.
@@ -125,6 +125,31 @@ constructor( @@ -125,6 +125,31 @@ constructor(
125 peerConnectionFactory: PeerConnectionFactory, 125 peerConnectionFactory: PeerConnectionFactory,
126 context: Context, 126 context: Context,
127 name: String, 127 name: String,
  128 + capturer: VideoCapturer,
  129 + options: LocalVideoTrackOptions = LocalVideoTrackOptions(),
  130 + rootEglBase: EglBase,
  131 + trackFactory: Factory
  132 + ): LocalVideoTrack {
  133 + val source = peerConnectionFactory.createVideoSource(false)
  134 + capturer.initialize(
  135 + SurfaceTextureHelper.create("VideoCaptureThread", rootEglBase.eglBaseContext),
  136 + context,
  137 + source.capturerObserver
  138 + )
  139 + val track = peerConnectionFactory.createVideoTrack(UUID.randomUUID().toString(), source)
  140 +
  141 + return trackFactory.create(
  142 + capturer = capturer,
  143 + source = source,
  144 + options = options,
  145 + name = name,
  146 + rtcTrack = track
  147 + )
  148 + }
  149 + internal fun createTrack(
  150 + peerConnectionFactory: PeerConnectionFactory,
  151 + context: Context,
  152 + name: String,
128 options: LocalVideoTrackOptions, 153 options: LocalVideoTrackOptions,
129 rootEglBase: EglBase, 154 rootEglBase: EglBase,
130 trackFactory: Factory 155 trackFactory: Factory
@@ -22,7 +22,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -22,7 +22,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
22 See the License for the specific language governing permissions and 22 See the License for the specific language governing permissions and
23 limitations under the License. 23 limitations under the License.
24 */ 24 */
25 -internal class SimulcastVideoEncoderFactoryWrapper( 25 +open class SimulcastVideoEncoderFactoryWrapper(
26 sharedContext: EglBase.Context?, 26 sharedContext: EglBase.Context?,
27 enableIntelVp8Encoder: Boolean, 27 enableIntelVp8Encoder: Boolean,
28 enableH264HighProfile: Boolean 28 enableH264HighProfile: Boolean
@@ -43,7 +43,7 @@ dependencies { @@ -43,7 +43,7 @@ dependencies {
43 api "androidx.lifecycle:lifecycle-common-java8:${versions.androidx_lifecycle}" 43 api "androidx.lifecycle:lifecycle-common-java8:${versions.androidx_lifecycle}"
44 api "com.google.protobuf:protobuf-javalite:${versions.protobuf}" 44 api "com.google.protobuf:protobuf-javalite:${versions.protobuf}"
45 api project(":livekit-android-sdk") 45 api project(":livekit-android-sdk")
46 - implementation 'androidx.preference:preference-ktx:1.1.1' 46 + api 'androidx.preference:preference-ktx:1.1.1'
47 // debugImplementation because LeakCanary should only run in debug builds. 47 // debugImplementation because LeakCanary should only run in debug builds.
48 debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1' 48 debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.8.1'
49 testImplementation 'junit:junit:4.+' 49 testImplementation 'junit:junit:4.+'
@@ -8,3 +8,4 @@ include ':sample-app', ':sample-app-compose', ':livekit-android-sdk' @@ -8,3 +8,4 @@ include ':sample-app', ':sample-app-compose', ':livekit-android-sdk'
8 rootProject.name='livekit-android' 8 rootProject.name='livekit-android'
9 include ':sample-app-common' 9 include ':sample-app-common'
10 include ':livekit-lint' 10 include ':livekit-lint'
  11 +include ':video-encode-decode-test'
  1 +plugins {
  2 + id 'com.android.application'
  3 + id 'kotlin-android'
  4 + id 'kotlin-parcelize'
  5 +}
  6 +
  7 +def getServerUrl() {
  8 + return hasProperty('livekitUrl') ? livekitUrl : ""
  9 +}
  10 +
  11 +def getApiKey() {
  12 + return hasProperty('livekitApiKey') ? livekitApiKey : ""
  13 +}
  14 +
  15 +def getApiSecret() {
  16 + return hasProperty('livekitApiSecret') ? livekitApiSecret : ""
  17 +}
  18 +
  19 +final serverUrl = getServerUrl()
  20 +final apiKey = getApiKey()
  21 +final apiSecret = getApiSecret()
  22 +
  23 +android {
  24 + compileSdkVersion androidSdk.compileVersion
  25 +
  26 + defaultConfig {
  27 + applicationId "io.livekit.android.videoencodedecodetest"
  28 + minSdkVersion androidSdk.minVersion
  29 + targetSdkVersion androidSdk.targetVersion
  30 + versionCode 1
  31 + versionName "1.0"
  32 +
  33 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
  34 + vectorDrawables {
  35 + useSupportLibrary true
  36 + }
  37 + }
  38 +
  39 + buildTypes {
  40 + release {
  41 + minifyEnabled false
  42 + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
  43 + buildConfigField "String", "SERVER_URL", "\"$serverUrl\""
  44 + buildConfigField "String", "API_KEY", "\"$apiKey\""
  45 + buildConfigField "String", "API_SECRET", "\"$apiSecret\""
  46 + }
  47 +
  48 + debug {
  49 + buildConfigField "String", "SERVER_URL", "\"$serverUrl\""
  50 + buildConfigField "String", "API_KEY", "\"$apiKey\""
  51 + buildConfigField "String", "API_SECRET", "\"$apiSecret\""
  52 + }
  53 + }
  54 + compileOptions {
  55 + sourceCompatibility java_version
  56 + targetCompatibility java_version
  57 + }
  58 + kotlinOptions {
  59 + jvmTarget = java_version
  60 + freeCompilerArgs += '-Xopt-in=kotlin.RequiresOptIn'
  61 + }
  62 + buildFeatures {
  63 + compose true
  64 + }
  65 + composeOptions {
  66 + kotlinCompilerExtensionVersion compose_compiler_version
  67 + }
  68 + packagingOptions {
  69 + resources {
  70 + excludes += '/META-INF/{AL2.0,LGPL2.1}'
  71 + }
  72 + }
  73 +}
  74 +
  75 +dependencies {
  76 + implementation fileTree(dir: 'libs', include: ['*.jar'])
  77 + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
  78 + implementation deps.coroutines.lib
  79 + implementation "androidx.core:core-ktx:${versions.androidx_core}"
  80 + implementation 'androidx.appcompat:appcompat:1.3.1'
  81 + implementation 'com.google.android.material:material:1.4.0'
  82 + implementation "androidx.compose.ui:ui:$compose_version"
  83 + implementation "androidx.compose.material:material:$compose_version"
  84 + implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
  85 + implementation "androidx.compose.runtime:runtime-livedata:$compose_version"
  86 + implementation "androidx.constraintlayout:constraintlayout-compose:1.0.0-rc01"
  87 + implementation "androidx.lifecycle:lifecycle-runtime-ktx:${versions.androidx_lifecycle}"
  88 + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${versions.androidx_lifecycle}"
  89 + implementation "androidx.lifecycle:lifecycle-common-java8:${versions.androidx_lifecycle}"
  90 + implementation 'androidx.activity:activity-compose:1.3.1'
  91 + implementation 'com.google.accompanist:accompanist-pager:0.19.0'
  92 + implementation 'com.google.accompanist:accompanist-pager-indicators:0.19.0'
  93 + implementation deps.timber
  94 + implementation project(":sample-app-common")
  95 + implementation project(":livekit-android-sdk")
  96 + testImplementation 'junit:junit:4.+'
  97 + androidTestImplementation 'androidx.test.ext:junit:1.1.3'
  98 + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
  99 + androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"
  100 + androidTestImplementation 'io.jsonwebtoken:jjwt-api:0.11.2'
  101 + androidTestImplementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
  102 + androidTestImplementation "io.jsonwebtoken:jjwt-jackson:0.11.2"
  103 + debugImplementation "androidx.compose.ui:ui-tooling:$compose_version"
  104 +}
  1 +# Add project specific ProGuard rules here.
  2 +# You can control the set of applied configuration files using the
  3 +# proguardFiles setting in build.gradle.
  4 +#
  5 +# For more details, see
  6 +# http://developer.android.com/guide/developing/tools/proguard.html
  7 +
  8 +# If your project uses WebView with JS, uncomment the following
  9 +# and specify the fully qualified class name to the JavaScript interface
  10 +# class:
  11 +#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
  12 +# public *;
  13 +#}
  14 +
  15 +# Uncomment this to preserve the line number information for
  16 +# debugging stack traces.
  17 +#-keepattributes SourceFile,LineNumberTable
  18 +
  19 +# If you keep the line number information, uncomment this to
  20 +# hide the original source file name.
  21 +#-renamesourcefileattribute SourceFile
  1 +package io.livekit.android.videoencodedecode
  2 +
  3 +import android.content.Context
  4 +import android.content.Intent
  5 +import androidx.activity.ComponentActivity
  6 +import androidx.compose.ui.test.junit4.AndroidComposeTestRule
  7 +import androidx.test.core.app.ApplicationProvider
  8 +import androidx.test.ext.junit.rules.ActivityScenarioRule
  9 +
  10 +/**
  11 + * Factory method to provide android specific implementation of createComposeRule, for a given
  12 + * activity class type A that needs to be launched via an intent.
  13 + *
  14 + * @param intentFactory A lambda that provides a Context that can used to create an intent. A intent needs to be returned.
  15 + */
  16 +inline fun <A: ComponentActivity> createAndroidIntentComposeRule(intentFactory: (context: Context) -> Intent) : AndroidComposeTestRule<ActivityScenarioRule<A>, A> {
  17 + val context = ApplicationProvider.getApplicationContext<Context>()
  18 + val intent = intentFactory(context)
  19 +
  20 + return AndroidComposeTestRule(
  21 + activityRule = ActivityScenarioRule(intent),
  22 + activityProvider = { scenarioRule -> scenarioRule.getActivity() }
  23 + )
  24 +}
  25 +
  26 +/**
  27 + * Gets the activity from a scenarioRule.
  28 + *
  29 + * https://androidx.tech/artifacts/compose.ui/ui-test-junit4/1.0.0-alpha11-source/androidx/compose/ui/test/junit4/AndroidIntentComposeTestRule.kt.html
  30 + */
  31 +fun <A : ComponentActivity> ActivityScenarioRule<A>.getActivity(): A {
  32 + var activity: A? = null
  33 +
  34 + scenario.onActivity { activity = it }
  35 +
  36 + return activity ?: throw IllegalStateException("Activity was not set in the ActivityScenarioRule!")
  37 +}
  1 +package io.livekit.android.videoencodedecode
  2 +
  3 +fun randomAlphanumericString(length: Int): String {
  4 + val builder = StringBuilder(length)
  5 + for (i in 0 until length) {
  6 + val value = (0..61).random()
  7 + builder.append(value.toAlphaNumeric())
  8 + }
  9 + return builder.toString()
  10 +}
  11 +
  12 +fun Int.toAlphaNumeric(): Char {
  13 + if (this < 0 || this > 62) {
  14 + throw IllegalArgumentException()
  15 + }
  16 +
  17 + var offset = this
  18 + if (offset < 10) {
  19 + return '0' + offset
  20 + }
  21 +
  22 + offset -= 10
  23 + if (offset < 26) {
  24 + return 'a' + offset
  25 + }
  26 + offset -= 26
  27 + if (offset < 26) {
  28 + return 'A' + offset
  29 + }
  30 + throw IllegalArgumentException()
  31 +}
  1 +package io.livekit.android.videoencodedecode
  2 +
  3 +import androidx.compose.ui.test.junit4.ComposeTestRule
  4 +import androidx.compose.ui.test.onAllNodesWithTag
  5 +import androidx.test.ext.junit.runners.AndroidJUnit4
  6 +import io.jsonwebtoken.Jwts
  7 +import io.jsonwebtoken.SignatureAlgorithm
  8 +import org.junit.Rule
  9 +import org.junit.Test
  10 +import org.junit.runner.RunWith
  11 +import java.util.*
  12 +import javax.crypto.spec.SecretKeySpec
  13 +
  14 +@RunWith(AndroidJUnit4::class)
  15 +abstract class VideoTest {
  16 +
  17 + val roomName = "browserStackTestRoom-${randomAlphanumericString(12)}"
  18 +
  19 + companion object {
  20 + val SERVER_URL = BuildConfig.SERVER_URL
  21 + val API_KEY = BuildConfig.API_KEY
  22 + val API_SECRET = BuildConfig.API_SECRET
  23 + }
  24 +
  25 + private fun createToken(name: String) = Jwts.builder()
  26 + .setIssuer(API_KEY)
  27 + .setExpiration(Date(System.currentTimeMillis() + 1000 * 60 * 60 /* 1hour */))
  28 + .setNotBefore(Date(0))
  29 + .setSubject(name)
  30 + .setId(name)
  31 + .addClaims(
  32 + mapOf(
  33 + "video" to mapOf(
  34 + "roomJoin" to true,
  35 + "room" to roomName
  36 + ),
  37 + "name" to name
  38 + )
  39 + )
  40 + .signWith(
  41 + SecretKeySpec(API_SECRET.toByteArray(), "HmacSHA256"),
  42 + SignatureAlgorithm.HS256
  43 + )
  44 + .compact()
  45 +
  46 + protected val token1 = createToken("phone1")
  47 + protected val token2 = createToken("phone2")
  48 +
  49 + @get:Rule
  50 + abstract val composeTestRule: ComposeTestRule
  51 +
  52 + @Test
  53 + fun videoReceivedTest() {
  54 + composeTestRule.waitForIdle()
  55 + composeTestRule.waitUntil(20 * 1000L) {
  56 + composeTestRule.onAllNodesWithTag(VIDEO_FRAME_INDICATOR).fetchSemanticsNodes(false, "").isNotEmpty()
  57 + }
  58 + }
  59 +
  60 +}
  1 +package io.livekit.android.videoencodedecode.test
  2 +
  3 +import androidx.test.platform.app.InstrumentationRegistry
  4 +import org.junit.Assert
  5 +import org.junit.Before
  6 +import org.junit.Test
  7 +import org.webrtc.DefaultVideoEncoderFactory
  8 +import org.webrtc.EglBase
  9 +import org.webrtc.PeerConnectionFactory
  10 +
  11 +class OfficialCodecSupportTest {
  12 + val defaultVideoEncoderFactory = DefaultVideoEncoderFactory(EglBase.create().eglBaseContext, true, true)
  13 +
  14 + @Before
  15 + fun setup() {
  16 + PeerConnectionFactory.initialize(
  17 + PeerConnectionFactory.InitializationOptions
  18 + .builder(InstrumentationRegistry.getInstrumentation().targetContext)
  19 + .createInitializationOptions()
  20 + )
  21 + }
  22 +
  23 + @Test
  24 + fun isVP8Supported() {
  25 + Assert.assertTrue(defaultVideoEncoderFactory.supportedCodecs.any { it.name == "VP8" })
  26 + }
  27 +
  28 + @Test
  29 + fun isH264Supported() {
  30 + Assert.assertTrue(defaultVideoEncoderFactory.supportedCodecs.any { it.name == "H264" })
  31 + }
  32 +}
  1 +package io.livekit.android.videoencodedecode.test.control
  2 +
  3 +import android.content.Intent
  4 +import androidx.compose.ui.test.junit4.ComposeTestRule
  5 +import io.livekit.android.videoencodedecode.CallActivity
  6 +import io.livekit.android.videoencodedecode.VideoTest
  7 +import io.livekit.android.videoencodedecode.createAndroidIntentComposeRule
  8 +
  9 +class H264DefaultVideoTest : VideoTest() {
  10 + override val composeTestRule: ComposeTestRule = createAndroidIntentComposeRule<CallActivity> { context ->
  11 + Intent(context, CallActivity::class.java).apply {
  12 + putExtra(
  13 + CallActivity.KEY_ARGS,
  14 + CallActivity.BundleArgs(
  15 + SERVER_URL,
  16 + token1,
  17 + token2,
  18 + useDefaultVideoEncoderFactory = true,
  19 + codecWhiteList = listOf("H264")
  20 + )
  21 + )
  22 + }
  23 + }
  24 +}
  1 +package io.livekit.android.videoencodedecode.test.control
  2 +
  3 +import android.content.Intent
  4 +import androidx.compose.ui.test.junit4.ComposeTestRule
  5 +import io.livekit.android.videoencodedecode.CallActivity
  6 +import io.livekit.android.videoencodedecode.VideoTest
  7 +import io.livekit.android.videoencodedecode.createAndroidIntentComposeRule
  8 +
  9 +class NoWhitelistDefaultVideoTest : VideoTest() {
  10 + override val composeTestRule: ComposeTestRule = createAndroidIntentComposeRule<CallActivity> { context ->
  11 + Intent(context, CallActivity::class.java).apply {
  12 + putExtra(
  13 + CallActivity.KEY_ARGS,
  14 + CallActivity.BundleArgs(
  15 + SERVER_URL,
  16 + token1,
  17 + token2,
  18 + useDefaultVideoEncoderFactory = true
  19 + )
  20 + )
  21 + }
  22 + }
  23 +}
  1 +package io.livekit.android.videoencodedecode.test.control
  2 +
  3 +import android.content.Intent
  4 +import androidx.compose.ui.test.junit4.ComposeTestRule
  5 +import io.livekit.android.videoencodedecode.CallActivity
  6 +import io.livekit.android.videoencodedecode.VideoTest
  7 +import io.livekit.android.videoencodedecode.createAndroidIntentComposeRule
  8 +
  9 +class VP8DefaultVideoTest : VideoTest() {
  10 + override val composeTestRule: ComposeTestRule = createAndroidIntentComposeRule<CallActivity> { context ->
  11 + Intent(context, CallActivity::class.java).apply {
  12 + putExtra(
  13 + CallActivity.KEY_ARGS,
  14 + CallActivity.BundleArgs(
  15 + SERVER_URL,
  16 + token1,
  17 + token2,
  18 + useDefaultVideoEncoderFactory = true,
  19 + codecWhiteList = listOf("VP8")
  20 + )
  21 + )
  22 + }
  23 + }
  24 +}
  1 +package io.livekit.android.videoencodedecode.test.test
  2 +
  3 +import android.content.Intent
  4 +import androidx.compose.ui.test.junit4.ComposeTestRule
  5 +import io.livekit.android.videoencodedecode.CallActivity
  6 +import io.livekit.android.videoencodedecode.VideoTest
  7 +import io.livekit.android.videoencodedecode.createAndroidIntentComposeRule
  8 +
  9 +class H264SimulcastVideoTest : VideoTest() {
  10 + override val composeTestRule: ComposeTestRule = createAndroidIntentComposeRule<CallActivity> { context ->
  11 + Intent(context, CallActivity::class.java).apply {
  12 + putExtra(
  13 + CallActivity.KEY_ARGS,
  14 + CallActivity.BundleArgs(
  15 + SERVER_URL,
  16 + token1,
  17 + token2,
  18 + useDefaultVideoEncoderFactory = false,
  19 + codecWhiteList = listOf("H264")
  20 + )
  21 + )
  22 + }
  23 + }
  24 +}
  1 +package io.livekit.android.videoencodedecode.test.test
  2 +
  3 +import android.content.Intent
  4 +import androidx.compose.ui.test.junit4.ComposeTestRule
  5 +import io.livekit.android.videoencodedecode.CallActivity
  6 +import io.livekit.android.videoencodedecode.VideoTest
  7 +import io.livekit.android.videoencodedecode.createAndroidIntentComposeRule
  8 +
  9 +class NoWhitelistSimulcastVideoTest : VideoTest() {
  10 + override val composeTestRule: ComposeTestRule = createAndroidIntentComposeRule<CallActivity> { context ->
  11 + Intent(context, CallActivity::class.java).apply {
  12 + putExtra(
  13 + CallActivity.KEY_ARGS,
  14 + CallActivity.BundleArgs(
  15 + SERVER_URL,
  16 + token1,
  17 + token2,
  18 + useDefaultVideoEncoderFactory = false
  19 + )
  20 + )
  21 + }
  22 + }
  23 +}
  1 +package io.livekit.android.videoencodedecode.test.test
  2 +
  3 +import android.content.Intent
  4 +import androidx.compose.ui.test.junit4.ComposeTestRule
  5 +import io.livekit.android.videoencodedecode.CallActivity
  6 +import io.livekit.android.videoencodedecode.VideoTest
  7 +import io.livekit.android.videoencodedecode.createAndroidIntentComposeRule
  8 +
  9 +class VP8DefaultVideoTest : VideoTest() {
  10 + override val composeTestRule: ComposeTestRule = createAndroidIntentComposeRule<CallActivity> { context ->
  11 + Intent(context, CallActivity::class.java).apply {
  12 + putExtra(
  13 + CallActivity.KEY_ARGS,
  14 + CallActivity.BundleArgs(
  15 + SERVER_URL,
  16 + token1,
  17 + token2,
  18 + useDefaultVideoEncoderFactory = false,
  19 + codecWhiteList = listOf("VP8")
  20 + )
  21 + )
  22 + }
  23 + }
  24 +}
  1 +<?xml version="1.0" encoding="utf-8"?>
  2 +<manifest xmlns:android="http://schemas.android.com/apk/res/android"
  3 + package="io.livekit.android.videoencodedecode">
  4 +
  5 + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
  6 + <uses-permission android:name="android.permission.INTERNET" />
  7 + <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
  8 +
  9 + <application
  10 + android:allowBackup="true"
  11 + android:name="io.livekit.android.videoencodedecode.SampleApplication"
  12 + android:fullBackupContent="true"
  13 + android:icon="@mipmap/ic_launcher"
  14 + android:label="@string/app_name"
  15 + android:roundIcon="@mipmap/ic_launcher_round"
  16 + android:networkSecurityConfig="@xml/network_security_config"
  17 + android:supportsRtl="true"
  18 + android:theme="@style/Theme.Livekitandroid">
  19 + <activity
  20 + android:name="io.livekit.android.videoencodedecode.MainActivity"
  21 + android:exported="true"
  22 + android:label="@string/app_name"
  23 + android:theme="@style/Theme.Livekitandroid.NoActionBar">
  24 + <intent-filter>
  25 + <action android:name="android.intent.action.MAIN" />
  26 +
  27 + <category android:name="android.intent.category.LAUNCHER" />
  28 + </intent-filter>
  29 + </activity>
  30 + <activity
  31 + android:name="io.livekit.android.videoencodedecode.CallActivity"
  32 + android:theme="@style/Theme.Livekitandroid.NoActionBar" />
  33 + </application>
  34 +
  35 +</manifest>
  1 +package io.livekit.android.videoencodedecode
  2 +
  3 +import android.os.Bundle
  4 +import android.os.Parcelable
  5 +import androidx.activity.compose.setContent
  6 +import androidx.appcompat.app.AppCompatActivity
  7 +import androidx.compose.foundation.background
  8 +import androidx.compose.foundation.layout.fillMaxSize
  9 +import androidx.compose.material.ExperimentalMaterialApi
  10 +import androidx.compose.material.Surface
  11 +import androidx.compose.runtime.Composable
  12 +import androidx.compose.ui.Modifier
  13 +import androidx.compose.ui.graphics.Color
  14 +import androidx.compose.ui.semantics.semantics
  15 +import androidx.compose.ui.semantics.testTag
  16 +import androidx.constraintlayout.compose.ConstraintLayout
  17 +import androidx.constraintlayout.compose.Dimension
  18 +import androidx.lifecycle.ViewModel
  19 +import androidx.lifecycle.ViewModelProvider
  20 +import com.google.accompanist.pager.ExperimentalPagerApi
  21 +import io.livekit.android.composesample.ui.theme.AppTheme
  22 +import kotlinx.parcelize.Parcelize
  23 +
  24 +@OptIn(ExperimentalPagerApi::class)
  25 +class CallActivity : AppCompatActivity() {
  26 +
  27 + private lateinit var viewModel1: CallViewModel
  28 + private lateinit var viewModel2: CallViewModel
  29 +
  30 + @OptIn(ExperimentalMaterialApi::class)
  31 + override fun onCreate(savedInstanceState: Bundle?) {
  32 + super.onCreate(savedInstanceState)
  33 +
  34 + val viewModelProvider = ViewModelProvider(this, object : ViewModelProvider.KeyedFactory() {
  35 + override fun <T : ViewModel> create(key: String, modelClass: Class<T>): T {
  36 + val args = intent.getParcelableExtra<BundleArgs>(KEY_ARGS)
  37 + ?: throw NullPointerException("args is null!")
  38 +
  39 + val token = if (key == VIEWMODEL_KEY1) args.token1 else args.token2
  40 + val showVideo = key == VIEWMODEL_KEY1
  41 + @Suppress("UNCHECKED_CAST")
  42 + return CallViewModel(
  43 + args.url,
  44 + token,
  45 + args.useDefaultVideoEncoderFactory,
  46 + args.codecWhiteList,
  47 + showVideo,
  48 + application
  49 + ) as T
  50 + }
  51 + })
  52 + viewModel1 = viewModelProvider.get(VIEWMODEL_KEY1, CallViewModel::class.java)
  53 + viewModel2 = viewModelProvider.get(VIEWMODEL_KEY2, CallViewModel::class.java)
  54 +
  55 + // Setup compose view.
  56 + setContent {
  57 + Content(
  58 + viewModel1,
  59 + viewModel2
  60 + )
  61 + }
  62 + }
  63 +
  64 + @ExperimentalMaterialApi
  65 + @Composable
  66 + fun Content(viewModel1: CallViewModel, viewModel2: CallViewModel) {
  67 + AppTheme(darkTheme = true) {
  68 + ConstraintLayout(
  69 + modifier = Modifier
  70 + .fillMaxSize()
  71 + .background(Color.Red)
  72 + ) {
  73 + val (topConnectionItem, bottomConnectionItem) = createRefs()
  74 +
  75 + Surface(
  76 + color = Color.Cyan,
  77 + modifier = Modifier
  78 + .semantics {
  79 + testTag = TEST_TAG1
  80 + }
  81 + .constrainAs(topConnectionItem) {
  82 + top.linkTo(parent.top)
  83 + start.linkTo(parent.start)
  84 + end.linkTo(parent.end)
  85 + bottom.linkTo(bottomConnectionItem.top)
  86 + width = Dimension.fillToConstraints
  87 + height = Dimension.fillToConstraints
  88 + }) {
  89 + ConnectionItem(viewModel = viewModel1)
  90 + }
  91 +
  92 + Surface(
  93 + color = Color.Magenta,
  94 + modifier = Modifier
  95 + .semantics {
  96 + testTag = TEST_TAG2
  97 + }
  98 + .constrainAs(bottomConnectionItem) {
  99 + top.linkTo(topConnectionItem.bottom)
  100 + start.linkTo(parent.start)
  101 + end.linkTo(parent.end)
  102 + bottom.linkTo(parent.bottom)
  103 + width = Dimension.fillToConstraints
  104 + height = Dimension.fillToConstraints
  105 + }) {
  106 + ConnectionItem(viewModel = viewModel2)
  107 + }
  108 + }
  109 + }
  110 + }
  111 +
  112 + companion object {
  113 + const val KEY_ARGS = "args"
  114 + const val VIEWMODEL_KEY1 = "key1"
  115 + const val VIEWMODEL_KEY2 = "key2"
  116 +
  117 + const val TEST_TAG1 = "tag1"
  118 + const val TEST_TAG2 = "tag2"
  119 + }
  120 +
  121 + @Parcelize
  122 + data class BundleArgs(
  123 + val url: String,
  124 + val token1: String,
  125 + val token2: String,
  126 + val useDefaultVideoEncoderFactory: Boolean = false,
  127 + val codecWhiteList: List<String>? = null,
  128 + ) : Parcelable
  129 +}
  1 +package io.livekit.android.videoencodedecode
  2 +
  3 +import android.app.Application
  4 +import android.graphics.Color
  5 +import androidx.lifecycle.AndroidViewModel
  6 +import androidx.lifecycle.LiveData
  7 +import androidx.lifecycle.viewModelScope
  8 +import com.github.ajalt.timberkt.Timber
  9 +import io.livekit.android.LiveKit
  10 +import io.livekit.android.RoomOptions
  11 +import io.livekit.android.dagger.LiveKitOverrides
  12 +import io.livekit.android.room.Room
  13 +import io.livekit.android.room.participant.Participant
  14 +import io.livekit.android.room.participant.VideoTrackPublishDefaults
  15 +import io.livekit.android.room.track.LocalVideoTrackOptions
  16 +import io.livekit.android.room.track.VideoCaptureParameter
  17 +import io.livekit.android.util.flow
  18 +import kotlinx.coroutines.ExperimentalCoroutinesApi
  19 +import kotlinx.coroutines.flow.*
  20 +import kotlinx.coroutines.launch
  21 +import org.webrtc.EglBase
  22 +
  23 +@OptIn(ExperimentalCoroutinesApi::class)
  24 +class CallViewModel(
  25 + private val url: String,
  26 + private val token: String,
  27 + private val useDefaultVideoEncoder: Boolean = false,
  28 + private val codecWhiteList: List<String>? = null,
  29 + private val showVideo: Boolean,
  30 + application: Application
  31 +) : AndroidViewModel(application) {
  32 + private val mutableRoom = MutableStateFlow<Room?>(null)
  33 + val room: MutableStateFlow<Room?> = mutableRoom
  34 + val participants = mutableRoom.flatMapLatest { room ->
  35 + if (room != null) {
  36 + room::remoteParticipants.flow
  37 + .map { remoteParticipants ->
  38 + listOf<Participant>(room.localParticipant) +
  39 + remoteParticipants
  40 + .keys
  41 + .sortedBy { it }
  42 + .mapNotNull { remoteParticipants[it] }
  43 + }
  44 + } else {
  45 + flowOf(emptyList())
  46 + }
  47 + }
  48 +
  49 + private val mutableError = MutableStateFlow<Throwable?>(null)
  50 + val error = mutableError.hide()
  51 +
  52 + init {
  53 + viewModelScope.launch {
  54 +
  55 + launch {
  56 + error.collect { Timber.e(it) }
  57 + }
  58 +
  59 + try {
  60 + val videoEncoderFactory = if (useDefaultVideoEncoder || codecWhiteList != null) {
  61 + val factory = if (useDefaultVideoEncoder) {
  62 + WhitelistDefaultVideoEncoderFactory(
  63 + EglBase.create().eglBaseContext,
  64 + enableIntelVp8Encoder = true,
  65 + enableH264HighProfile = true
  66 + )
  67 + } else {
  68 + WhitelistSimulcastVideoEncoderFactory(
  69 + EglBase.create().eglBaseContext,
  70 + enableIntelVp8Encoder = true,
  71 + enableH264HighProfile = true,
  72 + )
  73 + }
  74 + factory.apply { codecWhitelist = this@CallViewModel.codecWhiteList }
  75 + } else {
  76 + null
  77 + }
  78 + val overrides = LiveKitOverrides(videoEncoderFactory = videoEncoderFactory)
  79 +
  80 + val room = LiveKit.connect(
  81 + application,
  82 + url,
  83 + token,
  84 + roomOptions = RoomOptions(videoTrackPublishDefaults = VideoTrackPublishDefaults(simulcast = !useDefaultVideoEncoder)),
  85 + overrides = overrides
  86 + )
  87 +
  88 + // Create and publish audio/video tracks
  89 + val localParticipant = room.localParticipant
  90 +
  91 + if (showVideo) {
  92 + val capturer = DummyVideoCapturer(Color.RED)
  93 + val videoTrack = localParticipant.createVideoTrack(
  94 + capturer = capturer,
  95 + options = LocalVideoTrackOptions(captureParams = VideoCaptureParameter(128, 128, 30))
  96 + )
  97 + videoTrack.startCapture()
  98 + localParticipant.publishVideoTrack(videoTrack)
  99 + }
  100 + mutableRoom.value = room
  101 +
  102 + } catch (e: Throwable) {
  103 + mutableError.value = e
  104 + }
  105 + }
  106 + }
  107 +
  108 + override fun onCleared() {
  109 + super.onCleared()
  110 + mutableRoom.value?.disconnect()
  111 + }
  112 +}
  113 +
  114 +private fun <T> LiveData<T>.hide(): LiveData<T> = this
  115 +
  116 +private fun <T> MutableStateFlow<T>.hide(): StateFlow<T> = this
  117 +private fun <T> Flow<T>.hide(): Flow<T> = this
  1 +package io.livekit.android.videoencodedecode
  2 +
  3 +import androidx.compose.runtime.Composable
  4 +import androidx.compose.runtime.collectAsState
  5 +import androidx.compose.runtime.getValue
  6 +import io.livekit.android.room.Room
  7 +import io.livekit.android.room.participant.Participant
  8 +
  9 +/**
  10 + * Widget for showing the other participant in a connection.
  11 + */
  12 +@Composable
  13 +fun ConnectionItem(viewModel: CallViewModel) {
  14 +
  15 + val room by viewModel.room.collectAsState()
  16 + val participants by viewModel.participants.collectAsState(initial = emptyList())
  17 + if (room != null) {
  18 + RoomItem(room = room!!, participants)
  19 + }
  20 +}
  21 +
  22 +@Composable
  23 +fun RoomItem(room: Room, participants: List<Participant>) {
  24 + val remoteParticipant = participants.filterNot { it == room.localParticipant }.firstOrNull()
  25 + if (remoteParticipant != null) {
  26 + ParticipantItem(room = room, participant = remoteParticipant, isSpeaking = false)
  27 + }
  28 +}
  1 +package io.livekit.android.videoencodedecode
  2 +
  3 +import android.content.Context
  4 +import android.os.SystemClock
  5 +import androidx.annotation.ColorInt
  6 +import org.webrtc.*
  7 +import java.nio.ByteBuffer
  8 +import java.util.*
  9 +import java.util.concurrent.TimeUnit
  10 +
  11 +class DummyVideoCapturer(@ColorInt var color: Int) : VideoCapturer {
  12 + var capturerObserver: CapturerObserver? = null
  13 + val timer = Timer()
  14 + var frameWidth = 0
  15 + var frameHeight = 0
  16 + private val tickTask: TimerTask = object : TimerTask() {
  17 + override fun run() {
  18 + tick()
  19 + }
  20 + }
  21 +
  22 + fun tick() {
  23 + val videoFrame: VideoFrame = createFrame()
  24 + this.capturerObserver?.onFrameCaptured(videoFrame)
  25 + videoFrame.release()
  26 + }
  27 +
  28 + override fun initialize(
  29 + surfaceTextureHelper: SurfaceTextureHelper,
  30 + applicationContext: Context,
  31 + capturerObserver: CapturerObserver
  32 + ) {
  33 + this.capturerObserver = capturerObserver
  34 + }
  35 +
  36 + override fun startCapture(width: Int, height: Int, framerate: Int) {
  37 + frameWidth = width
  38 + frameHeight = height
  39 + this.timer.schedule(this.tickTask, 0L, (1000 / framerate).toLong())
  40 + }
  41 +
  42 + @Throws(InterruptedException::class)
  43 + override fun stopCapture() {
  44 + this.timer.cancel()
  45 + }
  46 +
  47 + override fun changeCaptureFormat(width: Int, height: Int, framerate: Int) {}
  48 +
  49 + override fun dispose() {
  50 + this.timer.cancel()
  51 + }
  52 +
  53 + private fun createFrame(): VideoFrame {
  54 +
  55 + val captureTimeNs = TimeUnit.MILLISECONDS.toNanos(SystemClock.elapsedRealtime())
  56 +
  57 + val buffer = JavaI420Buffer.allocate(this.frameWidth, this.frameHeight)
  58 + encodeYUV420SP(buffer, this.color, frameWidth, frameHeight)
  59 +
  60 + this.color = (color + 1) % 0xFFFFFF
  61 + return VideoFrame(buffer, 0, captureTimeNs)
  62 + }
  63 +
  64 + override fun isScreencast(): Boolean {
  65 + return false
  66 + }
  67 +
  68 + companion object {
  69 +
  70 + // adapted from https://stackoverflow.com/a/13055615/295675
  71 + fun encodeYUV420SP(yuvBuffer: JavaI420Buffer, color: Int, width: Int, height: Int) {
  72 + var yIndex = 0
  73 + var uvIndex = 0
  74 + var R: Int
  75 + var G: Int
  76 + var B: Int
  77 + var Y: Int
  78 + var U: Int
  79 + var V: Int
  80 +
  81 + val dataY: ByteBuffer = yuvBuffer.dataY
  82 + val dataU = yuvBuffer.dataU
  83 + val dataV = yuvBuffer.dataV
  84 +
  85 + var index = 0
  86 + for (j in 0 until height) {
  87 + for (i in 0 until width) {
  88 + R = color and 0xff0000 shr 16
  89 + G = color and 0xff00 shr 8
  90 + B = color and 0xff shr 0
  91 +
  92 + // well known RGB to YUV algorithm
  93 + Y = (66 * R + 129 * G + 25 * B + 128 shr 8) + 16
  94 + U = (-38 * R - 74 * G + 112 * B + 128 shr 8) + 128
  95 + V = (112 * R - 94 * G - 18 * B + 128 shr 8) + 128
  96 +
  97 + // NV21 has a plane of Y and interleaved planes of VU each sampled by a factor of 2
  98 + // meaning for every 4 Y pixels there are 1 V and 1 U. Note the sampling is every other
  99 + // pixel AND every other scanline.
  100 + dataY[yIndex++] = (if (Y < 0) 0 else if (Y > 255) 255 else Y).toByte()
  101 + if (j % 2 == 0 && index % 2 == 0) {
  102 + dataV[uvIndex] = (if (V < 0) 0 else if (V > 255) 255 else V).toByte()
  103 + dataU[uvIndex] = (if (U < 0) 0 else if (U > 255) 255 else U).toByte()
  104 + uvIndex++
  105 + }
  106 + index++
  107 + }
  108 + }
  109 + }
  110 + }
  111 +}
  112 +
  113 +private operator fun ByteBuffer.set(index: Int, value: Byte) {
  114 + put(index, value)
  115 +}
  1 +package io.livekit.android.videoencodedecode
  2 +
  3 +import android.Manifest
  4 +import android.content.Intent
  5 +import android.content.pm.PackageManager
  6 +import android.os.Bundle
  7 +import android.widget.Toast
  8 +import androidx.activity.ComponentActivity
  9 +import androidx.activity.compose.setContent
  10 +import androidx.activity.result.contract.ActivityResultContracts
  11 +import androidx.activity.viewModels
  12 +import androidx.compose.foundation.Image
  13 +import androidx.compose.foundation.layout.*
  14 +import androidx.compose.material.*
  15 +import androidx.compose.runtime.*
  16 +import androidx.compose.ui.Alignment
  17 +import androidx.compose.ui.Modifier
  18 +import androidx.compose.ui.res.painterResource
  19 +import androidx.compose.ui.tooling.preview.Preview
  20 +import androidx.compose.ui.unit.dp
  21 +import androidx.core.content.ContextCompat
  22 +import com.google.accompanist.pager.ExperimentalPagerApi
  23 +import io.livekit.android.composesample.ui.theme.AppTheme
  24 +
  25 +@ExperimentalPagerApi
  26 +class MainActivity : ComponentActivity() {
  27 +
  28 + val viewModel by viewModels<MainViewModel>()
  29 + override fun onCreate(savedInstanceState: Bundle?) {
  30 + super.onCreate(savedInstanceState)
  31 +
  32 + requestPermissions()
  33 + setContent {
  34 + MainContent(
  35 + defaultUrl = viewModel.getSavedUrl(),
  36 + defaultToken1 = viewModel.getSavedToken1(),
  37 + defaultToken2 = viewModel.getSavedToken2(),
  38 + onConnect = { url, token1, token2, ->
  39 + val intent = Intent(this@MainActivity, CallActivity::class.java).apply {
  40 + putExtra(
  41 + CallActivity.KEY_ARGS,
  42 + CallActivity.BundleArgs(
  43 + url,
  44 + token1,
  45 + token2,
  46 + )
  47 + )
  48 + }
  49 + startActivity(intent)
  50 + },
  51 + onSave = { url, token1, token2 ->
  52 + viewModel.setSavedUrl(url)
  53 + viewModel.setSavedToken1(token1)
  54 + viewModel.setSavedToken2(token2)
  55 +
  56 + Toast.makeText(
  57 + this@MainActivity,
  58 + "Values saved.",
  59 + Toast.LENGTH_SHORT
  60 + ).show()
  61 + },
  62 + onReset = {
  63 + viewModel.reset()
  64 + Toast.makeText(
  65 + this@MainActivity,
  66 + "Values reset.",
  67 + Toast.LENGTH_SHORT
  68 + ).show()
  69 + }
  70 + )
  71 + }
  72 + }
  73 +
  74 + @Preview(
  75 + showBackground = true,
  76 + showSystemUi = true,
  77 + )
  78 + @Composable
  79 + fun MainContent(
  80 + defaultUrl: String = MainViewModel.URL,
  81 + defaultToken1: String = MainViewModel.TOKEN1,
  82 + defaultToken2: String = MainViewModel.TOKEN2,
  83 + onConnect: (url: String, token1: String, token2: String) -> Unit = { _, _, _ -> },
  84 + onSave: (url: String, token1: String, token2: String) -> Unit = { _, _, _ -> },
  85 + onReset: () -> Unit = {},
  86 + ) {
  87 + AppTheme {
  88 + var url by remember { mutableStateOf(defaultUrl) }
  89 + var token1 by remember { mutableStateOf(defaultToken1) }
  90 + var token2 by remember { mutableStateOf(defaultToken2) }
  91 + // A surface container using the 'background' color from the theme
  92 + Surface(
  93 + color = MaterialTheme.colors.background,
  94 + modifier = Modifier.fillMaxSize()
  95 + ) {
  96 + Column(
  97 + horizontalAlignment = Alignment.CenterHorizontally,
  98 + modifier = Modifier.padding(10.dp)
  99 + ) {
  100 + Spacer(modifier = Modifier.height(50.dp))
  101 + Image(
  102 + painter = painterResource(id = R.drawable.banner_dark),
  103 + contentDescription = "",
  104 + )
  105 + Spacer(modifier = Modifier.height(20.dp))
  106 + OutlinedTextField(
  107 + value = url,
  108 + onValueChange = { url = it },
  109 + label = { Text("URL") },
  110 + modifier = Modifier.fillMaxWidth(),
  111 + )
  112 + Spacer(modifier = Modifier.height(20.dp))
  113 + OutlinedTextField(
  114 + value = token1,
  115 + onValueChange = { token1 = it },
  116 + label = { Text("Token1") },
  117 + modifier = Modifier.fillMaxWidth(),
  118 + )
  119 + Spacer(modifier = Modifier.height(20.dp))
  120 + OutlinedTextField(
  121 + value = token2,
  122 + onValueChange = { token2 = it },
  123 + label = { Text("Token2") },
  124 + modifier = Modifier.fillMaxWidth(),
  125 + )
  126 +
  127 + Spacer(modifier = Modifier.height(20.dp))
  128 + Button(onClick = { onConnect(url, token1, token2) }) {
  129 + Text("Connect")
  130 + }
  131 +
  132 + Spacer(modifier = Modifier.height(20.dp))
  133 + Button(onClick = { onSave(url, token1, token2) }) {
  134 + Text("Save Values")
  135 + }
  136 +
  137 + Spacer(modifier = Modifier.height(20.dp))
  138 + Button(onClick = {
  139 + onReset()
  140 + url = MainViewModel.URL
  141 + token1 = MainViewModel.TOKEN1
  142 + token2 = MainViewModel.TOKEN2
  143 + }) {
  144 + Text("Reset Values")
  145 + }
  146 + }
  147 + }
  148 + }
  149 + }
  150 +
  151 + private fun requestPermissions() {
  152 + val requestPermissionLauncher =
  153 + registerForActivityResult(
  154 + ActivityResultContracts.RequestMultiplePermissions()
  155 + ) { grants ->
  156 + for (grant in grants.entries) {
  157 + if (!grant.value) {
  158 + Toast.makeText(
  159 + this,
  160 + "Missing permission: ${grant.key}",
  161 + Toast.LENGTH_SHORT
  162 + )
  163 + .show()
  164 + }
  165 + }
  166 + }
  167 + val neededPermissions = listOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA)
  168 + .filter {
  169 + ContextCompat.checkSelfPermission(
  170 + this,
  171 + it
  172 + ) == PackageManager.PERMISSION_DENIED
  173 + }
  174 + .toTypedArray()
  175 + if (neededPermissions.isNotEmpty()) {
  176 + requestPermissionLauncher.launch(neededPermissions)
  177 + }
  178 + }
  179 +
  180 +}
  1 +package io.livekit.android.videoencodedecode
  2 +
  3 +import android.app.Application
  4 +import androidx.core.content.edit
  5 +import androidx.lifecycle.AndroidViewModel
  6 +import androidx.preference.PreferenceManager
  7 +
  8 +class MainViewModel(application: Application) : AndroidViewModel(application) {
  9 +
  10 + private val preferences = PreferenceManager.getDefaultSharedPreferences(application)
  11 +
  12 + fun getSavedUrl() = preferences.getString(PREFERENCES_KEY_URL, URL) as String
  13 + fun getSavedToken1() = preferences.getString(PREFERENCES_KEY_TOKEN1, TOKEN1) as String
  14 + fun getSavedToken2() = preferences.getString(PREFERENCES_KEY_TOKEN2, TOKEN2) as String
  15 +
  16 + fun setSavedUrl(url: String) {
  17 + preferences.edit {
  18 + putString(PREFERENCES_KEY_URL, url)
  19 + }
  20 + }
  21 +
  22 + fun setSavedToken1(token: String) {
  23 + preferences.edit {
  24 + putString(PREFERENCES_KEY_TOKEN1, token)
  25 + }
  26 + }
  27 +
  28 + fun setSavedToken2(token: String) {
  29 + preferences.edit {
  30 + putString(PREFERENCES_KEY_TOKEN2, token)
  31 + }
  32 + }
  33 +
  34 + fun reset() {
  35 + preferences.edit { clear() }
  36 + }
  37 +
  38 + companion object {
  39 + private const val PREFERENCES_KEY_URL = "url"
  40 + private const val PREFERENCES_KEY_TOKEN1 = "token1"
  41 + private const val PREFERENCES_KEY_TOKEN2 = "token2"
  42 +
  43 + const val URL = ""
  44 + const val TOKEN1 =
  45 + ""
  46 + const val TOKEN2 =
  47 + ""
  48 +
  49 + }
  50 +}
  1 +package io.livekit.android.videoencodedecode
  2 +
  3 +import androidx.compose.foundation.background
  4 +import androidx.compose.foundation.border
  5 +import androidx.compose.material.Icon
  6 +import androidx.compose.material.Surface
  7 +import androidx.compose.material.Text
  8 +import androidx.compose.runtime.Composable
  9 +import androidx.compose.runtime.collectAsState
  10 +import androidx.compose.runtime.getValue
  11 +import androidx.compose.ui.Modifier
  12 +import androidx.compose.ui.draw.alpha
  13 +import androidx.compose.ui.graphics.Color
  14 +import androidx.compose.ui.res.painterResource
  15 +import androidx.compose.ui.unit.dp
  16 +import androidx.constraintlayout.compose.ConstraintLayout
  17 +import androidx.constraintlayout.compose.Dimension
  18 +import io.livekit.android.composesample.ui.theme.BlueMain
  19 +import io.livekit.android.composesample.ui.theme.NoVideoBackground
  20 +import io.livekit.android.room.Room
  21 +import io.livekit.android.room.participant.ConnectionQuality
  22 +import io.livekit.android.room.participant.Participant
  23 +import io.livekit.android.util.flow
  24 +
  25 +/**
  26 + * Widget for displaying a participant.
  27 + */
  28 +@Composable
  29 +fun ParticipantItem(
  30 + room: Room,
  31 + participant: Participant,
  32 + modifier: Modifier = Modifier,
  33 + isSpeaking: Boolean,
  34 +) {
  35 +
  36 + val identity by participant::identity.flow.collectAsState()
  37 + val videoTracks by participant::videoTracks.flow.collectAsState()
  38 + val audioTracks by participant::audioTracks.flow.collectAsState()
  39 + val identityBarPadding = 4.dp
  40 + ConstraintLayout(
  41 + modifier = modifier.background(NoVideoBackground)
  42 + .run {
  43 + if (isSpeaking) {
  44 + border(2.dp, BlueMain)
  45 + } else {
  46 + this
  47 + }
  48 + }
  49 + ) {
  50 + val (videoItem, identityBar, identityText, muteIndicator, connectionIndicator) = createRefs()
  51 +
  52 + VideoItemTrackSelector(
  53 + room = room,
  54 + participant = participant,
  55 + modifier = Modifier.constrainAs(videoItem) {
  56 + top.linkTo(parent.top)
  57 + bottom.linkTo(parent.bottom)
  58 + start.linkTo(parent.start)
  59 + end.linkTo(parent.end)
  60 + width = Dimension.fillToConstraints
  61 + height = Dimension.fillToConstraints
  62 + }
  63 + )
  64 +
  65 + Surface(
  66 + color = Color(0x80000000),
  67 + modifier = Modifier.constrainAs(identityBar) {
  68 + bottom.linkTo(parent.bottom)
  69 + start.linkTo(parent.start)
  70 + end.linkTo(parent.end)
  71 + width = Dimension.fillToConstraints
  72 + height = Dimension.value(30.dp)
  73 + }
  74 + ) {}
  75 +
  76 + Text(
  77 + text = identity ?: "",
  78 + color = Color.White,
  79 + modifier = Modifier.constrainAs(identityText) {
  80 + top.linkTo(identityBar.top)
  81 + bottom.linkTo(identityBar.bottom)
  82 + start.linkTo(identityBar.start, margin = identityBarPadding)
  83 + end.linkTo(muteIndicator.end, margin = 10.dp)
  84 + width = Dimension.fillToConstraints
  85 + height = Dimension.wrapContent
  86 + },
  87 + )
  88 +
  89 + val isMuted = audioTracks.values.none { it.track != null && !it.muted }
  90 +
  91 + if (isMuted) {
  92 + Icon(
  93 + painter = painterResource(id = R.drawable.outline_mic_off_24),
  94 + contentDescription = "",
  95 + tint = Color.Red,
  96 + modifier = Modifier.constrainAs(muteIndicator) {
  97 + top.linkTo(identityBar.top)
  98 + bottom.linkTo(identityBar.bottom)
  99 + end.linkTo(identityBar.end, margin = identityBarPadding)
  100 + }
  101 + )
  102 + }
  103 +
  104 + val connectionQuality by participant::connectionQuality.flow.collectAsState()
  105 +
  106 + val connectionIcon = when (connectionQuality) {
  107 + ConnectionQuality.EXCELLENT -> R.drawable.wifi_strength_4
  108 + ConnectionQuality.GOOD -> R.drawable.wifi_strength_3
  109 + ConnectionQuality.POOR -> R.drawable.wifi_strength_alert_outline
  110 + ConnectionQuality.UNKNOWN -> R.drawable.wifi_strength_alert_outline
  111 + }
  112 +
  113 + if (connectionQuality == ConnectionQuality.POOR) {
  114 + Icon(
  115 + painter = painterResource(id = connectionIcon),
  116 + contentDescription = "",
  117 + tint = Color.Red,
  118 + modifier = Modifier
  119 + .constrainAs(connectionIndicator) {
  120 + top.linkTo(parent.top, margin = identityBarPadding)
  121 + end.linkTo(parent.end, margin = identityBarPadding)
  122 + }
  123 + .alpha(0.5f)
  124 + )
  125 + }
  126 + }
  127 +}
  1 +package io.livekit.android.videoencodedecode
  2 +
  3 +import android.app.Application
  4 +import io.livekit.android.LiveKit
  5 +import io.livekit.android.util.LoggingLevel
  6 +
  7 +class SampleApplication : Application() {
  8 +
  9 + override fun onCreate() {
  10 + super.onCreate()
  11 + LiveKit.loggingLevel = LoggingLevel.VERBOSE
  12 + }
  13 +}
  1 +package io.livekit.android.videoencodedecode
  2 +
  3 +import androidx.compose.foundation.layout.Box
  4 +import androidx.compose.foundation.layout.fillMaxSize
  5 +import androidx.compose.foundation.layout.size
  6 +import androidx.compose.material.Icon
  7 +import androidx.compose.material.Surface
  8 +import androidx.compose.runtime.*
  9 +import androidx.compose.ui.Alignment
  10 +import androidx.compose.ui.Modifier
  11 +import androidx.compose.ui.graphics.Color
  12 +import androidx.compose.ui.layout.onGloballyPositioned
  13 +import androidx.compose.ui.res.painterResource
  14 +import androidx.compose.ui.semantics.semantics
  15 +import androidx.compose.ui.semantics.testTag
  16 +import androidx.compose.ui.unit.dp
  17 +import androidx.compose.ui.viewinterop.AndroidView
  18 +import io.livekit.android.renderer.TextureViewRenderer
  19 +import io.livekit.android.room.Room
  20 +import io.livekit.android.room.participant.Participant
  21 +import io.livekit.android.room.track.RemoteVideoTrack
  22 +import io.livekit.android.room.track.Track
  23 +import io.livekit.android.room.track.VideoTrack
  24 +import io.livekit.android.room.track.video.ComposeVisibility
  25 +import io.livekit.android.util.flow
  26 +import kotlinx.coroutines.flow.*
  27 +
  28 +/**
  29 + * Widget for displaying a VideoTrack. Handles the Compose <-> AndroidView interop needed to use
  30 + * [TextureViewRenderer].
  31 + */
  32 +@Composable
  33 +fun VideoItem(
  34 + room: Room,
  35 + videoTrack: VideoTrack,
  36 + modifier: Modifier = Modifier
  37 +) {
  38 +
  39 + val videoSinkVisibility = remember(room, videoTrack) { ComposeVisibility() }
  40 + var boundVideoTrack by remember { mutableStateOf<VideoTrack?>(null) }
  41 + var videoView: TextureViewRenderer? by remember { mutableStateOf(null) }
  42 + var receivedVideoFrames by remember { mutableStateOf(false) }
  43 +
  44 + fun cleanupVideoTrack() {
  45 + videoView?.let { boundVideoTrack?.removeRenderer(it) }
  46 + boundVideoTrack = null
  47 + }
  48 +
  49 + fun setupVideoIfNeeded(videoTrack: VideoTrack, view: TextureViewRenderer) {
  50 + if (boundVideoTrack == videoTrack) {
  51 + return
  52 + }
  53 +
  54 + cleanupVideoTrack()
  55 +
  56 + boundVideoTrack = videoTrack
  57 + if (videoTrack is RemoteVideoTrack) {
  58 + videoTrack.addRenderer(view, videoSinkVisibility)
  59 + } else {
  60 + videoTrack.addRenderer(view)
  61 + }
  62 + }
  63 +
  64 + DisposableEffect(room, videoTrack) {
  65 + onDispose {
  66 + videoSinkVisibility.onDispose()
  67 + cleanupVideoTrack()
  68 + }
  69 + }
  70 +
  71 + DisposableEffect(currentCompositeKeyHash.toString()) {
  72 + onDispose {
  73 + videoView?.release()
  74 + }
  75 + }
  76 +
  77 + Box(
  78 + modifier = modifier
  79 + ) {
  80 + AndroidView(
  81 + factory = { context ->
  82 + TextureViewRenderer(context).apply {
  83 + room.initVideoRenderer(this)
  84 + setupVideoIfNeeded(videoTrack, this)
  85 + this.addFrameListener({
  86 + if (!receivedVideoFrames) {
  87 + receivedVideoFrames = true
  88 + }
  89 + }, 0f)
  90 + videoView = this
  91 + }
  92 + },
  93 + update = { view ->
  94 + setupVideoIfNeeded(videoTrack, view)
  95 + },
  96 + modifier = Modifier
  97 + .fillMaxSize()
  98 + .onGloballyPositioned { videoSinkVisibility.onGloballyPositioned(it) },
  99 + )
  100 +
  101 + // Frame Indicator
  102 + if (receivedVideoFrames) {
  103 + val frameIndicatorColor = Color.Green
  104 + Surface(
  105 + color = frameIndicatorColor,
  106 + modifier = Modifier
  107 + .size(40.dp)
  108 + .semantics { testTag = VIDEO_FRAME_INDICATOR }
  109 + ) {}
  110 + }
  111 + }
  112 +}
  113 +
  114 +const val VIDEO_FRAME_INDICATOR = "frame_indicator"
  115 +/**
  116 + * This widget primarily serves as a way to observe changes in [videoTracks].
  117 + */
  118 +@Composable
  119 +fun VideoItemTrackSelector(
  120 + room: Room,
  121 + participant: Participant,
  122 + modifier: Modifier = Modifier
  123 +) {
  124 +
  125 + val subscribedVideoTracksFlow by remember(participant) {
  126 + mutableStateOf(
  127 + participant::videoTracks.flow
  128 + .flatMapLatest { videoTracks ->
  129 + combine(
  130 + videoTracks.values
  131 + .map { trackPublication ->
  132 + // Re-emit when track changes
  133 + trackPublication::track.flow
  134 + .map { trackPublication }
  135 + }
  136 + ) { trackPubs ->
  137 + trackPubs.toList().filter { trackPublication -> trackPublication.track != null }
  138 + }
  139 + }
  140 + )
  141 + }
  142 +
  143 + val videoTracks by subscribedVideoTracksFlow.collectAsState(initial = emptyList())
  144 + val videoTrack = videoTracks.firstOrNull { pub -> pub.source == Track.Source.SCREEN_SHARE }?.track as? VideoTrack
  145 + ?: videoTracks.firstOrNull { pub -> pub.source == Track.Source.CAMERA }?.track as? VideoTrack
  146 + ?: videoTracks.firstOrNull()?.track as? VideoTrack
  147 +
  148 + if (videoTrack != null) {
  149 + VideoItem(
  150 + room = room,
  151 + videoTrack = videoTrack,
  152 + modifier = modifier
  153 + )
  154 + } else {
  155 + Box(modifier = modifier) {
  156 + Icon(
  157 + painter = painterResource(id = R.drawable.outline_videocam_off_24),
  158 + contentDescription = null,
  159 + tint = Color.White,
  160 + modifier = Modifier.align(Alignment.Center)
  161 + )
  162 + }
  163 + }
  164 +}
  1 +package io.livekit.android.videoencodedecode
  2 +
  3 +import androidx.activity.viewModels
  4 +import androidx.fragment.app.FragmentActivity
  5 +import androidx.lifecycle.ViewModel
  6 +import androidx.lifecycle.ViewModelProvider
  7 +
  8 +typealias CreateViewModel<VM> = () -> VM
  9 +
  10 +inline fun <reified VM : ViewModel> FragmentActivity.viewModelByFactory(
  11 + noinline create: CreateViewModel<VM>
  12 +): Lazy<VM> {
  13 + return viewModels {
  14 + createViewModelFactoryFactory(create)
  15 + }
  16 +}
  17 +
  18 +fun <VM> createViewModelFactoryFactory(
  19 + create: CreateViewModel<VM>
  20 +): ViewModelProvider.Factory {
  21 + return object : ViewModelProvider.Factory {
  22 + override fun <T : ViewModel> create(modelClass: Class<T>): T {
  23 + @Suppress("UNCHECKED_CAST")
  24 + return create() as? T
  25 + ?: throw IllegalArgumentException("Unknown viewmodel class!")
  26 + }
  27 + }
  28 +}
  1 +package io.livekit.android.videoencodedecode
  2 +
  3 +import com.github.ajalt.timberkt.Timber
  4 +import org.webrtc.DefaultVideoEncoderFactory
  5 +import org.webrtc.EglBase
  6 +import org.webrtc.VideoCodecInfo
  7 +
  8 +class WhitelistDefaultVideoEncoderFactory(
  9 + eglContext: EglBase.Context?,
  10 + enableIntelVp8Encoder: Boolean,
  11 + enableH264HighProfile: Boolean
  12 +) : DefaultVideoEncoderFactory(eglContext, enableIntelVp8Encoder, enableH264HighProfile), WhitelistEncoderFactory {
  13 +
  14 + override var codecWhitelist: List<String>? = null
  15 +
  16 + override fun getSupportedCodecs(): Array<VideoCodecInfo> {
  17 + val supportedCodecs = super.getSupportedCodecs()
  18 + Timber.v { "actual supported codecs: ${supportedCodecs.map { it.name }}" }
  19 +
  20 + val filteredCodecs = supportedCodecs.filter { codecWhitelist?.contains(it.name) ?: true }.toTypedArray()
  21 + Timber.v { "filtered supported codecs: ${filteredCodecs.map { it.name }}" }
  22 +
  23 + return filteredCodecs
  24 + }
  25 +}
  1 +package io.livekit.android.videoencodedecode
  2 +
  3 +interface WhitelistEncoderFactory {
  4 + var codecWhitelist: List<String>?
  5 +}
  1 +package io.livekit.android.videoencodedecode
  2 +
  3 +import com.github.ajalt.timberkt.Timber
  4 +import io.livekit.android.webrtc.SimulcastVideoEncoderFactoryWrapper
  5 +import org.webrtc.EglBase
  6 +import org.webrtc.VideoCodecInfo
  7 +
  8 +class WhitelistSimulcastVideoEncoderFactory(
  9 + sharedContext: EglBase.Context?,
  10 + enableIntelVp8Encoder: Boolean,
  11 + enableH264HighProfile: Boolean
  12 +) : SimulcastVideoEncoderFactoryWrapper(sharedContext, enableIntelVp8Encoder, enableH264HighProfile),
  13 + WhitelistEncoderFactory {
  14 + override var codecWhitelist: List<String>? = null
  15 +
  16 + override fun getSupportedCodecs(): Array<VideoCodecInfo> {
  17 + val supportedCodecs = super.getSupportedCodecs()
  18 + Timber.v { "actual supported codecs: ${supportedCodecs.map { it.name }}" }
  19 +
  20 + val filteredCodecs = supportedCodecs.filter { codecWhitelist?.contains(it.name) ?: true }.toTypedArray()
  21 + Timber.v { "filtered supported codecs: ${filteredCodecs.map { it.name }}" }
  22 +
  23 + return filteredCodecs
  24 + }
  25 +}
  1 +package io.livekit.android.composesample.ui
  2 +
  3 +import androidx.compose.foundation.background
  4 +import androidx.compose.foundation.layout.*
  5 +import androidx.compose.foundation.shape.RoundedCornerShape
  6 +import androidx.compose.material.Button
  7 +import androidx.compose.material.Text
  8 +import androidx.compose.runtime.Composable
  9 +import androidx.compose.ui.Alignment
  10 +import androidx.compose.ui.Modifier
  11 +import androidx.compose.ui.graphics.Color
  12 +import androidx.compose.ui.tooling.preview.Preview
  13 +import androidx.compose.ui.unit.dp
  14 +import androidx.compose.ui.window.Dialog
  15 +
  16 +@Preview
  17 +@Composable
  18 +fun DebugMenuDialog(
  19 + onDismissRequest: () -> Unit = {},
  20 + simulateMigration: () -> Unit = {},
  21 + fullReconnect: () -> Unit = {},
  22 +) {
  23 + Dialog(onDismissRequest = onDismissRequest) {
  24 + Column(
  25 + horizontalAlignment = Alignment.CenterHorizontally,
  26 + modifier = Modifier
  27 + .background(Color.DarkGray, shape = RoundedCornerShape(3.dp))
  28 + .fillMaxWidth()
  29 + .padding(10.dp)
  30 + ) {
  31 + Text("Debug Menu", color = Color.White)
  32 + Spacer(modifier = Modifier.height(10.dp))
  33 +
  34 + Button(onClick = {
  35 + simulateMigration()
  36 + onDismissRequest()
  37 + }) {
  38 + Text("Simulate Migration")
  39 + }
  40 + Button(onClick = {
  41 + fullReconnect()
  42 + onDismissRequest()
  43 + }) {
  44 + Text("Reconnect to room")
  45 + }
  46 + }
  47 + }
  48 +}
  1 +package io.livekit.android.composesample.ui.theme
  2 +
  3 +import androidx.compose.ui.graphics.Color
  4 +
  5 +val BlueMain = Color(0xFF007DFF)
  6 +val BlueDark = Color(0xFF0058B3)
  7 +val BlueLight = Color(0xFF66B1FF)
  8 +val NoVideoIconTint = Color(0xFF5A8BFF)
  9 +val NoVideoBackground = Color(0xFF00153C)
  1 +package io.livekit.android.composesample.ui.theme
  2 +
  3 +import androidx.compose.foundation.shape.RoundedCornerShape
  4 +import androidx.compose.material.Shapes
  5 +import androidx.compose.ui.unit.dp
  6 +
  7 +val Shapes = Shapes(
  8 + small = RoundedCornerShape(4.dp),
  9 + medium = RoundedCornerShape(4.dp),
  10 + large = RoundedCornerShape(0.dp)
  11 +)
  1 +package io.livekit.android.composesample.ui.theme
  2 +
  3 +import androidx.compose.material.MaterialTheme
  4 +import androidx.compose.material.darkColors
  5 +import androidx.compose.material.lightColors
  6 +import androidx.compose.runtime.Composable
  7 +import androidx.compose.ui.graphics.Color
  8 +
  9 +private val DarkColorPalette = darkColors(
  10 + primary = BlueMain,
  11 + primaryVariant = BlueDark,
  12 + secondary = BlueMain,
  13 + background = Color.Black,
  14 + surface = Color.Transparent,
  15 + onPrimary = Color.White,
  16 + onSecondary = Color.White,
  17 + onBackground = Color.White,
  18 + onSurface = Color.White,
  19 +)
  20 +
  21 +private val LightColorPalette = lightColors(
  22 + primary = BlueMain,
  23 + primaryVariant = BlueDark,
  24 + secondary = BlueMain,
  25 + /* Other default colors to override
  26 + background = Color.White,
  27 + surface = Color.White,
  28 + onPrimary = Color.White,
  29 + onSecondary = Color.Black,
  30 + onBackground = Color.Black,
  31 + onSurface = Color.Black,
  32 + */
  33 +)
  34 +
  35 +@Composable
  36 +fun AppTheme(
  37 + darkTheme: Boolean = true,
  38 + content: @Composable() () -> Unit
  39 +) {
  40 + val colors = if (darkTheme) {
  41 + DarkColorPalette
  42 + } else {
  43 + LightColorPalette
  44 + }
  45 +
  46 + MaterialTheme(
  47 + colors = colors,
  48 + typography = Typography,
  49 + shapes = Shapes,
  50 + content = content
  51 + )
  52 +}
  1 +package io.livekit.android.composesample.ui.theme
  2 +
  3 +import androidx.compose.material.Typography
  4 +import androidx.compose.ui.text.TextStyle
  5 +import androidx.compose.ui.text.font.FontFamily
  6 +import androidx.compose.ui.text.font.FontWeight
  7 +import androidx.compose.ui.unit.sp
  8 +
  9 +// Set of Material typography styles to start with
  10 +val Typography = Typography(
  11 + body1 = TextStyle(
  12 + fontFamily = FontFamily.Default,
  13 + fontWeight = FontWeight.Normal,
  14 + fontSize = 16.sp
  15 + )
  16 + /* Other default text styles to override
  17 + button = TextStyle(
  18 + fontFamily = FontFamily.Default,
  19 + fontWeight = FontWeight.W500,
  20 + fontSize = 14.sp
  21 + ),
  22 + caption = TextStyle(
  23 + fontFamily = FontFamily.Default,
  24 + fontWeight = FontWeight.Normal,
  25 + fontSize = 12.sp
  26 + )
  27 + */
  28 +)
  1 +<vector xmlns:android="http://schemas.android.com/apk/res/android"
  2 + xmlns:aapt="http://schemas.android.com/aapt"
  3 + android:width="108dp"
  4 + android:height="108dp"
  5 + android:viewportWidth="108"
  6 + android:viewportHeight="108">
  7 + <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">
  8 + <aapt:attr name="android:fillColor">
  9 + <gradient
  10 + android:endX="85.84757"
  11 + android:endY="92.4963"
  12 + android:startX="42.9492"
  13 + android:startY="49.59793"
  14 + android:type="linear">
  15 + <item
  16 + android:color="#44000000"
  17 + android:offset="0.0" />
  18 + <item
  19 + android:color="#00000000"
  20 + android:offset="1.0" />
  21 + </gradient>
  22 + </aapt:attr>
  23 + </path>
  24 + <path
  25 + android:fillColor="#FFFFFF"
  26 + android:fillType="nonZero"
  27 + 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"
  28 + android:strokeWidth="1"
  29 + android:strokeColor="#00000000" />
  30 +</vector>
  1 +<?xml version="1.0" encoding="utf-8"?>
  2 +<vector xmlns:android="http://schemas.android.com/apk/res/android"
  3 + android:width="108dp"
  4 + android:height="108dp"
  5 + android:viewportWidth="108"
  6 + android:viewportHeight="108">
  7 + <path
  8 + android:fillColor="#3DDC84"
  9 + android:pathData="M0,0h108v108h-108z" />
  10 + <path
  11 + android:fillColor="#00000000"
  12 + android:pathData="M9,0L9,108"
  13 + android:strokeWidth="0.8"
  14 + android:strokeColor="#33FFFFFF" />
  15 + <path
  16 + android:fillColor="#00000000"
  17 + android:pathData="M19,0L19,108"
  18 + android:strokeWidth="0.8"
  19 + android:strokeColor="#33FFFFFF" />
  20 + <path
  21 + android:fillColor="#00000000"
  22 + android:pathData="M29,0L29,108"
  23 + android:strokeWidth="0.8"
  24 + android:strokeColor="#33FFFFFF" />
  25 + <path
  26 + android:fillColor="#00000000"
  27 + android:pathData="M39,0L39,108"
  28 + android:strokeWidth="0.8"
  29 + android:strokeColor="#33FFFFFF" />
  30 + <path
  31 + android:fillColor="#00000000"
  32 + android:pathData="M49,0L49,108"
  33 + android:strokeWidth="0.8"
  34 + android:strokeColor="#33FFFFFF" />
  35 + <path
  36 + android:fillColor="#00000000"
  37 + android:pathData="M59,0L59,108"
  38 + android:strokeWidth="0.8"
  39 + android:strokeColor="#33FFFFFF" />
  40 + <path
  41 + android:fillColor="#00000000"
  42 + android:pathData="M69,0L69,108"
  43 + android:strokeWidth="0.8"
  44 + android:strokeColor="#33FFFFFF" />
  45 + <path
  46 + android:fillColor="#00000000"
  47 + android:pathData="M79,0L79,108"
  48 + android:strokeWidth="0.8"
  49 + android:strokeColor="#33FFFFFF" />
  50 + <path
  51 + android:fillColor="#00000000"
  52 + android:pathData="M89,0L89,108"
  53 + android:strokeWidth="0.8"
  54 + android:strokeColor="#33FFFFFF" />
  55 + <path
  56 + android:fillColor="#00000000"
  57 + android:pathData="M99,0L99,108"
  58 + android:strokeWidth="0.8"
  59 + android:strokeColor="#33FFFFFF" />
  60 + <path
  61 + android:fillColor="#00000000"
  62 + android:pathData="M0,9L108,9"
  63 + android:strokeWidth="0.8"
  64 + android:strokeColor="#33FFFFFF" />
  65 + <path
  66 + android:fillColor="#00000000"
  67 + android:pathData="M0,19L108,19"
  68 + android:strokeWidth="0.8"
  69 + android:strokeColor="#33FFFFFF" />
  70 + <path
  71 + android:fillColor="#00000000"
  72 + android:pathData="M0,29L108,29"
  73 + android:strokeWidth="0.8"
  74 + android:strokeColor="#33FFFFFF" />
  75 + <path
  76 + android:fillColor="#00000000"
  77 + android:pathData="M0,39L108,39"
  78 + android:strokeWidth="0.8"
  79 + android:strokeColor="#33FFFFFF" />
  80 + <path
  81 + android:fillColor="#00000000"
  82 + android:pathData="M0,49L108,49"
  83 + android:strokeWidth="0.8"
  84 + android:strokeColor="#33FFFFFF" />
  85 + <path
  86 + android:fillColor="#00000000"
  87 + android:pathData="M0,59L108,59"
  88 + android:strokeWidth="0.8"
  89 + android:strokeColor="#33FFFFFF" />
  90 + <path
  91 + android:fillColor="#00000000"
  92 + android:pathData="M0,69L108,69"
  93 + android:strokeWidth="0.8"
  94 + android:strokeColor="#33FFFFFF" />
  95 + <path
  96 + android:fillColor="#00000000"
  97 + android:pathData="M0,79L108,79"
  98 + android:strokeWidth="0.8"
  99 + android:strokeColor="#33FFFFFF" />
  100 + <path
  101 + android:fillColor="#00000000"
  102 + android:pathData="M0,89L108,89"
  103 + android:strokeWidth="0.8"
  104 + android:strokeColor="#33FFFFFF" />
  105 + <path
  106 + android:fillColor="#00000000"
  107 + android:pathData="M0,99L108,99"
  108 + android:strokeWidth="0.8"
  109 + android:strokeColor="#33FFFFFF" />
  110 + <path
  111 + android:fillColor="#00000000"
  112 + android:pathData="M19,29L89,29"
  113 + android:strokeWidth="0.8"
  114 + android:strokeColor="#33FFFFFF" />
  115 + <path
  116 + android:fillColor="#00000000"
  117 + android:pathData="M19,39L89,39"
  118 + android:strokeWidth="0.8"
  119 + android:strokeColor="#33FFFFFF" />
  120 + <path
  121 + android:fillColor="#00000000"
  122 + android:pathData="M19,49L89,49"
  123 + android:strokeWidth="0.8"
  124 + android:strokeColor="#33FFFFFF" />
  125 + <path
  126 + android:fillColor="#00000000"
  127 + android:pathData="M19,59L89,59"
  128 + android:strokeWidth="0.8"
  129 + android:strokeColor="#33FFFFFF" />
  130 + <path
  131 + android:fillColor="#00000000"
  132 + android:pathData="M19,69L89,69"
  133 + android:strokeWidth="0.8"
  134 + android:strokeColor="#33FFFFFF" />
  135 + <path
  136 + android:fillColor="#00000000"
  137 + android:pathData="M19,79L89,79"
  138 + android:strokeWidth="0.8"
  139 + android:strokeColor="#33FFFFFF" />
  140 + <path
  141 + android:fillColor="#00000000"
  142 + android:pathData="M29,19L29,89"
  143 + android:strokeWidth="0.8"
  144 + android:strokeColor="#33FFFFFF" />
  145 + <path
  146 + android:fillColor="#00000000"
  147 + android:pathData="M39,19L39,89"
  148 + android:strokeWidth="0.8"
  149 + android:strokeColor="#33FFFFFF" />
  150 + <path
  151 + android:fillColor="#00000000"
  152 + android:pathData="M49,19L49,89"
  153 + android:strokeWidth="0.8"
  154 + android:strokeColor="#33FFFFFF" />
  155 + <path
  156 + android:fillColor="#00000000"
  157 + android:pathData="M59,19L59,89"
  158 + android:strokeWidth="0.8"
  159 + android:strokeColor="#33FFFFFF" />
  160 + <path
  161 + android:fillColor="#00000000"
  162 + android:pathData="M69,19L69,89"
  163 + android:strokeWidth="0.8"
  164 + android:strokeColor="#33FFFFFF" />
  165 + <path
  166 + android:fillColor="#00000000"
  167 + android:pathData="M79,19L79,89"
  168 + android:strokeWidth="0.8"
  169 + android:strokeColor="#33FFFFFF" />
  170 +</vector>
  1 +<?xml version="1.0" encoding="utf-8"?>
  2 +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
  3 + <background android:drawable="@drawable/ic_launcher_background" />
  4 + <foreground android:drawable="@drawable/ic_launcher_foreground" />
  5 +</adaptive-icon>
  1 +<?xml version="1.0" encoding="utf-8"?>
  2 +<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
  3 + <background android:drawable="@drawable/ic_launcher_background" />
  4 + <foreground android:drawable="@drawable/ic_launcher_foreground" />
  5 +</adaptive-icon>
  1 +<?xml version="1.0" encoding="utf-8"?>
  2 +<resources>
  3 + <color name="purple_200">#FFBB86FC</color>
  4 + <color name="purple_500">#FF6200EE</color>
  5 + <color name="purple_700">#FF3700B3</color>
  6 + <color name="teal_200">#FF03DAC5</color>
  7 + <color name="teal_700">#FF018786</color>
  8 + <color name="black">#FF000000</color>
  9 + <color name="white">#FFFFFFFF</color>
  10 +</resources>
  1 +<resources>
  2 + <string name="app_name">Video Encode Decode Tester</string>
  3 +</resources>
  1 +<resources xmlns:tools="http://schemas.android.com/tools">
  2 + <!-- Base application theme. -->
  3 + <style name="Theme.Livekitandroid" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
  4 + <!-- Primary brand color. -->
  5 + <item name="colorPrimary">@color/colorPrimary</item>
  6 + <item name="colorPrimaryVariant">@color/colorPrimaryDark</item>
  7 + <item name="colorOnPrimary">@color/white</item>
  8 + <!-- Status bar color. -->
  9 + <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
  10 + <!-- Customize your theme here. -->
  11 + </style>
  12 +
  13 + <style name="Theme.Livekitandroid.NoActionBar">
  14 + <item name="windowActionBar">false</item>
  15 + <item name="windowNoTitle">true</item>
  16 + </style>
  17 +
  18 + <style name="Theme.Livekitandroid.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar" />
  19 +
  20 + <style name="Theme.Livekitandroid.PopupOverlay" parent="ThemeOverlay.AppCompat.Light" />
  21 +</resources>
  1 +<?xml version="1.0" encoding="utf-8"?>
  2 +<network-security-config>
  3 + <domain-config cleartextTrafficPermitted="true">
  4 + <domain includeSubdomains="true">example.com</domain>
  5 + </domain-config>
  6 +
  7 + <base-config cleartextTrafficPermitted="true">
  8 + <trust-anchors>
  9 + <certificates src="system" />
  10 + </trust-anchors>
  11 + </base-config>
  12 +</network-security-config>