Add screen share to 1:1 and group calling.

This commit is contained in:
Cody Henthorne
2026-05-08 13:13:44 -04:00
committed by Michelle Tang
parent d1e2fc0423
commit 39529af4e9
43 changed files with 904 additions and 192 deletions
+2 -1
View File
@@ -38,6 +38,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
@@ -1403,7 +1404,7 @@
<service
android:name="org.thoughtcrime.securesms.service.webrtc.ActiveCallManager$ActiveCallForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync|microphone|camera|phoneCall" />
android:foregroundServiceType="dataSync|microphone|camera|phoneCall|mediaProjection" />
<service
android:name="com.google.android.datatransport.runtime.scheduling.jobscheduling.JobInfoSchedulerService"
@@ -12,6 +12,6 @@ sealed interface LabsSettingsEvents {
data class ToggleGroupSuggestionsForMembers(val enabled: Boolean) : LabsSettingsEvents
data class ToggleBetterSearch(val enabled: Boolean) : LabsSettingsEvents
data class ToggleAutoLowerHand(val enabled: Boolean) : LabsSettingsEvents
data class ToggleStarredMessages(val enabled: Boolean) : LabsSettingsEvents
data class ToggleScreenShare(val enabled: Boolean) : LabsSettingsEvents
}
@@ -151,6 +151,15 @@ private fun LabsSettingsContent(
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleStarredMessages(it)) }
)
}
item {
Rows.ToggleRow(
checked = state.screenShare,
text = "Screen Sharing",
label = "Share your screen during calls. Adds a screen share option to the overflow menu in 1:1 and group calls.",
onCheckChanged = { onEvent(LabsSettingsEvents.ToggleScreenShare(it)) }
)
}
}
}
}
@@ -15,6 +15,6 @@ data class LabsSettingsState(
val groupSuggestionsForMembers: Boolean = false,
val betterSearch: Boolean = false,
val autoLowerHand: Boolean = false,
val starredMessages: Boolean = false
val starredMessages: Boolean = false,
val screenShare: Boolean = false
)
@@ -46,6 +46,10 @@ class LabsSettingsViewModel : ViewModel() {
SignalStore.labs.starredMessages = event.enabled
_state.value = _state.value.copy(starredMessages = event.enabled)
}
is LabsSettingsEvents.ToggleScreenShare -> {
SignalStore.labs.screenShare = event.enabled
_state.value = _state.value.copy(screenShare = event.enabled)
}
}
}
@@ -58,7 +62,8 @@ class LabsSettingsViewModel : ViewModel() {
betterSearch = SignalStore.labs.betterSearch,
autoLowerHand = SignalStore.labs.autoLowerHand,
starredMessages = SignalStore.labs.starredMessages
starredMessages = SignalStore.labs.starredMessages,
screenShare = SignalStore.labs.screenShare
)
}
}
@@ -8,6 +8,8 @@ import androidx.annotation.Px;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
import java.util.Set;
@@ -164,7 +166,9 @@ public final class WebRtcControls {
}
public boolean displayOverflow() {
return isAtLeastOutgoing() && hasAtLeastOneRemote && isGroupCall() && groupCallState == GroupCallState.CONNECTED;
boolean connectedGroupCall = isGroupCall() && groupCallState == GroupCallState.CONNECTED && hasAtLeastOneRemote;
boolean connected1to1Call = !isGroupCall() && callState == CallState.ONGOING && SignalStore.labs().getScreenShare();
return isAtLeastOutgoing() && (connectedGroupCall || connected1to1Call);
}
public boolean displayMuteAudio() {
@@ -46,6 +46,8 @@ data class AdditionalActionsState(
val isShown: Boolean = false,
val reactions: PersistentList<String> = persistentListOf(),
val isSelfHandRaised: Boolean = false,
val isScreenSharing: Boolean = false,
val displayScreenShareToggle: Boolean = false,
@Stable val listener: AdditionalActionsListener = AdditionalActionsListener.Empty
)
@@ -53,11 +55,13 @@ interface AdditionalActionsListener {
fun onReactClick(reaction: String)
fun onReactWithAnyClick()
fun onRaiseHandClick(raised: Boolean)
fun onScreenShareClick(sharing: Boolean)
object Empty : AdditionalActionsListener {
override fun onReactClick(reaction: String) = Unit
override fun onReactWithAnyClick() = Unit
override fun onRaiseHandClick(raised: Boolean) = Unit
override fun onScreenShareClick(sharing: Boolean) = Unit
}
}
@@ -91,7 +95,10 @@ private fun AdditionalActionsPopupContent(
CallScreenMenu(
onRaiseHandClick = state.listener::onRaiseHandClick,
isSelfHandRaised = state.isSelfHandRaised
isSelfHandRaised = state.isSelfHandRaised,
isScreenSharing = state.isScreenSharing,
displayScreenShareToggle = state.displayScreenShareToggle,
onScreenShareClick = state.listener::onScreenShareClick
)
}
}
@@ -135,7 +142,10 @@ private fun CallReactionScrubber(
@Composable
private fun CallScreenMenu(
isSelfHandRaised: Boolean,
onRaiseHandClick: (Boolean) -> Unit
onRaiseHandClick: (Boolean) -> Unit,
isScreenSharing: Boolean = false,
displayScreenShareToggle: Boolean = false,
onScreenShareClick: (Boolean) -> Unit = {}
) {
Column(
modifier = Modifier
@@ -147,6 +157,14 @@ private fun CallScreenMenu(
title = if (isSelfHandRaised) stringResource(R.string.CallOverflowPopupWindow__lower_hand) else stringResource(R.string.CallOverflowPopupWindow__raise_hand),
onClick = { onRaiseHandClick(!isSelfHandRaised) }
)
if (displayScreenShareToggle) {
CallScreenMenuOption(
imageVector = ImageVector.vectorResource(R.drawable.symbol_screen_share_24),
title = if (isScreenSharing) stringResource(R.string.CallOverflowPopupWindow__stop_screen_share) else stringResource(R.string.CallOverflowPopupWindow__share_screen),
onClick = { onScreenShareClick(!isScreenSharing) }
)
}
}
}
@@ -82,7 +82,7 @@ fun CallControls(
}
val hasCameraPermission = ContextCompat.checkSelfPermission(LocalContext.current, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
if (callControlsState.displayVideoToggle) {
if (callControlsState.displayVideoToggle && !callControlsState.isLocalScreenSharing) {
CallScreenTooltipBox(
text = stringResource(R.string.WebRtcCallActivity__tap_here_to_turn_on_your_video),
displayTooltip = displayVideoTooltip,
@@ -214,7 +214,8 @@ data class CallControlsState(
val displayAdditionalActions: Boolean = false,
val displayStartCallButton: Boolean = false,
val startCallButtonText: Int = R.string.WebRtcCallView__start_call,
val displayEndCallButton: Boolean = false
val displayEndCallButton: Boolean = false,
val isLocalScreenSharing: Boolean = false
) {
val hasAnyControls: Boolean
@@ -235,7 +236,8 @@ data class CallControlsState(
callParticipantsState: CallParticipantsState,
webRtcControls: WebRtcControls,
groupMemberCount: Int,
isAudioDeviceChangePending: Boolean = false
isAudioDeviceChangePending: Boolean = false,
isLocalScreenSharing: Boolean = false
): CallControlsState {
return CallControlsState(
isEarpieceAvailable = webRtcControls.isEarpieceAvailableForAudioToggle,
@@ -255,7 +257,8 @@ data class CallControlsState(
displayAdditionalActions = webRtcControls.displayOverflow(),
displayStartCallButton = webRtcControls.displayStartCallControls(),
startCallButtonText = webRtcControls.startCallButtonText,
displayEndCallButton = webRtcControls.displayEndCall()
displayEndCallButton = webRtcControls.displayEndCall(),
isLocalScreenSharing = isLocalScreenSharing
)
}
}
@@ -22,7 +22,7 @@ sealed interface CallEvent {
data class StartCall(val isVideoCall: Boolean) : CallEvent
data class ShowGroupCallSafetyNumberChange(val identityRecords: List<IdentityRecord>) : CallEvent
data object SwitchToSpeaker : CallEvent
data object ShowSwipeToSpeakerHint : CallEvent
data object ShowSwipeToScreenShareHint : CallEvent
data object ShowLargeGroupAutoMuteToast : CallEvent
data class ShowRemoteMuteToast(private val muted: Recipient, private val mutedBy: Recipient) : CallEvent {
fun getDescription(context: Context): String {
@@ -77,6 +77,7 @@ import org.thoughtcrime.securesms.events.CallParticipantId
import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent
import org.thoughtcrime.securesms.events.GroupCallReactionEvent
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.ringrtc.CameraState
@@ -171,11 +172,15 @@ fun CallScreen(
val additionalActionsPopupState = TriggerAlignedPopupState.rememberTriggerAlignedPopupState()
val additionalActionsState = remember(
callScreenState.reactions,
localParticipant.isHandRaised
localParticipant.isHandRaised,
callScreenState.isLocalScreenSharing,
callControlsState.displayEndCallButton
) {
AdditionalActionsState(
reactions = callScreenState.reactions,
isSelfHandRaised = localParticipant.isHandRaised,
isScreenSharing = callScreenState.isLocalScreenSharing,
displayScreenShareToggle = callControlsState.displayEndCallButton && SignalStore.labs.screenShare,
listener = additionalActionsListener,
triggerAlignedPopupState = additionalActionsPopupState
)
@@ -505,7 +510,7 @@ fun CallScreen(
)
SwipeToSpeakerHintPopup(
visible = callScreenState.displaySwipeToSpeakerHint,
hintType = callScreenState.swipeHint,
onDismiss = onSwipeToSpeakerHintDismissed,
modifier = Modifier
.statusBarsPadding()
@@ -36,6 +36,7 @@ interface CallScreenControlsListener {
fun onNavigateUpClicked()
fun toggleControls()
fun onAudioPermissionsRequested(onGranted: Runnable?)
fun onScreenShareChanged(sharing: Boolean)
object Empty : CallScreenControlsListener {
override fun onStartCall(isVideoCall: Boolean) = Unit
@@ -58,5 +59,6 @@ interface CallScreenControlsListener {
override fun onNavigateUpClicked() = Unit
override fun toggleControls() = Unit
override fun onAudioPermissionsRequested(onGranted: Runnable?) = Unit
override fun onScreenShareChanged(sharing: Boolean) = Unit
}
}
@@ -43,6 +43,7 @@ interface CallScreenMediator {
fun enableRingGroup(canRing: Boolean)
fun showSpeakerViewHint()
fun hideSpeakerViewHint()
fun showScreenShareHint()
fun showVideoTooltip(): Dismissible
fun showCameraTooltip(): Dismissible
fun onCallStateUpdate(callControlsChange: CallControlsChange)
@@ -25,7 +25,7 @@ data class CallScreenState(
val isDisplayingAudioToggleSheet: Boolean = false,
val displaySwitchCameraTooltip: Boolean = false,
val displayVideoTooltip: Boolean = false,
val displaySwipeToSpeakerHint: Boolean = false,
val swipeHint: SwipeHintType = SwipeHintType.NONE,
val displayWifiToCellularPopup: Boolean = false,
val remoteMuteToastMessage: String? = null,
val displayAdditionalActionsDialog: Boolean = false,
@@ -34,7 +34,14 @@ data class CallScreenState(
val isParticipantUpdatePopupEnabled: Boolean = true,
val isCallStateUpdatePopupEnabled: Boolean = false,
val isWaitingToBeLetIn: Boolean = false,
val reactions: PersistentList<String> = persistentListOf()
val reactions: PersistentList<String> = persistentListOf(),
val isLocalScreenSharing: Boolean = false
) {
fun isDisplayingControlMenu(): Boolean = isDisplayingAudioToggleSheet || displayAdditionalActionsDialog
}
enum class SwipeHintType {
NONE,
SPEAKER_VIEW,
SCREEN_SHARE
}
@@ -14,6 +14,7 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.lifecycle.ViewModel
@@ -110,6 +111,7 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
val recipient by viewModel.getRecipientFlow().collectAsStateWithLifecycle(Recipient.UNKNOWN)
val webRtcCallState by callScreenViewModel.callState.collectAsStateWithLifecycle()
val callScreenState by callScreenViewModel.callScreenState.collectAsStateWithLifecycle()
val isLocalScreenSharing by viewModel.isLocalScreenSharing.collectAsStateWithLifecycle()
val callControlsState by viewModel.getCallControlsState().collectAsStateWithLifecycle(CallControlsState())
val callParticipantsViewState by callScreenViewModel.callParticipantsViewState.collectAsStateWithLifecycle()
val callParticipantsState = remember(callParticipantsViewState) { callParticipantsViewState.callParticipantsState }
@@ -173,6 +175,22 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
}
}
LaunchedEffect(isLocalScreenSharing) {
callScreenViewModel.callScreenState.update { it.copy(isLocalScreenSharing = isLocalScreenSharing) }
}
LaunchedEffect(callScreenController, callScreenControlsListener) {
snapshotFlow { callScreenController.callParticipantsVerticalPagerState.settledPage }
.collect { page ->
val selected = if (page == 1) {
CallParticipantsState.SelectedPage.FOCUSED
} else {
CallParticipantsState.SelectedPage.GRID
}
callScreenControlsListener.onPageChanged(selected)
}
}
val controlAndInfoState by controlsAndInfoViewModel.state
SignalTheme(isDarkMode = true) {
@@ -217,7 +235,7 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
onControlsToggled = onControlsToggled,
onCallScreenDialogDismissed = { callScreenViewModel.dialog.update { CallScreenDialogType.NONE } },
onWifiToCellularPopupDismissed = { callScreenViewModel.callScreenState.update { it.copy(displayWifiToCellularPopup = false) } },
onSwipeToSpeakerHintDismissed = { callScreenViewModel.callScreenState.update { it.copy(displaySwipeToSpeakerHint = false) } },
onSwipeToSpeakerHintDismissed = { callScreenViewModel.callScreenState.update { it.copy(swipeHint = SwipeHintType.NONE) } },
onRemoteMuteToastDismissed = { callScreenViewModel.callScreenState.update { it.copy(remoteMuteToastMessage = null) } },
callParticipantUpdatePopupController = callParticipantUpdatePopupController,
isSelfAdmin = controlAndInfoState.isSelfAdmin(),
@@ -322,11 +340,15 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
}
override fun showSpeakerViewHint() {
callScreenViewModel.callScreenState.update { it.copy(displaySwipeToSpeakerHint = true) }
callScreenViewModel.callScreenState.update { it.copy(swipeHint = SwipeHintType.SPEAKER_VIEW) }
}
override fun hideSpeakerViewHint() {
callScreenViewModel.callScreenState.update { it.copy(displaySwipeToSpeakerHint = false) }
callScreenViewModel.callScreenState.update { it.copy(swipeHint = SwipeHintType.NONE) }
}
override fun showScreenShareHint() {
callScreenViewModel.callScreenState.update { it.copy(swipeHint = SwipeHintType.SCREEN_SHARE) }
}
override fun showVideoTooltip(): Dismissible {
@@ -393,6 +415,11 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo
callScreenViewModel.callScreenState.update { it.copy(displayAdditionalActionsDialog = false) }
}
override fun onScreenShareClick(sharing: Boolean) {
callScreenViewModel.callScreenState.update { it.copy(displayAdditionalActionsDialog = false) }
controlsListener.value.onScreenShareChanged(sharing)
}
private fun handleFailure() {
Toast.makeText(activity, R.string.CallLinkDetailsFragment__couldnt_save_changes, Toast.LENGTH_LONG).show()
}
@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.components.webrtc.v2
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -25,16 +26,23 @@ import kotlin.time.Duration.Companion.seconds
import org.signal.core.ui.R as CoreUiR
/**
* Popup shown to hint the user that they can swipe to view screen share.
* Popup shown to hint the user that they should swipe between the grid view and
* the focused page for speaker/screen share when available.
*/
@Composable
fun SwipeToSpeakerHintPopup(
visible: Boolean,
hintType: SwipeHintType,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
val textResId = when (hintType) {
SwipeHintType.SCREEN_SHARE -> R.string.CallToastPopupWindow__swipe_to_view_screen_share
SwipeHintType.SPEAKER_VIEW,
SwipeHintType.NONE -> R.string.CallToastPopupWindow__swipe_to_view_speaker
}
CallScreenPopup(
visible = visible,
visible = hintType != SwipeHintType.NONE,
onDismiss = onDismiss,
displayDuration = 3.seconds,
modifier = modifier
@@ -51,7 +59,7 @@ fun SwipeToSpeakerHintPopup(
)
Text(
text = stringResource(R.string.CallToastPopupWindow__swipe_to_view_screen_share),
text = stringResource(textResId),
color = colorResource(CoreUiR.color.signal_light_colorOnSecondaryContainer),
modifier = Modifier.padding(start = 8.dp)
)
@@ -63,9 +71,16 @@ fun SwipeToSpeakerHintPopup(
@Composable
private fun SwipeToSpeakerHintPopupPreview() {
Previews.Preview {
SwipeToSpeakerHintPopup(
visible = true,
onDismiss = {}
)
Column {
SwipeToSpeakerHintPopup(
hintType = SwipeHintType.SPEAKER_VIEW,
onDismiss = {}
)
SwipeToSpeakerHintPopup(
hintType = SwipeHintType.SCREEN_SHARE,
onDismiss = {}
)
}
}
}
@@ -14,6 +14,8 @@ import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.media.AudioManager
import android.media.projection.MediaProjectionConfig
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Bundle
import android.util.Rational
@@ -22,6 +24,7 @@ import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.RequiresApi
import androidx.appcompat.app.AppCompatDelegate
@@ -38,6 +41,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
@@ -123,6 +127,11 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
private var ephemeralStateDisposable = Disposable.empty()
private val callPermissionsDialogController = CallPermissionsDialogController()
private val eventBusSubscriber = EventBusSubscriber()
private val mediaProjectionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK && result.data != null) {
AppDependencies.signalCallManager.startScreenShare(result.data!!)
}
}
override fun attachBaseContext(newBase: Context) {
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
@@ -210,6 +219,17 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
callScreen.setMicEnabled(viewModel.microphoneEnabled.value)
}
}
lifecycleScope.launch {
viewModel
.isLocalScreenSharing
.drop(1)
.collect { sharing ->
if (!sharing && !lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) {
AppDependencies.signalCallManager.setEnableVideo(false)
}
}
}
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
@@ -312,7 +332,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
requestNewSizesThrottle.clear()
}
if (!isChangingConfigurations && !isInMultiWindowModeCompat()) {
if (!isChangingConfigurations && !isInMultiWindowModeCompat() && !viewModel.isLocalScreenSharing.value) {
AppDependencies.signalCallManager.setEnableVideo(false)
}
@@ -985,7 +1005,7 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
is CallEvent.StartCall -> startCall(event.isVideoCall)
is CallEvent.ShowGroupCallSafetyNumberChange -> SafetyNumberBottomSheet.forGroupCall(event.identityRecords).show(supportFragmentManager)
is CallEvent.SwitchToSpeaker -> callScreen.switchToSpeakerView()
is CallEvent.ShowSwipeToSpeakerHint -> callScreen.showSpeakerViewHint()
is CallEvent.ShowSwipeToScreenShareHint -> callScreen.showScreenShareHint()
is CallEvent.ShowRemoteMuteToast -> callScreen.showRemoteMuteToast(event.getDescription(this))
is CallEvent.ShowLargeGroupAutoMuteToast -> {
callScreen.onCallStateUpdate(CallControlsChange.MIC_OFF)
@@ -1390,6 +1410,20 @@ class WebRtcCallActivity : BaseActivity(), SafetyNumberChangeDialog.Callback, Re
override fun onAudioPermissionsRequested(onGranted: Runnable?) {
askAudioPermissions { onGranted?.run() }
}
override fun onScreenShareChanged(sharing: Boolean) {
if (sharing) {
val mediaProjectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val intent = if (Build.VERSION.SDK_INT >= 34) {
mediaProjectionManager.createScreenCaptureIntent(MediaProjectionConfig.createConfigForDefaultDisplay())
} else {
mediaProjectionManager.createScreenCaptureIntent()
}
mediaProjectionLauncher.launch(intent)
} else {
AppDependencies.signalCallManager.stopScreenShare()
}
}
}
private inner class PendingParticipantsViewListener : PendingParticipantsListener {
@@ -70,6 +70,9 @@ class WebRtcCallViewModel : ViewModel() {
private val ephemeralState = MutableStateFlow<WebRtcEphemeralState?>(null)
private val remoteMutesReported = MutableStateFlow(HashSet<CallParticipantId>())
private val _isLocalScreenSharing = MutableStateFlow(false)
val isLocalScreenSharing: StateFlow<Boolean> = _isLocalScreenSharing
private val controlsWithFoldableState: Flow<WebRtcControls> = combine(foldableState, webRtcControls, this::updateControlsFoldableState)
private val realWebRtcControls: StateFlow<WebRtcControls> = combine(isInPipMode, controlsWithFoldableState, this::getRealWebRtcControls)
.stateIn(viewModelScope, SharingStarted.Eagerly, WebRtcControls.NONE)
@@ -100,6 +103,7 @@ class WebRtcCallViewModel : ViewModel() {
private var callConnectedTime = -1L
private var answerWithVideoAvailable = false
private var previousParticipantList = Collections.emptyList<CallParticipant>()
private var hasSeededParticipantList = false
private var switchOnFirstScreenShare = true
private var showScreenShareTip = true
private var hasShownAutoMuteToast = false
@@ -181,9 +185,10 @@ class WebRtcCallViewModel : ViewModel() {
callParticipantsState,
getWebRtcControls(),
groupSize,
isAudioDeviceChangePending
) { participantsState, controls, groupMemberCount, audioChangePending ->
CallControlsState.fromViewModelData(participantsState, controls, groupMemberCount, audioChangePending)
isAudioDeviceChangePending,
_isLocalScreenSharing
) { participantsState, controls, groupMemberCount, audioChangePending, isLocalScreenSharing ->
CallControlsState.fromViewModelData(participantsState, controls, groupMemberCount, audioChangePending, isLocalScreenSharing)
}
}
@@ -263,7 +268,7 @@ class WebRtcCallViewModel : ViewModel() {
) {
showScreenShareTip = false
viewModelScope.launch {
events.emit(CallEvent.ShowSwipeToSpeakerHint)
events.emit(CallEvent.ShowSwipeToScreenShareHint)
}
}
@@ -319,6 +324,7 @@ class WebRtcCallViewModel : ViewModel() {
val wasMicrophoneEnabled = internalMicrophoneEnabled.value
internalMicrophoneEnabled.value = localParticipant.isMicrophoneEnabled
isAudioDeviceChangePending.value = webRtcViewModel.isAudioDeviceChangePending
_isLocalScreenSharing.value = webRtcViewModel.isLocalScreenSharing
if (internalMicrophoneEnabled.value) {
remoteMutedBy.update { null }
@@ -347,12 +353,13 @@ class WebRtcCallViewModel : ViewModel() {
}
if (webRtcViewModel.groupState.isConnected) {
if (!containsPlaceholders(previousParticipantList)) {
if (!containsPlaceholders(previousParticipantList) && hasSeededParticipantList) {
val update = CallParticipantListUpdate.computeDeltaUpdate(previousParticipantList, webRtcViewModel.remoteParticipants)
viewModelScope.launch {
callParticipantListUpdate.emit(update)
}
}
hasSeededParticipantList = true
for (remote in webRtcViewModel.remoteParticipants) {
if (remote.remotelyMutedBy == null) {
@@ -124,12 +124,14 @@ class WebRtcViewModel(state: WebRtcServiceState) {
val isAudioDeviceChangePending: Boolean = state.localDeviceState.isAudioDeviceChangePending
val localParticipant: CallParticipant = createLocal(
state.localDeviceState.cameraState,
(if (state.videoState.localSink != null) state.videoState.localSink else BroadcastVideoSink())!!,
state.localDeviceState.isMicrophoneEnabled,
state.localDeviceState.handRaisedTimestamp
cameraState = state.localDeviceState.cameraState,
renderer = state.videoState.localSink ?: BroadcastVideoSink(),
microphoneEnabled = state.localDeviceState.isMicrophoneEnabled,
handRaisedTimestamp = state.localDeviceState.handRaisedTimestamp
)
val isLocalScreenSharing: Boolean = state.localDeviceState.isScreenSharing
val remoteMutedBy: CallParticipant? = state.localDeviceState.remoteMutedBy
val isCellularConnection: Boolean = when (state.localDeviceState.networkConnectionType) {
@@ -10,8 +10,8 @@ class LabsValues internal constructor(store: KeyValueStore) : SignalStoreValues(
const val GROUP_SUGGESTIONS_FOR_MEMBERS: String = "labs.group_suggestions_for_members"
const val BETTER_SEARCH: String = "labs.better_search"
const val AUTO_LOWER_HAND: String = "labs.auto_lower_hand"
const val STARRED_MESSAGES: String = "labs.starred_messages"
const val SCREEN_SHARE: String = "labs.screen_share"
}
public override fun onFirstEverAppLaunch() = Unit
@@ -32,6 +32,8 @@ class LabsValues internal constructor(store: KeyValueStore) : SignalStoreValues(
var starredMessages by booleanValue(STARRED_MESSAGES, true).falseForExternalUsers()
var screenShare by booleanValue(SCREEN_SHARE, true).falseForExternalUsers()
private fun SignalStoreValueDelegate<Boolean>.falseForExternalUsers(): SignalStoreValueDelegate<Boolean> {
return this.map { actualValue -> RemoteConfig.internalUser && actualValue }
}
@@ -39,6 +39,10 @@ public class Camera implements CameraControl, CameraVideoCapturer.CameraSwitchHa
private static final String TAG = Log.tag(Camera.class);
private static final int CAPTURE_WIDTH = 1280;
private static final int CAPTURE_HEIGHT = 720;
private static final int CAPTURE_FPS = 30;
@NonNull private final Context context;
@Nullable private final CameraVideoCapturer capturer;
@Nullable private CameraEventListener cameraEventListener;
@@ -47,6 +51,8 @@ public class Camera implements CameraControl, CameraVideoCapturer.CameraSwitchHa
@NonNull private CameraState.Direction activeDirection;
private boolean enabled;
private boolean isInitialized;
private boolean capturing;
private boolean paused;
private int orientation;
@Nullable private volatile VideoSink vanitySink;
@@ -120,24 +126,42 @@ public class Camera implements CameraControl, CameraVideoCapturer.CameraSwitchHa
Log.i(TAG, "setEnabled(): " + enabled);
this.enabled = enabled;
this.paused = false;
if (capturer == null || !isInitialized) {
return;
}
try {
if (enabled) {
Log.i(TAG, "setEnabled(): starting capture");
capturer.startCapture(1280, 720, 30);
} else {
Log.i(TAG, "setEnabled(): stopping capture");
capturer.stopCapture();
}
} catch (InterruptedException e) {
Log.w(TAG, "Got interrupted while trying to stop video capture", e);
if (enabled) {
startCapture();
} else {
stopCapture();
}
}
public void pauseCapture() {
if (capturer != null && capturing) {
paused = true;
stopCapture();
}
}
public void resumeCapture() {
paused = false;
if (capturer != null && isInitialized && enabled && !capturing) {
startCapture();
}
}
/** Whether the camera should be capturing based on user state, regardless of the current pause status. */
public boolean shouldBeCapturing() {
return capturer != null && isInitialized && enabled;
}
public boolean isCapturing() {
return capturing;
}
public void setCameraEventListener(@Nullable CameraEventListener cameraEventListener) {
this.cameraEventListener = cameraEventListener;
}
@@ -151,10 +175,18 @@ public class Camera implements CameraControl, CameraVideoCapturer.CameraSwitchHa
this.vanitySink = vanitySink;
}
void deliverToVanitySink(@NonNull VideoFrame videoFrame) {
VideoSink sink = vanitySink;
if (sink != null) {
sink.onFrame(videoFrame);
}
}
public void dispose() {
if (capturer != null) {
capturer.dispose();
isInitialized = false;
capturing = false;
}
}
@@ -170,14 +202,30 @@ public class Camera implements CameraControl, CameraVideoCapturer.CameraSwitchHa
return new CameraState(getActiveDirection(), getCount());
}
@Nullable CameraVideoCapturer getCapturer() {
return capturer;
}
public boolean isInitialized() {
return isInitialized;
}
private void startCapture() {
Log.i(TAG, "startCapture()");
try {
capturer.startCapture(CAPTURE_WIDTH, CAPTURE_HEIGHT, CAPTURE_FPS);
capturing = true;
} catch (Exception e) {
Log.w(TAG, "Error starting capture", e);
}
}
private void stopCapture() {
Log.i(TAG, "stopCapture()");
try {
capturer.stopCapture();
} catch (InterruptedException e) {
Log.w(TAG, "Interrupted stopping capture", e);
}
capturing = false;
}
private @Nullable CameraVideoCapturer createVideoCapturer(@NonNull CameraEnumerator enumerator,
@NonNull CameraState.Direction direction)
{
@@ -332,6 +380,9 @@ public class Camera implements CameraControl, CameraVideoCapturer.CameraSwitchHa
@Override
public void onCapturerStopped() {
if (paused) {
return;
}
observer.onCapturerStopped();
if (cameraEventListener != null) cameraEventListener.onCameraStopped();
}
@@ -339,10 +390,6 @@ public class Camera implements CameraControl, CameraVideoCapturer.CameraSwitchHa
@Override
public void onFrameCaptured(VideoFrame videoFrame) {
observer.onFrameCaptured(videoFrame);
VideoSink sink = vanitySink;
if (sink != null) {
sink.onFrame(videoFrame);
}
}
}
}
@@ -0,0 +1,165 @@
package org.thoughtcrime.securesms.ringrtc
import android.content.Context
import android.content.Intent
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.Log.tag
import org.signal.ringrtc.CameraControl
import org.thoughtcrime.securesms.components.webrtc.EglBaseWrapper
import org.webrtc.CapturerObserver
import org.webrtc.VideoFrame
import org.webrtc.VideoSink
import kotlin.concurrent.Volatile
/**
* Owns the outgoing video pipeline: a [Camera] and a lazily-created
* [ScreenShareCapturer], and the routing logic that decides which one's
* frames flow into the WebRTC [CapturerObserver] that RingRTC supplies.
*/
class OutgoingVideoSourceRouter(
private val context: Context,
private val eglBase: EglBaseWrapper,
cameraEventListener: CameraEventListener,
desiredCameraDirection: CameraState.Direction
) : CameraControl {
companion object {
private val TAG = tag(OutgoingVideoSourceRouter::class.java)
}
private val camera: Camera = Camera(context, cameraEventListener, eglBase, desiredCameraDirection)
private var screenShareCapturer: ScreenShareCapturer? = null
private var downstream: CapturerObserver? = null
@Volatile
var isScreenSharing: Boolean = false
private set
override fun hasCapturer(): Boolean {
return camera.hasCapturer()
}
override fun initCapturer(observer: CapturerObserver) {
Log.i(TAG, "initCapturer()")
this.downstream = observer
camera.initCapturer(CameraSideObserver())
}
override fun setEnabled(enable: Boolean) {
camera.setEnabled(enable)
}
override fun flip() {
camera.flip()
}
override fun setOrientation(orientation: Int?) {
camera.setOrientation(orientation)
screenShareCapturer?.onConfigurationChanged()
}
val cameraState: CameraState
get() = camera.cameraState
val isInitialized: Boolean
get() = camera.isInitialized
fun setCameraEventListener(cameraEventListener: CameraEventListener?) {
camera.setCameraEventListener(cameraEventListener)
}
fun setVanitySink(vanitySink: VideoSink?) {
camera.setVanitySink(vanitySink)
}
fun startScreenShare(mediaProjectionData: Intent) {
if (isScreenSharing) {
Log.w(TAG, "Already screen sharing")
return
}
if (downstream == null) {
Log.w(TAG, "Cannot start screen share before initCapturer()")
return
}
Log.i(TAG, "startScreenShare()")
isScreenSharing = true
if (camera.isCapturing) {
camera.pauseCapture()
}
if (screenShareCapturer == null) {
screenShareCapturer = ScreenShareCapturer(context, eglBase, ScreenSideObserver())
}
screenShareCapturer!!.start(mediaProjectionData)
}
fun stopScreenShare() {
if (!isScreenSharing) {
return
}
Log.i(TAG, "stopScreenShare()")
screenShareCapturer?.stop()
isScreenSharing = false
if (camera.shouldBeCapturing()) {
camera.resumeCapture()
}
}
fun dispose() {
screenShareCapturer?.dispose()
screenShareCapturer = null
isScreenSharing = false
camera.dispose()
}
private inner class CameraSideObserver : CapturerObserver {
override fun onCapturerStarted(success: Boolean) {
if (!isScreenSharing) {
downstream?.onCapturerStarted(success)
}
}
override fun onCapturerStopped() {
if (!isScreenSharing) {
downstream?.onCapturerStopped()
}
}
override fun onFrameCaptured(videoFrame: VideoFrame) {
if (!isScreenSharing) {
downstream?.onFrameCaptured(videoFrame)
}
camera.deliverToVanitySink(videoFrame)
}
}
private inner class ScreenSideObserver : CapturerObserver {
override fun onCapturerStarted(success: Boolean) {
if (isScreenSharing) {
downstream?.onCapturerStarted(success)
}
}
override fun onCapturerStopped() {
if (isScreenSharing) {
downstream?.onCapturerStopped()
}
}
override fun onFrameCaptured(videoFrame: VideoFrame?) {
if (isScreenSharing) {
downstream?.onFrameCaptured(videoFrame)
}
}
}
}
@@ -0,0 +1,129 @@
package org.thoughtcrime.securesms.ringrtc
import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjection
import org.signal.core.util.logging.Log
import org.signal.core.util.logging.Log.tag
import org.thoughtcrime.securesms.components.webrtc.EglBaseWrapper
import org.webrtc.CapturerObserver
import org.webrtc.EglBase
import org.webrtc.ScreenCapturerAndroid
import org.webrtc.SurfaceTextureHelper
import kotlin.math.max
/**
* Captures the device screen via [MediaProjection] and forwards frames to a
* [CapturerObserver] sink.
*/
class ScreenShareCapturer(
private val context: Context,
private val eglBase: EglBaseWrapper,
private val sink: CapturerObserver
) {
private var screenCapturer: ScreenCapturerAndroid? = null
private var surfaceHelper: SurfaceTextureHelper? = null
private var captureWidth: Int = 0
private var captureHeight: Int = 0
var isCapturing: Boolean = false
private set
fun start(mediaProjectionData: Intent) {
if (isCapturing) {
Log.w(TAG, "Already capturing")
return
}
Log.i(TAG, "start()")
isCapturing = true
eglBase.performWithValidEglBase { base: EglBase? ->
screenCapturer = ScreenCapturerAndroid(
mediaProjectionData,
object : MediaProjection.Callback() {
override fun onStop() {
Log.i(TAG, "MediaProjection stopped")
}
}
)
val (width, height) = computeCaptureDimensions()
captureWidth = width
captureHeight = height
Log.i(TAG, "start(): capture dimensions " + width + "x" + height)
surfaceHelper = SurfaceTextureHelper.create("WebRTC-ScreenShareHelper", base!!.getEglBaseContext())
screenCapturer!!.initialize(surfaceHelper, context, sink)
screenCapturer!!.startCapture(width, height, FRAME_RATE)
}
}
fun onConfigurationChanged() {
if (!isCapturing) return
val (width, height) = computeCaptureDimensions()
if (width == captureWidth && height == captureHeight) {
return
}
Log.i(TAG, "onConfigurationChanged(): capture dimensions " + width + "x" + height)
captureWidth = width
captureHeight = height
screenCapturer?.changeCaptureFormat(width, height, FRAME_RATE)
}
private fun computeCaptureDimensions(): Pair<Int, Int> {
val metrics = context.resources.displayMetrics
var width = metrics.widthPixels
var height = metrics.heightPixels
val maxDimension = max(width, height)
if (maxDimension > MAX_DIMENSION) {
val scale = MAX_DIMENSION.toFloat() / maxDimension
width = (width * scale).toInt()
height = (height * scale).toInt()
}
// Encoders require even dimensions
width = width and 1.inv()
height = height and 1.inv()
return width to height
}
fun stop() {
if (!isCapturing) {
return
}
Log.i(TAG, "stop()")
if (screenCapturer != null) {
screenCapturer!!.stopCapture()
screenCapturer!!.dispose()
screenCapturer = null
}
if (surfaceHelper != null) {
surfaceHelper!!.dispose()
surfaceHelper = null
}
captureWidth = 0
captureHeight = 0
isCapturing = false
}
fun dispose() {
stop()
}
companion object {
private val TAG = tag(ScreenShareCapturer::class.java)
private const val MAX_DIMENSION = 1280
private const val FRAME_RATE = 15
}
}
@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.service
import android.annotation.SuppressLint
import android.app.Notification
import android.app.Service
import android.content.Context
@@ -181,6 +182,7 @@ abstract class SafeForegroundService : Service() {
super.onCreate()
}
@SuppressLint("WrongConstant")
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
checkNotNull(intent) { "Must have an intent!" }
@@ -188,8 +190,8 @@ abstract class SafeForegroundService : Service() {
if (intent.action == ACTION_TIMEOUT) {
Log.i(TAG, "Time limit for foreground services has been met. Skipping starting a foreground.")
} else if (Build.VERSION.SDK_INT >= 30 && serviceType != 0) {
startForeground(notificationId, getForegroundNotification(intent), serviceType)
} else if (Build.VERSION.SDK_INT >= 30 && serviceType(intent) != 0) {
startForeground(notificationId, getForegroundNotification(intent), serviceType(intent))
} else {
startForeground(notificationId, getForegroundNotification(intent))
}
@@ -248,7 +250,7 @@ abstract class SafeForegroundService : Service() {
/** Special service type to use when calling start service if needed */
@RequiresApi(30)
open val serviceType: Int = 0
open fun serviceType(intent: Intent): Int = 0
/** Notification to post as our foreground notification. */
abstract fun getForegroundNotification(intent: Intent): Notification
@@ -288,12 +288,16 @@ class ActiveCallManager(
private const val EXTRA_RECIPIENT_ID = "RECIPIENT_ID"
private const val EXTRA_IS_VIDEO_CALL = "IS_VIDEO_CALL"
private const val EXTRA_TYPE = "TYPE"
private const val EXTRA_SCREEN_SHARING = "SCREEN_SHARING"
fun update(context: Context, @CallNotificationBuilder.CallNotificationType type: Int, recipientId: RecipientId, isVideoCall: Boolean) {
@JvmStatic
@JvmOverloads
fun update(context: Context, @CallNotificationBuilder.CallNotificationType type: Int, recipientId: RecipientId, isVideoCall: Boolean, isScreenSharing: Boolean = false) {
val extras = bundleOf(
EXTRA_TYPE to type,
EXTRA_RECIPIENT_ID to recipientId,
EXTRA_IS_VIDEO_CALL to isVideoCall
EXTRA_IS_VIDEO_CALL to isVideoCall,
EXTRA_SCREEN_SHARING to isScreenSharing
)
if (!SafeForegroundService.update(context, ActiveCallForegroundService::class.java, extras)) {
@@ -308,34 +312,14 @@ class ActiveCallManager(
}
}
private var isScreenSharing: Boolean = false
override val tag: String
get() = TAG
override val notificationId: Int
get() = CallNotificationBuilder.WEBRTC_NOTIFICATION
@get:RequiresApi(30)
override val serviceType: Int
get() {
val telecom = Build.VERSION.SDK_INT >= 36 && AndroidTelecomUtil.hasActiveController()
var type = if (telecom) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
}
if (Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO)) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
}
if (Permissions.hasAll(this, Manifest.permission.CAMERA)) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
}
return type
}
@Suppress("DEPRECATION")
private var hangUpRtcOnDeviceCallAnswered: PhoneStateListener? = null
private var notificationDisposable: Disposable = Disposable.disposed()
@@ -385,6 +369,31 @@ class ActiveCallManager(
}
}
@RequiresApi(30)
override fun serviceType(intent: Intent): Int {
val telecom = Build.VERSION.SDK_INT >= 36 && AndroidTelecomUtil.hasActiveController()
var type = if (telecom) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
}
if (Permissions.hasAll(this, Manifest.permission.RECORD_AUDIO)) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
}
if (Permissions.hasAll(this, Manifest.permission.CAMERA)) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
}
if (intent.getBooleanExtra(EXTRA_SCREEN_SHARING, false)) {
type = type or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
}
return type
}
override fun getForegroundNotification(intent: Intent): Notification {
notificationDisposable.dispose()
@@ -423,6 +432,15 @@ class ActiveCallManager(
return createNotification(type, recipient, isVideoCall, skipAvatarLoad = requiresAsyncNotificationLoad)
}
override fun onServiceUpdateCommandReceived(intent: Intent) {
val wasScreenSharing = isScreenSharing
isScreenSharing = intent.getBooleanExtra(EXTRA_SCREEN_SHARING, false)
if (isScreenSharing && !wasScreenSharing) {
AppDependencies.signalCallManager.onScreenSharingServiceReady()
}
}
private fun createNotification(type: Int, recipient: Recipient, isVideoCall: Boolean, skipAvatarLoad: Boolean): Notification {
return CallNotificationBuilder.getCallInProgressNotification(
this,
@@ -7,7 +7,7 @@ import org.signal.ringrtc.CallException;
import org.signal.ringrtc.CallManager;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.ringrtc.Camera;
import org.thoughtcrime.securesms.ringrtc.OutgoingVideoSourceRouter;
import org.thoughtcrime.securesms.ringrtc.RemotePeer;
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
@@ -93,19 +93,19 @@ public class CallSetupActionProcessorDelegate extends WebRtcActionProcessor {
protected @NonNull WebRtcServiceState handleSetEnableVideo(@NonNull WebRtcServiceState currentState, boolean enable) {
Log.i(tag, "handleSetEnableVideo(): enable: " + enable);
Camera camera = currentState.getVideoState().requireCamera();
OutgoingVideoSourceRouter router = currentState.getVideoState().requireRouter();
if (camera.isInitialized()) {
camera.setEnabled(enable);
if (router.isInitialized()) {
router.setEnabled(enable);
}
currentState = currentState.builder()
.changeLocalDeviceState()
.cameraState(camera.getCameraState())
.cameraState(router.getCameraState())
.build();
//noinspection SimplifiableBooleanExpression
if ((enable && camera.isInitialized()) || !enable) {
if ((enable && router.isInitialized()) || !enable) {
try {
CallManager callManager = webRtcInteractor.getCallManager();
callManager.setVideoEnable(enable, false);
@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.service.webrtc;
import android.content.Intent;
import android.os.ResultReceiver;
import androidx.annotation.NonNull;
@@ -11,7 +12,11 @@ import org.signal.ringrtc.CallManager;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.CallParticipantId;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.ringrtc.OutgoingVideoSourceRouter;
import org.thoughtcrime.securesms.ringrtc.RemotePeer;
import org.thoughtcrime.securesms.service.webrtc.state.LocalDeviceState;
import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder;
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState;
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState;
@@ -50,7 +55,7 @@ public class ConnectedCallActionProcessor extends DeviceAwareActionProcessor {
currentState = currentState.builder()
.changeLocalDeviceState()
.cameraState(currentState.getVideoState().requireCamera().getCameraState())
.cameraState(currentState.getVideoState().requireRouter().getCameraState())
.build();
boolean localVideoEnabled = currentState.getLocalDeviceState().getCameraState().isEnabled();
@@ -122,6 +127,74 @@ public class ConnectedCallActionProcessor extends DeviceAwareActionProcessor {
return activeCallDelegate.handleScreenSharingEnable(currentState, enable);
}
@Override
protected @NonNull WebRtcServiceState handleSetLocalScreenShare(@NonNull WebRtcServiceState currentState, boolean enable, @Nullable Intent mediaProjectionData) {
Log.i(TAG, "handleSetLocalScreenShare(): enable: " + enable);
OutgoingVideoSourceRouter router = currentState.getVideoState().requireRouter();
RecipientId recipientId = currentState.getCallInfoState().requireActivePeer().getRecipient().getId();
if (enable && mediaProjectionData != null) {
Log.i(tag, "Updating service for media projection, then can screen share");
ActiveCallManager.ActiveCallForegroundService.update(context, CallNotificationBuilder.TYPE_ESTABLISHED, recipientId, true, true);
return currentState.builder()
.changeLocalDeviceState()
.setMediaProjectionIntent(mediaProjectionData)
.build();
} else {
router.stopScreenShare();
boolean cameraWasEnabled = currentState.getLocalDeviceState().getCameraState().isEnabled();
ActiveCallManager.ActiveCallForegroundService.update(context, CallNotificationBuilder.TYPE_ESTABLISHED, recipientId, cameraWasEnabled, false);
try {
webRtcInteractor.getCallManager().setVideoEnable(cameraWasEnabled, false);
} catch (CallException e) {
return callFailure(currentState, "setVideoEnable() after screen share failed: ", e);
}
return currentState.builder()
.changeLocalDeviceState()
.isScreenSharing(false)
.setMediaProjectionIntent(null)
.build();
}
}
@Override
protected @NonNull WebRtcServiceState handleScreenSharingServiceReady(@NonNull WebRtcServiceState currentState) {
Log.i(tag, "handleScreenSharingServiceReady()");
LocalDeviceState localDeviceState = currentState.getLocalDeviceState();
Intent mediaProjectionIntent = localDeviceState.getMediaProjectionIntent();
if (localDeviceState.isScreenSharing()) {
Log.w(tag, "handleScreenSharingServiceReady(): already screensharing!");
return currentState;
}
if (mediaProjectionIntent == null) {
Log.w(tag, "handleScreenSharingServiceReady(): Media intent is null, bailing");
return currentState;
}
OutgoingVideoSourceRouter router = currentState.getVideoState().requireRouter();
try {
webRtcInteractor.getCallManager().setVideoEnable(true, true);
} catch (CallException e) {
return callFailure(currentState, "setVideoEnable() for screen share failed: ", e);
}
router.startScreenShare(mediaProjectionIntent);
return currentState.builder()
.changeLocalDeviceState()
.isScreenSharing(true)
.build();
}
@Override
protected @NonNull WebRtcServiceState handleLocalHangup(@NonNull WebRtcServiceState currentState) {
return activeCallDelegate.handleLocalHangup(currentState);
@@ -61,11 +61,11 @@ public abstract class DeviceAwareActionProcessor extends WebRtcActionProcessor {
protected @NonNull WebRtcServiceState handleSetCameraFlip(@NonNull WebRtcServiceState currentState) {
Log.i(tag, "handleSetCameraFlip():");
if (currentState.getLocalDeviceState().getCameraState().isEnabled() && currentState.getVideoState().getCamera() != null) {
currentState.getVideoState().getCamera().flip();
if (currentState.getLocalDeviceState().getCameraState().isEnabled() && currentState.getVideoState().getRouter() != null) {
currentState.getVideoState().getRouter().flip();
return currentState.builder()
.changeLocalDeviceState()
.cameraState(currentState.getVideoState().getCamera().getCameraState())
.cameraState(currentState.getVideoState().getRouter().getCameraState())
.build();
}
return currentState;
@@ -317,7 +317,7 @@ public class GroupActionProcessor extends DeviceAwareActionProcessor {
.actionProcessor(actionProcessorFactory.createNetworkUnavailableActionProcessor(webRtcInteractor))
.changeVideoState()
.eglBase(videoState.getLockableEglBase())
.camera(videoState.getCamera())
.router(videoState.getRouter())
.localSink(videoState.getLocalSink())
.commit()
.changeCallInfoState()
@@ -1,13 +1,12 @@
package org.thoughtcrime.securesms.service.webrtc;
import android.content.Intent;
import android.os.ResultReceiver;
import android.util.LongSparseArray;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.stream.Collectors;
import org.signal.core.util.logging.Log;
import org.signal.ringrtc.CallException;
import org.signal.ringrtc.GroupCall;
@@ -19,11 +18,14 @@ import org.thoughtcrime.securesms.events.GroupCallSpeechEvent;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.ringrtc.Camera;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.ringrtc.OutgoingVideoSourceRouter;
import org.thoughtcrime.securesms.ringrtc.RemotePeer;
import org.thoughtcrime.securesms.service.webrtc.state.LocalDeviceState;
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState;
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState;
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceStateBuilder;
import org.thoughtcrime.securesms.webrtc.CallNotificationBuilder;
import java.util.ArrayList;
import java.util.Collection;
@@ -31,6 +33,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* Process actions for when the call has at least once been connected and joined.
@@ -87,19 +90,19 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor {
protected @NonNull WebRtcServiceState handleSetEnableVideo(@NonNull WebRtcServiceState currentState, boolean enable) {
Log.i(tag, "handleSetEnableVideo():");
GroupCall groupCall = currentState.getCallInfoState().requireGroupCall();
Camera camera = currentState.getVideoState().requireCamera();
GroupCall groupCall = currentState.getCallInfoState().requireGroupCall();
OutgoingVideoSourceRouter router = currentState.getVideoState().requireRouter();
try {
groupCall.setOutgoingVideoMuted(!enable, false);
} catch (CallException e) {
return groupCallFailure(currentState, "Unable set video muted", e);
}
camera.setEnabled(enable);
router.setEnabled(enable);
currentState = currentState.builder()
.changeLocalDeviceState()
.cameraState(camera.getCameraState())
.cameraState(router.getCameraState())
.build();
boolean localVideoEnabled = currentState.getLocalDeviceState().getCameraState().isEnabled();
@@ -111,6 +114,78 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor {
return currentState;
}
@Override
protected @NonNull WebRtcServiceState handleSetLocalScreenShare(@NonNull WebRtcServiceState currentState, boolean enable, @Nullable Intent mediaProjectionData) {
Log.i(tag, "handleSetLocalScreenShare(): enable: " + enable);
GroupCall groupCall = currentState.getCallInfoState().requireGroupCall();
OutgoingVideoSourceRouter router = currentState.getVideoState().requireRouter();
RecipientId recipientId = currentState.getCallInfoState().getCallRecipient().getId();
if (enable && mediaProjectionData != null) {
Log.i(tag, "Updating service for media projection, then can screen share");
ActiveCallManager.ActiveCallForegroundService.update(context, CallNotificationBuilder.TYPE_ESTABLISHED, recipientId, true, true);
return currentState.builder()
.changeLocalDeviceState()
.setMediaProjectionIntent(mediaProjectionData)
.build();
} else {
router.stopScreenShare();
boolean cameraWasEnabled = currentState.getLocalDeviceState().getCameraState().isEnabled();
ActiveCallManager.ActiveCallForegroundService.update(context, CallNotificationBuilder.TYPE_ESTABLISHED, recipientId, true, false);
try {
groupCall.setPresenting(false);
groupCall.setOutgoingVideoMuted(!cameraWasEnabled, false);
} catch (CallException e) {
return groupCallFailure(currentState, "Unable to restore video mute after screen share", e);
}
return currentState.builder()
.changeLocalDeviceState()
.isScreenSharing(false)
.setMediaProjectionIntent(null)
.build();
}
}
@Override
protected @NonNull WebRtcServiceState handleScreenSharingServiceReady(@NonNull WebRtcServiceState currentState) {
Log.i(tag, "handleScreenSharingServiceReady()");
LocalDeviceState localDeviceState = currentState.getLocalDeviceState();
Intent mediaProjectionIntent = localDeviceState.getMediaProjectionIntent();
if (localDeviceState.isScreenSharing()) {
Log.w(tag, "handleScreenSharingServiceReady(): already screensharing!");
return currentState;
}
if (mediaProjectionIntent == null) {
Log.w(tag, "handleScreenSharingServiceReady(): Media intent is null, bailing");
return currentState;
}
GroupCall groupCall = currentState.getCallInfoState().requireGroupCall();
OutgoingVideoSourceRouter router = currentState.getVideoState().requireRouter();
try {
groupCall.setOutgoingVideoMuted(false, true);
groupCall.setPresenting(true);
} catch (CallException e) {
return groupCallFailure(currentState, "Unable to unmute video for screen share", e);
}
router.startScreenShare(mediaProjectionIntent);
return currentState.builder()
.changeLocalDeviceState()
.isScreenSharing(true)
.build();
}
@Override
protected @NonNull WebRtcServiceState handleSetMuteAudio(@NonNull WebRtcServiceState currentState, boolean muted) {
try {
@@ -9,7 +9,7 @@ import org.signal.core.util.logging.Log;
import org.signal.ringrtc.CallException;
import org.signal.ringrtc.GroupCall;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.ringrtc.Camera;
import org.thoughtcrime.securesms.ringrtc.OutgoingVideoSourceRouter;
import org.thoughtcrime.securesms.ringrtc.RemotePeer;
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState;
import org.thoughtcrime.securesms.events.CallParticipant;
@@ -142,19 +142,19 @@ public class GroupJoiningActionProcessor extends GroupActionProcessor {
@Override
protected @NonNull WebRtcServiceState handleSetEnableVideo(@NonNull WebRtcServiceState currentState, boolean enable) {
GroupCall groupCall = currentState.getCallInfoState().requireGroupCall();
Camera camera = currentState.getVideoState().requireCamera();
GroupCall groupCall = currentState.getCallInfoState().requireGroupCall();
OutgoingVideoSourceRouter router = currentState.getVideoState().requireRouter();
try {
groupCall.setOutgoingVideoMuted(!enable, false);
} catch (CallException e) {
return groupCallFailure(currentState, "Unable to set video muted", e);
}
camera.setEnabled(enable);
router.setEnabled(enable);
currentState = currentState.builder()
.changeLocalDeviceState()
.cameraState(camera.getCameraState())
.cameraState(router.getCameraState())
.build();
WebRtcUtil.enableSpeakerPhoneIfNeeded(webRtcInteractor, currentState);
@@ -196,7 +196,7 @@ public class GroupPreJoinActionProcessor extends GroupActionProcessor {
webRtcInteractor.initializeAudioForCall(true);
try {
groupCall.setOutgoingVideoSource(currentState.getVideoState().requireLocalSink(), currentState.getVideoState().requireCamera());
groupCall.setOutgoingVideoSource(currentState.getVideoState().requireLocalSink(), currentState.getVideoState().requireRouter());
groupCall.setOutgoingVideoMuted(!currentState.getLocalDeviceState().getCameraState().isEnabled(), false);
groupCall.setOutgoingAudioMuted(!currentState.getLocalDeviceState().isMicrophoneEnabled());
groupCall.setDataMode(NetworkUtil.getCallingDataMode(context, groupCall.getLocalDeviceState().getNetworkRoute().getLocalAdapterType()));
@@ -220,13 +220,13 @@ public class GroupPreJoinActionProcessor extends GroupActionProcessor {
protected @NonNull WebRtcServiceState handleSetEnableVideo(@NonNull WebRtcServiceState currentState, boolean enable) {
Log.i(tag, "handleSetEnableVideo(): Changing for pre-join group call. enable: " + enable);
currentState.getVideoState().requireCamera().setEnabled(enable);
currentState.getVideoState().requireRouter().setEnabled(enable);
return currentState.builder()
.changeCallSetupState(RemotePeer.GROUP_CALL_ID)
.enableVideoOnCreate(enable)
.commit()
.changeLocalDeviceState()
.cameraState(currentState.getVideoState().requireCamera().getCameraState())
.cameraState(currentState.getVideoState().requireRouter().getCameraState())
.build();
}
@@ -22,7 +22,7 @@ import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.ringrtc.CallState;
import org.thoughtcrime.securesms.ringrtc.Camera;
import org.thoughtcrime.securesms.ringrtc.OutgoingVideoSourceRouter;
import org.thoughtcrime.securesms.ringrtc.RemotePeer;
import org.thoughtcrime.securesms.service.webrtc.state.CallSetupState;
import org.thoughtcrime.securesms.service.webrtc.state.VideoState;
@@ -109,7 +109,7 @@ public class IncomingCallActionProcessor extends DeviceAwareActionProcessor {
RingRtcDynamicConfiguration.getAudioConfig(),
videoState.requireLocalSink(),
callParticipant.getVideoSink(),
videoState.requireCamera(),
videoState.requireRouter(),
callSetupState.getIceServers(),
hideIp,
NetworkUtil.getCallingDataMode(context),
@@ -137,14 +137,14 @@ public class IncomingCallActionProcessor extends DeviceAwareActionProcessor {
Log.i(TAG, "handleAcceptCall(): call_id: " + activePeer.getCallId());
Camera camera = currentState.getVideoState().requireCamera();
camera.setVanitySink(null);
OutgoingVideoSourceRouter router = currentState.getVideoState().requireRouter();
router.setVanitySink(null);
if (!answerWithVideo && currentState.getLocalDeviceState().getCameraState().isEnabled()) {
camera.setEnabled(false);
router.setEnabled(false);
currentState = currentState.builder()
.changeLocalDeviceState()
.cameraState(camera.getCameraState())
.cameraState(router.getCameraState())
.build();
}
@@ -172,9 +172,9 @@ public class IncomingCallActionProcessor extends DeviceAwareActionProcessor {
Log.i(TAG, "handleDenyCall():");
Camera camera = currentState.getVideoState().getCamera();
if (camera != null) {
camera.setVanitySink(null);
OutgoingVideoSourceRouter router = currentState.getVideoState().getRouter();
if (router != null) {
router.setVanitySink(null);
}
webRtcInteractor.sendNotAcceptedCallEventSyncMessage(activePeer,
@@ -209,21 +209,21 @@ public class IncomingCallActionProcessor extends DeviceAwareActionProcessor {
return currentState;
}
Camera camera = currentState.getVideoState().requireCamera();
OutgoingVideoSourceRouter router = currentState.getVideoState().requireRouter();
if (enabled) {
Log.i(TAG, "handleSetIncomingRingingVanity(): enabling vanity camera");
camera.setVanitySink(currentState.getVideoState().requireLocalSink());
camera.setEnabled(true);
router.setVanitySink(currentState.getVideoState().requireLocalSink());
router.setEnabled(true);
} else {
Log.i(TAG, "handleSetIncomingRingingVanity(): disabling vanity camera");
camera.setVanitySink(null);
camera.setEnabled(false);
router.setVanitySink(null);
router.setEnabled(false);
}
return currentState.builder()
.changeLocalDeviceState()
.cameraState(camera.getCameraState())
.cameraState(router.getCameraState())
.build();
}
@@ -22,7 +22,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.notifications.DoNotDisturbUtil;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.ringrtc.Camera;
import org.thoughtcrime.securesms.ringrtc.OutgoingVideoSourceRouter;
import org.thoughtcrime.securesms.ringrtc.RemotePeer;
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
@@ -196,22 +196,22 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro
return currentState;
}
Camera camera = currentState.getVideoState().requireCamera();
OutgoingVideoSourceRouter router = currentState.getVideoState().requireRouter();
if (enabled && !camera.isInitialized()) {
if (enabled && !router.isInitialized()) {
Log.i(TAG, "handleSetIncomingRingingVanity(): initializing vanity camera");
return WebRtcVideoUtil.initializeVanityCamera(currentState);
} else if (enabled) {
Log.i(TAG, "handleSetIncomingRingingVanity(): enabling vanity camera");
camera.setEnabled(true);
router.setEnabled(true);
} else {
Log.i(TAG, "handleSetIncomingRingingVanity(): disabling vanity camera");
camera.setEnabled(false);
router.setEnabled(false);
}
return currentState.builder()
.changeLocalDeviceState()
.cameraState(camera.getCameraState())
.cameraState(router.getCameraState())
.build();
}
@@ -259,7 +259,7 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro
webRtcInteractor.initializeAudioForCall(true);
try {
groupCall.setOutgoingVideoSource(currentState.getVideoState().requireLocalSink(), currentState.getVideoState().requireCamera());
groupCall.setOutgoingVideoSource(currentState.getVideoState().requireLocalSink(), currentState.getVideoState().requireRouter());
groupCall.setOutgoingVideoMuted(!answerWithVideo, false);
groupCall.setOutgoingAudioMuted(!currentState.getLocalDeviceState().isMicrophoneEnabled());
groupCall.setDataMode(NetworkUtil.getCallingDataMode(context, groupCall.getLocalDeviceState().getNetworkRoute().getLocalAdapterType()));
@@ -270,11 +270,11 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro
}
if (answerWithVideo) {
Camera camera = currentState.getVideoState().requireCamera();
camera.setEnabled(true);
OutgoingVideoSourceRouter router = currentState.getVideoState().requireRouter();
router.setEnabled(true);
currentState = currentState.builder()
.changeLocalDeviceState()
.cameraState(camera.getCameraState())
.cameraState(router.getCameraState())
.build();
}
@@ -157,7 +157,7 @@ public class OutgoingCallActionProcessor extends DeviceAwareActionProcessor {
RingRtcDynamicConfiguration.getAudioConfig(),
videoState.requireLocalSink(),
callParticipant.getVideoSink(),
videoState.requireCamera(),
videoState.requireRouter(),
callSetupState.getIceServers(),
hideIp,
NetworkUtil.getCallingDataMode(context),
@@ -170,7 +170,7 @@ public class OutgoingCallActionProcessor extends DeviceAwareActionProcessor {
return currentState.builder()
.changeLocalDeviceState()
.cameraState(currentState.getVideoState().requireCamera().getCameraState())
.cameraState(currentState.getVideoState().requireRouter().getCameraState())
.build();
}
@@ -63,10 +63,10 @@ public class PreJoinActionProcessor extends DeviceAwareActionProcessor {
protected @NonNull WebRtcServiceState handleSetEnableVideo(@NonNull WebRtcServiceState currentState, boolean enable) {
Log.i(TAG, "handleSetEnableVideo(): Changing for pre-join call.");
currentState.getVideoState().getCamera().setEnabled(enable);
currentState.getVideoState().getRouter().setEnabled(enable);
return currentState.builder()
.changeLocalDeviceState()
.cameraState(currentState.getVideoState().getCamera().getCameraState())
.cameraState(currentState.getVideoState().getRouter().getCameraState())
.build();
}
@@ -265,6 +265,18 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall.
process((s, p) -> p.handleSetEnableVideo(s, enabled));
}
public void startScreenShare(@NonNull android.content.Intent mediaProjectionData) {
process((s, p) -> p.handleSetLocalScreenShare(s, true, mediaProjectionData));
}
public void stopScreenShare() {
process((s, p) -> p.handleSetLocalScreenShare(s, false, null));
}
public void onScreenSharingServiceReady() {
process((s, p) -> p.handleScreenSharingServiceReady(s));
}
public void setIncomingRingingVanity(boolean enabled) {
process((s, p) -> p.handleSetIncomingRingingVanity(s, enabled));
}
@@ -34,7 +34,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.ringrtc.CallState;
import org.thoughtcrime.securesms.ringrtc.Camera;
import org.thoughtcrime.securesms.ringrtc.OutgoingVideoSourceRouter;
import org.thoughtcrime.securesms.ringrtc.CameraState;
import org.thoughtcrime.securesms.ringrtc.RemotePeer;
import org.thoughtcrime.securesms.service.webrtc.WebRtcData.CallMetadata;
@@ -414,6 +414,16 @@ public abstract class WebRtcActionProcessor {
return currentState;
}
protected @NonNull WebRtcServiceState handleSetLocalScreenShare(@NonNull WebRtcServiceState currentState, boolean enable, @Nullable android.content.Intent mediaProjectionData) {
Log.i(tag, "handleSetLocalScreenShare not processed");
return currentState;
}
protected @NonNull WebRtcServiceState handleScreenSharingServiceReady(@NonNull WebRtcServiceState currentState) {
Log.i(tag, "handleScreenSharingServiceReady not processed");
return currentState;
}
protected @NonNull WebRtcServiceState handleReceivedHangup(@NonNull WebRtcServiceState currentState,
@NonNull CallMetadata callMetadata,
@NonNull HangupMetadata hangupMetadata)
@@ -638,9 +648,9 @@ public abstract class WebRtcActionProcessor {
}
protected @NonNull WebRtcServiceState handleOrientationChanged(@NonNull WebRtcServiceState currentState, boolean isLandscapeEnabled, int orientationDegrees) {
Camera camera = currentState.getVideoState().getCamera();
if (camera != null) {
camera.setOrientation(orientationDegrees);
OutgoingVideoSourceRouter router = currentState.getVideoState().getRouter();
if (router != null) {
router.setOrientation(orientationDegrees);
}
int sinkRotationDegrees = isLandscapeEnabled ? BroadcastVideoSink.DEVICE_ROTATION_IGNORE : orientationDegrees;
@@ -7,9 +7,9 @@ import androidx.annotation.NonNull;
import org.signal.core.util.ThreadUtil;
import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink;
import org.thoughtcrime.securesms.components.webrtc.EglBaseWrapper;
import org.thoughtcrime.securesms.ringrtc.Camera;
import org.thoughtcrime.securesms.ringrtc.CameraEventListener;
import org.thoughtcrime.securesms.ringrtc.CameraState;
import org.thoughtcrime.securesms.ringrtc.OutgoingVideoSourceRouter;
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState;
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceStateBuilder;
import org.webrtc.CapturerObserver;
@@ -17,8 +17,8 @@ import org.webrtc.VideoFrame;
import org.webrtc.VideoSink;
/**
* Helper for initializing, reinitializing, and deinitializing the camera and it's related
* infrastructure.
* Helper for initializing, reinitializing, and deinitializing the outgoing video
* pipeline (camera + screen share, owned by the {@link OutgoingVideoSourceRouter}).
*/
public final class WebRtcVideoUtil {
@@ -32,22 +32,25 @@ public final class WebRtcVideoUtil {
final WebRtcServiceStateBuilder builder = currentState.builder();
ThreadUtil.runOnMainSync(() -> {
EglBaseWrapper eglBase = EglBaseWrapper.acquireEglBase(eglBaseHolder);
BroadcastVideoSink localSink = new BroadcastVideoSink(eglBase,
true,
false,
currentState.getLocalDeviceState().getOrientation().getDegrees());
Camera camera = new Camera(context, cameraEventListener, eglBase, CameraState.Direction.FRONT);
EglBaseWrapper eglBase = EglBaseWrapper.acquireEglBase(eglBaseHolder);
BroadcastVideoSink localSink = new BroadcastVideoSink(eglBase,
true,
false,
currentState.getLocalDeviceState().getOrientation().getDegrees());
OutgoingVideoSourceRouter router = new OutgoingVideoSourceRouter(context,
eglBase,
cameraEventListener,
CameraState.Direction.FRONT);
camera.setOrientation(currentState.getLocalDeviceState().getOrientation().getDegrees());
router.setOrientation(currentState.getLocalDeviceState().getOrientation().getDegrees());
builder.changeVideoState()
.eglBase(eglBase)
.localSink(localSink)
.camera(camera)
.router(router)
.commit()
.changeLocalDeviceState()
.cameraState(camera.getCameraState())
.cameraState(router.getCameraState())
.commit();
});
@@ -61,23 +64,23 @@ public final class WebRtcVideoUtil {
final WebRtcServiceStateBuilder builder = currentState.builder();
ThreadUtil.runOnMainSync(() -> {
Camera camera = currentState.getVideoState().requireCamera();
camera.setCameraEventListener(null);
camera.setEnabled(false);
camera.dispose();
OutgoingVideoSourceRouter oldRouter = currentState.getVideoState().requireRouter();
oldRouter.setCameraEventListener(null);
oldRouter.setEnabled(false);
oldRouter.dispose();
camera = new Camera(context,
cameraEventListener,
currentState.getVideoState().getLockableEglBase(),
currentState.getLocalDeviceState().getCameraState().getActiveDirection());
OutgoingVideoSourceRouter router = new OutgoingVideoSourceRouter(context,
currentState.getVideoState().getLockableEglBase(),
cameraEventListener,
currentState.getLocalDeviceState().getCameraState().getActiveDirection());
camera.setOrientation(currentState.getLocalDeviceState().getOrientation().getDegrees());
router.setOrientation(currentState.getLocalDeviceState().getOrientation().getDegrees());
builder.changeVideoState()
.camera(camera)
.router(router)
.commit()
.changeLocalDeviceState()
.cameraState(camera.getCameraState())
.cameraState(router.getCameraState())
.commit();
});
@@ -85,15 +88,15 @@ public final class WebRtcVideoUtil {
}
public static @NonNull WebRtcServiceState deinitializeVideo(@NonNull WebRtcServiceState currentState) {
Camera camera = currentState.getVideoState().getCamera();
if (camera != null) {
camera.dispose();
OutgoingVideoSourceRouter router = currentState.getVideoState().getRouter();
if (router != null) {
router.dispose();
}
return currentState.builder()
.changeVideoState()
.eglBase(null)
.camera(null)
.router(null)
.localSink(null)
.commit()
.changeLocalDeviceState()
@@ -102,11 +105,11 @@ public final class WebRtcVideoUtil {
}
public static @NonNull WebRtcServiceState initializeVanityCamera(@NonNull WebRtcServiceState currentState) {
Camera camera = currentState.getVideoState().requireCamera();
VideoSink sink = currentState.getVideoState().requireLocalSink();
OutgoingVideoSourceRouter router = currentState.getVideoState().requireRouter();
VideoSink sink = currentState.getVideoState().requireLocalSink();
if (camera.hasCapturer()) {
camera.initCapturer(new CapturerObserver() {
if (router.hasCapturer()) {
router.initCapturer(new CapturerObserver() {
@Override
public void onFrameCaptured(VideoFrame videoFrame) {
sink.onFrame(videoFrame);
@@ -118,12 +121,12 @@ public final class WebRtcVideoUtil {
@Override
public void onCapturerStopped() {}
});
camera.setEnabled(true);
router.setEnabled(true);
}
return currentState.builder()
.changeLocalDeviceState()
.cameraState(camera.getCameraState())
.cameraState(router.getCameraState())
.build();
}
}
@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.service.webrtc.state
import android.content.Intent
import org.thoughtcrime.securesms.components.sensors.Orientation
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.ringrtc.CameraState
@@ -21,7 +22,9 @@ data class LocalDeviceState(
var isAudioDeviceChangePending: Boolean = false,
var networkConnectionType: PeerConnection.AdapterType = PeerConnection.AdapterType.UNKNOWN,
var handRaisedTimestamp: Long = CallParticipant.HAND_LOWERED,
var remoteMutedBy: CallParticipant? = null
var remoteMutedBy: CallParticipant? = null,
var isScreenSharing: Boolean = false,
var mediaProjectionIntent: Intent? = null
) {
fun duplicate(): LocalDeviceState {
@@ -5,7 +5,7 @@ import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink;
import org.thoughtcrime.securesms.components.webrtc.EglBaseWrapper;
import org.thoughtcrime.securesms.ringrtc.Camera;
import org.thoughtcrime.securesms.ringrtc.OutgoingVideoSourceRouter;
import java.util.Objects;
@@ -13,22 +13,25 @@ import java.util.Objects;
* Local device video state and infrastructure.
*/
public final class VideoState {
EglBaseWrapper eglBase;
BroadcastVideoSink localSink;
Camera camera;
EglBaseWrapper eglBase;
BroadcastVideoSink localSink;
OutgoingVideoSourceRouter router;
VideoState() {
this(null, null, null);
}
VideoState(@NonNull VideoState toCopy) {
this(toCopy.eglBase, toCopy.localSink, toCopy.camera);
this(toCopy.eglBase, toCopy.localSink, toCopy.router);
}
VideoState(@NonNull EglBaseWrapper eglBase, @Nullable BroadcastVideoSink localSink, @Nullable Camera camera) {
VideoState(@Nullable EglBaseWrapper eglBase,
@Nullable BroadcastVideoSink localSink,
@Nullable OutgoingVideoSourceRouter router)
{
this.eglBase = eglBase;
this.localSink = localSink;
this.camera = camera;
this.router = router;
}
public @NonNull EglBaseWrapper getLockableEglBase() {
@@ -43,11 +46,11 @@ public final class VideoState {
return Objects.requireNonNull(localSink);
}
public @Nullable Camera getCamera() {
return camera;
public @Nullable OutgoingVideoSourceRouter getRouter() {
return router;
}
public @NonNull Camera requireCamera() {
return Objects.requireNonNull(camera);
public @NonNull OutgoingVideoSourceRouter requireRouter() {
return Objects.requireNonNull(router);
}
}
@@ -1,5 +1,7 @@
package org.thoughtcrime.securesms.service.webrtc.state;
import android.content.Intent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -15,8 +17,8 @@ import org.thoughtcrime.securesms.events.GroupCallSpeechEvent;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.ringrtc.Camera;
import org.thoughtcrime.securesms.ringrtc.CameraState;
import org.thoughtcrime.securesms.ringrtc.OutgoingVideoSourceRouter;
import org.thoughtcrime.securesms.ringrtc.RemotePeer;
import org.thoughtcrime.securesms.service.webrtc.CallLinkDisconnectReason;
import org.thoughtcrime.securesms.service.webrtc.WebRtcActionProcessor;
@@ -158,6 +160,16 @@ public class WebRtcServiceStateBuilder {
toBuild.setMicrophoneEnabled(false);
return this;
}
public @NonNull LocalDeviceStateBuilder isScreenSharing(boolean isScreenSharing) {
toBuild.setScreenSharing(isScreenSharing);
return this;
}
public @NonNull LocalDeviceStateBuilder setMediaProjectionIntent(@Nullable Intent mediaProjectionIntent) {
toBuild.setMediaProjectionIntent(mediaProjectionIntent);
return this;
}
}
public class CallSetupStateBuilder {
@@ -263,8 +275,8 @@ public class WebRtcServiceStateBuilder {
return this;
}
public @NonNull VideoStateBuilder camera(@Nullable Camera camera) {
toBuild.camera = camera;
public @NonNull VideoStateBuilder router(@Nullable OutgoingVideoSourceRouter router) {
toBuild.router = router;
return this;
}
}
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/signal_light_colorOnPrimary"
android:pathData="M4 4h16c1.1 0 2 0.9 2 2v10c0 1.1-0.9 2-2 2h-5v1h2c0.55 0 1 0.45 1 1s-0.45 1-1 1H7c-0.55 0-1-0.45-1-1s0.45-1 1-1h2v-1H4c-1.1 0-2-0.9-2-2V6c0-1.1 0.9-2 2-2ZM4 6v10h16V6H4Z"/>
<path
android:fillColor="@color/signal_light_colorOnPrimary"
android:pathData="M12 8l3 3h-2v3h-2v-3H9l3-3Z"/>
</vector>
+6
View File
@@ -2705,6 +2705,10 @@
<string name="CallOverflowPopupWindow__lower_hand">Lower</string>
<!-- A negative button for a dialog confirming the user wants to lower their hand (withdraw a raised hand) -->
<string name="CallOverflowPopupWindow__cancel">Cancel</string>
<!-- A clickable button to share your screen in a call -->
<string name="CallOverflowPopupWindow__share_screen" translatable="false">Share screen (Labs)</string>
<!-- A clickable button to stop sharing your screen in a call -->
<string name="CallOverflowPopupWindow__stop_screen_share" translatable="false">Stop sharing (Labs)</string>
<!-- A button to take you to a list of participants with raised hands -->
<string name="CallOverflowPopupWindow__view">View</string>
@@ -2829,6 +2833,8 @@
<!-- CallToastPopupWindow -->
<string name="CallToastPopupWindow__swipe_to_view_screen_share">Swipe to view screen share</string>
<!-- Hint shown to teach the user they can swipe between the grid view and the active speaker view -->
<string name="CallToastPopupWindow__swipe_to_view_speaker">Swipe to view speaker</string>
<!-- ProxyBottomSheetFragment -->
<string name="ProxyBottomSheetFragment_proxy_server">Proxy server</string>