Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Screen sharing over WebRTC #5911

Merged
merged 12 commits into from
May 10, 2022
1 change: 1 addition & 0 deletions changelog.d/5911.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Screen sharing over WebRTC
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,5 @@ class DefaultVectorFeatures : VectorFeatures {
override fun isOnboardingPersonalizeEnabled() = false
override fun isOnboardingCombinedRegisterEnabled() = false
override fun isLiveLocationEnabled(): Boolean = false
override fun isScreenSharingEnabled(): Boolean = false
override fun isScreenSharingEnabled(): Boolean = true
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ class CallControlsView @JvmOverloads constructor(
views.videoToggleIcon.setImageResource(R.drawable.ic_video_off)
views.videoToggleIcon.contentDescription = resources.getString(R.string.a11y_start_camera)
}
views.videoToggleIcon.isEnabled = !state.isSharingScreen
views.videoToggleIcon.alpha = if (state.isSharingScreen) 0.5f else 1f

when (callState) {
is CallState.LocalRinging -> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import android.content.Intent
import android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP
import android.content.res.Configuration
import android.graphics.Color
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Bundle
Expand All @@ -32,6 +33,7 @@ import android.util.Rational
import android.view.MenuItem
import android.view.View
import android.view.WindowManager
import androidx.activity.result.ActivityResult
import androidx.annotation.StringRes
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
Expand Down Expand Up @@ -76,6 +78,7 @@ import org.matrix.android.sdk.api.session.call.TurnServerResponse
import org.matrix.android.sdk.api.session.room.model.call.EndCallReason
import org.webrtc.EglBase
import org.webrtc.RendererCommon
import org.webrtc.ScreenCapturerAndroid
import timber.log.Timber
import javax.inject.Inject

Expand Down Expand Up @@ -161,6 +164,9 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro
}
}
}

// Bind to service in case of user killed the app while there is an ongoing call
bindToScreenCaptureService()
}

override fun onNewIntent(intent: Intent?) {
Expand Down Expand Up @@ -636,18 +642,40 @@ class VectorCallActivity : VectorBaseActivity<ActivityCallBinding>(), CallContro

private val screenSharingPermissionActivityResultLauncher = registerStartForActivityResult { activityResult ->
if (activityResult.resultCode == Activity.RESULT_OK) {
callViewModel.handle(VectorCallViewActions.StartScreenSharing)
// We need to start a foreground service with a sticky notification during screen sharing
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ContextCompat.startForegroundService(
this,
Intent(this, ScreenCaptureService::class.java)
)
screenCaptureServiceConnection.bind()
// We need to start a foreground service with a sticky notification during screen sharing
startScreenSharingService(activityResult)
} else {
startScreenSharing(activityResult)
}
}
}

private fun startScreenSharing(activityResult: ActivityResult) {
val videoCapturer = ScreenCapturerAndroid(activityResult.data, object : MediaProjection.Callback() {
override fun onStop() {
Timber.i("User revoked the screen capturing permission")
}
})
callViewModel.handle(VectorCallViewActions.StartScreenSharing(videoCapturer))
}

private fun startScreenSharingService(activityResult: ActivityResult) {
ContextCompat.startForegroundService(
this,
Intent(this, ScreenCaptureService::class.java)
)
bindToScreenCaptureService(activityResult)
}

private fun bindToScreenCaptureService(activityResult: ActivityResult? = null) {
screenCaptureServiceConnection.bind(object : ScreenCaptureServiceConnection.Callback {
override fun onServiceConnected() {
activityResult?.let { startScreenSharing(it) }
}
})
}

private fun handleShowScreenSharingPermissionDialog() {
getSystemService<MediaProjectionManager>()?.let {
navigator.openScreenSharingPermissionDialog(it.createScreenCaptureIntent(), screenSharingPermissionActivityResultLauncher)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package im.vector.app.features.call
import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.call.audio.CallAudioManager
import im.vector.app.features.call.transfer.CallTransferResult
import org.webrtc.VideoCapturer

sealed class VectorCallViewActions : VectorViewModelAction {
object EndCall : VectorCallViewActions()
Expand All @@ -41,5 +42,5 @@ sealed class VectorCallViewActions : VectorViewModelAction {
data class CallTransferSelectionResult(val callTransferResult: CallTransferResult) : VectorCallViewActions()
object TransferCall : VectorCallViewActions()
object ToggleScreenSharing : VectorCallViewActions()
object StartScreenSharing : VectorCallViewActions()
data class StartScreenSharing(val videoCapturer: VideoCapturer) : VectorCallViewActions()
}
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,10 @@ class VectorCallViewModel @AssistedInject constructor(
override fun onCallEnded(callId: String) {
withState { state ->
if (state.otherKnownCallInfo?.callId == callId) {
setState { copy(otherKnownCallInfo = null) }
setState { copy(otherKnownCallInfo = null, isSharingScreen = false) }
}
}
_viewEvents.post(VectorCallViewEvents.StopScreenSharingService)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here I think the set of the isSharingScreen to false in the state is missing. Maybe we should reuse an mutualize what is done in handleToggleScreenSharing() when screen is shared:

if (isSharingScreen) {
    call?.stopSharingScreen()
    setState {
        copy(isSharingScreen = false)
    }
    _viewEvents.post(VectorCallViewEvents.StopScreenSharingService)
}

What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, although we finish the call activity as soon as the call ended.

}

override fun onCurrentCallChange(call: WebRtcCall?) {
Expand All @@ -155,9 +156,10 @@ class VectorCallViewModel @AssistedInject constructor(
}
}

override fun onAudioDevicesChange() {
val currentSoundDevice = callManager.audioManager.selectedDevice ?: return
if (currentSoundDevice == CallAudioManager.Device.Phone) {
override fun onAudioDevicesChange() = withState { state ->
val currentSoundDevice = callManager.audioManager.selectedDevice ?: return@withState
val webRtcCall = callManager.getCallById(state.callId)
if (webRtcCall != null && shouldActivateProximitySensor(webRtcCall)) {
proximityManager.start()
} else {
proximityManager.stop()
Expand Down Expand Up @@ -204,7 +206,7 @@ class VectorCallViewModel @AssistedInject constructor(
callManager.addListener(callManagerListener)
webRtcCall.addListener(callListener)
val currentSoundDevice = callManager.audioManager.selectedDevice
if (currentSoundDevice == CallAudioManager.Device.Phone) {
if (shouldActivateProximitySensor(webRtcCall)) {
proximityManager.start()
}
setState {
Expand All @@ -223,13 +225,18 @@ class VectorCallViewModel @AssistedInject constructor(
formattedDuration = webRtcCall.formattedDuration(),
isHD = webRtcCall.mxCall.isVideoCall && webRtcCall.currentCaptureFormat() is CaptureFormat.HD,
canOpponentBeTransferred = webRtcCall.mxCall.capabilities.supportCallTransfer(),
transferee = computeTransfereeState(webRtcCall.mxCall)
transferee = computeTransfereeState(webRtcCall.mxCall),
isSharingScreen = webRtcCall.isSharingScreen()
)
}
updateOtherKnownCall(webRtcCall)
}
}

private fun shouldActivateProximitySensor(webRtcCall: WebRtcCall): Boolean {
return callManager.audioManager.selectedDevice == CallAudioManager.Device.Phone && !webRtcCall.isSharingScreen()
}

private fun WebRtcCall.extractCallInfo(): VectorCallViewState.CallInfo {
val assertedIdentity = this.remoteAssertedIdentity
val matrixItem = if (assertedIdentity != null) {
Expand Down Expand Up @@ -348,7 +355,8 @@ class VectorCallViewModel @AssistedInject constructor(
handleToggleScreenSharing(state.isSharingScreen)
}
is VectorCallViewActions.StartScreenSharing -> {
call?.startSharingScreen()
call?.startSharingScreen(action.videoCapturer)
proximityManager.stop()
setState {
copy(isSharingScreen = true)
}
Expand All @@ -365,6 +373,9 @@ class VectorCallViewModel @AssistedInject constructor(
_viewEvents.post(
VectorCallViewEvents.StopScreenSharingService
)
if (callManager.audioManager.selectedDevice == CallAudioManager.Device.Phone) {
proximityManager.start()
}
} else {
_viewEvents.post(
VectorCallViewEvents.ShowScreenSharingPermissionDialog
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,20 @@ class ScreenCaptureServiceConnection @Inject constructor(
private val context: Context
) : ServiceConnection {

interface Callback {
fun onServiceConnected()
}

private var isBound = false
private var screenCaptureService: ScreenCaptureService? = null
private var callback: Callback? = null

fun bind(callback: Callback) {
this.callback = callback
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be better to reset the callback to null when unbound to the service in onServiceDisconnected for example.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, done.


fun bind() {
if (!isBound) {
if (isBound) {
callback.onServiceConnected()
} else {
Intent(context, ScreenCaptureService::class.java).also { intent ->
context.bindService(intent, this, 0)
}
Expand All @@ -45,10 +54,12 @@ class ScreenCaptureServiceConnection @Inject constructor(
override fun onServiceConnected(className: ComponentName, binder: IBinder) {
screenCaptureService = (binder as ScreenCaptureService.LocalBinder).getService()
isBound = true
callback?.onServiceConnected()
}

override fun onServiceDisconnected(className: ComponentName) {
isBound = false
screenCaptureService = null
callback = null
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,12 @@ import org.webrtc.MediaConstraints
import org.webrtc.MediaStream
import org.webrtc.PeerConnection
import org.webrtc.PeerConnectionFactory
import org.webrtc.RtpSender
import org.webrtc.RtpTransceiver
import org.webrtc.SessionDescription
import org.webrtc.SurfaceTextureHelper
import org.webrtc.SurfaceViewRenderer
import org.webrtc.VideoCapturer
import org.webrtc.VideoSource
import org.webrtc.VideoTrack
import timber.log.Timber
Expand All @@ -95,6 +97,7 @@ import kotlin.coroutines.CoroutineContext
private const val STREAM_ID = "userMedia"
private const val AUDIO_TRACK_ID = "${STREAM_ID}a0"
private const val VIDEO_TRACK_ID = "${STREAM_ID}v0"
private const val SCREEN_TRACK_ID = "${STREAM_ID}s0"
private val DEFAULT_AUDIO_CONSTRAINTS = MediaConstraints()
private const val INVITE_TIMEOUT_IN_MS = 60_000L

Expand Down Expand Up @@ -153,13 +156,16 @@ class WebRtcCall(
private var makingOffer: Boolean = false
private var ignoreOffer: Boolean = false

private var videoCapturer: CameraVideoCapturer? = null
private var videoCapturer: VideoCapturer? = null

private val availableCamera = ArrayList<CameraProxy>()
private var cameraInUse: CameraProxy? = null
private var currentCaptureFormat: CaptureFormat = CaptureFormat.HD
private var cameraAvailabilityCallback: CameraManager.AvailabilityCallback? = null

private var videoSender: RtpSender? = null
private var screenSender: RtpSender? = null

private val timer = CountUpTimer(1000L).apply {
tickListener = object : CountUpTimer.TickListener {
override fun onTick(milliseconds: Long) {
Expand Down Expand Up @@ -617,7 +623,7 @@ class WebRtcCall(
val videoTrack = peerConnectionFactory.createVideoTrack(VIDEO_TRACK_ID, videoSource)
Timber.tag(loggerTag.value).v("Add video track $VIDEO_TRACK_ID to call ${mxCall.callId}")
videoTrack.setEnabled(true)
peerConnection?.addTrack(videoTrack, listOf(STREAM_ID))
videoSender = peerConnection?.addTrack(videoTrack, listOf(STREAM_ID))
localVideoSource = videoSource
localVideoTrack = videoTrack
}
Expand Down Expand Up @@ -722,7 +728,7 @@ class WebRtcCall(
Timber.tag(loggerTag.value).v("switchCamera")
if (mxCall.state is CallState.Connected && mxCall.isVideoCall) {
val oppositeCamera = getOppositeCameraIfAny() ?: return@launch
videoCapturer?.switchCamera(
(videoCapturer as? CameraVideoCapturer)?.switchCamera(
object : CameraVideoCapturer.CameraSwitchHandler {
// Invoked on success. |isFrontCamera| is true if the new camera is front facing.
override fun onCameraSwitchDone(isFrontCamera: Boolean) {
Expand Down Expand Up @@ -770,12 +776,60 @@ class WebRtcCall(
return currentCaptureFormat
}

fun startSharingScreen() {
// TODO. Will be handled within the next PR.
fun startSharingScreen(videoCapturer: VideoCapturer) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you tried to extract the screen sharing capabilities into a dedicated component to avoid adding new responsabilities to WebRtcCall?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it is easily possible. This class has many local fields. We will start implementing Element Call soon, so we probably won't invest in this class more.

val factory = peerConnectionFactoryProvider.get() ?: return

this.videoCapturer = videoCapturer

val localMediaStream = factory.createLocalMediaStream(STREAM_ID)
val videoSource = factory.createVideoSource(videoCapturer.isScreencast)

startCapturingScreen(videoCapturer, videoSource)

removeLocalSurfaceRenderers()

showScreenLocally(factory, videoSource, localMediaStream)

videoSender?.let { removeStream(it) }

screenSender = peerConnection?.addTrack(localVideoTrack, listOf(STREAM_ID))
}

fun stopSharingScreen() {
// TODO. Will be handled within the next PR.
localVideoTrack?.setEnabled(false)
screenSender?.let { removeStream(it) }
if (mxCall.isVideoCall) {
peerConnectionFactoryProvider.get()?.let { configureVideoTrack(it) }
}
updateMuteStatus()
sessionScope?.launch(dispatcher) { attachViewRenderersInternal() }
}

private fun removeStream(sender: RtpSender) {
peerConnection?.removeTrack(sender)
}

private fun showScreenLocally(factory: PeerConnectionFactory, videoSource: VideoSource?, localMediaStream: MediaStream?) {
localVideoTrack = factory.createVideoTrack(SCREEN_TRACK_ID, videoSource).apply { setEnabled(true) }
localMediaStream?.addTrack(localVideoTrack)
localSurfaceRenderers.forEach { it.get()?.let { localVideoTrack?.addSink(it) } }
}

private fun removeLocalSurfaceRenderers() {
localSurfaceRenderers.forEach { it.get()?.let { localVideoTrack?.removeSink(it) } }
}

private fun startCapturingScreen(videoCapturer: VideoCapturer, videoSource: VideoSource) {
val surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", rootEglBase!!.eglBaseContext)
videoCapturer.initialize(surfaceTextureHelper, context, videoSource.capturerObserver)
videoCapturer.startCapture(currentCaptureFormat.width, currentCaptureFormat.height, currentCaptureFormat.fps)
}

/**
* Returns true if the user is sharing the screen, false otherwise.
*/
fun isSharingScreen(): Boolean {
return localVideoTrack?.enabled().orFalse() && localVideoTrack?.id() == SCREEN_TRACK_ID
}

private suspend fun release() {
Expand Down