mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-25 12:17:22 +00:00
Implement several parts of the call links admin UX.
This commit is contained in:
@@ -50,6 +50,7 @@ import org.greenrobot.eventbus.EventBus;
|
||||
import org.greenrobot.eventbus.Subscribe;
|
||||
import org.greenrobot.eventbus.ThreadMode;
|
||||
import org.signal.core.util.ThreadUtil;
|
||||
import org.signal.core.util.concurrent.LifecycleDisposable;
|
||||
import org.signal.core.util.concurrent.SignalExecutors;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.libsignal.protocol.IdentityKey;
|
||||
@@ -61,6 +62,8 @@ import org.thoughtcrime.securesms.components.webrtc.CallParticipantsState;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallStateUpdatePopupWindow;
|
||||
import org.thoughtcrime.securesms.components.webrtc.CallToastPopupWindow;
|
||||
import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeNotificationUtil;
|
||||
import org.thoughtcrime.securesms.components.webrtc.PendingParticipantsBottomSheet;
|
||||
import org.thoughtcrime.securesms.components.webrtc.PendingParticipantsView;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioDevice;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioOutput;
|
||||
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView;
|
||||
@@ -79,6 +82,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet;
|
||||
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
|
||||
import org.thoughtcrime.securesms.sms.MessageSender;
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil;
|
||||
import org.thoughtcrime.securesms.util.EllapsedTimeFormatter;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.FullscreenHelper;
|
||||
@@ -137,6 +141,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
private WindowInfoTrackerCallbackAdapter windowInfoTrackerCallbackAdapter;
|
||||
private ThrottledDebouncer requestNewSizesThrottle;
|
||||
private PictureInPictureParams.Builder pipBuilderParams;
|
||||
private LifecycleDisposable lifecycleDisposable;
|
||||
|
||||
private Disposable ephemeralStateDisposable = Disposable.empty();
|
||||
|
||||
@@ -150,6 +155,10 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
Log.i(TAG, "onCreate(" + getIntent().getBooleanExtra(EXTRA_STARTED_FROM_FULLSCREEN, false) + ")");
|
||||
|
||||
lifecycleDisposable = new LifecycleDisposable();
|
||||
lifecycleDisposable.bindTo(this);
|
||||
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED);
|
||||
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
super.onCreate(savedInstanceState);
|
||||
@@ -398,6 +407,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
viewModel.setIsLandscapeEnabled(info.isInPictureInPictureMode());
|
||||
});
|
||||
|
||||
callScreen.setPendingParticipantsViewListener(new PendingParticipantsViewListener());
|
||||
Disposable disposable = viewModel.getPendingParticipants()
|
||||
.subscribe(callScreen::updatePendingParticipantsList);
|
||||
|
||||
lifecycleDisposable.add(disposable);
|
||||
}
|
||||
|
||||
private void initializePictureInPictureParams() {
|
||||
@@ -949,6 +964,24 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
|
||||
}
|
||||
}
|
||||
|
||||
private class PendingParticipantsViewListener implements PendingParticipantsView.Listener {
|
||||
|
||||
@Override
|
||||
public void onAllowPendingRecipient(@NonNull Recipient pendingRecipient) {
|
||||
ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestAccepted(pendingRecipient);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRejectPendingRecipient(@NonNull Recipient pendingRecipient) {
|
||||
ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestRejected(pendingRecipient);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLaunchPendingRequestsSheet() {
|
||||
new PendingParticipantsBottomSheet().show(getSupportFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG);
|
||||
}
|
||||
}
|
||||
|
||||
private class WindowLayoutInfoConsumer implements Consumer<WindowLayoutInfo> {
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
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.remember
|
||||
import androidx.compose.runtime.rxjava3.subscribeAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import org.signal.core.ui.BottomSheets
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection
|
||||
import org.thoughtcrime.securesms.util.activityViewModel
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Displays a list of pending participants attempting to join this call.
|
||||
*/
|
||||
class PendingParticipantsBottomSheet : ComposeBottomSheetDialogFragment() {
|
||||
|
||||
companion object {
|
||||
const val REQUEST_KEY = "PendingParticipantsBottomSheet_result"
|
||||
}
|
||||
|
||||
private val viewModel: WebRtcCallViewModel by activityViewModel {
|
||||
error("Should already exist")
|
||||
}
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
@Composable
|
||||
override fun SheetContent() {
|
||||
val launchTime = remember {
|
||||
System.currentTimeMillis().milliseconds
|
||||
}
|
||||
|
||||
val participants = viewModel.pendingParticipants
|
||||
.map { it.getAllPendingParticipants(launchTime).toList() }
|
||||
.subscribeAsState(initial = emptyList())
|
||||
|
||||
PendingParticipantsSheet(
|
||||
pendingParticipants = participants.value,
|
||||
onApproveAll = this::onApproveAll,
|
||||
onDenyAll = this::onDenyAll,
|
||||
onApprove = this::onApprove,
|
||||
onDeny = this::onDeny
|
||||
)
|
||||
}
|
||||
|
||||
private fun onApprove(recipient: Recipient) {
|
||||
ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestAccepted(recipient)
|
||||
}
|
||||
|
||||
private fun onDeny(recipient: Recipient) {
|
||||
ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestRejected(recipient)
|
||||
}
|
||||
|
||||
private fun onApproveAll() {
|
||||
dismiss()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to true))
|
||||
}
|
||||
|
||||
private fun onDenyAll() {
|
||||
dismiss()
|
||||
setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to false))
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showSystemUi = true)
|
||||
@Composable
|
||||
private fun PendingParticipantsSheetPreview() {
|
||||
SignalTheme(isDarkMode = true) {
|
||||
Surface(shape = RoundedCornerShape(18.dp, 18.dp)) {
|
||||
PendingParticipantsSheet(
|
||||
pendingParticipants = (1 until 7).map {
|
||||
PendingParticipantCollection.Entry(Recipient.UNKNOWN, PendingParticipantCollection.State.PENDING, System.currentTimeMillis().milliseconds)
|
||||
},
|
||||
onApproveAll = {},
|
||||
onDenyAll = {},
|
||||
onApprove = {},
|
||||
onDeny = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingParticipantsSheet(
|
||||
pendingParticipants: List<PendingParticipantCollection.Entry>,
|
||||
onApproveAll: () -> Unit,
|
||||
onDenyAll: () -> Unit,
|
||||
onApprove: (Recipient) -> Unit,
|
||||
onDeny: (Recipient) -> Unit
|
||||
) {
|
||||
Box {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(bottom = 64.dp)
|
||||
) {
|
||||
BottomSheets.Handle()
|
||||
|
||||
Spacer(Modifier.size(14.dp))
|
||||
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.PendingParticipantsBottomSheet__requests_to_join_this_call),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Text(
|
||||
text = pluralStringResource(
|
||||
id = R.plurals.PendingParticipantsBottomSheet__d_people_waiting,
|
||||
count = pendingParticipants.size,
|
||||
pendingParticipants.size
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(Modifier.size(24.dp))
|
||||
}
|
||||
|
||||
items(pendingParticipants.size) { index ->
|
||||
PendingParticipantRow(
|
||||
participant = pendingParticipants[index],
|
||||
onApprove = onApprove,
|
||||
onDeny = onDeny
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.background(color = MaterialTheme.colorScheme.background)
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
TextButton(
|
||||
onClick = onDenyAll
|
||||
) {
|
||||
Text(
|
||||
text = "Deny all",
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.size(8.dp))
|
||||
|
||||
Buttons.LargeTonal(onClick = onApproveAll) {
|
||||
Text(
|
||||
text = "Approve all"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingParticipantRow(
|
||||
participant: PendingParticipantCollection.Entry,
|
||||
onApprove: (Recipient) -> Unit,
|
||||
onDeny: (Recipient) -> Unit
|
||||
) {
|
||||
val onApproveCallback = remember(participant.recipient) { { onApprove(participant.recipient) } }
|
||||
val onDenyCallback = remember(participant.recipient) { { onDeny(participant.recipient) } }
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
|
||||
) {
|
||||
PendingParticipantAvatar(recipient = participant.recipient)
|
||||
|
||||
Text(
|
||||
text = participant.recipient.getDisplayName(LocalContext.current),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 16.dp)
|
||||
)
|
||||
|
||||
CircularIconButton(
|
||||
symbol = ImageVector.vectorResource(id = R.drawable.symbol_x_compact_bold_16),
|
||||
contentDescription = stringResource(id = R.string.PendingParticipantsBottomSheet__reject),
|
||||
backgroundColor = colorResource(id = R.color.webrtc_hangup_background),
|
||||
onClick = onDenyCallback
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(24.dp))
|
||||
|
||||
CircularIconButton(
|
||||
symbol = ImageVector.vectorResource(id = R.drawable.symbol_check_compact_bold_16),
|
||||
contentDescription = stringResource(id = R.string.PendingParticipantsBottomSheet__approve),
|
||||
backgroundColor = colorResource(id = R.color.signal_accent_green),
|
||||
onClick = onApproveCallback
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CircularIconButton(
|
||||
symbol: ImageVector,
|
||||
contentDescription: String?,
|
||||
backgroundColor: Color,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Icon(
|
||||
imageVector = symbol,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.background(
|
||||
color = backgroundColor,
|
||||
shape = CircleShape
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(6.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PendingParticipantAvatar(recipient: Recipient) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Person,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(
|
||||
color = Color.Red,
|
||||
shape = CircleShape
|
||||
)
|
||||
)
|
||||
} else {
|
||||
AndroidView(
|
||||
factory = ::AvatarImageView,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
it.setAvatar(recipient)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.webrtc
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.Group
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.card.MaterialCardView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.AvatarImageView
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
/**
|
||||
* Card which displays pending participants state.
|
||||
*/
|
||||
class PendingParticipantsView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : MaterialCardView(context, attrs) {
|
||||
init {
|
||||
inflate(context, R.layout.pending_participant_view, this)
|
||||
}
|
||||
|
||||
var listener: Listener? = null
|
||||
|
||||
private val avatar: AvatarImageView = findViewById(R.id.pending_participants_avatar)
|
||||
private val name: TextView = findViewById(R.id.pending_participants_name)
|
||||
private val allow: View = findViewById(R.id.pending_participants_allow)
|
||||
private val reject: View = findViewById(R.id.pending_participants_reject)
|
||||
private val requestsGroup: Group = findViewById(R.id.pending_participants_requests_group)
|
||||
private val requestsButton: MaterialButton = findViewById(R.id.pending_participants_requests)
|
||||
|
||||
init {
|
||||
requestsButton.setOnClickListener {
|
||||
listener?.onLaunchPendingRequestsSheet()
|
||||
}
|
||||
}
|
||||
|
||||
fun applyState(pendingParticipantCollection: PendingParticipantCollection) {
|
||||
val unresolvedPendingParticipants: List<Recipient> = pendingParticipantCollection.getUnresolvedPendingParticipants().map { it.recipient }
|
||||
if (unresolvedPendingParticipants.isEmpty()) {
|
||||
visible = false
|
||||
return
|
||||
}
|
||||
|
||||
val firstRecipient: Recipient = unresolvedPendingParticipants.first()
|
||||
avatar.setAvatar(firstRecipient)
|
||||
name.text = firstRecipient.getShortDisplayName(context)
|
||||
|
||||
allow.setOnClickListener { listener?.onAllowPendingRecipient(firstRecipient) }
|
||||
reject.setOnClickListener { listener?.onRejectPendingRecipient(firstRecipient) }
|
||||
|
||||
if (unresolvedPendingParticipants.size > 1) {
|
||||
val requestCount = unresolvedPendingParticipants.size - 1
|
||||
requestsButton.text = resources.getQuantityString(R.plurals.PendingParticipantsView__plus_d_requests, requestCount, requestCount)
|
||||
requestsGroup.visible = true
|
||||
} else {
|
||||
requestsGroup.visible = false
|
||||
}
|
||||
|
||||
visible = true
|
||||
}
|
||||
|
||||
interface Listener {
|
||||
/**
|
||||
* Given recipient should be admitted to the call
|
||||
*/
|
||||
fun onAllowPendingRecipient(pendingRecipient: Recipient)
|
||||
|
||||
/**
|
||||
* Given recipient should be rejected from the call
|
||||
*/
|
||||
fun onRejectPendingRecipient(pendingRecipient: Recipient)
|
||||
|
||||
/**
|
||||
* Display the sheet containing all of the requests for the given call
|
||||
*/
|
||||
fun onLaunchPendingRequestsSheet()
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import org.thoughtcrime.securesms.mms.GlideApp;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.ringrtc.CameraState;
|
||||
import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection;
|
||||
import org.thoughtcrime.securesms.util.BlurTransformation;
|
||||
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
@@ -127,10 +128,12 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
private View fullScreenShade;
|
||||
private Toolbar collapsedToolbar;
|
||||
private Toolbar headerToolbar;
|
||||
private Stub<PendingParticipantsView> pendingParticipantsViewStub;
|
||||
|
||||
private WebRtcCallParticipantsPagerAdapter pagerAdapter;
|
||||
private WebRtcCallParticipantsRecyclerAdapter recyclerAdapter;
|
||||
private PictureInPictureExpansionHelper pictureInPictureExpansionHelper;
|
||||
private PendingParticipantsView.Listener pendingParticipantsViewListener;
|
||||
|
||||
private final Set<View> incomingCallViews = new HashSet<>();
|
||||
private final Set<View> topViews = new HashSet<>();
|
||||
@@ -203,6 +206,7 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
fullScreenShade = findViewById(R.id.call_screen_full_shade);
|
||||
collapsedToolbar = findViewById(R.id.webrtc_call_view_toolbar_text);
|
||||
headerToolbar = findViewById(R.id.webrtc_call_view_toolbar_no_text);
|
||||
pendingParticipantsViewStub = new Stub<>(findViewById(R.id.call_screen_pending_recipients));
|
||||
|
||||
View decline = findViewById(R.id.call_screen_decline_call);
|
||||
View answerLabel = findViewById(R.id.call_screen_answer_call_label);
|
||||
@@ -424,6 +428,22 @@ public class WebRtcCallView extends ConstraintLayout {
|
||||
micToggle.setChecked(isMicEnabled, false);
|
||||
}
|
||||
|
||||
public void setPendingParticipantsViewListener(@Nullable PendingParticipantsView.Listener listener) {
|
||||
pendingParticipantsViewListener = listener;
|
||||
}
|
||||
|
||||
public void updatePendingParticipantsList(@NonNull PendingParticipantCollection pendingParticipantCollection) {
|
||||
if (pendingParticipantCollection.getUnresolvedPendingParticipants().isEmpty()) {
|
||||
if (pendingParticipantsViewStub.resolved()) {
|
||||
pendingParticipantsViewStub.get().setListener(pendingParticipantsViewListener);
|
||||
pendingParticipantsViewStub.get().applyState(pendingParticipantCollection);
|
||||
}
|
||||
} else {
|
||||
pendingParticipantsViewStub.get().setListener(pendingParticipantsViewListener);
|
||||
pendingParticipantsViewStub.get().applyState(pendingParticipantCollection);
|
||||
}
|
||||
}
|
||||
|
||||
public void updateCallParticipants(@NonNull CallParticipantsViewState callParticipantsViewState) {
|
||||
lastState = callParticipantsViewState;
|
||||
|
||||
|
||||
@@ -30,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.service.webrtc.PendingParticipantCollection;
|
||||
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcEphemeralState;
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
|
||||
import org.thoughtcrime.securesms.util.NetworkUtil;
|
||||
@@ -44,6 +45,10 @@ import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Observable;
|
||||
import io.reactivex.rxjava3.subjects.BehaviorSubject;
|
||||
|
||||
public class WebRtcCallViewModel extends ViewModel {
|
||||
|
||||
private final MutableLiveData<Boolean> microphoneEnabled = new MutableLiveData<>(true);
|
||||
@@ -70,6 +75,8 @@ public class WebRtcCallViewModel extends ViewModel {
|
||||
private final Observer<List<GroupMemberEntry.FullMember>> groupMemberStateUpdater = m -> participantsState.setValue(CallParticipantsState.update(participantsState.getValue(), m));
|
||||
private final MutableLiveData<WebRtcEphemeralState> ephemeralState = new MutableLiveData<>();
|
||||
|
||||
private final BehaviorSubject<PendingParticipantCollection> pendingParticipants = BehaviorSubject.create();
|
||||
|
||||
private final Handler elapsedTimeHandler = new Handler(Looper.getMainLooper());
|
||||
private final Runnable elapsedTimeRunnable = this::handleTick;
|
||||
private final Runnable stopOutgoingRingingMode = this::stopOutgoingRingingMode;
|
||||
@@ -183,6 +190,10 @@ public class WebRtcCallViewModel extends ViewModel {
|
||||
return callStarting;
|
||||
}
|
||||
|
||||
public @NonNull Observable<PendingParticipantCollection> getPendingParticipants() {
|
||||
return pendingParticipants.observeOn(AndroidSchedulers.mainThread());
|
||||
}
|
||||
|
||||
@MainThread
|
||||
public void setIsInPipMode(boolean isInPipMode) {
|
||||
this.isInPipMode.setValue(isInPipMode);
|
||||
@@ -272,6 +283,8 @@ public class WebRtcCallViewModel extends ViewModel {
|
||||
webRtcViewModel.getParticipantLimit(),
|
||||
webRtcViewModel.getRecipient().isCallLink());
|
||||
|
||||
pendingParticipants.onNext(webRtcViewModel.getPendingParticipants());
|
||||
|
||||
if (newState.isInOutgoingRingingMode()) {
|
||||
cancelTimer();
|
||||
if (!wasInOutgoingRingingMode) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.thoughtcrime.securesms.components.webrtc.BroadcastVideoSink
|
||||
import org.thoughtcrime.securesms.events.CallParticipant.Companion.createLocal
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection
|
||||
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState
|
||||
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager
|
||||
import org.webrtc.PeerConnection
|
||||
@@ -92,6 +93,7 @@ class WebRtcViewModel(state: WebRtcServiceState) {
|
||||
val identityChangedParticipants: Set<RecipientId> = state.callInfoState.identityChangedRecipients
|
||||
val remoteDevicesCount: OptionalLong = state.callInfoState.remoteDevicesCount
|
||||
val participantLimit: Long? = state.callInfoState.participantLimit
|
||||
val pendingParticipants: PendingParticipantCollection = state.callInfoState.pendingParticipants
|
||||
|
||||
@get:JvmName("shouldRingGroup")
|
||||
val ringGroup: Boolean = state.getCallSetupState(state.callInfoState.activePeer?.callId).ringGroup
|
||||
|
||||
@@ -6,7 +6,15 @@
|
||||
package org.thoughtcrime.securesms.service.webrtc
|
||||
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.ringrtc.CallException
|
||||
import org.signal.ringrtc.GroupCall
|
||||
import org.signal.ringrtc.PeekInfo
|
||||
import org.thoughtcrime.securesms.database.CallLinkTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
|
||||
import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
|
||||
/**
|
||||
* Process actions for when the call link has at least once been connected and joined.
|
||||
@@ -25,4 +33,67 @@ class CallLinkConnectedActionProcessor(
|
||||
|
||||
return currentState
|
||||
}
|
||||
|
||||
override fun handleGroupJoinedMembershipChanged(currentState: WebRtcServiceState): WebRtcServiceState {
|
||||
Log.i(tag, "handleGroupJoinedMembershipChanged():")
|
||||
|
||||
val superState: WebRtcServiceState = super.handleGroupJoinedMembershipChanged(currentState)
|
||||
val groupCall: GroupCall = superState.callInfoState.requireGroupCall()
|
||||
val peekInfo: PeekInfo = groupCall.peekInfo ?: return superState
|
||||
|
||||
val callLinkRoomId: CallLinkRoomId = superState.callInfoState.callRecipient.requireCallLinkRoomId()
|
||||
val callLink: CallLinkTable.CallLink = SignalDatabase.callLinks.getCallLinkByRoomId(callLinkRoomId) ?: return superState
|
||||
|
||||
if (callLink.credentials?.adminPassBytes == null) {
|
||||
Log.i(tag, "User is not an admin.")
|
||||
return superState
|
||||
}
|
||||
|
||||
Log.i(tag, "Updating pending list with ${peekInfo.pendingUsers.size} entries.")
|
||||
val pendingParticipants: List<Recipient> = peekInfo.pendingUsers.map { Recipient.externalPush(ServiceId.ACI.from(it)) }
|
||||
|
||||
return superState.builder()
|
||||
.changeCallInfoState()
|
||||
.setPendingParticipants(pendingParticipants)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun handleSetCallLinkJoinRequestAccepted(currentState: WebRtcServiceState, participant: Recipient): WebRtcServiceState {
|
||||
Log.i(tag, "handleSetCallLinkJoinRequestAccepted():")
|
||||
|
||||
val groupCall: GroupCall = currentState.callInfoState.requireGroupCall()
|
||||
|
||||
return try {
|
||||
groupCall.approveUser(participant.requireAci().rawUuid)
|
||||
|
||||
currentState
|
||||
.builder()
|
||||
.changeCallInfoState()
|
||||
.setPendingParticipantApproved(participant)
|
||||
.build()
|
||||
} catch (e: CallException) {
|
||||
Log.w(tag, "Failed to approve user.", e)
|
||||
|
||||
currentState
|
||||
}
|
||||
}
|
||||
|
||||
override fun handleSetCallLinkJoinRequestRejected(currentState: WebRtcServiceState, participant: Recipient): WebRtcServiceState {
|
||||
Log.i(tag, "handleSetCallLinkJoinRequestRejected():")
|
||||
|
||||
val groupCall: GroupCall = currentState.callInfoState.requireGroupCall()
|
||||
|
||||
return try {
|
||||
groupCall.denyUser(participant.requireAci().rawUuid)
|
||||
|
||||
currentState
|
||||
.builder()
|
||||
.changeCallInfoState()
|
||||
.setPendingParticipantRejected(participant)
|
||||
.build()
|
||||
} catch (e: CallException) {
|
||||
Log.w(tag, "Failed to deny user.", e)
|
||||
currentState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.service.webrtc
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Holds state information about what users wish to join a given call link.
|
||||
*
|
||||
* Also holds information about approvals and denials, so that this state is
|
||||
* persisted over the lifecycle of the user's time in the call.
|
||||
*/
|
||||
data class PendingParticipantCollection(
|
||||
private val participantMap: Map<RecipientId, Entry> = emptyMap(),
|
||||
private val nowProvider: () -> Duration = { System.currentTimeMillis().milliseconds }
|
||||
) {
|
||||
|
||||
/**
|
||||
* Creates a new collection with the given recipients applied to it with the following rules:
|
||||
*
|
||||
* 1. If the recipient is already in the collection, ignore it
|
||||
* 1. Otherwise, insert the recipient at the end of the colleciton in the pending state
|
||||
* 1. Any recipients in the resulting collection that are [State.PENDING] and NOT in the passed recipient list are removed.
|
||||
*/
|
||||
fun withRecipients(recipients: List<Recipient>): PendingParticipantCollection {
|
||||
val now = nowProvider()
|
||||
val newEntries = recipients.filterNot { it.id in participantMap.keys }.map {
|
||||
it.id to Entry(
|
||||
it,
|
||||
State.PENDING,
|
||||
now
|
||||
)
|
||||
}
|
||||
|
||||
val recipientIds = recipients.map { it.id }
|
||||
val newEntryMap = (participantMap + newEntries).filterNot { it.value.state == State.PENDING && it.key !in recipientIds }
|
||||
|
||||
return copy(participantMap = newEntryMap)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new collection with the given recipient marked as [State.APPROVED]
|
||||
*/
|
||||
fun withApproval(recipient: Recipient): PendingParticipantCollection {
|
||||
val now = nowProvider()
|
||||
val entry = Entry(
|
||||
recipient = recipient,
|
||||
state = State.APPROVED,
|
||||
stateChangeAt = now
|
||||
)
|
||||
|
||||
return copy(
|
||||
participantMap = participantMap + (recipient.id to entry)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new collection with the given recipient marked as [State.DENIED]
|
||||
*/
|
||||
fun withDenial(recipient: Recipient): PendingParticipantCollection {
|
||||
val now = nowProvider()
|
||||
val entry = Entry(
|
||||
recipient = recipient,
|
||||
state = State.DENIED,
|
||||
stateChangeAt = now
|
||||
)
|
||||
|
||||
return copy(
|
||||
participantMap = participantMap + (recipient.id to entry)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all of the pending participants in the [State.PENDING] state.
|
||||
*/
|
||||
fun getUnresolvedPendingParticipants(): Set<Entry> {
|
||||
return participantMap.values.filter { it.state == State.PENDING }.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all of the pending participants regardless of state. Filterable
|
||||
* via a 'since' parameter so that we only display non-[State.PENDING] entries with
|
||||
* state change timestamps AFTER that parameter. [State.PENDING] entries will always
|
||||
* be returned.
|
||||
*
|
||||
* @param since A timestamp, for which we will only return non-[State.PENDING] occurring afterwards.
|
||||
*/
|
||||
fun getAllPendingParticipants(since: Duration): Set<Entry> {
|
||||
return participantMap.values.filter {
|
||||
it.state == State.PENDING || it.stateChangeAt > since
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* A [Recipient] and some [State] metadata
|
||||
*/
|
||||
data class Entry(
|
||||
val recipient: Recipient,
|
||||
val state: State,
|
||||
val stateChangeAt: Duration
|
||||
)
|
||||
|
||||
/**
|
||||
* The state of a given recipient's approval
|
||||
*/
|
||||
enum class State {
|
||||
/**
|
||||
* No action has been taken
|
||||
*/
|
||||
PENDING,
|
||||
|
||||
/**
|
||||
* The user has approved this recipient
|
||||
*/
|
||||
APPROVED,
|
||||
|
||||
/**
|
||||
* The user has denied this recipient
|
||||
*/
|
||||
DENIED
|
||||
}
|
||||
}
|
||||
@@ -354,6 +354,22 @@ private void processStateless(@NonNull Function1<WebRtcEphemeralState, WebRtcEph
|
||||
process((s, p) -> p.handleDropCall(s, callId));
|
||||
}
|
||||
|
||||
public void setCallLinkJoinRequestAccepted(@NonNull Recipient participant) {
|
||||
process((s, p) -> p.handleSetCallLinkJoinRequestAccepted(s, participant));
|
||||
}
|
||||
|
||||
public void setCallLinkJoinRequestRejected(@NonNull Recipient participant) {
|
||||
process((s, p) -> p.handleSetCallLinkJoinRequestRejected(s, participant));
|
||||
}
|
||||
|
||||
public void removeFromCallLink(@NonNull Recipient participant) {
|
||||
process((s, p) -> p.handleRemoveFromCallLink(s, participant));
|
||||
}
|
||||
|
||||
public void blockFromCallLink(@NonNull Recipient participant) {
|
||||
process((s, p) -> p.handleBlockFromCallLink(s, participant));
|
||||
}
|
||||
|
||||
public void peekCallLinkCall(@NonNull RecipientId id) {
|
||||
if (callManager == null) {
|
||||
Log.i(TAG, "Unable to peekCallLinkCall, call manager is null");
|
||||
|
||||
@@ -874,4 +874,32 @@ public abstract class WebRtcActionProcessor {
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Call Links
|
||||
|
||||
protected @NonNull WebRtcServiceState handleSetCallLinkJoinRequestAccepted(@NonNull WebRtcServiceState currentState, @NonNull Recipient participant) {
|
||||
Log.i(tag, "handleSetCallLinkJoinRequestAccepted not processed");
|
||||
|
||||
return currentState;
|
||||
}
|
||||
|
||||
protected @NonNull WebRtcServiceState handleSetCallLinkJoinRequestRejected(@NonNull WebRtcServiceState currentState, @NonNull Recipient participant) {
|
||||
Log.i(tag, "handleSetCallLinkJoinRequestRejected not processed");
|
||||
|
||||
return currentState;
|
||||
}
|
||||
|
||||
protected @NonNull WebRtcServiceState handleRemoveFromCallLink(@NonNull WebRtcServiceState currentState, @NonNull Recipient participant) {
|
||||
Log.i(tag, "handleRemoveFromCallLink not processed");
|
||||
|
||||
return currentState;
|
||||
}
|
||||
|
||||
protected @NonNull WebRtcServiceState handleBlockFromCallLink(@NonNull WebRtcServiceState currentState, @NonNull Recipient participant) {
|
||||
Log.i(tag, "handleBlockFromCallLink not processed");
|
||||
|
||||
return currentState;
|
||||
}
|
||||
|
||||
//endregion
|
||||
}
|
||||
|
||||
@@ -9,9 +9,12 @@ import org.thoughtcrime.securesms.events.WebRtcViewModel
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.ringrtc.RemotePeer
|
||||
import org.thoughtcrime.securesms.service.webrtc.PendingParticipantCollection
|
||||
|
||||
/**
|
||||
* General state of ongoing calls.
|
||||
*
|
||||
* @param pendingParticipants A list of pending users wishing to join a given call link.
|
||||
*/
|
||||
data class CallInfoState(
|
||||
var callState: WebRtcViewModel.State = WebRtcViewModel.State.IDLE,
|
||||
@@ -24,7 +27,8 @@ data class CallInfoState(
|
||||
@get:JvmName("getGroupCallState") var groupState: WebRtcViewModel.GroupCallState = WebRtcViewModel.GroupCallState.IDLE,
|
||||
var identityChangedRecipients: MutableSet<RecipientId> = mutableSetOf(),
|
||||
var remoteDevicesCount: OptionalLong = OptionalLong.empty(),
|
||||
var participantLimit: Long? = null
|
||||
var participantLimit: Long? = null,
|
||||
var pendingParticipants: PendingParticipantCollection = PendingParticipantCollection()
|
||||
) {
|
||||
|
||||
val remoteCallParticipants: List<CallParticipant>
|
||||
|
||||
@@ -23,6 +23,7 @@ import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
|
||||
import org.webrtc.PeerConnection;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
@@ -342,5 +343,20 @@ public class WebRtcServiceStateBuilder {
|
||||
toBuild.setParticipantLimit(participantLimit);
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull CallInfoStateBuilder setPendingParticipants(@NonNull List<Recipient> pendingParticipants) {
|
||||
toBuild.setPendingParticipants(toBuild.getPendingParticipants().withRecipients(pendingParticipants));
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull CallInfoStateBuilder setPendingParticipantApproved(@NonNull Recipient participant) {
|
||||
toBuild.setPendingParticipants(toBuild.getPendingParticipants().withApproval(participant));
|
||||
return this;
|
||||
}
|
||||
|
||||
public @NonNull CallInfoStateBuilder setPendingParticipantRejected(@NonNull Recipient participant) {
|
||||
toBuild.setPendingParticipants(toBuild.getPendingParticipants().withDenial(participant));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="28dp"
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="28dp"
|
||||
android:height="28dp"
|
||||
android:viewportWidth="28"
|
||||
android:viewportHeight="28">
|
||||
|
||||
9
app/src/main/res/drawable/symbol_check_bold_24.xml
Normal file
9
app/src/main/res/drawable/symbol_check_bold_24.xml
Normal 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="M19.04 5.16c0.46 0.3 0.6 0.91 0.3 1.38l-8.32 13c-0.17 0.27-0.47 0.44-0.8 0.46C9.9 20 9.6 19.87 9.4 19.62l-4.68-5.98c-0.34-0.44-0.26-1.07 0.17-1.4 0.44-0.35 1.07-0.27 1.4 0.16l3.82 4.87 7.56-11.8c0.3-0.47 0.91-0.61 1.38-0.31Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="16"
|
||||
android:viewportHeight="16">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M13.15 2.87c0.35 0.22 0.46 0.68 0.23 1.03l-6 9.5c-0.13 0.2-0.35 0.34-0.6 0.35-0.24 0.01-0.47-0.1-0.62-0.29l-3.5-4.5c-0.26-0.33-0.2-0.8 0.13-1.05 0.33-0.26 0.8-0.2 1.05 0.13L6.7 11.7l5.43-8.6c0.22-0.35 0.68-0.46 1.03-0.23Z"/>
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/symbol_x_bold_24.xml
Normal file
9
app/src/main/res/drawable/symbol_x_bold_24.xml
Normal 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="M18.7 6.7c0.4-0.38 0.4-1.02 0-1.4-0.38-0.4-1.02-0.4-1.4 0L12 10.58l-5.3-5.3C6.33 4.9 5.69 4.9 5.3 5.3c-0.4 0.4-0.4 1.03 0 1.42L10.58 12l-5.3 5.3c-0.39 0.38-0.39 1.02 0 1.4 0.4 0.4 1.03 0.4 1.42 0L12 13.42l5.3 5.3c0.38 0.39 1.02 0.39 1.4 0 0.4-0.4 0.4-1.03 0-1.42L13.42 12l5.3-5.3Z"/>
|
||||
</vector>
|
||||
9
app/src/main/res/drawable/symbol_x_compact_bold_16.xml
Normal file
9
app/src/main/res/drawable/symbol_x_compact_bold_16.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="16"
|
||||
android:viewportHeight="16">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M4.53 3.47c-0.3-0.3-0.77-0.3-1.06 0-0.3 0.3-0.3 0.77 0 1.06L6.94 8l-3.47 3.47c-0.3 0.3-0.3 0.77 0 1.06 0.3 0.3 0.77 0.3 1.06 0L8 9.06l3.47 3.47c0.3 0.3 0.77 0.3 1.06 0 0.3-0.3 0.3-0.77 0-1.06L9.06 8l3.47-3.47c0.3-0.3 0.3-0.77 0-1.06-0.3-0.3-0.77-0.3-1.06 0L8 6.94 4.53 3.47Z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright 2023 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<org.thoughtcrime.securesms.components.webrtc.PendingParticipantsView 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="wrap_content"
|
||||
android:layout_marginHorizontal="24dp"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:minHeight="72dp"
|
||||
app:cardBackgroundColor="@color/signal_colorSurface3"
|
||||
app:cardCornerRadius="18dp"
|
||||
app:cardElevation="0dp" />
|
||||
130
app/src/main/res/layout/pending_participant_view.xml
Normal file
130
app/src/main/res/layout/pending_participant_view.xml
Normal file
@@ -0,0 +1,130 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright 2023 Signal Messenger, LLC
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only
|
||||
-->
|
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="72dp"
|
||||
app:cardBackgroundColor="@color/signal_colorSurface3"
|
||||
app:cardCornerRadius="18dp"
|
||||
app:cardElevation="0dp"
|
||||
tools:layout_marginHorizontal="24dp"
|
||||
tools:parentTag="com.google.android.material.card.MaterialCardView">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="72dp">
|
||||
|
||||
<org.thoughtcrime.securesms.components.AvatarImageView
|
||||
android:id="@+id/pending_participants_avatar"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginTop="12dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
tools:background="@color/red_100" />
|
||||
|
||||
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
|
||||
android:id="@+id/pending_participants_name"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:textAppearance="@style/Signal.Text.TitleSmall"
|
||||
android:textColor="@color/signal_colorOnSurface"
|
||||
app:layout_constraintBottom_toTopOf="@id/pending_participants_status"
|
||||
app:layout_constraintStart_toEndOf="@id/pending_participants_avatar"
|
||||
app:layout_constraintTop_toTopOf="@id/pending_participants_avatar"
|
||||
app:layout_constraintVertical_chainStyle="packed"
|
||||
tools:text="Candice" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/pending_participants_status"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="@string/PendingParticipantsView__would_like_to_join"
|
||||
android:textAppearance="@style/Signal.Text.BodyMedium"
|
||||
android:textColor="@color/signal_colorOnSurfaceVariant"
|
||||
app:layout_constraintBottom_toBottomOf="@id/pending_participants_avatar"
|
||||
app:layout_constraintStart_toEndOf="@id/pending_participants_avatar"
|
||||
app:layout_constraintTop_toBottomOf="@id/pending_participants_name" />
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/pending_participants_reject"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:background="@color/webrtc_hangup_background"
|
||||
android:padding="6dp"
|
||||
android:tint="@color/core_white"
|
||||
app:contentPadding="8dp"
|
||||
app:layout_constraintEnd_toStartOf="@id/pending_participants_allow"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
|
||||
app:srcCompat="@drawable/symbol_x_24" />
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/pending_participants_allow"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:background="@color/signal_accent_green"
|
||||
android:padding="6dp"
|
||||
android:tint="@color/core_white"
|
||||
app:contentPadding="8dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
|
||||
app:srcCompat="@drawable/symbol_check_24" />
|
||||
|
||||
<androidx.constraintlayout.widget.Barrier
|
||||
android:id="@+id/pending_participants_barrier"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:barrierDirection="bottom"
|
||||
app:barrierMargin="12dp"
|
||||
app:constraint_referenced_ids="pending_participants_avatar,pending_participants_status,pending_participants_allow,pending_participants_reject" />
|
||||
|
||||
<View
|
||||
android:id="@+id/pending_participants_requests_backdrop"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:background="@color/signal_colorSurface5"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/pending_participants_barrier" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/pending_participants_requests"
|
||||
style="@style/Signal.Widget.Button.Small.Secondary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="8dp"
|
||||
android:textColor="@color/signal_colorOnSecondaryContainer"
|
||||
app:backgroundTint="@color/signal_colorSecondaryContainer"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/pending_participants_barrier"
|
||||
tools:text="+5 requests" />
|
||||
|
||||
<androidx.constraintlayout.widget.Group
|
||||
android:id="@+id/pending_participants_requests_group"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone"
|
||||
app:constraint_referenced_ids="pending_participants_requests,pending_participants_requests_backdrop"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</merge>
|
||||
@@ -474,6 +474,18 @@
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<ViewStub
|
||||
android:id="@+id/call_screen_pending_recipients"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="24dp"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:inflatedId="@+id/call_screen_pending_recipients_view"
|
||||
android:layout="@layout/call_screen_pending_participants_view"
|
||||
app:layout_constraintBottom_toTopOf="@id/call_screen_footer_gradient_barrier"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent" />
|
||||
|
||||
<androidx.constraintlayout.widget.Barrier
|
||||
android:id="@+id/call_screen_footer_gradient_barrier"
|
||||
android:layout_width="wrap_content"
|
||||
|
||||
@@ -6193,6 +6193,27 @@
|
||||
<!-- Body of a dialog that is displayed when we experienced a network error when looking up a username. -->
|
||||
<string name="UsernameLinkSettings_qr_result_network_error">Experienced a network error. Please try again.</string>
|
||||
|
||||
<!-- PendingParticipantsView -->
|
||||
<!-- Displayed in the popup card when a remote user attempts to join a call link -->
|
||||
<string name="PendingParticipantsView__would_like_to_join">Would like to join…</string>
|
||||
<!-- Displayed in a button on the popup card denoting that there are other pending requests to join a call link -->
|
||||
<plurals name="PendingParticipantsView__plus_d_requests">
|
||||
<item quantity="one">+%1$d request</item>
|
||||
<item quantity="other">+%1$d requests</item>
|
||||
</plurals>
|
||||
|
||||
<!-- PendingParticipantsBottomSheet -->
|
||||
<!-- Title of the bottom sheet displaying requests to join the call link -->
|
||||
<string name="PendingParticipantsBottomSheet__requests_to_join_this_call">Requests to join this call</string>
|
||||
<!-- Subtitle of the bottom sheet denoting the total number of people waiting -->
|
||||
<plurals name="PendingParticipantsBottomSheet__d_people_waiting">
|
||||
<item quantity="one">%1$d person waiting</item>
|
||||
<item quantity="other">%1$d people waiting</item>
|
||||
</plurals>
|
||||
<!-- Content description for rejecting a user -->
|
||||
<string name="PendingParticipantsBottomSheet__reject">Reject</string>
|
||||
<!-- Content desccription for confirming a user -->
|
||||
<string name="PendingParticipantsBottomSheet__approve">Approve</string>
|
||||
|
||||
<!-- EOF -->
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.service.webrtc
|
||||
|
||||
import android.app.Application
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.thoughtcrime.securesms.database.RecipientDatabaseTestUtils
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(application = Application::class)
|
||||
class PendingParticipantCollectionTest {
|
||||
|
||||
private val fakeNowProvider = FakeNowProvider()
|
||||
private val testSubject = PendingParticipantCollection(
|
||||
participantMap = emptyMap(),
|
||||
nowProvider = fakeNowProvider
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `Given an empty collection, when I getUnresolvedPendingParticipants, then I expect an empty set`() {
|
||||
val expected = emptySet<PendingParticipantCollection.Entry>()
|
||||
val actual = testSubject.getUnresolvedPendingParticipants()
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given an empty collection, when I getAllPendingParticipants, then I expect an empty set`() {
|
||||
val expected = emptySet<PendingParticipantCollection.Entry>()
|
||||
val actual = testSubject.getAllPendingParticipants(0.milliseconds)
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given an empty collection, when I withRecipients, then I expect all recipients to be added`() {
|
||||
val recipients = createRecipients(10)
|
||||
val expected = (0 until 10)
|
||||
.map {
|
||||
PendingParticipantCollection.Entry(
|
||||
recipient = recipients[it],
|
||||
state = PendingParticipantCollection.State.PENDING,
|
||||
stateChangeAt = 0.milliseconds
|
||||
)
|
||||
}
|
||||
.toSet()
|
||||
|
||||
val actual = testSubject.withRecipients(recipients).getUnresolvedPendingParticipants()
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a collection with 10 PENDING entries, when I getAllPendingParticipants, then I expect all entries`() {
|
||||
val recipients = createRecipients(10)
|
||||
val expected = (0 until 10)
|
||||
.map {
|
||||
PendingParticipantCollection.Entry(
|
||||
recipient = recipients[it],
|
||||
state = PendingParticipantCollection.State.PENDING,
|
||||
stateChangeAt = 0.milliseconds
|
||||
)
|
||||
}
|
||||
.toSet()
|
||||
|
||||
val actual = testSubject.withRecipients(recipients).getAllPendingParticipants(0.milliseconds)
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a collection with 10 PENDING entries and duration in the future, when I getAllPendingParticipants, then I expect all entries`() {
|
||||
val recipients = createRecipients(10)
|
||||
val expected = (0 until 10)
|
||||
.map {
|
||||
PendingParticipantCollection.Entry(
|
||||
recipient = recipients[it],
|
||||
state = PendingParticipantCollection.State.PENDING,
|
||||
stateChangeAt = 0.milliseconds
|
||||
)
|
||||
}
|
||||
.toSet()
|
||||
|
||||
val actual = testSubject.withRecipients(recipients).getAllPendingParticipants(1.milliseconds)
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a collection with 10 PENDING entries and duration in the past, when I getAllPendingParticipants, then I expect all entries`() {
|
||||
fakeNowProvider.invoke()
|
||||
val recipients = createRecipients(10)
|
||||
val expected = (0 until 10)
|
||||
.map {
|
||||
PendingParticipantCollection.Entry(
|
||||
recipient = recipients[it],
|
||||
state = PendingParticipantCollection.State.PENDING,
|
||||
stateChangeAt = 1.milliseconds
|
||||
)
|
||||
}
|
||||
.toSet()
|
||||
|
||||
val actual = testSubject.withRecipients(recipients).getAllPendingParticipants(0.milliseconds)
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given an approved recipient with a state change after since, when I getAllPendingParticipants, then I expect approved recipient`() {
|
||||
val recipients = createRecipients(2)
|
||||
val subject = testSubject.withRecipients(recipients).withApproval(recipients[0])
|
||||
val expected = PendingParticipantCollection.Entry(
|
||||
recipient = recipients[0],
|
||||
state = PendingParticipantCollection.State.APPROVED,
|
||||
stateChangeAt = 1.milliseconds
|
||||
)
|
||||
val actual = subject.getAllPendingParticipants(0.milliseconds).first()
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given an approved recipient with a state change before since, when I getAllPendingParticipants, then I do not expect the approved recipient`() {
|
||||
val recipients = createRecipients(2)
|
||||
val subject = testSubject.withRecipients(recipients).withApproval(recipients[0])
|
||||
val expected = PendingParticipantCollection.Entry(
|
||||
recipient = recipients[1],
|
||||
state = PendingParticipantCollection.State.PENDING,
|
||||
stateChangeAt = 0.milliseconds
|
||||
)
|
||||
val actual = subject.getAllPendingParticipants(2.milliseconds).first()
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given an approved recipient, when I getUnresolvedPendingParticipants, then I do not expect the approved recipient`() {
|
||||
val recipients = createRecipients(2)
|
||||
val subject = testSubject.withRecipients(recipients).withApproval(recipients[0])
|
||||
val expected = PendingParticipantCollection.Entry(
|
||||
recipient = recipients[1],
|
||||
state = PendingParticipantCollection.State.PENDING,
|
||||
stateChangeAt = 0.milliseconds
|
||||
)
|
||||
val actual = subject.getUnresolvedPendingParticipants().first()
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a denied recipient with a state change after since, when I getAllPendingParticipants, then I expect denied recipient`() {
|
||||
val recipients = createRecipients(2)
|
||||
val subject = testSubject.withRecipients(recipients).withDenial(recipients[0])
|
||||
val expected = PendingParticipantCollection.Entry(
|
||||
recipient = recipients[0],
|
||||
state = PendingParticipantCollection.State.DENIED,
|
||||
stateChangeAt = 1.milliseconds
|
||||
)
|
||||
val actual = subject.getAllPendingParticipants(0.milliseconds).first()
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a denied recipient with a state change before since, when I getAllPendingParticipants, then I do not expect the denied recipient`() {
|
||||
val recipients = createRecipients(2)
|
||||
val subject = testSubject.withRecipients(recipients).withDenial(recipients[0])
|
||||
val expected = PendingParticipantCollection.Entry(
|
||||
recipient = recipients[1],
|
||||
state = PendingParticipantCollection.State.PENDING,
|
||||
stateChangeAt = 0.milliseconds
|
||||
)
|
||||
val actual = subject.getAllPendingParticipants(2.milliseconds).first()
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a denied recipient, when I getUnresolvedPendingParticipants, then I do not expect the denied recipient`() {
|
||||
val recipients = createRecipients(2)
|
||||
val subject = testSubject.withRecipients(recipients).withDenial(recipients[0])
|
||||
val expected = PendingParticipantCollection.Entry(
|
||||
recipient = recipients[1],
|
||||
state = PendingParticipantCollection.State.PENDING,
|
||||
stateChangeAt = 0.milliseconds
|
||||
)
|
||||
val actual = subject.getUnresolvedPendingParticipants().first()
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a list of PENDING, when I withRecipients with empty list, then I clear the collection`() {
|
||||
val recipients = createRecipients(10)
|
||||
val subject = testSubject.withRecipients(recipients).withRecipients(emptyList())
|
||||
val expected = emptySet<PendingParticipantCollection.Entry>()
|
||||
val actual = subject.getUnresolvedPendingParticipants()
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Given a mixed list, when I withRecipients with empty list, then I clear the PENDINGs`() {
|
||||
val recipients = createRecipients(2)
|
||||
val subject = testSubject.withRecipients(recipients).withApproval(recipients[0]).withRecipients(emptyList())
|
||||
val expected = setOf(
|
||||
PendingParticipantCollection.Entry(
|
||||
recipient = recipients[0],
|
||||
state = PendingParticipantCollection.State.APPROVED,
|
||||
stateChangeAt = 1.milliseconds
|
||||
)
|
||||
)
|
||||
val actual = subject.getAllPendingParticipants(0.milliseconds)
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
|
||||
private fun createRecipients(count: Int): List<Recipient> {
|
||||
return (1..count).map { RecipientDatabaseTestUtils.createRecipient() }
|
||||
}
|
||||
|
||||
private class FakeNowProvider : () -> Duration {
|
||||
|
||||
private val nowIterator = (0 until 500).iterator()
|
||||
|
||||
override fun invoke(): Duration {
|
||||
return nowIterator.next().milliseconds
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user