Initial raise hand support.

This commit is contained in:
Nicholas Tinsley 2023-12-08 21:07:00 -05:00 committed by Cody Henthorne
parent f2a7824168
commit c2f5a6390e
28 changed files with 577 additions and 23 deletions

View file

@ -10,7 +10,9 @@ import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.PopupWindow
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.widget.PopupWindowCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.WebRtcCallActivity
@ -22,15 +24,21 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
*/
class CallOverflowPopupWindow(private val activity: WebRtcCallActivity, parentViewGroup: ViewGroup) : PopupWindow(
LayoutInflater.from(activity).inflate(R.layout.call_overflow_holder, parentViewGroup, false),
activity.resources.getDimension(R.dimen.reaction_scrubber_width).toInt(),
ViewGroup.LayoutParams.WRAP_CONTENT
activity.resources.getDimension(R.dimen.calling_reaction_popup_menu_width).toInt(),
activity.resources.getDimension(R.dimen.calling_reaction_popup_menu_height).toInt()
) {
init {
(contentView as CallReactionScrubber).initialize(activity.supportFragmentManager, activity) {
val root = (contentView as LinearLayout)
root.findViewById<CallReactionScrubber>(R.id.reaction_scrubber).initialize(activity.supportFragmentManager) {
ApplicationDependencies.getSignalCallManager().react(it)
dismiss()
}
root.findViewById<ConstraintLayout>(R.id.raise_hand_layout_parent).setOnClickListener {
ApplicationDependencies.getSignalCallManager().raiseHand(true)
dismiss()
}
}
fun show(anchor: View) {
@ -45,7 +53,7 @@ class CallOverflowPopupWindow(private val activity: WebRtcCallActivity, parentVi
val windowWidth = windowRect.width()
val popupWidth = resources.getDimension(R.dimen.reaction_scrubber_width).toInt()
val popupHeight = resources.getDimension(R.dimen.calling_reaction_emoji_height).toInt()
val popupHeight = resources.getDimension(R.dimen.calling_reaction_popup_menu_height).toInt()
val xOffset = windowWidth - popupWidth - margin
val yOffset = -popupHeight - margin

View file

@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls.FoldableState
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.events.CallParticipant.Companion.createLocal
import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent
import org.thoughtcrime.securesms.events.GroupCallReactionEvent
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
@ -26,10 +27,11 @@ data class CallParticipantsState(
val callState: WebRtcViewModel.State = WebRtcViewModel.State.CALL_DISCONNECTED,
val groupCallState: WebRtcViewModel.GroupCallState = WebRtcViewModel.GroupCallState.IDLE,
private val remoteParticipants: ParticipantCollection = ParticipantCollection(SMALL_GROUP_MAX),
val localParticipant: CallParticipant = createLocal(CameraState.UNKNOWN, BroadcastVideoSink(), false),
val localParticipant: CallParticipant = createLocal(CameraState.UNKNOWN, BroadcastVideoSink(), microphoneEnabled = false, isHandRaised = false),
val focusedParticipant: CallParticipant = CallParticipant.EMPTY,
val localRenderState: WebRtcLocalRenderState = WebRtcLocalRenderState.GONE,
val reactions: List<GroupCallReactionEvent> = emptyList(),
val raisedHands: List<GroupCallRaiseHandEvent> = emptyList(),
val isInPipMode: Boolean = false,
private val showVideoForOutgoing: Boolean = false,
val isViewingFocusedParticipant: Boolean = false,
@ -227,6 +229,7 @@ data class CallParticipantsState(
localRenderState = localRenderState,
showVideoForOutgoing = newShowVideoForOutgoing,
recipient = webRtcViewModel.recipient,
raisedHands = webRtcViewModel.raisedHands,
remoteDevicesCount = webRtcViewModel.remoteDevicesCount,
ringGroup = webRtcViewModel.ringGroup,
isInOutgoingRingingMode = isInOutgoingRingingMode,

View file

@ -39,7 +39,7 @@ class CallReactionScrubber @JvmOverloads constructor(
customEmojiIndex = emojiViews.size - 1
}
fun initialize(fragmentManager: FragmentManager, callback: ReactWithAnyEmojiBottomSheetDialogFragment.Callback, listener: (String) -> Unit) {
fun initialize(fragmentManager: FragmentManager, listener: (String) -> Unit) {
val emojis = SignalStore.emojiValues().reactions
for (i in emojiViews.indices) {
val view = emojiViews[i]

View file

@ -24,6 +24,7 @@ import androidx.annotation.RequiresApi;
import androidx.annotation.StringRes;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.Toolbar;
import androidx.compose.ui.platform.ComposeView;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.constraintlayout.widget.Guideline;
@ -124,6 +125,8 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
private RecyclerView groupReactionsFeed;
private MultiReactionBurstLayout reactionViews;
private Guideline aboveControlsGuideline;
private ComposeView raiseHandSnackbar;
private WebRtcCallParticipantsPagerAdapter pagerAdapter;
@ -202,6 +205,7 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
groupReactionsFeed = findViewById(R.id.call_screen_reactions_feed);
reactionViews = findViewById(R.id.call_screen_reactions_container);
aboveControlsGuideline = findViewById(R.id.call_screen_above_controls_guideline);
raiseHandSnackbar = findViewById(R.id.call_screen_raise_hand_view);
View decline = findViewById(R.id.call_screen_decline_call);
View answerLabel = findViewById(R.id.call_screen_answer_call_label);
@ -728,6 +732,10 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
visibleViewSet.add(groupReactionsFeed);
}
if (webRtcControls.displayRaiseHand()) {
visibleViewSet.add(raiseHandSnackbar);
}
boolean forceUpdate = webRtcControls.adjustForFold() && !controls.adjustForFold();
controls = webRtcControls;
@ -834,6 +842,12 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
ConstraintSet.TOP,
ViewUtil.dpToPx(layoutPositions.reactionBottomMargin));
constraintSet.connect(R.id.call_screen_raise_hand_view,
ConstraintSet.BOTTOM,
layoutPositions.reactionBottomViewId,
ConstraintSet.TOP,
ViewUtil.dpToPx(layoutPositions.reactionBottomMargin));
constraintSet.applyTo(this);
}

View file

@ -216,6 +216,10 @@ public final class WebRtcControls {
return !isInPipMode;
}
public boolean displayRaiseHand() {
return FeatureFlags.groupCallRaiseHand() && !isInPipMode;
}
public @NonNull WebRtcAudioOutput getAudioOutput() {
switch (activeDevice) {
case SPEAKER_PHONE:

View file

@ -23,6 +23,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@ -48,7 +49,9 @@ import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.events.CallParticipant
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
@ -72,7 +75,8 @@ object CallInfoView {
remoteParticipants = state.allRemoteParticipants.sortedBy { it.callParticipantId.recipientId },
localParticipant = state.localParticipant,
groupMembers = state.groupMembers.filterNot { it.member.isSelf },
callRecipient = state.recipient
callRecipient = state.recipient,
raisedHands = state.raisedHands
)
}
.subscribeAsState(ParticipantsState())
@ -94,8 +98,9 @@ object CallInfoView {
private fun CallInfoPreview() {
SignalTheme(isDarkMode = true) {
Surface {
val remoteParticipants = listOf(CallParticipant(recipient = Recipient.UNKNOWN))
CallInfo(
participantsState = ParticipantsState(remoteParticipants = listOf(CallParticipant(recipient = Recipient.UNKNOWN))),
participantsState = ParticipantsState(remoteParticipants = remoteParticipants, raisedHands = remoteParticipants.map { GroupCallRaiseHandEvent(it.recipient, System.currentTimeMillis()) }),
controlAndInfoState = ControlAndInfoState()
)
}
@ -127,6 +132,31 @@ private fun CallInfo(
)
}
if (participantsState.raisedHands.isNotEmpty()) {
item {
Box(
modifier = Modifier
.padding(horizontal = 24.dp)
.defaultMinSize(minHeight = 52.dp)
.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
Text(
text = pluralStringResource(id = R.plurals.CallParticipantsListDialog__raised_hands, count = participantsState.raisedHands.size, participantsState.raisedHands.size),
style = MaterialTheme.typography.titleSmall
)
}
}
items(
items = participantsState.raisedHands,
key = { it.sender.id },
contentType = { null }
) {
HandRaisedRow(recipient = it.sender)
}
}
item {
Box(
modifier = Modifier
@ -173,6 +203,8 @@ private fun CallInfo(
showIcons = false,
isVideoEnabled = false,
isMicrophoneEnabled = false,
showHandRaised = false,
canLowerHand = false,
isSelfAdmin = false,
onBlockClicked = {}
)
@ -214,6 +246,16 @@ private fun CallParticipantRowPreview() {
}
}
@Preview
@Composable
private fun HandRaisedRowPreview() {
SignalTheme(isDarkMode = true) {
Surface {
HandRaisedRow(Recipient.UNKNOWN, canLowerHand = true)
}
}
}
@Composable
private fun CallParticipantRow(
callParticipant: CallParticipant,
@ -226,11 +268,28 @@ private fun CallParticipantRow(
showIcons = true,
isVideoEnabled = callParticipant.isVideoEnabled,
isMicrophoneEnabled = callParticipant.isMicrophoneEnabled,
showHandRaised = false,
canLowerHand = false,
isSelfAdmin = isSelfAdmin,
onBlockClicked = { onBlockClicked(callParticipant) }
)
}
@Composable
private fun HandRaisedRow(recipient: Recipient, canLowerHand: Boolean = recipient.isSelf) {
CallParticipantRow(
initialRecipient = recipient,
name = recipient.getShortDisplayName(LocalContext.current),
showIcons = true,
isVideoEnabled = true,
isMicrophoneEnabled = true,
showHandRaised = true,
canLowerHand = canLowerHand,
isSelfAdmin = false,
onBlockClicked = {}
)
}
@Composable
private fun CallParticipantRow(
initialRecipient: Recipient,
@ -238,6 +297,8 @@ private fun CallParticipantRow(
showIcons: Boolean,
isVideoEnabled: Boolean,
isMicrophoneEnabled: Boolean,
showHandRaised: Boolean,
canLowerHand: Boolean,
isSelfAdmin: Boolean,
onBlockClicked: () -> Unit
) {
@ -275,6 +336,25 @@ private fun CallParticipantRow(
.align(Alignment.CenterVertically)
)
if (showIcons && showHandRaised && canLowerHand) {
TextButton(onClick = {
if (recipient.isSelf) {
ApplicationDependencies.getSignalCallManager().raiseHand(false)
}
}) {
Text(text = stringResource(id = R.string.CallOverflowPopupWindow__lower_hand))
}
Spacer(modifier = Modifier.width(16.dp))
}
if (showIcons && showHandRaised) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_raise_hand_24),
contentDescription = null,
modifier = Modifier.align(Alignment.CenterVertically)
)
}
if (showIcons && !isVideoEnabled) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_video_slash_24),
@ -322,6 +402,8 @@ private fun GroupMemberRow(
showIcons = false,
isVideoEnabled = false,
isMicrophoneEnabled = false,
showHandRaised = false,
canLowerHand = false,
isSelfAdmin = isSelfAdmin
) {}
}
@ -334,7 +416,8 @@ private data class ParticipantsState(
val remoteParticipants: List<CallParticipant> = emptyList(),
val localParticipant: CallParticipant? = null,
val groupMembers: List<GroupMemberEntry.FullMember> = emptyList(),
val callRecipient: Recipient = Recipient.UNKNOWN
val callRecipient: Recipient = Recipient.UNKNOWN,
val raisedHands: List<GroupCallRaiseHandEvent> = emptyList()
) {
val participantsForList: List<CallParticipant> = if (includeSelf && localParticipant != null) {

View file

@ -69,6 +69,7 @@ class ControlsAndInfoController(
private val frame: FrameLayout
private val behavior: BottomSheetBehavior<View>
private val callInfoComposeView: ComposeView
private val raiseHandComposeView: ComposeView
private val callControls: ConstraintLayout
private val bottomSheetVisibilityListeners = mutableSetOf<BottomSheetVisibilityListener>()
private val scheduleHideControlsRunnable: Runnable = Runnable { onScheduledHide() }
@ -86,6 +87,7 @@ class ControlsAndInfoController(
behavior = BottomSheetBehavior.from(frame)
callInfoComposeView = webRtcCallView.findViewById(R.id.call_info_compose)
callControls = webRtcCallView.findViewById(R.id.call_controls_constraint_layout)
raiseHandComposeView = webRtcCallView.findViewById(R.id.call_screen_raise_hand_view)
callInfoComposeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
@ -98,6 +100,13 @@ class ControlsAndInfoController(
callInfoComposeView.alpha = 0f
callInfoComposeView.translationY = infoTranslationDistance
raiseHandComposeView.apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
RaiseHandSnackbar.View(viewModel, showCallInfoListener = ::showCallInfo)
}
}
frame.background = MaterialShapeDrawable(
ShapeAppearanceModel.builder()
.setTopLeftCorner(CornerFamily.ROUNDED, 18.dp.toFloat())

View file

@ -0,0 +1,217 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc.controls
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rxjava3.subscribeAsState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.reactivex.rxjava3.core.BackpressureStrategy
import kotlinx.coroutines.delay
import org.signal.core.ui.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent
import org.thoughtcrime.securesms.recipients.Recipient
import java.util.concurrent.TimeUnit
import kotlin.time.Duration
/**
* This is a UI element to display the status of one or more people with raised hands in a group call.
* It supports both an expanded and collapsed mode.
*/
object RaiseHandSnackbar {
const val TAG = "RaiseHandSnackbar"
val COLLAPSE_DELAY_MS = TimeUnit.SECONDS.toMillis(4L)
@Composable
fun View(webRtcCallViewModel: WebRtcCallViewModel, showCallInfoListener: () -> Unit, modifier: Modifier = Modifier) {
var isExpanded by remember { mutableStateOf(ExpansionState(false, false)) }
val webRtcState by webRtcCallViewModel.callParticipantsState
.toFlowable(BackpressureStrategy.LATEST)
.map { state ->
val raisedHands = state.raisedHands.sortedByDescending { it.timestamp }
val shouldExpand = RaiseHandState.shouldExpand(raisedHands)
if (!isExpanded.forced) {
isExpanded = ExpansionState(shouldExpand, false)
}
raisedHands
}.subscribeAsState(initial = emptyList())
val state by remember {
derivedStateOf {
RaiseHandState(raisedHands = webRtcState, expansionState = isExpanded)
}
}
LaunchedEffect(isExpanded) {
delay(COLLAPSE_DELAY_MS)
isExpanded = ExpansionState(false, false)
}
RaiseHand(state, modifier, { isExpanded = ExpansionState(true, true) }, showCallInfoListener)
}
}
@Preview
@Composable
private fun RaiseHandSnackbarPreview() {
RaiseHand(
state = RaiseHandState(listOf(GroupCallRaiseHandEvent(Recipient.UNKNOWN, System.currentTimeMillis())))
)
}
@Composable
private fun RaiseHand(
state: RaiseHandState,
modifier: Modifier = Modifier,
setExpanded: (Boolean) -> Unit = {},
showCallInfoListener: () -> Unit = {}
) {
AnimatedVisibility(visible = state.raisedHands.isNotEmpty()) {
SignalTheme(
isDarkMode = true
) {
Surface(
modifier = modifier
.padding(horizontal = 16.dp)
.clip(shape = RoundedCornerShape(16.dp, 16.dp, 16.dp, 16.dp))
.background(MaterialTheme.colorScheme.surface)
) {
val boxModifier = modifier
.height(48.dp)
.animateContentSize()
.padding(horizontal = 16.dp)
.clickable(
!state.expansionState.isExpanded,
stringResource(id = R.string.CallOverflowPopupWindow__expand_snackbar_accessibility_label),
Role.Button
) { setExpanded(true) }
Box(
contentAlignment = Alignment.CenterStart,
modifier = if (state.expansionState.isExpanded) {
boxModifier.fillMaxWidth()
} else {
boxModifier.wrapContentWidth()
}
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_raise_hand_24),
contentDescription = null,
modifier = Modifier.align(Alignment.CenterVertically)
)
Text(
text = getSnackbarText(state),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(start = 16.dp)
)
if (state.expansionState.isExpanded && state.raisedHands.isNotEmpty()) {
Spacer(modifier = Modifier.weight(1f))
if (state.raisedHands.first().sender.isSelf) {
TextButton(onClick = {
ApplicationDependencies.getSignalCallManager().raiseHand(false)
}) {
Text(text = stringResource(id = R.string.CallOverflowPopupWindow__lower_hand))
}
} else {
TextButton(onClick = showCallInfoListener) {
Text(text = stringResource(id = R.string.CallOverflowPopupWindow__view))
}
}
}
}
}
}
}
}
}
@Composable
private fun getSnackbarText(state: RaiseHandState): String {
if (state.isEmpty()) {
return ""
}
return if (!state.expansionState.isExpanded) {
pluralStringResource(id = R.plurals.CallRaiseHandSnackbar_raised_hands, count = state.raisedHands.size, getShortDisplayName(state.raisedHands), state.raisedHands.size - 1)
} else {
if (state.raisedHands.size == 1 && state.raisedHands.first().sender.isSelf) {
stringResource(id = R.string.CallOverflowPopupWindow__you_raised_your_hand)
} else {
pluralStringResource(id = R.plurals.CallOverflowPopupWindow__raised_a_hand, count = state.raisedHands.size, state.raisedHands.first().sender.getShortDisplayName(LocalContext.current), state.raisedHands.size - 1)
}
}
}
@Composable
private fun getShortDisplayName(raisedHands: List<GroupCallRaiseHandEvent>): String {
val recipient = raisedHands.first().sender
return if (recipient.isSelf) {
stringResource(id = R.string.CallParticipant__you)
} else {
recipient.getShortDisplayName(LocalContext.current)
}
}
private data class RaiseHandState(
val raisedHands: List<GroupCallRaiseHandEvent> = emptyList(),
val expansionState: ExpansionState = ExpansionState(false, false)
) {
fun isEmpty(): Boolean {
return raisedHands.isEmpty()
}
companion object {
@JvmStatic
fun shouldExpand(raisedHands: List<GroupCallRaiseHandEvent>): Boolean {
val now = System.currentTimeMillis()
return raisedHands.any { it.getCollapseTimestamp() > now }
}
}
}
private data class ExpansionState(
val isExpanded: Boolean,
val forced: Boolean
)

View file

@ -16,6 +16,7 @@ data class CallParticipant constructor(
val isForwardingVideo: Boolean = true,
val isVideoEnabled: Boolean = false,
val isMicrophoneEnabled: Boolean = false,
val isHandRaised: Boolean = false,
val lastSpoke: Long = 0,
val audioLevel: AudioLevel? = null,
val isMediaKeysReceived: Boolean = true,
@ -109,7 +110,8 @@ data class CallParticipant constructor(
fun createLocal(
cameraState: CameraState,
renderer: BroadcastVideoSink,
microphoneEnabled: Boolean
microphoneEnabled: Boolean,
isHandRaised: Boolean
): CallParticipant {
return CallParticipant(
callParticipantId = CallParticipantId(Recipient.self()),
@ -117,7 +119,8 @@ data class CallParticipant constructor(
videoSink = renderer,
cameraState = cameraState,
isVideoEnabled = cameraState.isEnabled && cameraState.cameraCount > 0,
isMicrophoneEnabled = microphoneEnabled
isMicrophoneEnabled = microphoneEnabled,
isHandRaised = isHandRaised
)
}
@ -130,6 +133,7 @@ data class CallParticipant constructor(
isForwardingVideo: Boolean,
audioEnabled: Boolean,
videoEnabled: Boolean,
isHandRaised: Boolean,
lastSpoke: Long,
mediaKeysReceived: Boolean,
addedToCallTime: Long,
@ -144,6 +148,7 @@ data class CallParticipant constructor(
isForwardingVideo = isForwardingVideo,
isVideoEnabled = videoEnabled,
isMicrophoneEnabled = audioEnabled,
isHandRaised = isHandRaised,
lastSpoke = lastSpoke,
isMediaKeysReceived = mediaKeysReceived,
addedToCallTime = addedToCallTime,

View file

@ -0,0 +1,19 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.events
import org.thoughtcrime.securesms.recipients.Recipient
import java.util.concurrent.TimeUnit
data class GroupCallRaiseHandEvent(val sender: Recipient, val timestamp: Long) {
fun getCollapseTimestamp(): Long {
return timestamp + TimeUnit.SECONDS.toMillis(LIFESPAN_SECONDS)
}
companion object {
const val LIFESPAN_SECONDS = 4L
}
}

View file

@ -92,6 +92,7 @@ class WebRtcViewModel(state: WebRtcServiceState) {
val isRemoteVideoOffer: Boolean = state.getCallSetupState(state.callInfoState.activePeer?.callId).isRemoteVideoOffer
val callConnectedTime: Long = state.callInfoState.callConnectedTime
val remoteParticipants: List<CallParticipant> = state.callInfoState.remoteCallParticipants
val raisedHands: List<GroupCallRaiseHandEvent> = state.callInfoState.raisedHands
val identityChangedParticipants: Set<RecipientId> = state.callInfoState.identityChangedRecipients
val remoteDevicesCount: OptionalLong = state.callInfoState.remoteDevicesCount
val participantLimit: Long? = state.callInfoState.participantLimit
@ -109,7 +110,8 @@ class WebRtcViewModel(state: WebRtcServiceState) {
val localParticipant: CallParticipant = createLocal(
state.localDeviceState.cameraState,
(if (state.videoState.localSink != null) state.videoState.localSink else BroadcastVideoSink())!!,
state.localDeviceState.isMicrophoneEnabled
state.localDeviceState.isMicrophoneEnabled,
state.callInfoState.raisedHands.map { it.sender }.contains(Recipient.self())
)
val isCellularConnection: Boolean = when (state.localDeviceState.networkConnectionType) {

View file

@ -50,6 +50,7 @@ public class BeginCallActionProcessorDelegate extends WebRtcActionProcessor {
true,
true,
false,
currentState.getCallInfoState().getRaisedHands().contains(remotePeer.getRecipient()),
0,
true,
0,
@ -108,6 +109,7 @@ public class BeginCallActionProcessorDelegate extends WebRtcActionProcessor {
true,
true,
false,
currentState.getCallInfoState().getRaisedHands().contains(remotePeer.getRecipient()),
0,
true,
0,

View file

@ -116,6 +116,7 @@ public class GroupActionProcessor extends DeviceAwareActionProcessor {
device.getForwardingVideo() == null || device.getForwardingVideo(),
Boolean.FALSE.equals(device.getAudioMuted()),
Boolean.FALSE.equals(device.getVideoMuted()),
currentState.getCallInfoState().getRaisedHands().contains(recipient),
device.getSpeakerTime(),
device.getMediaKeysReceived(),
device.getAddedTime(),

View file

@ -14,6 +14,7 @@ import org.signal.ringrtc.GroupCall;
import org.signal.ringrtc.PeekInfo;
import org.thoughtcrime.securesms.events.CallParticipant;
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;
@ -26,10 +27,15 @@ import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Collectors;
import ezvcard.util.StringUtils;
/**
* Process actions for when the call has at least once been connected and joined.
@ -202,6 +208,21 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor {
return terminateGroupCall(currentState);
}
@Override
protected @NonNull WebRtcServiceState handleSelfRaiseHand(@NonNull WebRtcServiceState currentState, boolean raised) {
Log.i(tag, "handleSelfRaiseHand():");
try {
final CallInfoState callInfoState = currentState.getCallInfoState();
callInfoState.requireGroupCall().raiseHand(raised);
return currentState;
} catch (CallException e) {
Log.w(TAG, "Unable to " + (raised ? "raise" : "lower") + " hand in group call", e);
}
return currentState;
}
@Override
protected @NonNull WebRtcEphemeralState handleSendGroupReact(@NonNull WebRtcServiceState currentState, @NonNull WebRtcEphemeralState ephemeralState, @NonNull String reaction) {
try {
@ -242,4 +263,37 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor {
return new GroupCallReactionEvent(participant.getRecipient(), reaction.value, System.currentTimeMillis());
}
@Override
protected @NonNull WebRtcServiceState handleGroupCallRaisedHand(@NonNull WebRtcServiceState currentState, List<Long> raisedHands) {
Log.i(tag, "handleGroupCallRaisedHand():");
List<GroupCallRaiseHandEvent> existingHands = currentState.getCallInfoState().getRaisedHands();
List<CallParticipant> participants = currentState.getCallInfoState().getRemoteCallParticipants();
List<GroupCallRaiseHandEvent> currentRaisedHands = raisedHands
.stream().map(demuxId -> {
if (Objects.equals(demuxId, currentState.getCallInfoState().requireGroupCall().getLocalDeviceState().getDemuxId())) {
return Recipient.self();
}
CallParticipant participant = participants.stream().filter(it -> it.getCallParticipantId().getDemuxId() == demuxId).findFirst().orElse(null);
if (participant == null) {
Log.v(TAG, "Could not find CallParticipantId in list of call participants based on demuxId for raise hand.");
return null;
}
return participant.getRecipient();
})
.filter(Objects::nonNull)
.map(recipient -> {
final Optional<GroupCallRaiseHandEvent> matchingEvent = existingHands.stream().filter(existingEvent -> existingEvent.getSender().equals(recipient)).findFirst();
if (matchingEvent.isPresent()) {
return matchingEvent.get();
} else {
return new GroupCallRaiseHandEvent(recipient, System.currentTimeMillis());
}
})
.collect(Collectors.toList());
return currentState.builder().changeCallInfoState().setRaisedHand(currentRaisedHands).build();
}
}

View file

@ -137,6 +137,7 @@ public class GroupPreJoinActionProcessor extends GroupActionProcessor {
true,
true,
true,
currentState.getCallInfoState().getRaisedHands().contains(recipient),
0,
false,
0,

View file

@ -167,6 +167,7 @@ public final class IncomingGroupCallActionProcessor extends DeviceAwareActionPro
true,
true,
false,
currentState.getCallInfoState().getRaisedHands().contains(remotePeerGroup.getRecipient()),
0,
true,
0,

View file

@ -295,6 +295,10 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall.
process((s, p) -> p.handleScreenOffChange(s));
}
public void raiseHand(boolean raised) {
process((s, p) -> p.handleSelfRaiseHand(s, raised));
}
public void react(@NonNull String reaction) {
processStateless(s -> serviceState.getActionProcessor().handleSendGroupReact(serviceState, s, reaction));
}
@ -909,7 +913,9 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall.
@Override
public void onRaisedHands(@NonNull GroupCall groupCall, List<Long> raisedHands) {
// TODO: Implement handling of raise hand.
if (FeatureFlags.groupCallRaiseHand()) {
process((s, p) -> p.handleGroupCallRaisedHand(s, raisedHands));
}
}
@Override

View file

@ -546,6 +546,12 @@ public abstract class WebRtcActionProcessor {
return currentState;
}
protected @NonNull WebRtcServiceState handleSelfRaiseHand(@NonNull WebRtcServiceState currentState, boolean raised) {
Log.i(tag, "raiseHand not processed");
return currentState;
}
protected @NonNull WebRtcEphemeralState handleSendGroupReact(@NonNull WebRtcServiceState currentState, @NonNull WebRtcEphemeralState ephemeralState, @NonNull String reaction) {
Log.i(tag, "react not processed");
return ephemeralState;
@ -739,6 +745,11 @@ public abstract class WebRtcActionProcessor {
return ephemeralState;
}
protected @NonNull WebRtcServiceState handleGroupCallRaisedHand(@NonNull WebRtcServiceState currentState, List<Long> raisedHands) {
Log.i(tag, "handleGroupCallRaisedHand not processed");
return currentState;
}
protected @NonNull WebRtcServiceState handleGroupRequestMembershipProof(@NonNull WebRtcServiceState currentState, int groupCallHashCode) {
Log.i(tag, "handleGroupRequestMembershipProof not processed");
return currentState;

View file

@ -5,6 +5,7 @@ import org.signal.ringrtc.CallId
import org.signal.ringrtc.GroupCall
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.events.CallParticipantId
import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@ -30,7 +31,8 @@ data class CallInfoState(
var remoteDevicesCount: OptionalLong = OptionalLong.empty(),
var participantLimit: Long? = null,
var pendingParticipants: PendingParticipantCollection = PendingParticipantCollection(),
var callLinkDisconnectReason: CallLinkDisconnectReason? = null
var callLinkDisconnectReason: CallLinkDisconnectReason? = null,
var raisedHands: List<GroupCallRaiseHandEvent> = emptyList()
) {
val remoteCallParticipants: List<CallParticipant>

View file

@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink;
import org.thoughtcrime.securesms.components.webrtc.EglBaseWrapper;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.CallParticipantId;
import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@ -364,5 +365,10 @@ public class WebRtcServiceStateBuilder {
toBuild.setCallLinkDisconnectReason(callLinkDisconnectReason);
return this;
}
public @NonNull CallInfoStateBuilder setRaisedHand(@NonNull List<GroupCallRaiseHandEvent> raisedHands) {
toBuild.setRaisedHands(raisedHands);
return this;
}
}
}

View file

@ -118,6 +118,7 @@ public final class FeatureFlags {
public static final String SEPA_ENABLED_REGIONS = "global.donations.sepaEnabledRegions";
private static final String CALLING_REACTIONS = "android.calling.reactions";
private static final String NOTIFICATION_THUMBNAIL_BLOCKLIST = "android.notificationThumbnailProductBlocklist";
private static final String CALLING_RAISE_HAND = "android.calling.raiseHand";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@ -187,7 +188,8 @@ public final class FeatureFlags {
IDEAL_ENABLED_REGIONS,
SEPA_ENABLED_REGIONS,
CALLING_REACTIONS,
NOTIFICATION_THUMBNAIL_BLOCKLIST
NOTIFICATION_THUMBNAIL_BLOCKLIST,
CALLING_RAISE_HAND
);
@VisibleForTesting
@ -259,7 +261,8 @@ public final class FeatureFlags {
CRASH_PROMPT_CONFIG,
BLOCK_SSE,
CALLING_REACTIONS,
NOTIFICATION_THUMBNAIL_BLOCKLIST
NOTIFICATION_THUMBNAIL_BLOCKLIST,
CALLING_RAISE_HAND
);
/**
@ -672,6 +675,13 @@ public final class FeatureFlags {
return getBoolean(CALLING_REACTIONS, false);
}
/**
* Whether or not group call raise hand is enabled.
*/
public static boolean groupCallRaiseHand() {
return getBoolean(CALLING_RAISE_HAND, false);
}
/** List of device products that are blocked from showing notification thumbnails. */
public static String notificationThumbnailProductBlocklist() {
return getString(NOTIFICATION_THUMBNAIL_BLOCKLIST, "");

View file

@ -0,0 +1,9 @@
<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="#FF000000"
android:pathData="M20.3 16.1c-0.54 3.83-3.82 6.77-7.8 6.77-3.2 0-5.94-1.9-7.18-4.63l-3.3-6.21-0.09-0.17C1.55 11 1.86 9.99 2.66 9.5l0.16-0.1 0.04-0.02 0.3-0.15c1.22-0.55 2.66-0.17 3.47 0.9V5c0-1.31 1.06-2.38 2.37-2.38 0.22 0 0.44 0.04 0.64 0.1 0.14-1.18 1.15-2.1 2.36-2.1 1.05 0 1.94 0.68 2.25 1.63 0.24-0.08 0.49-0.13 0.75-0.13 1.31 0 2.38 1.07 2.38 2.38v0.2c0.2-0.05 0.4-0.08 0.62-0.08 1.31 0 2.38 1.07 2.38 2.38v8.5c0 0.2-0.03 0.4-0.08 0.6ZM9.63 5c0-0.35-0.28-0.63-0.63-0.63S8.38 4.66 8.38 5v9H6.75L5.4 11.48l-0.11-0.2c-0.3-0.49-0.9-0.67-1.42-0.44l-0.21 0.11-0.07 0.04H3.57c-0.05 0.04-0.07 0.1-0.04 0.16v0.01l0.04 0.07 3.18 5.98v0.01l0.17 0.32c0.96 2.12 3.1 3.6 5.58 3.6 3.21 0 5.85-2.48 6.1-5.63h0.03V7c0-0.35-0.28-0.63-0.63-0.63S17.38 6.66 17.38 7v4.5h-1.75v-7c0-0.35-0.28-0.63-0.63-0.63s-0.62 0.28-0.62 0.63v7h-1.75V3c0-0.35-0.28-0.63-0.63-0.63S11.38 2.66 11.38 3v8.5H9.63V5Z"/>
</vector>

View file

@ -3,10 +3,50 @@
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<org.thoughtcrime.securesms.components.webrtc.CallReactionScrubber
xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/conversation_reaction_overlay_background"
android:elevation="4dp"
/>
android:orientation="vertical">
<org.thoughtcrime.securesms.components.webrtc.CallReactionScrubber
android:id="@+id/reaction_scrubber"
android:layout_width="match_parent"
android:layout_height="@dimen/calling_reaction_emoji_height"
android:background="@drawable/conversation_reaction_overlay_background"
android:elevation="4dp" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/raise_hand_layout_parent"
android:layout_width="match_parent"
android:layout_height="@dimen/calling_reaction_emoji_height"
android:layout_marginTop="8dp"
android:background="@drawable/conversation_reaction_overlay_background">
<ImageView
android:id="@+id/raise_hand_icon"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="8dp"
android:contentDescription="@string/CallOverflowPopupWindow__raise_hand_illustration_content_description"
android:src="@drawable/symbol_raise_hand_24"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="@color/signal_colorOnSurface" />
<TextView
android:id="@+id/raise_hand_label"
style="@style/Signal.Text.BodyLarge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="@string/CallOverflowPopupWindow__raise_hand"
android:textColor="@color/signal_colorOnSurface"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/raise_hand_icon"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

View file

@ -21,6 +21,7 @@
android:orientation="horizontal"
tools:layout_constraintGuide_end="200dp" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/call_screen_participants_parent"
android:layout_width="0dp"
@ -396,5 +397,12 @@
app:barrierDirection="top"
app:constraint_referenced_ids="call_screen_answer_call,call_screen_decline_call,call_screen_audio_mic_toggle,call_screen_camera_direction_toggle,call_screen_video_toggle,call_screen_answer_without_video,call_screen_speaker_toggle,call_screen_end_call" />
<androidx.compose.ui.platform.ComposeView
android:id="@+id/call_screen_raise_hand_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@id/call_screen_above_controls_guideline"
app:layout_constraintEnd_toEndOf="parent" />
<include layout="@layout/webrtc_call_controls"/>
</merge>

View file

@ -79,9 +79,15 @@
<dimen name="reaction_scrubber_anim_start_translation_y">25dp</dimen>
<dimen name="reaction_scrubber_anim_end_translation_y">0dp</dimen>
<dimen name="reaction_scrubber_width">320dp</dimen>
<dimen name="calling_reaction_scrubber_margin">4dp</dimen>
<dimen name="calling_reaction_emoji_height">48dp</dimen>
<dimen name="calling_reaction_popup_menu_width">320dp</dimen>
<dimen name="calling_reaction_popup_menu_height">120dp</dimen>
<dimen name="calling_raise_hand_snackbar_margin">16dp</dimen>
<dimen name="conversation_item_reply_size">38dp</dimen>
<dimen name="conversation_item_avatar_size">28dp</dimen>

View file

@ -1849,6 +1849,34 @@
<!-- A text description of the earpiece icon, used for accessibility. -->
<string name="WebRtcAudioOutputBottomSheet__earpiece_icon_content_description">An icon representing a device\'s earpiece.</string>
<!-- A clickable button to "raise your hand" in a group call to signify you have something to say -->
<string name="CallOverflowPopupWindow__raise_hand">Raise hand</string>
<!-- A description of a clickable image representing a raised hand -->
<string name="CallOverflowPopupWindow__raise_hand_illustration_content_description">Raise hand</string>
<!-- A dialog prompt to confirm you want to lower your hand -->
<string name="CallOverflowPopupWindow__lower_your_hand">Lower your hand?</string>
<!-- A dialog button to confirm you would like to lower your hand -->
<string name="CallOverflowPopupWindow__lower_hand">Lower hand</string>
<!-- A notification to the user that they successfully raised their hand -->
<string name="CallOverflowPopupWindow__you_raised_your_hand">You raised your hand</string>
<!-- A button to take you to a list of participants with raised hands -->
<string name="CallOverflowPopupWindow__view">View</string>
<!-- A notification to the user that one or more participants in the call successfully raised their hand. In the singular case, it is a name. In the plural case, it is a name or "You" -->
<plurals name="CallOverflowPopupWindow__raised_a_hand">
<item quantity="one">%1$s has raised a hand</item>
<item quantity="other">%1$s + %2$d have raised a hand</item>
</plurals>
<!-- A badge to show how many hands are raised. The first string may be a name or "You" -->
<plurals name="CallRaiseHandSnackbar_raised_hands">
<item quantity="one">%1$s</item>
<item quantity="other">%1$s +%2$d</item>
</plurals>
<!-- An accessibility label for screen readers on a view that can be expanded -->
<string name="CallOverflowPopupWindow__expand_snackbar_accessibility_label">Expand raised hand view</string>
<!-- CallParticipantsListDialog -->
<plurals name="CallParticipantsListDialog_in_this_call">
<item quantity="one">In this call (%1$d)</item>
@ -1862,6 +1890,10 @@
<item quantity="one">Signal will Notify (%1$d)</item>
<item quantity="other">Signal will Notify (%1$d)</item>
</plurals>
<plurals name="CallParticipantsListDialog__raised_hands">
<item quantity="one">Raised hand (%1$d)</item>
<item quantity="other">Raised hands (%1$d)</item>
</plurals>
<!-- CallParticipantView -->
<string name="CallParticipantView__s_is_blocked">%1$s is blocked</string>

View file

@ -178,7 +178,7 @@ public class CallParticipantListUpdateTest {
private static CallParticipant createParticipant(long recipientId, long deMuxId, @NonNull CallParticipant.DeviceOrdinal deviceOrdinal) {
Recipient recipient = new Recipient(RecipientId.from(recipientId), mock(RecipientDetails.class), true);
return CallParticipant.createRemote(new CallParticipantId(deMuxId, recipient.getId()), recipient, null, new BroadcastVideoSink(), false, false, false, -1, false, 0, false, deviceOrdinal);
return CallParticipant.createRemote(new CallParticipantId(deMuxId, recipient.getId()), recipient, null, new BroadcastVideoSink(), false, false, false, false, -1, false, 0, false, deviceOrdinal);
}
}

View file

@ -243,6 +243,7 @@ public class ParticipantCollectionTest {
false,
false,
false,
false,
lastSpoke,
false,
added,