mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-24 13:08:46 +00:00
Implement auto-lower-hand.
This commit is contained in:
committed by
Cody Henthorne
parent
32b710a3ca
commit
b6f98521c8
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
Reference in New Issue
Block a user