diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/RaiseHandSnackbar.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/RaiseHandSnackbar.kt index befe02788f..309e0f530e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/RaiseHandSnackbar.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/controls/RaiseHandSnackbar.kt @@ -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): Str private data class RaiseHandState( val raisedHands: List = 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): Boolean { - val now = System.currentTimeMillis() - return raisedHands.any { it.getCollapseTimestamp() > now } + fun getMaxCollapseTimestamp(raisedHands: List, 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 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt index cdb72ce4ae..60898f7c8b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/v2/WebRtcCallViewModel.kt @@ -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(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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/GroupCallRaiseHandEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/events/GroupCallRaiseHandEvent.kt index 2996f4abf5..4cb3534442 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/events/GroupCallRaiseHandEvent.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/events/GroupCallRaiseHandEvent.kt @@ -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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/GroupCallSpeechEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/events/GroupCallSpeechEvent.kt new file mode 100644 index 0000000000..b6513e2ec5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/events/GroupCallSpeechEvent.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt index 17eb3ae65d..b4c6c94b7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt @@ -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) { 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 73ee3a8590..338fc35f26 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 @@ -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(); + } } 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 5086e61a15..c93e44020c 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 @@ -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 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 b2a0dd7830..0b63951e2d 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 @@ -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 recipientIds, @NonNull WebRtcViewModel.State errorCallState) diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallInfoState.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallInfoState.kt index ce7fe52f8d..c39db9224f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallInfoState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/CallInfoState.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceStateBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceStateBuilder.java index b627db4a29..a3b06813d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceStateBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/state/WebRtcServiceStateBuilder.java @@ -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; + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 899482f70c..a6aa9e24e8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2508,6 +2508,8 @@ View + + Lower your hand? %1$s raised a hand