diff --git a/.changeset/nice-clocks-refuse.md b/.changeset/nice-clocks-refuse.md new file mode 100644 index 000000000..21fd9dbe1 --- /dev/null +++ b/.changeset/nice-clocks-refuse.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": minor +--- + +Detect rotation for screenshare tracks diff --git a/.changeset/stale-ways-bathe.md b/.changeset/stale-ways-bathe.md new file mode 100644 index 000000000..46d9812c7 --- /dev/null +++ b/.changeset/stale-ways-bathe.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": patch +--- + +Update Kotlin dependency to 1.9.25 diff --git a/.changeset/thirty-readers-lie.md b/.changeset/thirty-readers-lie.md new file mode 100644 index 000000000..f90a77a5a --- /dev/null +++ b/.changeset/thirty-readers-lie.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": minor +--- + +Add separate default capture/publish options for screenshare tracks diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 69e86158b..4cb745724 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/deps.gradle b/deps.gradle index 508b04e10..cece9c4ab 100644 --- a/deps.gradle +++ b/deps.gradle @@ -1,13 +1,13 @@ ext { - android_build_tools_version = '8.2.2' + android_build_tools_version = '8.7.2' compose_version = '1.2.1' - compose_compiler_version = '1.4.5' - kotlin_version = '1.8.20' + compose_compiler_version = '1.5.15' + kotlin_version = '1.9.25' java_version = JavaVersion.VERSION_1_8 - dokka_version = '1.8.20' + dokka_version = '1.9.20' androidSdk = [ - compileVersion: 34, - targetVersion : 34, + compileVersion: 35, + targetVersion : 35, minVersion : 21, ] generated = [ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 11710f526..b477abed9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -92,7 +92,7 @@ mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version = "4.1. #noinspection GradleDependency mockito-inline = { module = "org.mockito:mockito-inline", version = "4.11.0" } -robolectric = { module = "org.robolectric:robolectric", version = "4.11.1" } +robolectric = { module = "org.robolectric:robolectric", version = "4.14.1" } turbine = { module = "app.cash.turbine:turbine", version = "1.0.0" } appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d692dd21d..2963667f8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon May 01 22:58:53 JST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/livekit-android-sdk/build.gradle b/livekit-android-sdk/build.gradle index 5fe4f0acf..6521cd5b8 100644 --- a/livekit-android-sdk/build.gradle +++ b/livekit-android-sdk/build.gradle @@ -52,8 +52,9 @@ android { targetCompatibility java_version } packagingOptions { - // Exclude our protos from being included in the final aar. - exclude "**/*.proto" + resources { + excludes += ['**/*.proto'] + } } buildFeatures { diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/RoomOptions.kt b/livekit-android-sdk/src/main/java/io/livekit/android/RoomOptions.kt index 334d92b10..a73e90690 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/RoomOptions.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/RoomOptions.kt @@ -43,4 +43,6 @@ data class RoomOptions( val videoTrackCaptureDefaults: LocalVideoTrackOptions? = null, val audioTrackPublishDefaults: AudioTrackPublishDefaults? = null, val videoTrackPublishDefaults: VideoTrackPublishDefaults? = null, + val screenShareTrackCaptureDefaults: LocalVideoTrackOptions? = null, + val screenShareTrackPublishDefaults: VideoTrackPublishDefaults? = null, ) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/DefaultsManager.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/DefaultsManager.kt index ffba84309..21f03db84 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/DefaultsManager.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/DefaultsManager.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 LiveKit, Inc. + * Copyright 2023-2024 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,7 @@ import io.livekit.android.room.participant.AudioTrackPublishDefaults import io.livekit.android.room.participant.VideoTrackPublishDefaults import io.livekit.android.room.track.LocalAudioTrackOptions import io.livekit.android.room.track.LocalVideoTrackOptions +import io.livekit.android.room.track.ScreenSharePresets import javax.inject.Inject import javax.inject.Singleton @@ -31,4 +32,6 @@ constructor() { var audioTrackPublishDefaults: AudioTrackPublishDefaults = AudioTrackPublishDefaults() var videoTrackCaptureDefaults: LocalVideoTrackOptions = LocalVideoTrackOptions() var videoTrackPublishDefaults: VideoTrackPublishDefaults = VideoTrackPublishDefaults() + var screenShareTrackCaptureDefaults: LocalVideoTrackOptions = LocalVideoTrackOptions(isScreencast = true, captureParams = ScreenSharePresets.ORIGINAL.capture) + var screenShareTrackPublishDefaults: VideoTrackPublishDefaults = VideoTrackPublishDefaults(videoEncoding = ScreenSharePresets.ORIGINAL.encoding) } diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt index fc54b8157..d7bd703f8 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/Room.kt @@ -251,6 +251,16 @@ constructor( */ var videoTrackPublishDefaults: VideoTrackPublishDefaults by defaultsManager::videoTrackPublishDefaults + /** + * Default options to use when creating a screen share track. + */ + var screenShareTrackCaptureDefaults: LocalVideoTrackOptions by defaultsManager::screenShareTrackCaptureDefaults + + /** + * Default options to use when publishing a screen share track. + */ + var screenShareTrackPublishDefaults: VideoTrackPublishDefaults by defaultsManager::screenShareTrackPublishDefaults + val localParticipant: LocalParticipant = localParticipantFactory.create(dynacast = false).apply { internalListener = this@Room } @@ -285,11 +295,13 @@ constructor( RoomOptions( adaptiveStream = adaptiveStream, dynacast = dynacast, + e2eeOptions = e2eeOptions, audioTrackCaptureDefaults = audioTrackCaptureDefaults, videoTrackCaptureDefaults = videoTrackCaptureDefaults, audioTrackPublishDefaults = audioTrackPublishDefaults, videoTrackPublishDefaults = videoTrackPublishDefaults, - e2eeOptions = e2eeOptions, + screenShareTrackCaptureDefaults = screenShareTrackCaptureDefaults, + screenShareTrackPublishDefaults = screenShareTrackPublishDefaults, ) /** @@ -502,6 +514,12 @@ constructor( options.videoTrackPublishDefaults?.let { videoTrackPublishDefaults = it } + options.screenShareTrackCaptureDefaults?.let { + screenShareTrackCaptureDefaults = it + } + options.screenShareTrackPublishDefaults?.let { + screenShareTrackPublishDefaults = it + } adaptiveStream = options.adaptiveStream dynacast = options.dynacast e2eeOptions = options.e2eeOptions diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt index cfc899e83..8383e15ab 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt @@ -93,6 +93,8 @@ internal constructor( var audioTrackPublishDefaults: AudioTrackPublishDefaults by defaultsManager::audioTrackPublishDefaults var videoTrackCaptureDefaults: LocalVideoTrackOptions by defaultsManager::videoTrackCaptureDefaults var videoTrackPublishDefaults: VideoTrackPublishDefaults by defaultsManager::videoTrackPublishDefaults + var screenShareTrackCaptureDefaults: LocalVideoTrackOptions by defaultsManager::screenShareTrackCaptureDefaults + var screenShareTrackPublishDefaults: VideoTrackPublishDefaults by defaultsManager::screenShareTrackPublishDefaults private var republishes: List? = null private val localTrackPublications @@ -181,13 +183,13 @@ internal constructor( * @param name The name of the track. * @param mediaProjectionPermissionResultData The resultData returned from launching * [MediaProjectionManager.createScreenCaptureIntent()](https://developer.android.com/reference/android/media/projection/MediaProjectionManager#createScreenCaptureIntent()). - * @param options The capture options to use for this track, or [Room.videoTrackCaptureDefaults] if none is passed. + * @param options The capture options to use for this track, or [Room.screenShareTrackCaptureDefaults] if none is passed. * @param videoProcessor A video processor to attach to this track that can modify the frames before publishing. */ fun createScreencastTrack( name: String = "", mediaProjectionPermissionResultData: Intent, - options: LocalVideoTrackOptions = videoTrackCaptureDefaults.copy(), + options: LocalVideoTrackOptions = screenShareTrackCaptureDefaults.copy(), videoProcessor: VideoProcessor? = null, ): LocalScreencastVideoTrack { val screencastOptions = options.copy(isScreencast = true) @@ -249,8 +251,8 @@ internal constructor( * @param mediaProjectionPermissionResultData The resultData returned from launching * [MediaProjectionManager.createScreenCaptureIntent()](https://developer.android.com/reference/android/media/projection/MediaProjectionManager#createScreenCaptureIntent()). * @throws IllegalArgumentException if attempting to enable screenshare without [mediaProjectionPermissionResultData] - * @see Room.videoTrackCaptureDefaults - * @see Room.videoTrackPublishDefaults + * @see Room.screenShareTrackCaptureDefaults + * @see Room.screenShareTrackPublishDefaults */ suspend fun setScreenShareEnabled( enabled: Boolean, @@ -294,7 +296,7 @@ internal constructor( createScreencastTrack(mediaProjectionPermissionResultData = mediaProjectionPermissionResultData) track.startForegroundService(null, null) track.startCapture() - publishVideoTrack(track) + publishVideoTrack(track, options = VideoTrackPublishOptions(null, screenShareTrackPublishDefaults)) } else -> { diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalScreencastVideoTrack.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalScreencastVideoTrack.kt index 495b2004d..23a9ba410 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalScreencastVideoTrack.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalScreencastVideoTrack.kt @@ -22,15 +22,35 @@ import android.app.NotificationManager import android.content.Context import android.content.Intent import android.media.projection.MediaProjection +import android.util.DisplayMetrics +import android.view.OrientationEventListener +import android.view.WindowManager import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.livekit.android.room.DefaultsManager +import io.livekit.android.room.participant.LocalParticipant import io.livekit.android.room.track.screencapture.ScreenCaptureConnection import io.livekit.android.room.track.screencapture.ScreenCaptureService -import livekit.org.webrtc.* -import java.util.* +import io.livekit.android.util.LKLog +import livekit.org.webrtc.EglBase +import livekit.org.webrtc.PeerConnectionFactory +import livekit.org.webrtc.ScreenCapturerAndroid +import livekit.org.webrtc.SurfaceTextureHelper +import livekit.org.webrtc.VideoCapturer +import livekit.org.webrtc.VideoProcessor +import livekit.org.webrtc.VideoSource +import java.util.UUID +/** + * A video track that captures the screen for publishing. + * + * Note: A foreground service is generally required for use. Use [startForegroundService] or start + * your own foreground service before starting the video track. + * + * @see LocalParticipant.createScreencastTrack + * @see LocalScreencastVideoTrack.startForegroundService + */ class LocalScreencastVideoTrack @AssistedInject constructor( @@ -58,6 +78,76 @@ constructor( videoTrackFactory, ) { + private var prevDisplayWidth = 0 + private var prevDisplayHeight = 0 + private val displayMetrics = DisplayMetrics() + private val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager + private val orientationEventListener = object : OrientationEventListener(context) { + override fun onOrientationChanged(orientation: Int) { + if (isDisposed) { + this.disable() + return + } + updateCaptureFormatIfNeeded() + } + } + + private fun getCaptureDimensions(displayWidth: Int, displayHeight: Int): Pair { + val captureWidth: Int + val captureHeight: Int + + if (options.captureParams.width == 0 && options.captureParams.height == 0) { + // Use raw display size + captureWidth = displayWidth + captureHeight = displayHeight + } else { + // Use captureParams.width as longest side and captureParams.height as shortest side. + if (displayWidth > displayHeight) { + captureWidth = options.captureParams.width + captureHeight = options.captureParams.height + } else { + captureWidth = options.captureParams.height + captureHeight = options.captureParams.width + } + } + + return captureWidth to captureHeight + } + + private fun updateCaptureFormatIfNeeded() { + windowManager.defaultDisplay.getRealMetrics(displayMetrics) + val displayWidth = displayMetrics.widthPixels + val displayHeight = displayMetrics.heightPixels + + // Updates whenever the display rotates + if (displayWidth != prevDisplayWidth || displayHeight != prevDisplayHeight) { + prevDisplayWidth = displayWidth + prevDisplayHeight = displayHeight + + val (captureWidth, captureHeight) = getCaptureDimensions(displayWidth, displayHeight) + + try { + capturer.changeCaptureFormat(captureWidth, captureHeight, options.captureParams.maxFps) + } catch (e: Exception) { + LKLog.w(e) { "Exception when changing capture format of the screen share track." } + } + } + } + + override fun startCapture() { + // Don't use super.startCapture, must calculate correct dimensions + windowManager.defaultDisplay.getRealMetrics(displayMetrics) + val displayWidth = displayMetrics.widthPixels + val displayHeight = displayMetrics.heightPixels + val (captureWidth, captureHeight) = getCaptureDimensions(displayWidth, displayHeight) + + capturer.startCapture(captureWidth, captureHeight, options.captureParams.maxFps) + + if (orientationEventListener.canDetectOrientation()) { + orientationEventListener.enable() + } + } + private val serviceConnection = ScreenCaptureConnection(context) init { @@ -95,6 +185,7 @@ constructor( override fun stop() { super.stop() serviceConnection.stop() + orientationEventListener.disable() } @AssistedFactory @@ -129,7 +220,7 @@ constructor( options: LocalVideoTrackOptions, rootEglBase: EglBase, screencastVideoTrackFactory: Factory, - videoProcessor: VideoProcessor? + videoProcessor: VideoProcessor?, ): LocalScreencastVideoTrack { val source = peerConnectionFactory.createVideoSource(options.isScreencast) source.setVideoProcessor(videoProcessor) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalVideoTrackOptions.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalVideoTrackOptions.kt index bdbfb7d46..09c2d8320 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalVideoTrackOptions.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalVideoTrackOptions.kt @@ -172,3 +172,48 @@ enum class VideoPreset43( VideoEncoding(3_800_000, 30), ), } + +/** + * 16:9 Video presets along with suggested bitrates. + */ +enum class ScreenSharePresets( + override val capture: VideoCaptureParameter, + override val encoding: VideoEncoding, +) : VideoPreset { + H360_FPS3( + VideoCaptureParameter(640, 360, 3), + VideoEncoding(200_000, 3), + ), + H360_FPS15( + VideoCaptureParameter(640, 360, 15), + VideoEncoding(400_000, 15), + ), + H720_FPS5( + VideoCaptureParameter(1280, 720, 5), + VideoEncoding(800_000, 5), + ), + H720_FPS15( + VideoCaptureParameter(1280, 720, 15), + VideoEncoding(1_500_000, 15), + ), + H720_FPS30( + VideoCaptureParameter(1280, 720, 30), + VideoEncoding(2_000_000, 30), + ), + H1080_FPS15( + VideoCaptureParameter(1920, 1080, 15), + VideoEncoding(2_500_000, 15), + ), + H1080_FPS30( + VideoCaptureParameter(1920, 1080, 30), + VideoEncoding(5_000_000, 30), + ), + + /** + * Uses the original resolution without resizing. + */ + ORIGINAL( + VideoCaptureParameter(0, 0, 30), + VideoEncoding(7_000_000, 30), + ) +} diff --git a/livekit-android-test/build.gradle b/livekit-android-test/build.gradle index 3374894cb..16ec5b411 100644 --- a/livekit-android-test/build.gradle +++ b/livekit-android-test/build.gradle @@ -17,9 +17,6 @@ android { consumerProguardFiles "consumer-rules.pro" } - lintOptions { - disable 'VisibleForTests' - } buildTypes { release { minifyEnabled false @@ -39,6 +36,9 @@ android { includeAndroidResources = true } } + lint { + disable 'VisibleForTests' + } } dokkaHtml { diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockAudioProcessingController.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockAudioProcessingController.kt index af07d7261..48c3e7298 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockAudioProcessingController.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockAudioProcessingController.kt @@ -39,15 +39,19 @@ class MockAudioProcessingController : AudioProcessingController { @get:FlowObservable override var bypassCapturePostProcessing: Boolean by flowDelegate(false) + @Deprecated("Use the capturePostProcessing variable directly instead") override fun setCapturePostProcessing(processing: AudioProcessorInterface?) { } + @Deprecated("Use the bypassCapturePostProcessing variable directly instead") override fun setBypassForCapturePostProcessing(bypass: Boolean) { } + @Deprecated("Use the renderPreProcessing variable directly instead") override fun setRenderPreProcessing(processing: AudioProcessorInterface?) { } + @Deprecated("Use the bypassRendererPreProcessing variable directly instead") override fun setBypassForRenderPreProcessing(bypass: Boolean) { } }