Add Small Group Ringing support.

This commit is contained in:
Cody Henthorne
2021-08-24 10:18:39 -04:00
committed by Alex Hart
parent 5787a5f68a
commit db7272730e
39 changed files with 1597 additions and 609 deletions

View File

@@ -1,378 +0,0 @@
package org.thoughtcrime.securesms.components.webrtc;
import android.content.Context;
import androidx.annotation.NonNull;
import com.annimon.stream.ComparatorCompat;
import com.annimon.stream.OptionalLong;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.events.CallParticipant;
import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.ringrtc.CameraState;
import org.thoughtcrime.securesms.service.webrtc.collections.ParticipantCollection;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Represents the state of all participants, remote and local, combined with view state
* needed to properly render the participants. The view state primarily consists of
* if we are in System PIP mode and if we should show our video for an outgoing call.
*/
public final class CallParticipantsState {
private static final int SMALL_GROUP_MAX = 6;
public static final CallParticipantsState STARTING_STATE = new CallParticipantsState(WebRtcViewModel.State.CALL_DISCONNECTED,
WebRtcViewModel.GroupCallState.IDLE,
new ParticipantCollection(SMALL_GROUP_MAX),
CallParticipant.createLocal(CameraState.UNKNOWN, new BroadcastVideoSink(), false),
CallParticipant.EMPTY,
WebRtcLocalRenderState.GONE,
false,
false,
false,
OptionalLong.empty(),
WebRtcControls.FoldableState.flat());
private final WebRtcViewModel.State callState;
private final WebRtcViewModel.GroupCallState groupCallState;
private final ParticipantCollection remoteParticipants;
private final CallParticipant localParticipant;
private final CallParticipant focusedParticipant;
private final WebRtcLocalRenderState localRenderState;
private final boolean isInPipMode;
private final boolean showVideoForOutgoing;
private final boolean isViewingFocusedParticipant;
private final OptionalLong remoteDevicesCount;
private final WebRtcControls.FoldableState foldableState;
public CallParticipantsState(@NonNull WebRtcViewModel.State callState,
@NonNull WebRtcViewModel.GroupCallState groupCallState,
@NonNull ParticipantCollection remoteParticipants,
@NonNull CallParticipant localParticipant,
@NonNull CallParticipant focusedParticipant,
@NonNull WebRtcLocalRenderState localRenderState,
boolean isInPipMode,
boolean showVideoForOutgoing,
boolean isViewingFocusedParticipant,
OptionalLong remoteDevicesCount,
@NonNull WebRtcControls.FoldableState foldableState)
{
this.callState = callState;
this.groupCallState = groupCallState;
this.remoteParticipants = remoteParticipants;
this.localParticipant = localParticipant;
this.localRenderState = localRenderState;
this.focusedParticipant = focusedParticipant;
this.isInPipMode = isInPipMode;
this.showVideoForOutgoing = showVideoForOutgoing;
this.isViewingFocusedParticipant = isViewingFocusedParticipant;
this.remoteDevicesCount = remoteDevicesCount;
this.foldableState = foldableState;
}
public @NonNull WebRtcViewModel.State getCallState() {
return callState;
}
public @NonNull WebRtcViewModel.GroupCallState getGroupCallState() {
return groupCallState;
}
public @NonNull List<CallParticipant> getGridParticipants() {
return remoteParticipants.getGridParticipants();
}
public @NonNull List<CallParticipant> getListParticipants() {
List<CallParticipant> listParticipants = new ArrayList<>();
if (isViewingFocusedParticipant && getAllRemoteParticipants().size() > 1) {
listParticipants.addAll(getAllRemoteParticipants());
listParticipants.remove(focusedParticipant);
} else {
listParticipants.addAll(remoteParticipants.getListParticipants());
}
if (foldableState.isFlat()) {
listParticipants.add(CallParticipant.EMPTY);
}
Collections.reverse(listParticipants);
return listParticipants;
}
public @NonNull String getRemoteParticipantsDescription(@NonNull Context context) {
switch (remoteParticipants.size()) {
case 0:
return context.getString(R.string.WebRtcCallView__no_one_else_is_here);
case 1: {
if (callState == WebRtcViewModel.State.CALL_PRE_JOIN && groupCallState.isNotIdle()) {
return context.getString(remoteParticipants.get(0).isSelf() ? R.string.WebRtcCallView__s_are_in_this_call
: R.string.WebRtcCallView__s_is_in_this_call,
remoteParticipants.get(0).getShortRecipientDisplayName(context));
} else {
if (focusedParticipant != CallParticipant.EMPTY && focusedParticipant.isScreenSharing()) {
return context.getString(R.string.WebRtcCallView__s_is_presenting, focusedParticipant.getShortRecipientDisplayName(context));
} else {
return remoteParticipants.get(0).getRecipientDisplayName(context);
}
}
}
case 2: {
if (focusedParticipant != CallParticipant.EMPTY && focusedParticipant.isScreenSharing()) {
return context.getString(R.string.WebRtcCallView__s_is_presenting, focusedParticipant.getShortRecipientDisplayName(context));
} else {
return context.getString(R.string.WebRtcCallView__s_and_s_are_in_this_call,
remoteParticipants.get(0).getShortRecipientDisplayName(context),
remoteParticipants.get(1).getShortRecipientDisplayName(context));
}
}
default: {
if (focusedParticipant != CallParticipant.EMPTY && focusedParticipant.isScreenSharing()) {
return context.getString(R.string.WebRtcCallView__s_is_presenting, focusedParticipant.getShortRecipientDisplayName(context));
} else {
int others = remoteParticipants.size() - 2;
return context.getResources().getQuantityString(R.plurals.WebRtcCallView__s_s_and_d_others_are_in_this_call,
others,
remoteParticipants.get(0).getShortRecipientDisplayName(context),
remoteParticipants.get(1).getShortRecipientDisplayName(context),
others);
}
}
}
}
public @NonNull List<CallParticipant> getAllRemoteParticipants() {
return remoteParticipants.getAllParticipants();
}
public @NonNull CallParticipant getLocalParticipant() {
return localParticipant;
}
public @NonNull CallParticipant getFocusedParticipant() {
return focusedParticipant;
}
public @NonNull WebRtcLocalRenderState getLocalRenderState() {
return localRenderState;
}
public boolean isFolded() {
return foldableState.isFolded();
}
public boolean isLargeVideoGroup() {
return getAllRemoteParticipants().size() > SMALL_GROUP_MAX;
}
public boolean isInPipMode() {
return isInPipMode;
}
public boolean isViewingFocusedParticipant() {
return isViewingFocusedParticipant;
}
public boolean needsNewRequestSizes() {
if (groupCallState.isNotIdle()) {
return Stream.of(getAllRemoteParticipants()).anyMatch(p -> p.getVideoSink().needsNewRequestingSize());
} else {
return false;
}
}
public @NonNull OptionalLong getRemoteDevicesCount() {
return remoteDevicesCount;
}
public @NonNull OptionalLong getParticipantCount() {
boolean includeSelf = groupCallState == WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED;
return remoteDevicesCount.map(l -> l + (includeSelf ? 1L : 0L))
.or(() -> includeSelf ? OptionalLong.of(1L) : OptionalLong.empty());
}
public boolean isIncomingRing() {
return callState == WebRtcViewModel.State.CALL_INCOMING;
}
public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState,
@NonNull WebRtcViewModel webRtcViewModel,
boolean enableVideo)
{
boolean newShowVideoForOutgoing = oldState.showVideoForOutgoing;
if (enableVideo) {
newShowVideoForOutgoing = webRtcViewModel.getState() == WebRtcViewModel.State.CALL_OUTGOING;
} else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_OUTGOING) {
newShowVideoForOutgoing = false;
}
WebRtcLocalRenderState localRenderState = determineLocalRenderMode(webRtcViewModel.getLocalParticipant(),
oldState.isInPipMode,
newShowVideoForOutgoing,
webRtcViewModel.getGroupState().isNotIdle(),
webRtcViewModel.getState(),
webRtcViewModel.getRemoteParticipants().size(),
oldState.isViewingFocusedParticipant,
oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED);
return new CallParticipantsState(webRtcViewModel.getState(),
webRtcViewModel.getGroupState(),
oldState.remoteParticipants.getNext(webRtcViewModel.getRemoteParticipants()),
webRtcViewModel.getLocalParticipant(),
getFocusedParticipant(webRtcViewModel.getRemoteParticipants()),
localRenderState,
oldState.isInPipMode,
newShowVideoForOutgoing,
oldState.isViewingFocusedParticipant,
webRtcViewModel.getRemoteDevicesCount(),
oldState.foldableState);
}
public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, boolean isInPip) {
WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant,
isInPip,
oldState.showVideoForOutgoing,
oldState.getGroupCallState().isNotIdle(),
oldState.callState,
oldState.getAllRemoteParticipants().size(),
oldState.isViewingFocusedParticipant,
oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED);
return new CallParticipantsState(oldState.callState,
oldState.groupCallState,
oldState.remoteParticipants,
oldState.localParticipant,
oldState.focusedParticipant,
localRenderState,
isInPip,
oldState.showVideoForOutgoing,
oldState.isViewingFocusedParticipant,
oldState.remoteDevicesCount,
oldState.foldableState);
}
public static @NonNull CallParticipantsState setExpanded(@NonNull CallParticipantsState oldState, boolean expanded) {
WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant,
oldState.isInPipMode,
oldState.showVideoForOutgoing,
oldState.getGroupCallState().isNotIdle(),
oldState.callState,
oldState.getAllRemoteParticipants().size(),
oldState.isViewingFocusedParticipant,
expanded);
return new CallParticipantsState(oldState.callState,
oldState.groupCallState,
oldState.remoteParticipants,
oldState.localParticipant,
oldState.focusedParticipant,
localRenderState,
oldState.isInPipMode,
oldState.showVideoForOutgoing,
oldState.isViewingFocusedParticipant,
oldState.remoteDevicesCount,
oldState.foldableState);
}
public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, @NonNull SelectedPage selectedPage) {
WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant,
oldState.isInPipMode,
oldState.showVideoForOutgoing,
oldState.getGroupCallState().isNotIdle(),
oldState.callState,
oldState.getAllRemoteParticipants().size(),
selectedPage == SelectedPage.FOCUSED,
oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED);
return new CallParticipantsState(oldState.callState,
oldState.groupCallState,
oldState.remoteParticipants,
oldState.localParticipant,
oldState.focusedParticipant,
localRenderState,
oldState.isInPipMode,
oldState.showVideoForOutgoing,
selectedPage == SelectedPage.FOCUSED,
oldState.remoteDevicesCount,
oldState.foldableState);
}
public static @NonNull CallParticipantsState update(@NonNull CallParticipantsState oldState, @NonNull WebRtcControls.FoldableState foldableState) {
WebRtcLocalRenderState localRenderState = determineLocalRenderMode(oldState.localParticipant,
oldState.isInPipMode,
oldState.showVideoForOutgoing,
oldState.getGroupCallState().isNotIdle(),
oldState.callState,
oldState.getAllRemoteParticipants().size(),
oldState.isViewingFocusedParticipant,
oldState.getLocalRenderState() == WebRtcLocalRenderState.EXPANDED);
return new CallParticipantsState(oldState.callState,
oldState.groupCallState,
oldState.remoteParticipants,
oldState.localParticipant,
oldState.focusedParticipant,
localRenderState,
oldState.isInPipMode,
oldState.showVideoForOutgoing,
oldState.isViewingFocusedParticipant,
oldState.remoteDevicesCount,
foldableState);
}
private static @NonNull WebRtcLocalRenderState determineLocalRenderMode(@NonNull CallParticipant localParticipant,
boolean isInPip,
boolean showVideoForOutgoing,
boolean isNonIdleGroupCall,
@NonNull WebRtcViewModel.State callState,
int numberOfRemoteParticipants,
boolean isViewingFocusedParticipant,
boolean isExpanded)
{
boolean displayLocal = (numberOfRemoteParticipants == 0 || !isInPip) && (isNonIdleGroupCall || localParticipant.isVideoEnabled());
WebRtcLocalRenderState localRenderState = WebRtcLocalRenderState.GONE;
if (isExpanded && (localParticipant.isVideoEnabled() || isNonIdleGroupCall)) {
return WebRtcLocalRenderState.EXPANDED;
} else if (displayLocal || showVideoForOutgoing) {
if (callState == WebRtcViewModel.State.CALL_CONNECTED) {
if (isViewingFocusedParticipant || numberOfRemoteParticipants > 1) {
localRenderState = WebRtcLocalRenderState.SMALLER_RECTANGLE;
} else if (numberOfRemoteParticipants == 1) {
localRenderState = WebRtcLocalRenderState.SMALL_RECTANGLE;
} else {
localRenderState = localParticipant.isVideoEnabled() ? WebRtcLocalRenderState.LARGE : WebRtcLocalRenderState.LARGE_NO_VIDEO;
}
} else if (callState != WebRtcViewModel.State.CALL_INCOMING && callState != WebRtcViewModel.State.CALL_DISCONNECTED) {
localRenderState = localParticipant.isVideoEnabled() ? WebRtcLocalRenderState.LARGE : WebRtcLocalRenderState.LARGE_NO_VIDEO;
}
} else if (callState == WebRtcViewModel.State.CALL_PRE_JOIN) {
localRenderState = WebRtcLocalRenderState.LARGE_NO_VIDEO;
}
return localRenderState;
}
private static @NonNull CallParticipant getFocusedParticipant(@NonNull List<CallParticipant> participants) {
List<CallParticipant> participantsByLastSpoke = new ArrayList<>(participants);
Collections.sort(participantsByLastSpoke, ComparatorCompat.reversed((p1, p2) -> Long.compare(p1.getLastSpoke(), p2.getLastSpoke())));
return participantsByLastSpoke.isEmpty() ? CallParticipant.EMPTY
: participantsByLastSpoke.stream()
.filter(CallParticipant::isScreenSharing)
.findAny().orElse(participantsByLastSpoke.get(0));
}
public enum SelectedPage {
GRID,
FOCUSED
}
}

View File

@@ -0,0 +1,345 @@
package org.thoughtcrime.securesms.components.webrtc
import android.content.Context
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import com.annimon.stream.OptionalLong
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.WebRtcViewModel
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.ringrtc.CameraState
import org.thoughtcrime.securesms.service.webrtc.collections.ParticipantCollection
import java.util.concurrent.TimeUnit
/**
* Represents the state of all participants, remote and local, combined with view state
* needed to properly render the participants. The view state primarily consists of
* if we are in System PIP mode and if we should show our video for an outgoing call.
*/
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 focusedParticipant: CallParticipant = CallParticipant.EMPTY,
val localRenderState: WebRtcLocalRenderState = WebRtcLocalRenderState.GONE,
val isInPipMode: Boolean = false,
private val showVideoForOutgoing: Boolean = false,
val isViewingFocusedParticipant: Boolean = false,
val remoteDevicesCount: OptionalLong = OptionalLong.empty(),
private val foldableState: FoldableState = FoldableState.flat(),
val isInOutgoingRingingMode: Boolean = false,
val ringGroup: Boolean = false,
val ringerRecipient: Recipient = Recipient.UNKNOWN,
val groupMembers: List<GroupMemberEntry.FullMember> = emptyList()
) {
val allRemoteParticipants: List<CallParticipant> = remoteParticipants.allParticipants
val isFolded: Boolean = foldableState.isFolded
val isLargeVideoGroup: Boolean = allRemoteParticipants.size > SMALL_GROUP_MAX
val isIncomingRing: Boolean = callState == WebRtcViewModel.State.CALL_INCOMING
val gridParticipants: List<CallParticipant>
get() {
return remoteParticipants.gridParticipants
}
val listParticipants: List<CallParticipant>
get() {
val listParticipants: MutableList<CallParticipant> = mutableListOf()
if (isViewingFocusedParticipant && allRemoteParticipants.size > 1) {
listParticipants.addAll(allRemoteParticipants)
listParticipants.remove(focusedParticipant)
} else {
listParticipants.addAll(remoteParticipants.listParticipants)
}
if (foldableState.isFlat) {
listParticipants.add(CallParticipant.EMPTY)
}
listParticipants.reverse()
return listParticipants
}
val participantCount: OptionalLong
get() {
val includeSelf = groupCallState == WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED
return remoteDevicesCount.map { l: Long -> l + if (includeSelf) 1L else 0L }
.or { if (includeSelf) OptionalLong.of(1L) else OptionalLong.empty() }
}
fun getPreJoinGroupDescription(context: Context): String? {
if (callState != WebRtcViewModel.State.CALL_PRE_JOIN || groupCallState.isIdle) {
return null
}
return if (remoteParticipants.isEmpty) {
describeGroupMembers(
context = context,
oneParticipant = if (ringGroup) R.string.WebRtcCallView__signal_will_ring_s else R.string.WebRtcCallView__s_will_be_notified,
twoParticipants = if (ringGroup) R.string.WebRtcCallView__signal_will_ring_s_and_s else R.string.WebRtcCallView__s_and_s_will_be_notified,
multipleParticipants = if (ringGroup) R.plurals.WebRtcCallView__signal_will_ring_s_s_and_d_others else R.plurals.WebRtcCallView__s_s_and_d_others_will_be_notified,
members = groupMembers
)
} else {
when (remoteParticipants.size()) {
0 -> context.getString(R.string.WebRtcCallView__no_one_else_is_here)
1 -> context.getString(if (remoteParticipants[0].isSelf) R.string.WebRtcCallView__s_are_in_this_call else R.string.WebRtcCallView__s_is_in_this_call, remoteParticipants[0].getShortRecipientDisplayName(context))
2 -> context.getString(
R.string.WebRtcCallView__s_and_s_are_in_this_call,
remoteParticipants[0].getShortRecipientDisplayName(context),
remoteParticipants[1].getShortRecipientDisplayName(context)
)
else -> {
val others = remoteParticipants.size() - 2
context.resources.getQuantityString(
R.plurals.WebRtcCallView__s_s_and_d_others_are_in_this_call,
others,
remoteParticipants[0].getShortRecipientDisplayName(context),
remoteParticipants[1].getShortRecipientDisplayName(context),
others
)
}
}
}
}
fun getOutgoingRingingGroupDescription(context: Context): String? {
if (callState == WebRtcViewModel.State.CALL_CONNECTED &&
groupCallState == WebRtcViewModel.GroupCallState.CONNECTED_AND_JOINED &&
isInOutgoingRingingMode
) {
return describeGroupMembers(
context = context,
oneParticipant = R.string.WebRtcCallView__ringing_s,
twoParticipants = R.string.WebRtcCallView__ringing_s_and_s,
multipleParticipants = R.plurals.WebRtcCallView__ringing_s_s_and_d_others,
members = groupMembers
)
}
return null
}
fun getIncomingRingingGroupDescription(context: Context): String? {
if (callState == WebRtcViewModel.State.CALL_INCOMING &&
groupCallState == WebRtcViewModel.GroupCallState.RINGING &&
ringerRecipient.hasUuid()
) {
val ringerName = ringerRecipient.getShortDisplayName(context)
val membersWithoutYouOrRinger: List<GroupMemberEntry.FullMember> = groupMembers.filterNot { it.member.isSelf || ringerRecipient.requireUuid() == it.member.uuid.orNull() }
return when (membersWithoutYouOrRinger.size) {
0 -> context.getString(R.string.WebRtcCallView__s_is_calling_you, ringerName)
1 -> context.getString(
R.string.WebRtcCallView__s_is_calling_you_and_s,
ringerName,
membersWithoutYouOrRinger[0].member.getShortDisplayName(context)
)
2 -> context.getString(
R.string.WebRtcCallView__s_is_calling_you_s_and_s,
ringerName,
membersWithoutYouOrRinger[0].member.getShortDisplayName(context),
membersWithoutYouOrRinger[1].member.getShortDisplayName(context)
)
else -> {
val others = membersWithoutYouOrRinger.size - 2
context.resources.getQuantityString(
R.plurals.WebRtcCallView__s_is_calling_you_s_s_and_d_others,
others,
ringerName,
membersWithoutYouOrRinger[0].member.getShortDisplayName(context),
membersWithoutYouOrRinger[1].member.getShortDisplayName(context),
others
)
}
}
}
return null
}
fun needsNewRequestSizes(): Boolean {
return if (groupCallState.isNotIdle) {
allRemoteParticipants.any { it.videoSink.needsNewRequestingSize() }
} else {
false
}
}
companion object {
private const val SMALL_GROUP_MAX = 6
@JvmField
val MAX_OUTGOING_GROUP_RING_DURATION = TimeUnit.MINUTES.toMillis(1)
@JvmField
val STARTING_STATE = CallParticipantsState()
@JvmStatic
fun update(
oldState: CallParticipantsState,
webRtcViewModel: WebRtcViewModel,
enableVideo: Boolean
): CallParticipantsState {
var newShowVideoForOutgoing: Boolean = oldState.showVideoForOutgoing
if (enableVideo) {
newShowVideoForOutgoing = webRtcViewModel.state == WebRtcViewModel.State.CALL_OUTGOING
} else if (webRtcViewModel.state != WebRtcViewModel.State.CALL_OUTGOING) {
newShowVideoForOutgoing = false
}
val isInOutgoingRingingMode = if (oldState.isInOutgoingRingingMode) {
webRtcViewModel.callConnectedTime + MAX_OUTGOING_GROUP_RING_DURATION > System.currentTimeMillis() && webRtcViewModel.remoteParticipants.size == 0
} else {
oldState.ringGroup &&
webRtcViewModel.callConnectedTime + MAX_OUTGOING_GROUP_RING_DURATION > System.currentTimeMillis() &&
webRtcViewModel.remoteParticipants.size == 0 &&
oldState.callState == WebRtcViewModel.State.CALL_OUTGOING &&
webRtcViewModel.state == WebRtcViewModel.State.CALL_CONNECTED
}
val localRenderState: WebRtcLocalRenderState = determineLocalRenderMode(
oldState = oldState,
localParticipant = webRtcViewModel.localParticipant,
showVideoForOutgoing = newShowVideoForOutgoing,
isNonIdleGroupCall = webRtcViewModel.groupState.isNotIdle,
callState = webRtcViewModel.state,
numberOfRemoteParticipants = webRtcViewModel.remoteParticipants.size
)
return oldState.copy(
callState = webRtcViewModel.state,
groupCallState = webRtcViewModel.groupState,
remoteParticipants = oldState.remoteParticipants.getNext(webRtcViewModel.remoteParticipants),
localParticipant = webRtcViewModel.localParticipant,
focusedParticipant = getFocusedParticipant(webRtcViewModel.remoteParticipants),
localRenderState = localRenderState,
showVideoForOutgoing = newShowVideoForOutgoing,
remoteDevicesCount = webRtcViewModel.remoteDevicesCount,
ringGroup = webRtcViewModel.shouldRingGroup(),
isInOutgoingRingingMode = isInOutgoingRingingMode,
ringerRecipient = webRtcViewModel.ringerRecipient
)
}
@JvmStatic
fun update(oldState: CallParticipantsState, isInPip: Boolean): CallParticipantsState {
val localRenderState: WebRtcLocalRenderState = determineLocalRenderMode(oldState = oldState, isInPip = isInPip)
return oldState.copy(localRenderState = localRenderState, isInPipMode = isInPip)
}
@JvmStatic
fun setExpanded(oldState: CallParticipantsState, expanded: Boolean): CallParticipantsState {
val localRenderState: WebRtcLocalRenderState = determineLocalRenderMode(oldState = oldState, isExpanded = expanded)
return oldState.copy(localRenderState = localRenderState)
}
@JvmStatic
fun update(oldState: CallParticipantsState, selectedPage: SelectedPage): CallParticipantsState {
val localRenderState: WebRtcLocalRenderState = determineLocalRenderMode(oldState = oldState, isViewingFocusedParticipant = selectedPage == SelectedPage.FOCUSED)
return oldState.copy(localRenderState = localRenderState, isViewingFocusedParticipant = selectedPage == SelectedPage.FOCUSED)
}
@JvmStatic
fun update(oldState: CallParticipantsState, foldableState: FoldableState): CallParticipantsState {
val localRenderState: WebRtcLocalRenderState = determineLocalRenderMode(oldState = oldState)
return oldState.copy(localRenderState = localRenderState, foldableState = foldableState)
}
@JvmStatic
fun update(oldState: CallParticipantsState, groupMembers: List<GroupMemberEntry.FullMember>): CallParticipantsState {
return oldState.copy(groupMembers = groupMembers)
}
private fun determineLocalRenderMode(
oldState: CallParticipantsState,
localParticipant: CallParticipant = oldState.localParticipant,
isInPip: Boolean = oldState.isInPipMode,
showVideoForOutgoing: Boolean = oldState.showVideoForOutgoing,
isNonIdleGroupCall: Boolean = oldState.groupCallState.isNotIdle,
callState: WebRtcViewModel.State = oldState.callState,
numberOfRemoteParticipants: Int = oldState.allRemoteParticipants.size,
isViewingFocusedParticipant: Boolean = oldState.isViewingFocusedParticipant,
isExpanded: Boolean = oldState.localRenderState == WebRtcLocalRenderState.EXPANDED
): WebRtcLocalRenderState {
val displayLocal: Boolean = (numberOfRemoteParticipants == 0 || !isInPip) && (isNonIdleGroupCall || localParticipant.isVideoEnabled)
var localRenderState: WebRtcLocalRenderState = WebRtcLocalRenderState.GONE
if (isExpanded && (localParticipant.isVideoEnabled || isNonIdleGroupCall)) {
return WebRtcLocalRenderState.EXPANDED
} else if (displayLocal || showVideoForOutgoing) {
if (callState == WebRtcViewModel.State.CALL_CONNECTED) {
localRenderState = if (isViewingFocusedParticipant || numberOfRemoteParticipants > 1) {
WebRtcLocalRenderState.SMALLER_RECTANGLE
} else if (numberOfRemoteParticipants == 1) {
WebRtcLocalRenderState.SMALL_RECTANGLE
} else {
if (localParticipant.isVideoEnabled) WebRtcLocalRenderState.LARGE else WebRtcLocalRenderState.LARGE_NO_VIDEO
}
} else if (callState != WebRtcViewModel.State.CALL_INCOMING && callState != WebRtcViewModel.State.CALL_DISCONNECTED) {
localRenderState = if (localParticipant.isVideoEnabled) WebRtcLocalRenderState.LARGE else WebRtcLocalRenderState.LARGE_NO_VIDEO
}
} else if (callState == WebRtcViewModel.State.CALL_PRE_JOIN) {
localRenderState = WebRtcLocalRenderState.LARGE_NO_VIDEO
}
return localRenderState
}
private fun getFocusedParticipant(participants: List<CallParticipant>): CallParticipant {
val participantsByLastSpoke: List<CallParticipant> = participants.sortedByDescending(CallParticipant::lastSpoke)
return if (participantsByLastSpoke.isEmpty()) {
CallParticipant.EMPTY
} else {
participantsByLastSpoke.firstOrNull(CallParticipant::isScreenSharing) ?: participantsByLastSpoke[0]
}
}
private fun describeGroupMembers(
context: Context,
@StringRes oneParticipant: Int,
@StringRes twoParticipants: Int,
@PluralsRes multipleParticipants: Int,
members: List<GroupMemberEntry.FullMember>
): String {
val membersWithoutYou: List<GroupMemberEntry.FullMember> = members.filterNot { it.member.isSelf }
return when (membersWithoutYou.size) {
0 -> ""
1 -> context.getString(
oneParticipant,
membersWithoutYou[0].member.getShortDisplayName(context)
)
2 -> context.getString(
twoParticipants,
membersWithoutYou[0].member.getShortDisplayName(context),
membersWithoutYou[1].member.getShortDisplayName(context)
)
else -> {
val others = membersWithoutYou.size - 2
context.resources.getQuantityString(
multipleParticipants,
others,
membersWithoutYou[0].member.getShortDisplayName(context),
membersWithoutYou[1].member.getShortDisplayName(context),
others
)
}
}
}
}
enum class SelectedPage {
GRID, FOCUSED
}
}

View File

@@ -91,6 +91,8 @@ public class WebRtcCallView extends ConstraintLayout {
private ImageView answer;
private ImageView cameraDirectionToggle;
private TextView cameraDirectionToggleLabel;
private AccessibleToggleButton ringToggle;
private TextView ringToggleLabel;
private PictureInPictureGestureHelper pictureInPictureGestureHelper;
private ImageView hangup;
private TextView hangupLabel;
@@ -171,6 +173,8 @@ public class WebRtcCallView extends ConstraintLayout {
answer = findViewById(R.id.call_screen_answer_call);
cameraDirectionToggle = findViewById(R.id.call_screen_camera_direction_toggle);
cameraDirectionToggleLabel = findViewById(R.id.call_screen_camera_direction_toggle_label);
ringToggle = findViewById(R.id.call_screen_audio_ring_toggle);
ringToggleLabel = findViewById(R.id.call_screen_audio_ring_toggle_label);
hangup = findViewById(R.id.call_screen_end_call);
hangupLabel = findViewById(R.id.call_screen_end_call_label);
answerWithAudio = findViewById(R.id.call_screen_answer_with_audio);
@@ -239,6 +243,10 @@ public class WebRtcCallView extends ConstraintLayout {
runIfNonNull(controlsListener, listener -> listener.onMicChanged(isOn));
});
ringToggle.setOnCheckedChangeListener((v, isOn) -> {
runIfNonNull(controlsListener, listener -> listener.onRingGroupChanged(isOn, ringToggle.isActivated()));
});
cameraDirectionToggle.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onCameraDirectionChanged));
hangup.setOnClickListener(v -> runIfNonNull(controlsListener, ControlsListener::onEndCallPressed));
@@ -358,8 +366,14 @@ public class WebRtcCallView extends ConstraintLayout {
pages.add(WebRtcCallParticipantsPage.forSingleParticipant(state.getFocusedParticipant(), state.isInPipMode(), isPortrait, isLandscapeEnabled));
}
if (state.getCallState() == WebRtcViewModel.State.CALL_PRE_JOIN && state.getGroupCallState().isNotIdle()) {
status.setText(state.getRemoteParticipantsDescription(getContext()));
if (state.getGroupCallState().isNotIdle()) {
if (state.getCallState() == WebRtcViewModel.State.CALL_PRE_JOIN) {
status.setText(state.getPreJoinGroupDescription(getContext()));
} else if (state.getCallState() == WebRtcViewModel.State.CALL_CONNECTED && state.isInOutgoingRingingMode()) {
status.setText(state.getOutgoingRingingGroupDescription(getContext()));
} else if (state.getGroupCallState().isRinging()) {
status.setText(state.getIncomingRingingGroupDescription(getContext()));
}
}
if (state.getGroupCallState().isNotIdle()) {
@@ -641,6 +655,11 @@ public class WebRtcCallView extends ConstraintLayout {
fullScreenShade.setVisibility(GONE);
}
if (webRtcControls.displayRingToggle()) {
visibleViewSet.add(ringToggle);
visibleViewSet.add(ringToggleLabel);
}
if (webRtcControls.isFadeOutEnabled()) {
if (!controls.isFadeOutEnabled()) {
scheduleFadeOut();
@@ -947,6 +966,7 @@ public class WebRtcCallView extends ConstraintLayout {
micToggle.setBackgroundResource(R.drawable.webrtc_call_screen_mic_toggle);
videoToggle.setBackgroundResource(R.drawable.webrtc_call_screen_video_toggle);
audioToggle.setImageResource(R.drawable.webrtc_call_screen_speaker_toggle);
ringToggle.setBackgroundResource(R.drawable.webrtc_call_screen_ring_toggle);
}
private void updateButtonStateForSmallButtons() {
@@ -955,6 +975,7 @@ public class WebRtcCallView extends ConstraintLayout {
micToggle.setBackgroundResource(R.drawable.webrtc_call_screen_mic_toggle_small);
videoToggle.setBackgroundResource(R.drawable.webrtc_call_screen_video_toggle_small);
audioToggle.setImageResource(R.drawable.webrtc_call_screen_speaker_toggle_small);
ringToggle.setBackgroundResource(R.drawable.webrtc_call_screen_ring_toggle_small);
}
private boolean showParticipantsList() {
@@ -968,6 +989,14 @@ public class WebRtcCallView extends ConstraintLayout {
}
}
public void setRingGroup(boolean shouldRingGroup) {
ringToggle.setChecked(shouldRingGroup, false);
}
public void enableRingGroup(boolean enabled) {
ringToggle.setActivated(enabled);
}
public interface ControlsListener {
void onStartCall(boolean isVideoCall);
void onCancelStartCall();
@@ -985,5 +1014,6 @@ public class WebRtcCallView extends ConstraintLayout {
void onShowParticipantsList();
void onPageChanged(@NonNull CallParticipantsState.SelectedPage page);
void onLocalPictureInPictureClicked();
void onRingGroupChanged(boolean ringGroup, boolean ringingAllowed);
}
}

View File

@@ -9,6 +9,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
@@ -29,6 +30,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
@@ -40,32 +42,38 @@ import java.util.Objects;
public class WebRtcCallViewModel extends ViewModel {
private final MutableLiveData<Boolean> microphoneEnabled = new MutableLiveData<>(true);
private final MutableLiveData<Boolean> isInPipMode = new MutableLiveData<>(false);
private final MutableLiveData<WebRtcControls> webRtcControls = new MutableLiveData<>(WebRtcControls.NONE);
private final MutableLiveData<WebRtcControls.FoldableState> foldableState = new MutableLiveData<>(WebRtcControls.FoldableState.flat());
private final LiveData<WebRtcControls> controlsWithFoldableState = LiveDataUtil.combineLatest(foldableState, webRtcControls, this::updateControlsFoldableState);
private final LiveData<WebRtcControls> realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, controlsWithFoldableState, this::getRealWebRtcControls);
private final SingleLiveEvent<Event> events = new SingleLiveEvent<Event>();
private final MutableLiveData<Long> elapsed = new MutableLiveData<>(-1L);
private final MutableLiveData<LiveRecipient> liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live());
private final MutableLiveData<CallParticipantsState> participantsState = new MutableLiveData<>(CallParticipantsState.STARTING_STATE);
private final SingleLiveEvent<CallParticipantListUpdate> callParticipantListUpdate = new SingleLiveEvent<>();
private final MutableLiveData<Collection<RecipientId>> identityChangedRecipients = new MutableLiveData<>(Collections.emptyList());
private final LiveData<SafetyNumberChangeEvent> safetyNumberChangeEvent = LiveDataUtil.combineLatest(isInPipMode, identityChangedRecipients, SafetyNumberChangeEvent::new);
private final LiveData<Recipient> groupRecipient = LiveDataUtil.filter(Transformations.switchMap(liveRecipient, LiveRecipient::getLiveData), Recipient::isActiveGroup);
private final LiveData<List<GroupMemberEntry.FullMember>> groupMembers = LiveDataUtil.skip(Transformations.switchMap(groupRecipient, r -> Transformations.distinctUntilChanged(new LiveGroup(r.requireGroupId()).getFullMembers())), 1);
private final LiveData<Boolean> shouldShowSpeakerHint = Transformations.map(participantsState, this::shouldShowSpeakerHint);
private final LiveData<Orientation> orientation;
private final MutableLiveData<Boolean> isLandscapeEnabled = new MutableLiveData<>();
private final LiveData<Integer> controlsRotation;
private final MutableLiveData<Boolean> microphoneEnabled = new MutableLiveData<>(true);
private final MutableLiveData<Boolean> isInPipMode = new MutableLiveData<>(false);
private final MutableLiveData<WebRtcControls> webRtcControls = new MutableLiveData<>(WebRtcControls.NONE);
private final MutableLiveData<WebRtcControls.FoldableState> foldableState = new MutableLiveData<>(WebRtcControls.FoldableState.flat());
private final LiveData<WebRtcControls> controlsWithFoldableState = LiveDataUtil.combineLatest(foldableState, webRtcControls, this::updateControlsFoldableState);
private final LiveData<WebRtcControls> realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, controlsWithFoldableState, this::getRealWebRtcControls);
private final SingleLiveEvent<Event> events = new SingleLiveEvent<>();
private final MutableLiveData<Long> elapsed = new MutableLiveData<>(-1L);
private final MutableLiveData<LiveRecipient> liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live());
private final DefaultValueLiveData<CallParticipantsState> participantsState = new DefaultValueLiveData<>(CallParticipantsState.STARTING_STATE);
private final SingleLiveEvent<CallParticipantListUpdate> callParticipantListUpdate = new SingleLiveEvent<>();
private final MutableLiveData<Collection<RecipientId>> identityChangedRecipients = new MutableLiveData<>(Collections.emptyList());
private final LiveData<SafetyNumberChangeEvent> safetyNumberChangeEvent = LiveDataUtil.combineLatest(isInPipMode, identityChangedRecipients, SafetyNumberChangeEvent::new);
private final LiveData<Recipient> groupRecipient = LiveDataUtil.filter(Transformations.switchMap(liveRecipient, LiveRecipient::getLiveData), Recipient::isActiveGroup);
private final LiveData<List<GroupMemberEntry.FullMember>> groupMembers = Transformations.switchMap(groupRecipient, r -> Transformations.distinctUntilChanged(new LiveGroup(r.requireGroupId()).getFullMembers()));
private final LiveData<List<GroupMemberEntry.FullMember>> groupMembersChanged = LiveDataUtil.skip(groupMembers, 1);
private final LiveData<Integer> groupMemberCount = Transformations.map(groupMembers, List::size);
private final LiveData<Boolean> shouldShowSpeakerHint = Transformations.map(participantsState, this::shouldShowSpeakerHint);
private final LiveData<Orientation> orientation;
private final MutableLiveData<Boolean> isLandscapeEnabled = new MutableLiveData<>();
private final LiveData<Integer> controlsRotation;
private final Observer<List<GroupMemberEntry.FullMember>> groupMemberStateUpdater = m -> participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), m));
private final Handler elapsedTimeHandler = new Handler(Looper.getMainLooper());
private final Runnable elapsedTimeRunnable = this::handleTick;
private final Runnable stopOutgoingRingingMode = this::stopOutgoingRingingMode;
private boolean canDisplayTooltipIfNeeded = true;
private boolean hasEnabledLocalVideo = false;
private boolean wasInOutgoingRingingMode = false;
private long callConnectedTime = -1;
private Handler elapsedTimeHandler = new Handler(Looper.getMainLooper());
private boolean answerWithVideoAvailable = false;
private Runnable elapsedTimeRunnable = this::handleTick;
private boolean canEnterPipMode = false;
private List<CallParticipant> previousParticipantsList = Collections.emptyList();
private boolean callStarting = false;
@@ -79,6 +87,8 @@ public class WebRtcCallViewModel extends ViewModel {
controlsRotation = LiveDataUtil.combineLatest(Transformations.distinctUntilChanged(isLandscapeEnabled),
Transformations.distinctUntilChanged(orientation),
this::resolveRotation);
groupMembers.observeForever(groupMemberStateUpdater);
}
public LiveData<Integer> getControlsRotation() {
@@ -135,8 +145,12 @@ public class WebRtcCallViewModel extends ViewModel {
return safetyNumberChangeEvent;
}
public LiveData<List<GroupMemberEntry.FullMember>> getGroupMembers() {
return groupMembers;
public LiveData<List<GroupMemberEntry.FullMember>> getGroupMembersChanged() {
return groupMembersChanged;
}
public LiveData<Integer> getGroupMemberCount() {
return groupMemberCount;
}
public LiveData<Boolean> shouldShowSpeakerHint() {
@@ -159,7 +173,6 @@ public class WebRtcCallViewModel extends ViewModel {
public void setIsInPipMode(boolean isInPipMode) {
this.isInPipMode.setValue(isInPipMode);
//noinspection ConstantConditions
participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), isInPipMode));
}
@@ -174,11 +187,11 @@ public class WebRtcCallViewModel extends ViewModel {
}
CallParticipantsState state = participantsState.getValue();
if (state != null &&
showScreenShareTip &&
if (showScreenShareTip &&
state.getFocusedParticipant().isScreenSharing() &&
state.isViewingFocusedParticipant() &&
page == CallParticipantsState.SelectedPage.GRID) {
page == CallParticipantsState.SelectedPage.GRID)
{
showScreenShareTip = false;
events.setValue(new Event.ShowSwipeToSpeakerHint());
}
@@ -211,15 +224,14 @@ public class WebRtcCallViewModel extends ViewModel {
microphoneEnabled.setValue(localParticipant.isMicrophoneEnabled());
CallParticipantsState state = participantsState.getValue();
if (state != null) {
boolean wasScreenSharing = state.getFocusedParticipant().isScreenSharing();
CallParticipantsState newState = CallParticipantsState.update(state, webRtcViewModel, enableVideo);
participantsState.setValue(newState);
if (switchOnFirstScreenShare && !wasScreenSharing && newState.getFocusedParticipant().isScreenSharing()) {
switchOnFirstScreenShare = false;
events.setValue(new Event.SwitchToSpeaker());
}
CallParticipantsState state = participantsState.getValue();
boolean wasScreenSharing = state.getFocusedParticipant().isScreenSharing();
CallParticipantsState newState = CallParticipantsState.update(state, webRtcViewModel, enableVideo);
participantsState.setValue(newState);
if (switchOnFirstScreenShare && !wasScreenSharing && newState.getFocusedParticipant().isScreenSharing()) {
switchOnFirstScreenShare = false;
events.setValue(new Event.SwitchToSpeaker());
}
if (webRtcViewModel.getGroupState().isConnected()) {
@@ -245,12 +257,20 @@ public class WebRtcCallViewModel extends ViewModel {
webRtcViewModel.getRemoteDevicesCount().orElse(0),
webRtcViewModel.getParticipantLimit());
if (webRtcViewModel.getState() == WebRtcViewModel.State.CALL_CONNECTED && callConnectedTime == -1) {
callConnectedTime = webRtcViewModel.getCallConnectedTime();
startTimer();
} else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_CONNECTED || webRtcViewModel.getGroupState().isNotIdleOrConnected()) {
if (newState.isInOutgoingRingingMode()) {
cancelTimer();
callConnectedTime = -1;
if (!wasInOutgoingRingingMode) {
elapsedTimeHandler.postDelayed(stopOutgoingRingingMode, CallParticipantsState.MAX_OUTGOING_GROUP_RING_DURATION);
}
wasInOutgoingRingingMode = true;
} else {
if (webRtcViewModel.getState() == WebRtcViewModel.State.CALL_CONNECTED && callConnectedTime == -1) {
callConnectedTime = wasInOutgoingRingingMode ? System.currentTimeMillis() : webRtcViewModel.getCallConnectedTime();
startTimer();
} else if (webRtcViewModel.getState() != WebRtcViewModel.State.CALL_CONNECTED || webRtcViewModel.getGroupState().isNotIdleOrConnected()) {
cancelTimer();
callConnectedTime = -1;
}
}
if (localParticipant.getCameraState().isEnabled()) {
@@ -371,18 +391,26 @@ public class WebRtcCallViewModel extends ViewModel {
}
private boolean shouldShowSpeakerHint(@NonNull CallParticipantsState state) {
return !state.isInPipMode() &&
return !state.isInPipMode() &&
state.getRemoteDevicesCount().orElse(0) > 1 &&
state.getGroupCallState().isConnected() &&
state.getGroupCallState().isConnected() &&
!SignalStore.tooltips().hasSeenGroupCallSpeakerView();
}
private void startTimer() {
cancelTimer();
elapsedTimeHandler.removeCallbacks(stopOutgoingRingingMode);
elapsedTimeHandler.post(elapsedTimeRunnable);
}
private void stopOutgoingRingingMode() {
if (callConnectedTime == -1) {
callConnectedTime = System.currentTimeMillis();
startTimer();
}
}
private void handleTick() {
if (callConnectedTime == -1) {
return;
@@ -403,6 +431,7 @@ public class WebRtcCallViewModel extends ViewModel {
protected void onCleared() {
super.onCleared();
cancelTimer();
groupMembers.removeObserver(groupMemberStateUpdater);
}
public void startCall(boolean isVideoCall) {

View File

@@ -8,6 +8,7 @@ import androidx.annotation.Px;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.FeatureFlags;
public final class WebRtcControls {
@@ -183,6 +184,10 @@ public final class WebRtcControls {
return isPreJoin() || isIncoming();
}
boolean displayRingToggle() {
return FeatureFlags.groupCallRinging() && isPreJoin() && isGroupCall() && !hasAtLeastOneRemote;
}
private boolean isError() {
return callState == CallState.ERROR;
}