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' |
video-encode-decode-test/.gitignore
0 → 100644
| 1 | +/build |
video-encode-decode-test/build.gradle
0 → 100644
| 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 | +} |
video-encode-decode-test/proguard-rules.pro
0 → 100644
| 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 | +} |
video-encode-decode-test/src/androidTest/java/io/livekit/android/videoencodedecode/RandomString.kt
0 → 100644
| 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 | +} |
video-encode-decode-test/src/androidTest/java/io/livekit/android/videoencodedecode/VideoTest.kt
0 → 100644
| 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> |
video-encode-decode-test/src/main/java/io/livekit/android/videoencodedecode/CallActivity.kt
0 → 100644
| 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 | +} |
video-encode-decode-test/src/main/java/io/livekit/android/videoencodedecode/CallViewModel.kt
0 → 100644
| 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 |
video-encode-decode-test/src/main/java/io/livekit/android/videoencodedecode/ConnectionItem.kt
0 → 100644
| 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 | +} |
video-encode-decode-test/src/main/java/io/livekit/android/videoencodedecode/DummyVideoCapturer.kt
0 → 100644
| 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 | +} |
video-encode-decode-test/src/main/java/io/livekit/android/videoencodedecode/MainActivity.kt
0 → 100644
| 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 | +} |
video-encode-decode-test/src/main/java/io/livekit/android/videoencodedecode/MainViewModel.kt
0 → 100644
| 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 | +} |
video-encode-decode-test/src/main/java/io/livekit/android/videoencodedecode/ParticipantItem.kt
0 → 100644
| 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 | +} |
video-encode-decode-test/src/main/java/io/livekit/android/videoencodedecode/SampleApplication.kt
0 → 100644
| 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 | +} |
video-encode-decode-test/src/main/java/io/livekit/android/videoencodedecode/ViewModelLazyExt.kt
0 → 100644
| 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 | +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 | +} |
video-encode-decode-test/src/main/java/io/livekit/android/videoencodedecode/ui/DebugMenuDialog.kt
0 → 100644
| 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 | +} |
video-encode-decode-test/src/main/java/io/livekit/android/videoencodedecode/ui/theme/Color.kt
0 → 100644
| 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) |
video-encode-decode-test/src/main/java/io/livekit/android/videoencodedecode/ui/theme/Shape.kt
0 → 100644
| 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 | +) |
video-encode-decode-test/src/main/java/io/livekit/android/videoencodedecode/ui/theme/Theme.kt
0 → 100644
| 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 | +} |
video-encode-decode-test/src/main/java/io/livekit/android/videoencodedecode/ui/theme/Type.kt
0 → 100644
| 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 | +<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 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> |
-
请 注册 或 登录 后发表评论