From b054a30fa75250cc2d2835fb029e442e74188941 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Wed, 4 Mar 2026 10:55:02 -0500 Subject: [PATCH] Add support for remote muting call participants. --- .../webrtc/controls/CallInfoView.kt | 91 +++++-- .../webrtc/controls/ControlAndInfoState.kt | 3 +- .../controls/ControlsAndInfoViewModel.kt | 13 + .../controls/ParticipantActionsSheet.kt | 235 ++++++++++++++++++ .../components/webrtc/v2/CallInfoCallbacks.kt | 33 +++ .../webrtc/v2/CallParticipantsPager.kt | 23 +- .../components/webrtc/v2/CallScreen.kt | 205 ++++++++++++++- .../webrtc/v2/ComposeCallScreenMediator.kt | 13 +- .../webrtc/GroupConnectedActionProcessor.java | 13 + .../service/webrtc/SignalCallManager.java | 4 + .../service/webrtc/WebRtcActionProcessor.java | 6 + app/src/main/res/values/strings.xml | 8 + 12 files changed, 617 insertions(+), 30 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ParticipantActionsSheet.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt index 377cfc9437..641645a76b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/CallInfoView.kt @@ -69,6 +69,7 @@ import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent import org.thoughtcrime.securesms.events.WebRtcViewModel import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.RemoteConfig /** * Renders information about a call (1:1, group, or call link) and provides actions available for @@ -114,6 +115,12 @@ object CallInfoView { onShareLinkClicked = callbacks::onShareLinkClicked, onEditNameClicked = onEditNameClicked, onBlock = callbacks::onBlock, + onMuteAudio = callbacks::onMuteAudio, + onRemoveFromCall = callbacks::onRemoveFromCall, + onContactDetails = callbacks::onContactDetails, + onViewSafetyNumber = callbacks::onViewSafetyNumber, + onGoToChat = callbacks::onGoToChat, + isInternalUser = RemoteConfig.internalUser, modifier = modifier ) } @@ -122,6 +129,11 @@ object CallInfoView { fun onShareLinkClicked() fun onEditNameClicked(name: String) fun onBlock(callParticipant: CallParticipant) + fun onMuteAudio(callParticipant: CallParticipant) + fun onRemoveFromCall(callParticipant: CallParticipant) + fun onContactDetails(callParticipant: CallParticipant) + fun onViewSafetyNumber(callParticipant: CallParticipant) + fun onGoToChat(callParticipant: CallParticipant) } } @@ -135,7 +147,12 @@ private fun CallInfoPreview() { controlAndInfoState = ControlAndInfoState(), onShareLinkClicked = { }, onEditNameClicked = { }, - onBlock = { } + onBlock = { }, + onMuteAudio = { }, + onRemoveFromCall = { }, + onContactDetails = { }, + onViewSafetyNumber = { }, + onGoToChat = { } ) } } @@ -147,8 +164,15 @@ private fun CallInfo( onShareLinkClicked: () -> Unit, onEditNameClicked: () -> Unit, onBlock: (CallParticipant) -> Unit, + onMuteAudio: (CallParticipant) -> Unit = {}, + onRemoveFromCall: (CallParticipant) -> Unit = {}, + onContactDetails: (CallParticipant) -> Unit = {}, + onViewSafetyNumber: (CallParticipant) -> Unit = {}, + onGoToChat: (CallParticipant) -> Unit = {}, + isInternalUser: Boolean = false, modifier: Modifier = Modifier ) { + var selectedParticipant by remember { mutableStateOf(null) } val listState = rememberLazyListState() LaunchedEffect(controlAndInfoState.resetScrollState) { @@ -252,7 +276,16 @@ private fun CallInfo( CallParticipantRow( callParticipant = it, isSelfAdmin = controlAndInfoState.isSelfAdmin() && !participantsState.inCallLobby, - onBlockClicked = onBlock + onBlockClicked = onBlock, + onParticipantClicked = if (isInternalUser) { + { participant -> + if (!participant.recipient.isSelf) { + selectedParticipant = participant + } + } + } else { + null + } ) } @@ -312,6 +345,20 @@ private fun CallInfo( Spacer(modifier = Modifier.size(48.dp)) } } + + selectedParticipant?.let { participant -> + ParticipantActionsSheet( + callParticipant = participant, + isSelfAdmin = controlAndInfoState.isSelfAdmin(), + isCallLink = controlAndInfoState.callLink != null, + onDismiss = { selectedParticipant = null }, + onMuteAudio = onMuteAudio, + onRemoveFromCall = onRemoveFromCall, + onContactDetails = onContactDetails, + onViewSafetyNumber = onViewSafetyNumber, + onGoToChat = onGoToChat + ) + } } @Composable @@ -336,9 +383,10 @@ private fun CallParticipantRowPreview() { Previews.Preview { Surface { CallParticipantRow( - CallParticipant(recipient = Recipient(isResolving = false, systemContactName = "Miles Morales")), - isSelfAdmin = true - ) {} + callParticipant = CallParticipant(recipient = Recipient(isResolving = false, systemContactName = "Miles Morales")), + isSelfAdmin = true, + onBlockClicked = {} + ) } } } @@ -357,7 +405,8 @@ private fun HandRaisedRowPreview() { private fun CallParticipantRow( callParticipant: CallParticipant, isSelfAdmin: Boolean, - onBlockClicked: (CallParticipant) -> Unit + onBlockClicked: (CallParticipant) -> Unit, + onParticipantClicked: ((CallParticipant) -> Unit)? = null ) { CallParticipantRow( initialRecipient = callParticipant.recipient, @@ -368,7 +417,12 @@ private fun CallParticipantRow( showHandRaised = false, canLowerHand = false, isSelfAdmin = isSelfAdmin, - onBlockClicked = { onBlockClicked(callParticipant) } + onBlockClicked = { onBlockClicked(callParticipant) }, + onRowClicked = if (onParticipantClicked != null && !callParticipant.recipient.isSelf) { + { onParticipantClicked(callParticipant) } + } else { + null + } ) } @@ -396,14 +450,22 @@ private fun CallParticipantRow( isMicrophoneEnabled: Boolean, showHandRaised: Boolean, canLowerHand: Boolean, - isSelfAdmin: Boolean, - onBlockClicked: () -> Unit + isSelfAdmin: Boolean = false, + onBlockClicked: () -> Unit = {}, + onRowClicked: (() -> Unit)? = null ) { - Row( - modifier = Modifier + val rowModifier = if (onRowClicked != null) { + Modifier + .fillMaxWidth() + .clickable(onClick = onRowClicked) + .padding(Rows.defaultPadding()) + } else { + Modifier .fillMaxWidth() .padding(Rows.defaultPadding()) - ) { + } + + Row(modifier = rowModifier) { val recipient by ((if (LocalInspectionMode.current) Observable.just(Recipient.UNKNOWN) else Recipient.observable(initialRecipient.id))) .toFlowable(BackpressureStrategy.LATEST) .toLiveData() @@ -512,8 +574,9 @@ private fun GroupMemberRow( isMicrophoneEnabled = false, showHandRaised = false, canLowerHand = false, - isSelfAdmin = isSelfAdmin - ) {} + isSelfAdmin = isSelfAdmin, + onBlockClicked = {} + ) } @Composable diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlAndInfoState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlAndInfoState.kt index 93a60a0c83..8c0f20d20e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlAndInfoState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlAndInfoState.kt @@ -11,9 +11,10 @@ import org.thoughtcrime.securesms.database.CallLinkTable @Immutable data class ControlAndInfoState( val callLink: CallLinkTable.CallLink? = null, + val isGroupAdmin: Boolean = false, val resetScrollState: Long = 0 ) { fun isSelfAdmin(): Boolean { - return callLink?.credentials?.adminPassBytes != null + return callLink?.credentials?.adminPassBytes != null || isGroupAdmin } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoViewModel.kt index f997668f94..f843f10e4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ControlsAndInfoViewModel.kt @@ -13,9 +13,12 @@ import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.kotlin.plusAssign import io.reactivex.rxjava3.kotlin.subscribeBy +import io.reactivex.rxjava3.schedulers.Schedulers import org.thoughtcrime.securesms.calls.links.CallLinks import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsRepository +import org.thoughtcrime.securesms.database.GroupTable +import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult @@ -42,6 +45,16 @@ class ControlsAndInfoViewModel( disposables += CallLinks.watchCallLink(recipient.requireCallLinkRoomId()).subscribeBy { _state.value = _state.value.copy(callLink = it) } + } else if (recipient.isGroup && callRecipientId != recipient.id) { + callRecipientId = recipient.id + disposables += Single.fromCallable { + val groupRecord = SignalDatabase.groups.getGroup(recipient.requireGroupId()) + groupRecord.isPresent && groupRecord.get().memberLevel(Recipient.self()) == GroupTable.MemberLevel.ADMINISTRATOR + } + .subscribeOn(Schedulers.io()) + .subscribeBy { isAdmin -> + _state.value = _state.value.copy(isGroupAdmin = isAdmin) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ParticipantActionsSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ParticipantActionsSheet.kt new file mode 100644 index 0000000000..5132c02782 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/ParticipantActionsSheet.kt @@ -0,0 +1,235 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc.controls + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.toLiveData +import io.reactivex.rxjava3.core.BackpressureStrategy +import io.reactivex.rxjava3.core.Observable +import org.signal.core.ui.compose.AllNightPreviews +import org.signal.core.ui.compose.Dividers +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Rows +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.AvatarImageView +import org.thoughtcrime.securesms.events.CallParticipant +import org.thoughtcrime.securesms.recipients.Recipient + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ParticipantActionsSheet( + callParticipant: CallParticipant, + isSelfAdmin: Boolean, + isCallLink: Boolean, + onDismiss: () -> Unit, + onMuteAudio: (CallParticipant) -> Unit, + onRemoveFromCall: (CallParticipant) -> Unit, + onContactDetails: (CallParticipant) -> Unit, + onViewSafetyNumber: (CallParticipant) -> Unit, + onGoToChat: (CallParticipant) -> Unit +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState + ) { + val recipient by ( + (if (LocalInspectionMode.current) Observable.just(Recipient.UNKNOWN) else Recipient.observable(callParticipant.recipient.id)) + .toFlowable(BackpressureStrategy.LATEST) + .toLiveData() + .observeAsState(initial = callParticipant.recipient) + ) + + ParticipantActionsSheetContent( + recipient = recipient, + callParticipant = callParticipant, + isSelfAdmin = isSelfAdmin, + isCallLink = isCallLink, + onDismiss = onDismiss, + onMuteAudio = onMuteAudio, + onRemoveFromCall = onRemoveFromCall, + onContactDetails = onContactDetails, + onViewSafetyNumber = onViewSafetyNumber, + onGoToChat = onGoToChat + ) + } +} + +@Composable +private fun ParticipantActionsSheetContent( + recipient: Recipient, + callParticipant: CallParticipant, + isSelfAdmin: Boolean, + isCallLink: Boolean, + onDismiss: () -> Unit, + onMuteAudio: (CallParticipant) -> Unit, + onRemoveFromCall: (CallParticipant) -> Unit, + onContactDetails: (CallParticipant) -> Unit, + onViewSafetyNumber: (CallParticipant) -> Unit, + onGoToChat: (CallParticipant) -> Unit +) { + ParticipantHeader(recipient = recipient) + + val hasAdminActions = isSelfAdmin && (callParticipant.isMicrophoneEnabled || isCallLink) + + if (hasAdminActions) { + Dividers.Default() + + if (callParticipant.isMicrophoneEnabled) { + Rows.TextRow( + text = stringResource(id = R.string.CallParticipantSheet__mute_audio), + icon = painterResource(id = R.drawable.symbol_mic_slash_24), + onClick = { + onMuteAudio(callParticipant) + onDismiss() + } + ) + } + + if (isCallLink) { + Rows.TextRow( + text = stringResource(id = R.string.CallParticipantSheet__remove_from_call), + icon = painterResource(id = R.drawable.symbol_minus_circle_24), + onClick = { + onRemoveFromCall(callParticipant) + onDismiss() + } + ) + } + } + + Dividers.Default() + + Rows.TextRow( + text = stringResource(id = R.string.CallParticipantSheet__contact_details), + icon = painterResource(id = R.drawable.symbol_person_24), + onClick = { + onContactDetails(callParticipant) + onDismiss() + } + ) + + Rows.TextRow( + text = stringResource(id = R.string.ConversationSettingsFragment__view_safety_number), + icon = painterResource(id = R.drawable.symbol_safety_number_24), + onClick = { + onViewSafetyNumber(callParticipant) + onDismiss() + } + ) + + Rows.TextRow( + text = stringResource(id = R.string.CallContextMenu__go_to_chat), + icon = painterResource(id = R.drawable.symbol_open_24), + onClick = { + onGoToChat(callParticipant) + onDismiss() + } + ) + + Spacer(modifier = Modifier.size(48.dp)) +} + +@Composable +private fun ParticipantHeader(recipient: Recipient) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + if (LocalInspectionMode.current) { + Spacer(modifier = Modifier.size(64.dp)) + } else { + AndroidView( + factory = ::AvatarImageView, + modifier = Modifier.size(64.dp) + ) { + it.setAvatarUsingProfile(recipient) + } + } + + Spacer(modifier = Modifier.size(12.dp)) + + Text( + text = recipient.getDisplayName(androidx.compose.ui.platform.LocalContext.current), + style = MaterialTheme.typography.titleLarge + ) + + val e164 = recipient.e164 + if (e164.isPresent) { + Spacer(modifier = Modifier.size(2.dp)) + Text( + text = e164.get(), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } +} + +@AllNightPreviews +@Composable +private fun ParticipantActionsSheetAdminPreview() { + Previews.BottomSheetPreview { + ParticipantActionsSheetContent( + recipient = Recipient(isResolving = false, systemContactName = "Peter Parker"), + callParticipant = CallParticipant( + recipient = Recipient(isResolving = false, systemContactName = "Peter Parker"), + isMicrophoneEnabled = true + ), + isSelfAdmin = true, + isCallLink = true, + onDismiss = {}, + onMuteAudio = {}, + onRemoveFromCall = {}, + onContactDetails = {}, + onViewSafetyNumber = {}, + onGoToChat = {} + ) + } +} + +@AllNightPreviews +@Composable +private fun ParticipantActionsSheetNonAdminPreview() { + Previews.BottomSheetPreview { + ParticipantActionsSheetContent( + recipient = Recipient(isResolving = false, systemContactName = "Gwen Stacy"), + callParticipant = CallParticipant( + recipient = Recipient(isResolving = false, systemContactName = "Gwen Stacy") + ), + isSelfAdmin = false, + isCallLink = false, + onDismiss = {}, + onMuteAudio = {}, + onRemoveFromCall = {}, + onContactDetails = {}, + onViewSafetyNumber = {}, + onGoToChat = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallInfoCallbacks.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallInfoCallbacks.kt index 82e1c7c6e2..683e79463c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallInfoCallbacks.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallInfoCallbacks.kt @@ -15,10 +15,13 @@ import org.thoughtcrime.securesms.BaseActivity import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.calls.links.CallLinks import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment +import org.thoughtcrime.securesms.components.settings.conversation.ConversationSettingsActivity import org.thoughtcrime.securesms.components.webrtc.controls.CallInfoView import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.events.CallParticipant +import org.thoughtcrime.securesms.util.CommunicationActions +import org.thoughtcrime.securesms.verify.VerifyIdentityActivity /** * Callbacks for the CallInfoView, shared between CallActivity and ControlsAndInfoController. @@ -60,4 +63,34 @@ class CallInfoCallbacks( } .show() } + + override fun onMuteAudio(callParticipant: CallParticipant) { + AppDependencies.signalCallManager.sendRemoteMuteRequest(callParticipant) + } + + override fun onRemoveFromCall(callParticipant: CallParticipant) { + MaterialAlertDialogBuilder(activity) + .setNegativeButton(android.R.string.cancel, null) + .setMessage(activity.resources.getString(R.string.CallLinkInfoSheet__remove_s_from_the_call, callParticipant.recipient.getShortDisplayName(activity))) + .setPositiveButton(R.string.CallLinkInfoSheet__remove) { _, _ -> + AppDependencies.signalCallManager.removeFromCallLink(callParticipant) + } + .setNeutralButton(R.string.CallLinkInfoSheet__block_from_call) { _, _ -> + AppDependencies.signalCallManager.blockFromCallLink(callParticipant) + } + .show() + } + + override fun onContactDetails(callParticipant: CallParticipant) { + activity.startActivity(ConversationSettingsActivity.forRecipient(activity, callParticipant.recipient.id)) + } + + override fun onViewSafetyNumber(callParticipant: CallParticipant) { + val identityRecord = AppDependencies.protocolStore.aci().identities().getIdentityRecord(callParticipant.recipient.id) + VerifyIdentityActivity.startOrShowExchangeMessagesDialog(activity, identityRecord.orElse(null)) + } + + override fun onGoToChat(callParticipant: CallParticipant) { + CommunicationActions.startConversation(activity, callParticipant.recipient, null) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt index 5c8a6e98fc..61ea695014 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallParticipantsPager.kt @@ -5,6 +5,7 @@ package org.thoughtcrime.securesms.components.webrtc.v2 +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.statusBarsPadding @@ -15,7 +16,9 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.movableContentOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput import org.signal.core.ui.compose.AllNightPreviews import org.signal.core.ui.compose.Previews import org.thoughtcrime.securesms.conversation.colors.ChatColorsPalette @@ -28,12 +31,17 @@ import org.thoughtcrime.securesms.recipients.RecipientId fun CallParticipantsPager( callParticipantsPagerState: CallParticipantsPagerState, pagerState: PagerState, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onTap: (() -> Unit)? = null, + onParticipantLongPress: ((CallParticipant) -> Unit)? = null ) { if (callParticipantsPagerState.focusedParticipant == null) { return } + val currentOnTap = rememberUpdatedState(onTap) + val currentOnLongPress = rememberUpdatedState(onParticipantLongPress) + val firstParticipantAR = rememberParticipantAspectRatio( callParticipantsPagerState.callParticipants.firstOrNull()?.videoSink ) @@ -48,13 +56,24 @@ fun CallParticipantsPager( modifier = mod, itemKey = { it.callParticipantId } ) { participant, itemModifier -> + val longPressModifier = if (!participant.recipient.isSelf && currentOnLongPress.value != null) { + itemModifier.pointerInput(participant.callParticipantId) { + detectTapGestures( + onTap = { currentOnTap.value?.invoke() }, + onLongPress = { currentOnLongPress.value?.invoke(participant) } + ) + } + } else { + itemModifier + } + RemoteParticipantContent( participant = participant, renderInPip = state.isRenderInPip, raiseHandAllowed = false, onInfoMoreInfoClick = null, showAudioIndicator = state.callParticipants.size > 1, - modifier = itemModifier + modifier = longPressModifier ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt index dc81b2d870..da81552958 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/CallScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -25,7 +26,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue @@ -35,6 +39,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState @@ -48,6 +53,8 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -57,6 +64,7 @@ import org.signal.core.ui.compose.Previews import org.signal.core.ui.compose.TriggerAlignedPopupState import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.util.DimensionUnit +import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.emoji.EmojiStrings import org.thoughtcrime.securesms.components.webrtc.WebRtcLocalRenderState import org.thoughtcrime.securesms.components.webrtc.controls.RaiseHandSnackbar @@ -118,7 +126,15 @@ fun CallScreen( onCallScreenDialogDismissed: () -> Unit = {}, onWifiToCellularPopupDismissed: () -> Unit = {}, onSwipeToSpeakerHintDismissed: () -> Unit = {}, - onRemoteMuteToastDismissed: () -> Unit = {} + onRemoteMuteToastDismissed: () -> Unit = {}, + isInternalUser: Boolean = false, + isSelfAdmin: Boolean = false, + isCallLink: Boolean = false, + onMuteAudio: (CallParticipant) -> Unit = {}, + onRemoveFromCall: (CallParticipant) -> Unit = {}, + onContactDetails: (CallParticipant) -> Unit = {}, + onViewSafetyNumber: (CallParticipant) -> Unit = {}, + onGoToChat: (CallParticipant) -> Unit = {} ) { if (webRtcCallState == WebRtcViewModel.State.CALL_INCOMING) { IncomingCallScreen( @@ -312,22 +328,53 @@ fun CallScreen( ) } } else if (webRtcCallState.isPassedPreJoin) { + var longPressedParticipantId by remember { mutableStateOf(null) } + val longPressedParticipant = longPressedParticipantId?.let { id -> + callParticipantsPagerState.callParticipants.find { it.callParticipantId == id } + } + CallElementsLayout( callGridSlot = { - CallParticipantsPager( - callParticipantsPagerState = callParticipantsPagerState, - pagerState = callScreenController.callParticipantsVerticalPagerState, - modifier = Modifier - .fillMaxSize() - .clickable( - onClick = { + Box { + CallParticipantsPager( + callParticipantsPagerState = callParticipantsPagerState, + pagerState = callScreenController.callParticipantsVerticalPagerState, + modifier = Modifier + .fillMaxSize() + .clickable( + onClick = { + scope.launch { + callScreenController.handleEvent(CallScreenController.Event.TOGGLE_CONTROLS) + } + }, + enabled = !callControlsState.skipHiddenState + ), + onTap = { + if (!callControlsState.skipHiddenState) { scope.launch { callScreenController.handleEvent(CallScreenController.Event.TOGGLE_CONTROLS) } - }, - enabled = !callControlsState.skipHiddenState - ) - ) + } + }, + onParticipantLongPress = if (isInternalUser) { + { participant -> longPressedParticipantId = participant.callParticipantId } + } else { + null + } + ) + + ParticipantContextMenu( + participant = longPressedParticipant, + isSelfAdmin = isSelfAdmin, + isCallLink = isCallLink, + onDismiss = { longPressedParticipantId = null }, + onMuteAudio = onMuteAudio, + onRemoveFromCall = onRemoveFromCall, + onContactDetails = onContactDetails, + onViewSafetyNumber = onViewSafetyNumber, + onGoToChat = onGoToChat + ) + } }, pictureInPictureSlot = { MoveableLocalVideoRenderer( @@ -518,6 +565,140 @@ private fun AnimatedCallStateUpdate( } } +@Composable +private fun ParticipantContextMenu( + participant: CallParticipant?, + isSelfAdmin: Boolean, + isCallLink: Boolean, + onDismiss: () -> Unit, + onMuteAudio: (CallParticipant) -> Unit, + onRemoveFromCall: (CallParticipant) -> Unit, + onContactDetails: (CallParticipant) -> Unit, + onViewSafetyNumber: (CallParticipant) -> Unit, + onGoToChat: (CallParticipant) -> Unit +) { + DropdownMenu( + expanded = participant != null, + onDismissRequest = onDismiss + ) { + val resolved = participant ?: return@DropdownMenu + + DropdownMenuItem( + text = { + Text( + text = resolved.recipient.getShortDisplayName(androidx.compose.ui.platform.LocalContext.current), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface + ) + }, + onClick = {}, + enabled = false + ) + + // Divider (default divider has too much padding) + Box( + Modifier + .fillMaxWidth() + .height(1.5.dp) + .background(color = MaterialTheme.colorScheme.surfaceVariant) + ) + + if (isSelfAdmin && resolved.isMicrophoneEnabled) { + DropdownMenuItem( + text = { Text(stringResource(R.string.CallParticipantSheet__mute_audio)) }, + leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_mic_slash_24), contentDescription = null) }, + onClick = { + onMuteAudio(resolved) + onDismiss() + } + ) + } + + if (isSelfAdmin && isCallLink) { + DropdownMenuItem( + text = { Text(stringResource(R.string.CallParticipantSheet__remove_from_call)) }, + leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_minus_circle_24), contentDescription = null) }, + onClick = { + onRemoveFromCall(resolved) + onDismiss() + } + ) + } + + DropdownMenuItem( + text = { Text(stringResource(R.string.CallParticipantSheet__contact_details)) }, + leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_person_24), contentDescription = null) }, + onClick = { + onContactDetails(resolved) + onDismiss() + } + ) + + DropdownMenuItem( + text = { Text(stringResource(R.string.ConversationSettingsFragment__view_safety_number)) }, + leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_safety_number_24), contentDescription = null) }, + onClick = { + onViewSafetyNumber(resolved) + onDismiss() + } + ) + + DropdownMenuItem( + text = { Text(stringResource(R.string.CallContextMenu__go_to_chat)) }, + leadingIcon = { Icon(painter = painterResource(R.drawable.symbol_open_24), contentDescription = null) }, + onClick = { + onGoToChat(resolved) + onDismiss() + } + ) + } +} + +@AllNightPreviews +@Composable +private fun ParticipantContextMenuAdminPreview() { + Previews.Preview { + Box { + ParticipantContextMenu( + participant = CallParticipant( + recipient = Recipient(isResolving = false, systemContactName = "Peter Parker"), + isMicrophoneEnabled = true + ), + isSelfAdmin = true, + isCallLink = true, + onDismiss = {}, + onMuteAudio = {}, + onRemoveFromCall = {}, + onContactDetails = {}, + onViewSafetyNumber = {}, + onGoToChat = {} + ) + } + } +} + +@AllNightPreviews +@Composable +private fun ParticipantContextMenuNonAdminPreview() { + Previews.Preview { + Box { + ParticipantContextMenu( + participant = CallParticipant( + recipient = Recipient(isResolving = false, systemContactName = "Gwen Stacy") + ), + isSelfAdmin = false, + isCallLink = false, + onDismiss = {}, + onMuteAudio = {}, + onRemoveFromCall = {}, + onContactDetails = {}, + onViewSafetyNumber = {}, + onGoToChat = {} + ) + } + } +} + @AllNightPreviews @Composable private fun CallScreenPreview() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt index 2cb2e42c35..baec5b4d4e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/ComposeCallScreenMediator.kt @@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDial import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState +import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.util.WindowUtil import org.thoughtcrime.securesms.webrtc.CallParticipantsViewState import kotlin.time.Duration.Companion.seconds @@ -173,6 +174,8 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo } } + val controlAndInfoState by controlsAndInfoViewModel.state + SignalTheme(isDarkMode = true) { CallScreen( callRecipient = recipient, @@ -217,7 +220,15 @@ class ComposeCallScreenMediator(private val activity: WebRtcCallActivity, viewMo onWifiToCellularPopupDismissed = { callScreenViewModel.callScreenState.update { it.copy(displayWifiToCellularPopup = false) } }, onSwipeToSpeakerHintDismissed = { callScreenViewModel.callScreenState.update { it.copy(displaySwipeToSpeakerHint = false) } }, onRemoteMuteToastDismissed = { callScreenViewModel.callScreenState.update { it.copy(remoteMuteToastMessage = null) } }, - callParticipantUpdatePopupController = callParticipantUpdatePopupController + callParticipantUpdatePopupController = callParticipantUpdatePopupController, + isInternalUser = RemoteConfig.internalUser, + isSelfAdmin = controlAndInfoState.isSelfAdmin(), + isCallLink = controlAndInfoState.callLink != null, + onMuteAudio = callInfoCallbacks::onMuteAudio, + onRemoveFromCall = callInfoCallbacks::onRemoveFromCall, + onContactDetails = callInfoCallbacks::onContactDetails, + onViewSafetyNumber = callInfoCallbacks::onViewSafetyNumber, + onGoToChat = callInfoCallbacks::onGoToChat ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java index 1fac274cc6..0c2e901cb6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/GroupConnectedActionProcessor.java @@ -366,6 +366,19 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor { return currentState; } + @Override + protected @NonNull WebRtcServiceState handleSendRemoteMuteRequest(@NonNull WebRtcServiceState currentState, @NonNull CallParticipant participant) { + Log.i(tag, "handleSendRemoteMuteRequest():"); + + GroupCall groupCall = currentState.getCallInfoState().requireGroupCall(); + try { + groupCall.sendRemoteMuteRequest(participant.getCallParticipantId().demuxId); + } catch (CallException e) { + Log.w(tag, "Failed to send remote mute request.", e); + } + return currentState; + } + @Override protected @NonNull WebRtcServiceState handleGroupCallSpeechEvent(@NonNull WebRtcServiceState currentState, @NonNull GroupCall.SpeechEvent speechEvent) { Log.i(tag, "handleGroupCallSpeechEvent :: " + speechEvent.name()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java index da402ba187..5ec02b80ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java @@ -393,6 +393,10 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall. process((s, p) -> p.handleSetCallLinkJoinRequestRejected(s, participant)); } + public void sendRemoteMuteRequest(@NonNull CallParticipant participant) { + process((s, p) -> p.handleSendRemoteMuteRequest(s, participant)); + } + public void removeFromCallLink(@NonNull CallParticipant participant) { process((s, p) -> p.handleRemoveFromCallLink(s, participant)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java index 6c4cc49b41..3669ee72bb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java @@ -973,6 +973,12 @@ public abstract class WebRtcActionProcessor { return currentState; } + protected @NonNull WebRtcServiceState handleSendRemoteMuteRequest(@NonNull WebRtcServiceState currentState, @NonNull CallParticipant participant) { + Log.i(tag, "handleSendRemoteMuteRequest not processed"); + + return currentState; + } + protected @NonNull WebRtcServiceState handleRemoveFromCallLink(@NonNull WebRtcServiceState currentState, @NonNull CallParticipant participant) { Log.i(tag, "handleRemoveFromCallLink not processed"); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b589e9f926..9f5771121e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7745,6 +7745,14 @@ Block from call + + + Mute audio + + Remove from call + + Contact details + Create call link