diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt index b87eac12c..a39e88751 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/WebRtcCallActivity.kt @@ -126,6 +126,12 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { supportActionBar?.setDisplayHomeAsUpEnabled(false) } + binding.floatingRendererContainer.setOnClickListener { + val swapVideoViewIntent = + WebRtcCallService.swapVideoViews(this, viewModel.videoViewSwapped) + startService(swapVideoViewIntent) + } + binding.microphoneButton.setOnClickListener { val audioEnabledIntent = WebRtcCallService.microphoneIntent(this, !viewModel.microphoneEnabled) @@ -330,28 +336,57 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { launch { viewModel.localVideoEnabledState.collect { isEnabled -> + binding.localFloatingRenderer.removeAllViews() binding.localRenderer.removeAllViews() if (isEnabled) { viewModel.localRenderer?.let { surfaceView -> - surfaceView.setZOrderOnTop(true) binding.localRenderer.addView(surfaceView) } + viewModel.localFloatingRenderer?.let { surfaceView -> + surfaceView.setZOrderOnTop(true) + binding.localFloatingRenderer.addView(surfaceView) + + } } - binding.localRenderer.isVisible = isEnabled + binding.localFloatingRenderer.isVisible = isEnabled && !viewModel.videoViewSwapped + binding.localRenderer.isVisible = isEnabled && viewModel.videoViewSwapped binding.enableCameraButton.isSelected = isEnabled + binding.floatingRendererContainer.isVisible = binding.localFloatingRenderer.isVisible + binding.videocamOffIcon.isVisible = !binding.localFloatingRenderer.isVisible + binding.remoteRecipient.isVisible = !(binding.remoteRenderer.isVisible || binding.localRenderer.isVisible) + binding.swapViewIcon.bringToFront() } } launch { viewModel.remoteVideoEnabledState.collect { isEnabled -> binding.remoteRenderer.removeAllViews() + binding.remoteFloatingRenderer.removeAllViews() if (isEnabled) { viewModel.remoteRenderer?.let { surfaceView -> binding.remoteRenderer.addView(surfaceView) } + viewModel.remoteFloatingRenderer?.let { surfaceView -> + surfaceView.setZOrderOnTop(true) + binding.remoteFloatingRenderer.addView(surfaceView) + } } - binding.remoteRenderer.isVisible = isEnabled - binding.remoteRecipient.isVisible = !isEnabled + binding.remoteRenderer.isVisible = isEnabled && !viewModel.videoViewSwapped + binding.remoteFloatingRenderer.isVisible = isEnabled && viewModel.videoViewSwapped + binding.videocamOffIcon.isVisible = !binding.remoteFloatingRenderer.isVisible + binding.floatingRendererContainer.isVisible = binding.remoteFloatingRenderer.isVisible + binding.remoteRecipient.isVisible = !(binding.remoteRenderer.isVisible || binding.localRenderer.isVisible) + binding.swapViewIcon.bringToFront() + } + } + + launch { + viewModel.videoViewSwappedState.collect{ isSwapped -> + binding.remoteRenderer.isVisible = !isSwapped && viewModel.remoteVideoEnabled + binding.remoteFloatingRenderer.isVisible = isSwapped && viewModel.remoteVideoEnabled + binding.localFloatingRenderer.isVisible = !isSwapped && viewModel.videoEnabled + binding.localRenderer.isVisible = isSwapped && viewModel.videoEnabled + binding.floatingRendererContainer.isVisible = binding.localFloatingRenderer.isVisible || binding.remoteFloatingRenderer.isVisible } } } @@ -366,6 +401,7 @@ class WebRtcCallActivity : PassphraseRequiredActionBarActivity() { override fun onStop() { super.onStop() uiJob?.cancel() + binding.remoteFloatingRenderer.removeAllViews() binding.remoteRenderer.removeAllViews() binding.localRenderer.removeAllViews() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt index 3ae3d30f0..34fe18120 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/WebRtcCallService.kt @@ -62,6 +62,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { const val ACTION_LOCAL_HANGUP = "LOCAL_HANGUP" const val ACTION_SET_MUTE_AUDIO = "SET_MUTE_AUDIO" const val ACTION_SET_MUTE_VIDEO = "SET_MUTE_VIDEO" + const val ACTION_SWAP_VIDEO_VIEW = "SWAP_VIDEO_VIEW" const val ACTION_FLIP_CAMERA = "FLIP_CAMERA" const val ACTION_UPDATE_AUDIO = "UPDATE_AUDIO" const val ACTION_WIRED_HEADSET_CHANGE = "WIRED_HEADSET_CHANGE" @@ -81,6 +82,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { const val EXTRA_RECIPIENT_ADDRESS = "RECIPIENT_ID" const val EXTRA_ENABLED = "ENABLED" const val EXTRA_AUDIO_COMMAND = "AUDIO_COMMAND" + const val EXTRA_SWAPPED = "is_video_swapped" const val EXTRA_MUTE = "mute_value" const val EXTRA_AVAILABLE = "enabled_value" const val EXTRA_REMOTE_DESCRIPTION = "remote_description" @@ -108,6 +110,11 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { fun acceptCallIntent(context: Context) = Intent(context, WebRtcCallService::class.java) .setAction(ACTION_ANSWER_CALL) + fun swapVideoViews(context: Context, swapped: Boolean) = + Intent(context, WebRtcCallService::class.java) + .setAction(ACTION_SWAP_VIDEO_VIEW) + .putExtra(EXTRA_SWAPPED, swapped) + fun microphoneIntent(context: Context, enabled: Boolean) = Intent(context, WebRtcCallService::class.java) .setAction(ACTION_SET_MUTE_AUDIO) @@ -294,6 +301,7 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { action == ACTION_DENY_CALL -> handleDenyCall(intent) action == ACTION_LOCAL_HANGUP -> handleLocalHangup(intent) action == ACTION_REMOTE_HANGUP -> handleRemoteHangup(intent) + action == ACTION_SWAP_VIDEO_VIEW ->handleSwapVideoView(intent) action == ACTION_SET_MUTE_AUDIO -> handleSetMuteAudio(intent) action == ACTION_SET_MUTE_VIDEO -> handleSetMuteVideo(intent) action == ACTION_FLIP_CAMERA -> handleSetCameraFlip(intent) @@ -598,6 +606,11 @@ class WebRtcCallService : LifecycleService(), CallManager.WebRtcListener { onHangup() } + private fun handleSwapVideoView(intent: Intent) { + val swapped = intent.getBooleanExtra(EXTRA_SWAPPED, false) + callManager.handleSwapVideoView(swapped) + } + private fun handleSetMuteAudio(intent: Intent) { val muted = intent.getBooleanExtra(EXTRA_MUTE, false) callManager.handleSetMuteAudio(muted) diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index 894de9de6..53afa6f64 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -3,7 +3,10 @@ package org.thoughtcrime.securesms.webrtc import android.content.Context import android.content.pm.PackageManager import android.telephony.TelephonyManager +import android.view.SurfaceView +import android.view.View import androidx.core.content.ContextCompat +import androidx.core.view.isVisible import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.serialization.json.Json @@ -29,6 +32,7 @@ import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioDeviceUpdat import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.AudioEnabled import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.RecipientUpdate import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.VideoEnabled +import org.thoughtcrime.securesms.webrtc.CallManager.StateEvent.VideoSwapped import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat import org.thoughtcrime.securesms.webrtc.audio.OutgoingRinger import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager @@ -61,6 +65,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va SignalAudioManager.EventListener, CameraEventListener, DataChannel.Observer { sealed class StateEvent { + data class VideoSwapped(val isSwapped: Boolean): StateEvent() data class AudioEnabled(val isEnabled: Boolean): StateEvent() data class VideoEnabled(val isEnabled: Boolean): StateEvent() data class CallStateUpdate(val state: CallState): StateEvent() @@ -103,6 +108,8 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va val videoEvents = _videoEvents.asSharedFlow() private val _remoteVideoEvents = MutableStateFlow(VideoEnabled(false)) val remoteVideoEvents = _remoteVideoEvents.asSharedFlow() + private val _videoViewSwappedEvents = MutableStateFlow(VideoSwapped(false)) + val videoViewSwappedEvents = _videoViewSwappedEvents.asSharedFlow() private val stateProcessor = StateProcessor(CallState.Idle) @@ -146,8 +153,10 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va private val outgoingIceDebouncer = Debouncer(200L) var localRenderer: SurfaceViewRenderer? = null + var localFloatingRenderer: SurfaceViewRenderer? = null var remoteRotationSink: RemoteRotationVideoProxySink? = null var remoteRenderer: SurfaceViewRenderer? = null + var remoteFloatingRenderer: SurfaceViewRenderer? = null private var peerConnectionFactory: PeerConnectionFactory? = null fun clearPendingIceUpdates() { @@ -214,15 +223,26 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va // setScalingType(SCALE_ASPECT_FIT) } + localFloatingRenderer = SurfaceViewRenderer(context).apply { +// setScalingType(SCALE_ASPECT_FIT) + } remoteRenderer = SurfaceViewRenderer(context).apply { // setScalingType(SCALE_ASPECT_FIT) } + + remoteFloatingRenderer = SurfaceViewRenderer(context).apply { +// setScalingType(SCALE_ASPECT_FIT) + } + remoteRotationSink = RemoteRotationVideoProxySink() localRenderer?.init(base.eglBaseContext, null) localRenderer?.setMirror(localCameraState.activeDirection == CameraState.Direction.FRONT) + localFloatingRenderer?.init(base.eglBaseContext, null) + localFloatingRenderer?.setMirror(localCameraState.activeDirection == CameraState.Direction.FRONT) remoteRenderer?.init(base.eglBaseContext, null) + remoteFloatingRenderer?.init(base.eglBaseContext, null) remoteRotationSink!!.setSink(remoteRenderer!!) val encoderFactory = DefaultVideoEncoderFactory(base.eglBaseContext, true, true) @@ -377,12 +397,16 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va peerConnection?.dispose() peerConnection = null + localFloatingRenderer?.release() localRenderer?.release() remoteRotationSink?.release() + remoteFloatingRenderer?.release() remoteRenderer?.release() eglBase?.release() + localFloatingRenderer = null localRenderer = null + remoteFloatingRenderer = null remoteRenderer = null eglBase = null @@ -395,6 +419,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va _audioEvents.value = AudioEnabled(false) _videoEvents.value = VideoEnabled(false) _remoteVideoEvents.value = VideoEnabled(false) + _videoViewSwappedEvents.value = VideoSwapped(false) pendingOutgoingIceUpdates.clear() pendingIncomingIceUpdates.clear() } @@ -460,7 +485,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va val recipient = recipient ?: return Promise.ofFail(NullPointerException("recipient is null")) val offer = pendingOffer ?: return Promise.ofFail(NullPointerException("pendingOffer is null")) val factory = peerConnectionFactory ?: return Promise.ofFail(NullPointerException("peerConnectionFactory is null")) - val local = localRenderer ?: return Promise.ofFail(NullPointerException("localRenderer is null")) + val local = localFloatingRenderer ?: return Promise.ofFail(NullPointerException("localRenderer is null")) val base = eglBase ?: return Promise.ofFail(NullPointerException("eglBase is null")) val connection = PeerConnectionWrapper( context, @@ -505,7 +530,7 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va ?: return Promise.ofFail(NullPointerException("recipient is null")) val factory = peerConnectionFactory ?: return Promise.ofFail(NullPointerException("peerConnectionFactory is null")) - val local = localRenderer + val local = localFloatingRenderer ?: return Promise.ofFail(NullPointerException("localRenderer is null")) val base = eglBase ?: return Promise.ofFail(NullPointerException("eglBase is null")) @@ -595,6 +620,17 @@ class CallManager(context: Context, audioManager: AudioManagerCompat, private va } } + fun handleSwapVideoView(swapped: Boolean) { + _videoViewSwappedEvents.value = VideoSwapped(!swapped) + if (!swapped) { + peerConnection?.rotationVideoSink?.setSink(localRenderer) + remoteRotationSink?.setSink(remoteFloatingRenderer!!) + } else { + peerConnection?.rotationVideoSink?.setSink(localFloatingRenderer) + remoteRotationSink?.setSink(remoteRenderer!!) + } + } + fun handleSetMuteAudio(muted: Boolean) { _audioEvents.value = AudioEnabled(!muted) peerConnection?.setAudioEnabled(!muted) diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt index 4f27e5d1a..c6cfb3788 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallViewModel.kt @@ -32,14 +32,30 @@ class CallViewModel @Inject constructor(private val callManager: CallManager): V val localRenderer: SurfaceViewRenderer? get() = callManager.localRenderer + val localFloatingRenderer: SurfaceViewRenderer? + get() = callManager.localFloatingRenderer + val remoteRenderer: SurfaceViewRenderer? get() = callManager.remoteRenderer + val remoteFloatingRenderer: SurfaceViewRenderer? + get() = callManager.remoteFloatingRenderer + private var _videoEnabled: Boolean = false val videoEnabled: Boolean get() = _videoEnabled + private var _remoteVideoEnabled: Boolean = false + + val remoteVideoEnabled: Boolean + get() = _remoteVideoEnabled + + private var _videoViewSwapped: Boolean = false + + val videoViewSwapped: Boolean + get() = _videoViewSwapped + private var _microphoneEnabled: Boolean = true val microphoneEnabled: Boolean @@ -65,7 +81,14 @@ class CallViewModel @Inject constructor(private val callManager: CallManager): V .onEach { _videoEnabled = it } val remoteVideoEnabledState - get() = callManager.remoteVideoEvents.map { it.isEnabled } + get() = callManager.remoteVideoEvents + .map { it.isEnabled } + .onEach { _remoteVideoEnabled = it } + + val videoViewSwappedState + get() = callManager.videoViewSwappedEvents + .map { it.isSwapped } + .onEach { _videoViewSwapped = it } var deviceRotation: Int = 0 set(value) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt index 26d8fc223..03f69f46e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/PeerConnectionWrapper.kt @@ -41,7 +41,7 @@ class PeerConnectionWrapper(private val context: Context, private val mediaStream: MediaStream private val videoSource: VideoSource? private val videoTrack: VideoTrack? - private val rotationVideoSink = RotationVideoSink() + public val rotationVideoSink = RotationVideoSink() val readyForIce get() = peerConnection?.localDescription != null && peerConnection?.remoteDescription != null diff --git a/app/src/main/res/drawable/ic_baseline_screen_rotation_alt_24.xml b/app/src/main/res/drawable/ic_baseline_screen_rotation_alt_24.xml new file mode 100644 index 000000000..553db9c08 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_screen_rotation_alt_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_webrtc.xml b/app/src/main/res/layout/activity_webrtc.xml index e497456e6..ebef19ce5 100644 --- a/app/src/main/res/layout/activity_webrtc.xml +++ b/app/src/main/res/layout/activity_webrtc.xml @@ -22,6 +22,12 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:layout_gravity="center"/> + + + android:layout_width="0dp" + android:visibility="invisible" + android:background="@color/black"> + + +