Implement auto-lower-hand.

This commit is contained in:
Alex Hart
2025-03-24 15:16:41 -03:00
committed by Cody Henthorne
parent 32b710a3ca
commit b6f98521c8
11 changed files with 114 additions and 21 deletions

View File

@@ -43,15 +43,21 @@ 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 androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.map
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.ringrtc.GroupCall
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.webrtc.v2.WebRtcCallViewModel
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.events.GroupCallRaiseHandEvent
import org.thoughtcrime.securesms.events.GroupCallSpeechEvent
import java.util.concurrent.TimeUnit
import kotlin.math.max
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
/**
* This is a UI element to display the status of one or more people with raised hands in a group call.
@@ -63,7 +69,7 @@ object RaiseHandSnackbar {
@Composable
fun View(webRtcCallViewModel: WebRtcCallViewModel, showCallInfoListener: () -> Unit, modifier: Modifier = Modifier) {
var expansionState by remember { mutableStateOf(ExpansionState(shouldExpand = false, forced = false)) }
var expansionState by remember { mutableStateOf(ExpansionState(shouldExpand = false, forced = false, collapseTimestamp = Duration.ZERO)) }
val raisedHandsState by remember {
webRtcCallViewModel.callParticipantsState
@@ -76,29 +82,36 @@ object RaiseHandSnackbar {
1
}
} else {
it.timestamp
it.timestamp.inWholeMilliseconds
}
}
val shouldExpand = RaiseHandState.shouldExpand(raisedHands)
if (!expansionState.forced) {
expansionState = ExpansionState(shouldExpand, false)
}
raisedHands
}
}.collectAsState(initial = emptyList())
val speechEvent by webRtcCallViewModel.groupCallSpeechEvents.collectAsStateWithLifecycle()
val state by remember {
derivedStateOf {
RaiseHandState(raisedHands = raisedHandsState, expansionState = expansionState)
RaiseHandState(raisedHands = raisedHandsState, expansionState = expansionState, speechEvent = speechEvent)
}
}
LaunchedEffect(raisedHandsState, speechEvent) {
val maxCollapseTimestamp = RaiseHandState.getMaxCollapseTimestamp(raisedHandsState, speechEvent)
if (!expansionState.forced) {
val shouldExpand = System.currentTimeMillis().milliseconds < maxCollapseTimestamp
expansionState = ExpansionState(shouldExpand, false, maxCollapseTimestamp)
}
}
LaunchedEffect(expansionState) {
delay(COLLAPSE_DELAY_MS)
expansionState = ExpansionState(shouldExpand = false, forced = false)
expansionState = ExpansionState(shouldExpand = false, forced = false, collapseTimestamp = expansionState.collapseTimestamp)
}
RaiseHand(state, modifier, { expansionState = ExpansionState(shouldExpand = true, forced = true) }, showCallInfoListener = showCallInfoListener)
RaiseHand(state, modifier, { expansionState = ExpansionState(shouldExpand = true, forced = true, collapseTimestamp = expansionState.collapseTimestamp) }, showCallInfoListener = showCallInfoListener)
}
}
@@ -198,6 +211,19 @@ private fun getSnackbarText(state: RaiseHandState): String {
return ""
}
val shouldDisplayLowerYourHand = remember(state) {
val now = System.currentTimeMillis().milliseconds
val hasUnexpiredSelf = state.raisedHands.any { it.sender.isSelf && it.sender.isPrimary && it.getCollapseTimestamp() >= now }
val expiration = state.speechEvent?.getCollapseTimestamp() ?: Duration.ZERO
val isUnexpired = expiration >= now
state.speechEvent?.speechEvent == GroupCall.SpeechEvent.LOWER_HAND_SUGGESTION && isUnexpired && hasUnexpiredSelf
}
if (shouldDisplayLowerYourHand && state.isExpanded) {
return stringResource(id = R.string.CallRaiseHandSnackbar__lower_your_hand)
}
val displayedName = getShortDisplayName(raisedHands = state.raisedHands)
val additionalHandsCount = state.raisedHands.size - 1
return if (!state.isExpanded) {
@@ -238,7 +264,8 @@ private fun getShortDisplayName(raisedHands: List<GroupCallRaiseHandEvent>): Str
private data class RaiseHandState(
val raisedHands: List<GroupCallRaiseHandEvent> = emptyList(),
val expansionState: ExpansionState = ExpansionState(shouldExpand = false, forced = false)
val expansionState: ExpansionState = ExpansionState(shouldExpand = false, forced = false, collapseTimestamp = Duration.ZERO),
val speechEvent: GroupCallSpeechEvent? = null
) {
val isExpanded = expansionState.shouldExpand && raisedHands.isNotEmpty()
@@ -246,14 +273,15 @@ private data class RaiseHandState(
companion object {
@JvmStatic
fun shouldExpand(raisedHands: List<GroupCallRaiseHandEvent>): Boolean {
val now = System.currentTimeMillis()
return raisedHands.any { it.getCollapseTimestamp() > now }
fun getMaxCollapseTimestamp(raisedHands: List<GroupCallRaiseHandEvent>, speechEvent: GroupCallSpeechEvent?): Duration {
val maxRaisedHandTimestamp = raisedHands.maxByOrNull { it.getCollapseTimestamp() }?.getCollapseTimestamp() ?: Duration.ZERO
return max(maxRaisedHandTimestamp.inWholeMilliseconds, (speechEvent?.getCollapseTimestamp() ?: Duration.ZERO).inWholeMilliseconds).milliseconds
}
}
}
private data class ExpansionState(
val shouldExpand: Boolean,
val forced: Boolean
val forced: Boolean,
val collapseTimestamp: Duration
)

View File

@@ -38,6 +38,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.events.CallParticipantId
import org.thoughtcrime.securesms.events.GroupCallSpeechEvent
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -83,6 +84,8 @@ class WebRtcCallViewModel : ViewModel() {
private val elapsedTimeRunnable = Runnable { handleTick() }
private val stopOutgoingRingingMode = Runnable { stopOutgoingRingingMode() }
val groupCallSpeechEvents = MutableStateFlow<GroupCallSpeechEvent?>(null)
private var canDisplayTooltipIfNeeded = true
private var canDisplaySwitchCameraTooltipIfNeeded = true
private var canDisplayPopupIfNeeded = true
@@ -275,6 +278,10 @@ class WebRtcCallViewModel : ViewModel() {
isCallStarting = false
}
groupCallSpeechEvents.update {
webRtcViewModel.groupCallSpeechEvent
}
val localParticipant = webRtcViewModel.localParticipant
internalMicrophoneEnabled.value = localParticipant.isMicrophoneEnabled

View File

@@ -5,15 +5,19 @@
package org.thoughtcrime.securesms.events
import java.util.concurrent.TimeUnit
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
data class GroupCallRaiseHandEvent(val sender: CallParticipant, val timestamp: Long) {
data class GroupCallRaiseHandEvent(val sender: CallParticipant, private val timestampMillis: Long) {
fun getCollapseTimestamp(): Long {
return timestamp + TimeUnit.SECONDS.toMillis(LIFESPAN_SECONDS)
val timestamp = timestampMillis.milliseconds
fun getCollapseTimestamp(): Duration {
return timestamp + LIFESPAN
}
companion object {
const val LIFESPAN_SECONDS = 4L
private val LIFESPAN = 4L.seconds
}
}

View File

@@ -0,0 +1,24 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.events
import org.signal.ringrtc.GroupCall.SpeechEvent
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
data class GroupCallSpeechEvent @JvmOverloads constructor(
val speechEvent: SpeechEvent,
private val timestampMs: Long = System.currentTimeMillis()
) {
fun getCollapseTimestamp(): Duration {
return timestampMs.milliseconds + LIFESPAN
}
companion object {
private val LIFESPAN = 4L.seconds
}
}

View File

@@ -106,6 +106,7 @@ class WebRtcViewModel(state: WebRtcServiceState) {
val isCallLink: Boolean = state.callInfoState.callRecipient.isCallLink
val callLinkDisconnectReason: CallLinkDisconnectReason? = state.callInfoState.callLinkDisconnectReason
val groupCallEndReason: GroupCallEndReason? = state.callInfoState.groupCallEndReason
val groupCallSpeechEvent: GroupCallSpeechEvent? = state.callInfoState.groupCallSpeechEvent
@get:JvmName("hasAtLeastOneRemote")
val hasAtLeastOneRemote = if (state.callInfoState.callRecipient.isIndividual) {

View File

@@ -15,6 +15,7 @@ import org.signal.ringrtc.PeekInfo;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.CallParticipantId;
import org.thoughtcrime.securesms.events.GroupCallReactionEvent;
import org.thoughtcrime.securesms.events.GroupCallSpeechEvent;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -297,4 +298,14 @@ public class GroupConnectedActionProcessor extends GroupActionProcessor {
return currentState;
}
@Override
protected @NonNull WebRtcServiceState handleGroupCallSpeechEvent(@NonNull WebRtcServiceState currentState, @NonNull GroupCall.SpeechEvent speechEvent) {
Log.i(tag, "handleGroupCallSpeechEvent :: " + speechEvent.name());
return currentState.builder()
.changeCallInfoState()
.setGroupCallSpeechEvent(new GroupCallSpeechEvent(speechEvent))
.build();
}
}

View File

@@ -68,6 +68,7 @@ import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState;
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState;
import org.thoughtcrime.securesms.util.AppForegroundObserver;
import org.thoughtcrime.securesms.util.RecipientAccessList;
import org.thoughtcrime.securesms.util.RemoteConfig;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.rx.RxStore;
@@ -972,7 +973,9 @@ public final class SignalCallManager implements CallManager.Observer, GroupCall.
@Override
public void onSpeakingNotification(@NonNull GroupCall groupCall, @NonNull GroupCall.SpeechEvent speechEvent) {
if (RemoteConfig.internalUser()) {
process((s, p) -> p.handleGroupCallSpeechEvent(s, speechEvent));
}
}
@Override

View File

@@ -786,6 +786,11 @@ public abstract class WebRtcActionProcessor {
return currentState;
}
protected @NonNull WebRtcServiceState handleGroupCallSpeechEvent(@NonNull WebRtcServiceState currentState, @NonNull GroupCall.SpeechEvent speechEvent) {
Log.i(tag, "handleGroupCallSpeechEvent not processed");
return currentState;
}
protected @NonNull WebRtcServiceState handleGroupMessageSentError(@NonNull WebRtcServiceState currentState,
@NonNull Collection<RecipientId> recipientIds,
@NonNull WebRtcViewModel.State errorCallState)

View File

@@ -6,6 +6,7 @@ import org.signal.ringrtc.GroupCall
import org.signal.ringrtc.GroupCall.GroupCallEndReason
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.events.CallParticipantId
import org.thoughtcrime.securesms.events.GroupCallSpeechEvent
import org.thoughtcrime.securesms.events.WebRtcViewModel
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -32,7 +33,8 @@ data class CallInfoState(
var participantLimit: Long? = null,
var pendingParticipants: PendingParticipantCollection = PendingParticipantCollection(),
var callLinkDisconnectReason: CallLinkDisconnectReason? = null,
var groupCallEndReason: GroupCallEndReason? = null
var groupCallEndReason: GroupCallEndReason? = null,
var groupCallSpeechEvent: GroupCallSpeechEvent? = null
) {
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.GroupCallSpeechEvent;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -374,5 +375,10 @@ public class WebRtcServiceStateBuilder {
toBuild.setGroupCallEndReason(groupCallEndReason);
return this;
}
public @NonNull CallInfoStateBuilder setGroupCallSpeechEvent(@Nullable GroupCallSpeechEvent groupCallSpeechEvent) {
toBuild.setGroupCallSpeechEvent(groupCallSpeechEvent);
return this;
}
}
}

View File

@@ -2508,6 +2508,8 @@
<string name="CallOverflowPopupWindow__view">View</string>
<!-- A notification to the user that their hand is raised but they are currently speaking -->
<string name="CallRaiseHandSnackbar__lower_your_hand">Lower your hand?</string>
<!-- A notification to the user that they successfully raised their hand. The placeholder string is CallParticipant__you, or CallParticipant__you_on_another_device -->
<string name="CallRaiseHandSnackbar__expanded_second_person_raised_a_hand_single">%1$s raised a hand</string>
<!-- A notification to the user that they and at least one more participants in the call successfully raised their hand. The placeholder is a number quantifying how many other hands are also raised, besides the user. -->