From 30d0b6fd0e0616f4a5ac6e44f6e58a3e714fa946 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 8 Aug 2023 15:10:47 -0300 Subject: [PATCH] Add additional call links moderation ui. --- .../securesms/WebRtcCallActivity.java | 92 +++++++++++++++--- .../securesms/calls/links/SignalCallRow.kt | 16 +++- .../components/webrtc/CallLinkInfoSheet.kt | 71 ++++++++++---- .../webrtc/CallParticipantsState.kt | 11 ++- .../components/webrtc/InCallStatus.kt | 22 +++++ .../webrtc/PendingParticipantsBottomSheet.kt | 93 +++++++++++++++---- .../components/webrtc/WebRtcCallView.java | 5 + .../webrtc/WebRtcCallViewModel.java | 26 +++++- .../securesms/events/CallParticipantId.java | 36 ++++++- .../securesms/events/WebRtcViewModel.kt | 1 + .../CallLinkConnectedActionProcessor.kt | 44 +++++++-- .../webrtc/PendingParticipantCollection.kt | 53 +++++++---- .../service/webrtc/SignalCallManager.java | 9 +- .../service/webrtc/WebRtcActionProcessor.java | 8 +- .../securesms/util/CommunicationActions.java | 23 ++--- .../webrtc/CallParticipantsViewState.kt | 3 +- .../call_screen_call_link_warning_card.xml | 25 +++++ app/src/main/res/layout/webrtc_call_view.xml | 12 +++ app/src/main/res/values/strings.xml | 44 +++++++++ .../PendingParticipantCollectionTest.kt | 42 ++++++++- 20 files changed, 531 insertions(+), 105 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/webrtc/InCallStatus.kt create mode 100644 app/src/main/res/layout/call_screen_call_link_warning_card.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java index c5a7436be0..faf00dbb17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java @@ -62,6 +62,7 @@ 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.InCallStatus; import org.thoughtcrime.securesms.components.webrtc.PendingParticipantsBottomSheet; import org.thoughtcrime.securesms.components.webrtc.PendingParticipantsView; import org.thoughtcrime.securesms.components.webrtc.WebRtcAudioDevice; @@ -98,6 +99,7 @@ import org.whispersystems.signalservice.api.messages.calls.HangupMessage; import java.util.List; import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.Disposable; @@ -114,7 +116,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan /** * ANSWER the call via voice-only. */ - public static final String ANSWER_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".ANSWER_ACTION"; + public static final String ANSWER_ACTION = WebRtcCallActivity.class.getCanonicalName() + ".ANSWER_ACTION"; /** * ANSWER the call via video. @@ -125,6 +127,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan public static final String EXTRA_ENABLE_VIDEO_IF_AVAILABLE = WebRtcCallActivity.class.getCanonicalName() + ".ENABLE_VIDEO_IF_AVAILABLE"; public static final String EXTRA_STARTED_FROM_FULLSCREEN = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_FULLSCREEN"; + public static final String EXTRA_STARTED_FROM_CALL_LINK = WebRtcCallActivity.class.getCanonicalName() + ".STARTED_FROM_CALL_LINK"; private CallParticipantsListUpdatePopupWindow participantUpdateWindow; private CallStateUpdatePopupWindow callStateUpdatePopupWindow; @@ -198,6 +201,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan windowInfoTrackerCallbackAdapter.addWindowLayoutInfoListener(this, SignalExecutors.BOUNDED, windowLayoutInfoConsumer); requestNewSizesThrottle = new ThrottledDebouncer(TimeUnit.SECONDS.toMillis(1)); + + initializePendingParticipantFragmentListener(); } @Override @@ -344,6 +349,54 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan } } + private void initializePendingParticipantFragmentListener() { + if (!FeatureFlags.adHocCalling()) { + return; + } + + getSupportFragmentManager().setFragmentResultListener( + PendingParticipantsBottomSheet.REQUEST_KEY, + this, + (requestKey, result) -> { + PendingParticipantsBottomSheet.Action action = PendingParticipantsBottomSheet.getAction(result); + List recipientIds = viewModel.getPendingParticipantsSnapshot() + .getUnresolvedPendingParticipants() + .stream() + .map(r -> r.getRecipient().getId()) + .collect(Collectors.toList()); + + switch (action) { + case NONE: + break; + case APPROVE_ALL: + new MaterialAlertDialogBuilder(this) + .setTitle(getResources().getQuantityString(R.plurals.WebRtcCallActivity__approve_d_requests, recipientIds.size(), recipientIds.size())) + .setMessage(getResources().getQuantityString(R.plurals.WebRtcCallActivity__d_people_will_be_added_to_the_call, recipientIds.size(), recipientIds.size())) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.WebRtcCallActivity__approve_all, (dialog, which) -> { + for (RecipientId id : recipientIds) { + ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestAccepted(id); + } + }) + .show(); + break; + case DENY_ALL: + new MaterialAlertDialogBuilder(this) + .setTitle(getResources().getQuantityString(R.plurals.WebRtcCallActivity__deny_d_requests, recipientIds.size(), recipientIds.size())) + .setMessage(getResources().getQuantityString(R.plurals.WebRtcCallActivity__d_people_will_be_added_to_the_call, recipientIds.size(), recipientIds.size())) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.WebRtcCallActivity__deny_all, (dialog, which) -> { + for (RecipientId id : recipientIds) { + ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestRejected(id); + } + }) + .show(); + break; + } + } + ); + } + private void initializeScreenshotSecurity() { if (TextSecurePreferences.isScreenSecurityEnabled(this)) { getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); @@ -373,12 +426,14 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan viewModel.getMicrophoneEnabled().observe(this, callScreen::setMicEnabled); viewModel.getWebRtcControls().observe(this, callScreen::setWebRtcControls); viewModel.getEvents().observe(this, this::handleViewModelEvent); - viewModel.getCallTime().observe(this, this::handleCallTime); + lifecycleDisposable.add(viewModel.getInCallstatus().subscribe(this::handleInCallStatus)); + + boolean isStartedFromCallLink = getIntent().getBooleanExtra(WebRtcCallActivity.EXTRA_STARTED_FROM_CALL_LINK, false); LiveDataUtil.combineLatest(viewModel.getCallParticipantsState(), viewModel.getOrientationAndLandscapeEnabled(), viewModel.getEphemeralState(), - (s, o, e) -> new CallParticipantsViewState(s, e, o.first == PORTRAIT_BOTTOM_EDGE, o.second)) + (s, o, e) -> new CallParticipantsViewState(s, e, o.first == PORTRAIT_BOTTOM_EDGE, o.second, isStartedFromCallLink)) .observe(this, p -> callScreen.updateCallParticipants(p)); viewModel.getCallParticipantListUpdate().observe(this, participantUpdateWindow::addCallParticipantListUpdate); viewModel.getSafetyNumberChangeEvent().observe(this, this::handleSafetyNumberChangeEvent); @@ -474,14 +529,27 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan } } - private void handleCallTime(long callTime) { - EllapsedTimeFormatter ellapsedTimeFormatter = EllapsedTimeFormatter.fromDurationMillis(callTime); + private void handleInCallStatus(@NonNull InCallStatus inCallStatus) { + if (inCallStatus instanceof InCallStatus.ElapsedTime) { - if (ellapsedTimeFormatter == null) { - return; + EllapsedTimeFormatter ellapsedTimeFormatter = EllapsedTimeFormatter.fromDurationMillis(((InCallStatus.ElapsedTime) inCallStatus).getElapsedTime()); + + if (ellapsedTimeFormatter == null) { + return; + } + + callScreen.setStatus(getString(R.string.WebRtcCallActivity__signal_s, ellapsedTimeFormatter.toString())); + } else if (inCallStatus instanceof InCallStatus.PendingUsers) { + int waiting = ((InCallStatus.PendingUsers) inCallStatus).getPendingUserCount(); + + callScreen.setStatus(getResources().getQuantityString( + R.plurals.WebRtcCallActivity__d_people_waiting, + waiting, + waiting + )); + } else { + throw new AssertionError(); } - - callScreen.setStatus(getString(R.string.WebRtcCallActivity__signal_s, ellapsedTimeFormatter.toString())); } private void handleSetAudioHandset() { @@ -702,7 +770,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan } @Override - public void onMessageResentAfterSafetyNumberChange() { } + public void onMessageResentAfterSafetyNumberChange() {} @Override public void onCanceled() { @@ -968,12 +1036,12 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan @Override public void onAllowPendingRecipient(@NonNull Recipient pendingRecipient) { - ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestAccepted(pendingRecipient); + ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestAccepted(pendingRecipient.getId()); } @Override public void onRejectPendingRecipient(@NonNull Recipient pendingRecipient) { - ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestRejected(pendingRecipient); + ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestRejected(pendingRecipient.getId()); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/SignalCallRow.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/SignalCallRow.kt index d844098384..7f9d620a5a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/SignalCallRow.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/SignalCallRow.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource @@ -35,7 +36,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import org.signal.core.ui.Buttons import org.signal.core.ui.theme.SignalTheme -import org.signal.ringrtc.CallLinkRootKey import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.conversation.colors.AvatarColorPair import org.thoughtcrime.securesms.database.CallLinkTable @@ -49,10 +49,10 @@ import java.time.Instant @Composable private fun SignalCallRowPreview() { val callLink = remember { - val credentials = CallLinkCredentials.generate() + val credentials = CallLinkCredentials(byteArrayOf(1, 2, 3, 4), byteArrayOf(5, 6, 7, 8)) CallLinkTable.CallLink( recipientId = RecipientId.UNKNOWN, - roomId = CallLinkRoomId.fromCallLinkRootKey(CallLinkRootKey(credentials.linkKeyBytes)), + roomId = CallLinkRoomId.fromBytes(byteArrayOf(1, 3, 5, 7)), credentials = credentials, state = SignalCallLinkState( name = "Call Name", @@ -76,6 +76,14 @@ fun SignalCallRow( onJoinClicked: (() -> Unit)?, modifier: Modifier = Modifier ) { + val callUrl = if (LocalInspectionMode.current) { + "https://signal.call.example.com" + } else { + remember(callLink.credentials) { + callLink.credentials?.let { CallLinks.url(it.linkKeyBytes) } ?: "" + } + } + Row( modifier = modifier .fillMaxWidth() @@ -113,7 +121,7 @@ fun SignalCallRow( text = callLink.state.name.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) } ) Text( - text = callLink.credentials?.let { CallLinks.url(it.linkKeyBytes) } ?: "", + text = callUrl, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallLinkInfoSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallLinkInfoSheet.kt index 12eae1757a..df114964fd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallLinkInfoSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallLinkInfoSheet.kt @@ -32,8 +32,10 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember 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.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview @@ -45,6 +47,7 @@ import androidx.core.os.bundleOf import androidx.fragment.app.FragmentManager import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels +import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.kotlin.subscribeBy import kotlinx.collections.immutable.ImmutableList @@ -66,6 +69,8 @@ import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsViewModel import org.thoughtcrime.securesms.components.AvatarImageView import org.thoughtcrime.securesms.compose.ComposeBottomSheetDialogFragment import org.thoughtcrime.securesms.database.CallLinkTable +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.events.CallParticipant import org.thoughtcrime.securesms.events.WebRtcViewModel import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId @@ -115,8 +120,8 @@ class CallLinkInfoSheet : ComposeBottomSheetDialogFragment() { override fun SheetContent() { val callLinkDetailsState by callLinkDetailsViewModel.state val callParticipantsState by webRtcCallViewModel.callParticipantsState.observeAsState() - val participants = if (callParticipantsState?.callState == WebRtcViewModel.State.CALL_CONNECTED) { - listOf(Recipient.self()) + (callParticipantsState?.allRemoteParticipants?.map { it.recipient } ?: emptyList()) + val participants: ImmutableList = if (callParticipantsState?.callState == WebRtcViewModel.State.CALL_CONNECTED) { + listOf(CallParticipant(recipient = Recipient.self())) + (callParticipantsState?.allRemoteParticipants?.map { it } ?: emptyList()) } else { emptyList() }.toImmutableList() @@ -137,11 +142,24 @@ class CallLinkInfoSheet : ComposeBottomSheetDialogFragment() { onShareLinkClicked = this::shareLink, onEditNameClicked = onEditNameClicked, onToggleAdminApprovalClicked = this::onApproveAllMembersChanged, - onBlock = {} // TODO [alex] -- Blocking + onBlock = this::onBlockParticipant ) } } + private fun onBlockParticipant(callParticipant: CallParticipant) { + MaterialAlertDialogBuilder(requireContext()) + .setNegativeButton(android.R.string.cancel, null) + .setMessage(getString(R.string.CallLinkInfoSheet__remove_s_from_the_call, callParticipant.recipient.getShortDisplayName(requireContext()))) + .setPositiveButton(R.string.CallLinkInfoSheet__remove) { _, _ -> + ApplicationDependencies.getSignalCallManager().removeFromCallLink(callParticipant) + } + .setNeutralButton(R.string.CallLinkInfoSheet__block_from_call) { _, _ -> + ApplicationDependencies.getSignalCallManager().blockFromCallLink(callParticipant) + } + .show() + } + private fun onApproveAllMembersChanged(checked: Boolean) { callLinkDetailsViewModel.setApproveAllMembers(checked) .observeOn(AndroidSchedulers.mainThread()) @@ -210,7 +228,7 @@ private fun SheetPreview() { ), state = SignalCallLinkState() ), - participants = listOf(Recipient.UNKNOWN).toImmutableList(), + participants = listOf(CallParticipant(recipient = Recipient.UNKNOWN)).toImmutableList(), onShareLinkClicked = {}, onEditNameClicked = {}, onToggleAdminApprovalClicked = {}, @@ -223,17 +241,24 @@ private fun SheetPreview() { @Composable private fun Sheet( callLink: CallLinkTable.CallLink, - participants: ImmutableList, + participants: ImmutableList, onShareLinkClicked: () -> Unit, onEditNameClicked: () -> Unit, onToggleAdminApprovalClicked: (Boolean) -> Unit, - onBlock: (Recipient) -> Unit + onBlock: (CallParticipant) -> Unit ) { LazyColumn( horizontalAlignment = Alignment.CenterHorizontally ) { item { BottomSheets.Handle() + + Text( + text = stringResource(id = R.string.CallLinkInfoSheet__call_info), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(vertical = 24.dp) + ) + SignalCallRow(callLink = callLink, onJoinClicked = null) Rows.TextRow( text = stringResource(id = R.string.CallLinkDetailsFragment__share_link), @@ -251,9 +276,9 @@ private fun Sheet( ) } - items(participants, { it.id }, { null }) { + items(participants, { it.callParticipantId }, { null }) { CallLinkMemberRow( - recipient = it, + callParticipant = it, isSelfAdmin = callLink.credentials?.adminPassBytes != null, onBlockClicked = onBlock ) @@ -282,7 +307,7 @@ private fun CallLinkMemberRowPreview() { SignalTheme(isDarkMode = true) { Surface { CallLinkMemberRow( - Recipient.UNKNOWN, + CallParticipant(recipient = Recipient.UNKNOWN), isSelfAdmin = true, {} ) @@ -292,37 +317,45 @@ private fun CallLinkMemberRowPreview() { @Composable private fun CallLinkMemberRow( - recipient: Recipient, + callParticipant: CallParticipant, isSelfAdmin: Boolean, - onBlockClicked: (Recipient) -> Unit + onBlockClicked: (CallParticipant) -> Unit ) { Row( modifier = Modifier .fillMaxWidth() .padding(Rows.defaultPadding()) ) { - AndroidView( - factory = ::AvatarImageView, - modifier = Modifier.size(40.dp) - ) { - it.setAvatarUsingProfile(recipient) + if (LocalInspectionMode.current) { + Spacer( + modifier = Modifier + .size(40.dp) + .background(color = Color.Red, shape = CircleShape) + ) + } else { + AndroidView( + factory = ::AvatarImageView, + modifier = Modifier.size(40.dp) + ) { + it.setAvatarUsingProfile(callParticipant.recipient) + } } Spacer(modifier = Modifier.width(24.dp)) Text( - text = recipient.getShortDisplayName(LocalContext.current), + text = callParticipant.recipient.getShortDisplayName(LocalContext.current), modifier = Modifier .weight(1f) .align(Alignment.CenterVertically) ) - if (isSelfAdmin) { + if (isSelfAdmin && !callParticipant.recipient.isSelf) { Icon( imageVector = ImageVector.vectorResource(id = R.drawable.symbol_minus_circle_24), contentDescription = null, modifier = Modifier - .clickable(onClick = { onBlockClicked(recipient) }) + .clickable(onClick = { onBlockClicked(callParticipant) }) .align(Alignment.CenterVertically) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt index 3cbbe7971f..8686355cba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/CallParticipantsState.kt @@ -36,7 +36,8 @@ data class CallParticipantsState( val isInOutgoingRingingMode: Boolean = false, val ringGroup: Boolean = false, val ringerRecipient: Recipient = Recipient.UNKNOWN, - val groupMembers: List = emptyList() + val groupMembers: List = emptyList(), + val isCallLink: Boolean = false ) { val allRemoteParticipants: List = remoteParticipants.allParticipants @@ -80,6 +81,7 @@ data class CallParticipantsState( return if (remoteParticipants.isEmpty) { describeGroupMembers( context = context, + noParticipants = if (isCallLink) R.string.WebRtcCallView__signal_call_link else null, 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, @@ -115,6 +117,7 @@ data class CallParticipantsState( ) { return describeGroupMembers( context = context, + noParticipants = null, oneParticipant = R.string.WebRtcCallView__ringing_s, twoParticipants = R.string.WebRtcCallView__ringing_s_and_s, multipleParticipants = R.plurals.WebRtcCallView__ringing_s_s_and_d_others, @@ -223,7 +226,8 @@ data class CallParticipantsState( remoteDevicesCount = webRtcViewModel.remoteDevicesCount, ringGroup = webRtcViewModel.ringGroup, isInOutgoingRingingMode = isInOutgoingRingingMode, - ringerRecipient = webRtcViewModel.ringerRecipient + ringerRecipient = webRtcViewModel.ringerRecipient, + isCallLink = webRtcViewModel.isCallLink ) } @@ -315,6 +319,7 @@ data class CallParticipantsState( private fun describeGroupMembers( context: Context, + @StringRes noParticipants: Int?, @StringRes oneParticipant: Int, @StringRes twoParticipants: Int, @PluralsRes multipleParticipants: Int, @@ -323,7 +328,7 @@ data class CallParticipantsState( val eligibleMembers: List = members.filterNot { it.member.isSelf || it.member.isBlocked } return when (eligibleMembers.size) { - 0 -> "" + 0 -> noParticipants?.let { context.getString(noParticipants) } ?: "" 1 -> context.getString( oneParticipant, eligibleMembers[0].member.getShortDisplayName(context) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/InCallStatus.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/InCallStatus.kt new file mode 100644 index 0000000000..2b5abd98e8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/InCallStatus.kt @@ -0,0 +1,22 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.webrtc + +/** + * Data interface for the in-call status text to be displayed while a call + * is ongoing. + */ +sealed interface InCallStatus { + /** + * The elapsed time the call has been connected for. + */ + data class ElapsedTime(val elapsedTime: Long) : InCallStatus + + /** + * The number of users requesting to join a call. + */ + data class PendingUsers(val pendingUserCount: Int) : InCallStatus +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PendingParticipantsBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PendingParticipantsBottomSheet.kt index 1381113ef1..15b87d897f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PendingParticipantsBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/PendingParticipantsBottomSheet.kt @@ -5,6 +5,7 @@ package org.thoughtcrime.securesms.components.webrtc +import android.os.Bundle import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -60,6 +61,13 @@ class PendingParticipantsBottomSheet : ComposeBottomSheetDialogFragment() { companion object { const val REQUEST_KEY = "PendingParticipantsBottomSheet_result" + private const val ACTION = "PendingParticipantsBottomSheet_action" + + @JvmStatic + fun getAction(bundle: Bundle): Action { + val code = bundle.getInt(ACTION, 0) + return Action.values().first { it.code == code } + } } private val viewModel: WebRtcCallViewModel by activityViewModel { @@ -88,21 +96,39 @@ class PendingParticipantsBottomSheet : ComposeBottomSheetDialogFragment() { } private fun onApprove(recipient: Recipient) { - ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestAccepted(recipient) + ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestAccepted(recipient.id) } private fun onDeny(recipient: Recipient) { - ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestRejected(recipient) + ApplicationDependencies.getSignalCallManager().setCallLinkJoinRequestRejected(recipient.id) } private fun onApproveAll() { dismiss() - setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to true)) + + setFragmentResult( + REQUEST_KEY, + bundleOf( + ACTION to Action.APPROVE_ALL.code + ) + ) } private fun onDenyAll() { dismiss() - setFragmentResult(REQUEST_KEY, bundleOf(REQUEST_KEY to false)) + + setFragmentResult( + REQUEST_KEY, + bundleOf( + ACTION to Action.DENY_ALL.code + ) + ) + } + + enum class Action(val code: Int) { + NONE(0), + APPROVE_ALL(1), + DENY_ALL(2) } } @@ -112,8 +138,16 @@ 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) + pendingParticipants = listOf( + PendingParticipantCollection.State.PENDING, + PendingParticipantCollection.State.APPROVED, + PendingParticipantCollection.State.DENIED + ).map { + PendingParticipantCollection.Entry( + recipient = Recipient.UNKNOWN, + state = it, + stateChangeAt = System.currentTimeMillis().milliseconds + ) }, onApproveAll = {}, onDenyAll = {}, @@ -232,6 +266,11 @@ private fun PendingParticipantRow( 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), + state = when (participant.state) { + PendingParticipantCollection.State.PENDING -> CircularIconButtonState.NORMAL + PendingParticipantCollection.State.APPROVED -> CircularIconButtonState.INVISIBLE + PendingParticipantCollection.State.DENIED -> CircularIconButtonState.DISABLED + }, onClick = onDenyCallback ) @@ -241,6 +280,11 @@ private fun PendingParticipantRow( 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), + state = when (participant.state) { + PendingParticipantCollection.State.PENDING -> CircularIconButtonState.NORMAL + PendingParticipantCollection.State.APPROVED -> CircularIconButtonState.DISABLED + PendingParticipantCollection.State.DENIED -> CircularIconButtonState.INVISIBLE + }, onClick = onApproveCallback ) } @@ -251,20 +295,27 @@ private fun CircularIconButton( symbol: ImageVector, contentDescription: String?, backgroundColor: Color, + state: CircularIconButtonState, onClick: () -> Unit ) { - Icon( - imageVector = symbol, - contentDescription = contentDescription, - modifier = Modifier - .size(28.dp) - .background( - color = backgroundColor, - shape = CircleShape - ) - .clickable(onClick = onClick) - .padding(6.dp) - ) + if (state == CircularIconButtonState.INVISIBLE) { + Spacer(modifier = Modifier.size(28.dp)) + } else { + val enabled = state != CircularIconButtonState.DISABLED + + Icon( + imageVector = symbol, + contentDescription = contentDescription, + modifier = Modifier + .size(28.dp) + .background( + color = if (enabled) backgroundColor else MaterialTheme.colorScheme.secondaryContainer, + shape = CircleShape + ) + .clickable(enabled = enabled, onClick = onClick) + .padding(6.dp) + ) + } } @Composable @@ -289,3 +340,9 @@ private fun PendingParticipantAvatar(recipient: Recipient) { } } } + +private enum class CircularIconButtonState { + NORMAL, + DISABLED, + INVISIBLE +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java index 65df1a6648..ecebe2a379 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallView.java @@ -129,6 +129,7 @@ public class WebRtcCallView extends ConstraintLayout { private Toolbar collapsedToolbar; private Toolbar headerToolbar; private Stub pendingParticipantsViewStub; + private Stub callLinkWarningCard; private WebRtcCallParticipantsPagerAdapter pagerAdapter; private WebRtcCallParticipantsRecyclerAdapter recyclerAdapter; @@ -207,6 +208,7 @@ public class WebRtcCallView extends ConstraintLayout { 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)); + callLinkWarningCard = new Stub<>(findViewById(R.id.call_screen_call_link_warning)); View decline = findViewById(R.id.call_screen_decline_call); View answerLabel = findViewById(R.id.call_screen_answer_call_label); @@ -462,10 +464,13 @@ public class WebRtcCallView extends ConstraintLayout { if (state.getGroupCallState().isNotIdle()) { if (state.getCallState() == WebRtcViewModel.State.CALL_PRE_JOIN) { + callLinkWarningCard.setVisibility(callParticipantsViewState.isStartedFromCallLink() ? View.VISIBLE : View.GONE); setStatus(state.getPreJoinGroupDescription(getContext())); } else if (state.getCallState() == WebRtcViewModel.State.CALL_CONNECTED && state.isInOutgoingRingingMode()) { + callLinkWarningCard.setVisibility(View.GONE); setStatus(state.getOutgoingRingingGroupDescription(getContext())); } else if (state.getGroupCallState().isRinging()) { + callLinkWarningCard.setVisibility(View.GONE); setStatus(state.getIncomingRingingGroupDescription(getContext())); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java index f8a1671720..284f4241ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/webrtc/WebRtcCallViewModel.java @@ -58,7 +58,7 @@ public class WebRtcCallViewModel extends ViewModel { private final LiveData controlsWithFoldableState = LiveDataUtil.combineLatest(foldableState, webRtcControls, this::updateControlsFoldableState); private final LiveData realWebRtcControls = LiveDataUtil.combineLatest(isInPipMode, controlsWithFoldableState, this::getRealWebRtcControls); private final SingleLiveEvent events = new SingleLiveEvent<>(); - private final MutableLiveData elapsed = new MutableLiveData<>(-1L); + private final BehaviorSubject elapsed = BehaviorSubject.createDefault(-1L); private final MutableLiveData liveRecipient = new MutableLiveData<>(Recipient.UNKNOWN.live()); private final DefaultValueLiveData participantsState = new DefaultValueLiveData<>(CallParticipantsState.STARTING_STATE); private final SingleLiveEvent callParticipantListUpdate = new SingleLiveEvent<>(); @@ -142,8 +142,22 @@ public class WebRtcCallViewModel extends ViewModel { return events; } - public LiveData getCallTime() { - return Transformations.map(elapsed, timeInCall -> callConnectedTime == -1 ? -1 : timeInCall); + public Observable getInCallstatus() { + Observable elapsedTime = elapsed.map(timeInCall -> callConnectedTime == -1 ? -1 : timeInCall); + + return Observable.combineLatest( + elapsedTime, + pendingParticipants, + (time, participants) -> { + Set pending = participants.getUnresolvedPendingParticipants(); + + if (pending.isEmpty()) { + return new InCallStatus.ElapsedTime(time); + } else { + return new InCallStatus.PendingUsers(pending.size()); + } + } + ).distinctUntilChanged(); } public LiveData getCallParticipantsState() { @@ -194,6 +208,10 @@ public class WebRtcCallViewModel extends ViewModel { return pendingParticipants.observeOn(AndroidSchedulers.mainThread()); } + public @NonNull PendingParticipantCollection getPendingParticipantsSnapshot() { + return pendingParticipants.getValue(); + } + @MainThread public void setIsInPipMode(boolean isInPipMode) { this.isInPipMode.setValue(isInPipMode); @@ -466,7 +484,7 @@ public class WebRtcCallViewModel extends ViewModel { long newValue = (System.currentTimeMillis() - callConnectedTime) / 1000; - elapsed.postValue(newValue); + elapsed.onNext(newValue); elapsedTimeHandler.postDelayed(elapsedTimeRunnable, 1000); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipantId.java b/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipantId.java index 16e4355102..6f38886f1e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipantId.java +++ b/app/src/main/java/org/thoughtcrime/securesms/events/CallParticipantId.java @@ -1,6 +1,10 @@ package org.thoughtcrime.securesms.events; +import android.os.Parcel; +import android.os.Parcelable; + import androidx.annotation.NonNull; +import androidx.core.os.ParcelCompat; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; @@ -11,7 +15,7 @@ import java.util.Objects; * Allow system to identify a call participant by their device demux id and their * recipient id. */ -public final class CallParticipantId { +public final class CallParticipantId implements Parcelable { public static final long DEFAULT_ID = -1; @@ -48,4 +52,34 @@ public final class CallParticipantId { public int hashCode() { return Objects.hash(demuxId, recipientId); } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(@NonNull Parcel dest, int flags) { + dest.writeLong(demuxId); + dest.writeParcelable(recipientId, flags); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public CallParticipantId createFromParcel(Parcel in) { + return new CallParticipantId( + in.readLong(), + Objects.requireNonNull( + ParcelCompat.readParcelable(in, + RecipientId.class.getClassLoader(), + RecipientId.class) + ) + ); + } + + @Override + public CallParticipantId[] newArray(int size) { + return new CallParticipantId[size]; + } + }; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt index 9f6620fb3a..c8abe509a9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/events/WebRtcViewModel.kt @@ -94,6 +94,7 @@ class WebRtcViewModel(state: WebRtcServiceState) { val remoteDevicesCount: OptionalLong = state.callInfoState.remoteDevicesCount val participantLimit: Long? = state.callInfoState.participantLimit val pendingParticipants: PendingParticipantCollection = state.callInfoState.pendingParticipants + val isCallLink: Boolean = state.callInfoState.callRecipient.isCallLink @get:JvmName("shouldRingGroup") val ringGroup: Boolean = state.getCallSetupState(state.callInfoState.activePeer?.callId).ringGroup diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallLinkConnectedActionProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallLinkConnectedActionProcessor.kt index 385bbf4bb3..ce23fc873c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallLinkConnectedActionProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/CallLinkConnectedActionProcessor.kt @@ -11,7 +11,9 @@ 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.events.CallParticipant import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId import org.thoughtcrime.securesms.service.webrtc.state.WebRtcServiceState import org.whispersystems.signalservice.api.push.ServiceId @@ -58,18 +60,19 @@ class CallLinkConnectedActionProcessor( .build() } - override fun handleSetCallLinkJoinRequestAccepted(currentState: WebRtcServiceState, participant: Recipient): WebRtcServiceState { + override fun handleSetCallLinkJoinRequestAccepted(currentState: WebRtcServiceState, participant: RecipientId): WebRtcServiceState { Log.i(tag, "handleSetCallLinkJoinRequestAccepted():") val groupCall: GroupCall = currentState.callInfoState.requireGroupCall() + val recipient = Recipient.resolved(participant) return try { - groupCall.approveUser(participant.requireAci().rawUuid) + groupCall.approveUser(recipient.requireAci().rawUuid) currentState .builder() .changeCallInfoState() - .setPendingParticipantApproved(participant) + .setPendingParticipantApproved(recipient) .build() } catch (e: CallException) { Log.w(tag, "Failed to approve user.", e) @@ -78,22 +81,51 @@ class CallLinkConnectedActionProcessor( } } - override fun handleSetCallLinkJoinRequestRejected(currentState: WebRtcServiceState, participant: Recipient): WebRtcServiceState { + override fun handleSetCallLinkJoinRequestRejected(currentState: WebRtcServiceState, participant: RecipientId): WebRtcServiceState { Log.i(tag, "handleSetCallLinkJoinRequestRejected():") val groupCall: GroupCall = currentState.callInfoState.requireGroupCall() + val recipient = Recipient.resolved(participant) return try { - groupCall.denyUser(participant.requireAci().rawUuid) + groupCall.denyUser(recipient.requireAci().rawUuid) currentState .builder() .changeCallInfoState() - .setPendingParticipantRejected(participant) + .setPendingParticipantRejected(recipient) .build() } catch (e: CallException) { Log.w(tag, "Failed to deny user.", e) currentState } } + + override fun handleRemoveFromCallLink(currentState: WebRtcServiceState, participant: CallParticipant): WebRtcServiceState { + Log.i(tag, "handleRemoveFromCallLink():") + + val groupCall: GroupCall = currentState.callInfoState.requireGroupCall() + + try { + groupCall.removeClient(participant.callParticipantId.demuxId) + } catch (e: CallException) { + Log.w(tag, "Failed to remove user.", e) + } + + return currentState + } + + override fun handleBlockFromCallLink(currentState: WebRtcServiceState, participant: CallParticipant): WebRtcServiceState { + Log.i(tag, "handleBlockFromCallLink():") + + val groupCall: GroupCall = currentState.callInfoState.requireGroupCall() + + try { + groupCall.blockClient(participant.callParticipantId.demuxId) + } catch (e: CallException) { + Log.w(tag, "Failed to block user.", e) + } + + return currentState + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/PendingParticipantCollection.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/PendingParticipantCollection.kt index 332c1c5222..9f30863e20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/PendingParticipantCollection.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/PendingParticipantCollection.kt @@ -21,12 +21,17 @@ data class PendingParticipantCollection( private val nowProvider: () -> Duration = { System.currentTimeMillis().milliseconds } ) { + companion object { + private val MAX_DENIALS = 2 + } + /** * 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. Otherwise, insert the recipient at the end of the collection in the pending state * 1. Any recipients in the resulting collection that are [State.PENDING] and NOT in the passed recipient list are removed. + * 1. Any recipients in the resulting collection that are [State.DENIED] and have a denial count less than [MAX_DENIALS] is moved to [State.PENDING] */ fun withRecipients(recipients: List): PendingParticipantCollection { val now = nowProvider() @@ -38,25 +43,36 @@ data class PendingParticipantCollection( ) } - val recipientIds = recipients.map { it.id } - val newEntryMap = (participantMap + newEntries).filterNot { it.value.state == State.PENDING && it.key !in recipientIds } + val submittedIdSet = recipients.map { it.id }.toSet() + val newEntryMap = (participantMap + newEntries) + .filterNot { it.value.state == State.PENDING && it.key !in submittedIdSet } + .mapValues { + if (it.value.state == State.DENIED && it.key in submittedIdSet && it.value.denialCount < MAX_DENIALS) { + it.value.copy(state = State.PENDING, stateChangeAt = now) + } else { + it.value + } + } return copy(participantMap = newEntryMap) } /** - * Creates a new collection with the given recipient marked as [State.APPROVED] + * Creates a new collection with the given recipient marked as [State.APPROVED]. + * Resets the denial count for that recipient. */ fun withApproval(recipient: Recipient): PendingParticipantCollection { val now = nowProvider() - val entry = Entry( - recipient = recipient, - state = State.APPROVED, - stateChangeAt = now - ) + val entry = participantMap[recipient.id] ?: return this return copy( - participantMap = participantMap + (recipient.id to entry) + participantMap = participantMap + ( + recipient.id to entry.copy( + denialCount = 0, + state = State.APPROVED, + stateChangeAt = now + ) + ) ) } @@ -65,14 +81,16 @@ data class PendingParticipantCollection( */ fun withDenial(recipient: Recipient): PendingParticipantCollection { val now = nowProvider() - val entry = Entry( - recipient = recipient, - state = State.DENIED, - stateChangeAt = now - ) + val entry = participantMap[recipient.id] ?: return this return copy( - participantMap = participantMap + (recipient.id to entry) + participantMap = participantMap + ( + recipient.id to entry.copy( + denialCount = entry.denialCount + 1, + state = State.DENIED, + stateChangeAt = now + ) + ) ) } @@ -103,7 +121,8 @@ data class PendingParticipantCollection( data class Entry( val recipient: Recipient, val state: State, - val stateChangeAt: Duration + val stateChangeAt: Duration, + val denialCount: Int = 0 ) /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java index 973fa0ae9a..25175ea71c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/SignalCallManager.java @@ -40,6 +40,7 @@ import org.thoughtcrime.securesms.database.GroupTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.GroupRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; +import org.thoughtcrime.securesms.events.CallParticipant; import org.thoughtcrime.securesms.events.GroupCallPeekEvent; import org.thoughtcrime.securesms.events.WebRtcViewModel; import org.thoughtcrime.securesms.groups.GroupId; @@ -354,19 +355,19 @@ private void processStateless(@NonNull Function1 p.handleDropCall(s, callId)); } - public void setCallLinkJoinRequestAccepted(@NonNull Recipient participant) { + public void setCallLinkJoinRequestAccepted(@NonNull RecipientId participant) { process((s, p) -> p.handleSetCallLinkJoinRequestAccepted(s, participant)); } - public void setCallLinkJoinRequestRejected(@NonNull Recipient participant) { + public void setCallLinkJoinRequestRejected(@NonNull RecipientId participant) { process((s, p) -> p.handleSetCallLinkJoinRequestRejected(s, participant)); } - public void removeFromCallLink(@NonNull Recipient participant) { + public void removeFromCallLink(@NonNull CallParticipant participant) { process((s, p) -> p.handleRemoveFromCallLink(s, participant)); } - public void blockFromCallLink(@NonNull Recipient participant) { + public void blockFromCallLink(@NonNull CallParticipant participant) { process((s, p) -> p.handleBlockFromCallLink(s, participant)); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java index 9823b2ac4a..73b9f5e69e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/WebRtcActionProcessor.java @@ -877,25 +877,25 @@ public abstract class WebRtcActionProcessor { //region Call Links - protected @NonNull WebRtcServiceState handleSetCallLinkJoinRequestAccepted(@NonNull WebRtcServiceState currentState, @NonNull Recipient participant) { + protected @NonNull WebRtcServiceState handleSetCallLinkJoinRequestAccepted(@NonNull WebRtcServiceState currentState, @NonNull RecipientId participant) { Log.i(tag, "handleSetCallLinkJoinRequestAccepted not processed"); return currentState; } - protected @NonNull WebRtcServiceState handleSetCallLinkJoinRequestRejected(@NonNull WebRtcServiceState currentState, @NonNull Recipient participant) { + protected @NonNull WebRtcServiceState handleSetCallLinkJoinRequestRejected(@NonNull WebRtcServiceState currentState, @NonNull RecipientId participant) { Log.i(tag, "handleSetCallLinkJoinRequestRejected not processed"); return currentState; } - protected @NonNull WebRtcServiceState handleRemoveFromCallLink(@NonNull WebRtcServiceState currentState, @NonNull Recipient participant) { + protected @NonNull WebRtcServiceState handleRemoveFromCallLink(@NonNull WebRtcServiceState currentState, @NonNull CallParticipant participant) { Log.i(tag, "handleRemoveFromCallLink not processed"); return currentState; } - protected @NonNull WebRtcServiceState handleBlockFromCallLink(@NonNull WebRtcServiceState currentState, @NonNull Recipient participant) { + protected @NonNull WebRtcServiceState handleBlockFromCallLink(@NonNull WebRtcServiceState currentState, @NonNull CallParticipant participant) { Log.i(tag, "handleBlockFromCallLink not processed"); return currentState; diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java index ec6e8cc961..a9749e6a56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java @@ -84,11 +84,11 @@ public class CommunicationActions { @Override protected void onReceiveResult(int resultCode, Bundle resultData) { if (resultCode == 1) { - startCallInternal(callContext, recipient, false); + startCallInternal(callContext, recipient, false, false); } else { new MaterialAlertDialogBuilder(callContext.getContext()) .setMessage(R.string.CommunicationActions_start_voice_call) - .setPositiveButton(R.string.CommunicationActions_call, (d, w) -> startCallInternal(callContext, recipient, false)) + .setPositiveButton(R.string.CommunicationActions_call, (d, w) -> startCallInternal(callContext, recipient, false, false)) .setNegativeButton(R.string.CommunicationActions_cancel, (d, w) -> d.dismiss()) .setCancelable(true) .show(); @@ -104,17 +104,17 @@ public class CommunicationActions { * Start a video call. Assumes that permission request results will be routed to a handler on the Fragment. */ public static void startVideoCall(@NonNull Fragment fragment, @NonNull Recipient recipient) { - startVideoCall(new FragmentCallContext(fragment), recipient); + startVideoCall(new FragmentCallContext(fragment), recipient, false); } /** * Start a video call. Assumes that permission request results will be routed to a handler on the Activity. */ public static void startVideoCall(@NonNull Activity activity, @NonNull Recipient recipient) { - startVideoCall(new ActivityCallContext(activity), recipient); + startVideoCall(new ActivityCallContext(activity), recipient, false); } - private static void startVideoCall(@NonNull CallContext callContext, @NonNull Recipient recipient) { + private static void startVideoCall(@NonNull CallContext callContext, @NonNull Recipient recipient, boolean fromCallLink) { if (TelephonyUtil.isAnyPstnLineBusy(callContext.getContext())) { Toast.makeText(callContext.getContext(), R.string.CommunicationActions_a_cellular_call_is_already_in_progress, @@ -126,7 +126,7 @@ public class CommunicationActions { ApplicationDependencies.getSignalCallManager().isCallActive(new ResultReceiver(new Handler(Looper.getMainLooper())) { @Override protected void onReceiveResult(int resultCode, Bundle resultData) { - startCallInternal(callContext, recipient, resultCode != 1); + startCallInternal(callContext, recipient, resultCode != 1, fromCallLink); } }); } @@ -377,7 +377,7 @@ public class CommunicationActions { .setPositiveButton(android.R.string.ok, null) .show(); } else { - startVideoCall(callContext, callLinkRecipient.get()); + startVideoCall(callContext, callLinkRecipient.get(), true); } }); } @@ -394,8 +394,8 @@ public class CommunicationActions { } } - private static void startCallInternal(@NonNull CallContext callContext, @NonNull Recipient recipient, boolean isVideo) { - if (isVideo) startVideoCallInternal(callContext, recipient); + private static void startCallInternal(@NonNull CallContext callContext, @NonNull Recipient recipient, boolean isVideo, boolean fromCallLink) { + if (isVideo) startVideoCallInternal(callContext, recipient, fromCallLink); else startAudioCallInternal(callContext, recipient); } @@ -420,7 +420,7 @@ public class CommunicationActions { .execute(); } - private static void startVideoCallInternal(@NonNull CallContext callContext, @NonNull Recipient recipient) { + private static void startVideoCallInternal(@NonNull CallContext callContext, @NonNull Recipient recipient, boolean fromCallLink) { callContext.getPermissionsBuilder() .request(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA) .ifNecessary() @@ -434,7 +434,8 @@ public class CommunicationActions { Intent activityIntent = new Intent(callContext.getContext(), WebRtcCallActivity.class); activityIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .putExtra(WebRtcCallActivity.EXTRA_ENABLE_VIDEO_IF_AVAILABLE, true); + .putExtra(WebRtcCallActivity.EXTRA_ENABLE_VIDEO_IF_AVAILABLE, true) + .putExtra(WebRtcCallActivity.EXTRA_STARTED_FROM_CALL_LINK, fromCallLink); callContext.startActivity(activityIntent); }) diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallParticipantsViewState.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallParticipantsViewState.kt index 7debae8c96..84dec97aba 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallParticipantsViewState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallParticipantsViewState.kt @@ -7,7 +7,8 @@ class CallParticipantsViewState( callParticipantsState: CallParticipantsState, ephemeralState: WebRtcEphemeralState, val isPortrait: Boolean, - val isLandscapeEnabled: Boolean + val isLandscapeEnabled: Boolean, + val isStartedFromCallLink: Boolean ) { val callParticipantsState = CallParticipantsState.update(callParticipantsState, ephemeralState) diff --git a/app/src/main/res/layout/call_screen_call_link_warning_card.xml b/app/src/main/res/layout/call_screen_call_link_warning_card.xml new file mode 100644 index 0000000000..166ee9c675 --- /dev/null +++ b/app/src/main/res/layout/call_screen_call_link_warning_card.xml @@ -0,0 +1,25 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/webrtc_call_view.xml b/app/src/main/res/layout/webrtc_call_view.xml index 5ba0cc6388..0b6905a0b1 100644 --- a/app/src/main/res/layout/webrtc_call_view.xml +++ b/app/src/main/res/layout/webrtc_call_view.xml @@ -486,6 +486,18 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> + + Open settings Not now + + + Approve %1$d request? + Approve %1$d requests? + + + Approve all + + + %1$d person will be added to the call. + %1$d people will be added to the call. + + + + Deny %1$d request? + Deny %1$d requests? + + + + %1$d person will not be added to the call. + %1$d people will not be added to the call. + + + Deny all + + + %1$d person waiting + %1$d people waiting + + Signal Call @@ -1740,6 +1770,10 @@ Reconnecting… Joining… Disconnected + + Signal call link + + Anyone who joins this call via the link will see your name, photo, and phone number. Signal will ring %1$s Signal will ring %1$s and %2$s @@ -6094,6 +6128,16 @@ Share a link for a Signal call + + + Call info + + Remove %1$s from the call? + + Remove + + Block from call + Create call link diff --git a/app/src/test/java/org/thoughtcrime/securesms/service/webrtc/PendingParticipantCollectionTest.kt b/app/src/test/java/org/thoughtcrime/securesms/service/webrtc/PendingParticipantCollectionTest.kt index abae21412a..58f64ba8d2 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/service/webrtc/PendingParticipantCollectionTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/service/webrtc/PendingParticipantCollectionTest.kt @@ -164,7 +164,8 @@ class PendingParticipantCollectionTest { val expected = PendingParticipantCollection.Entry( recipient = recipients[0], state = PendingParticipantCollection.State.DENIED, - stateChangeAt = 1.milliseconds + stateChangeAt = 1.milliseconds, + denialCount = 1 ) val actual = subject.getAllPendingParticipants(0.milliseconds).first() @@ -225,6 +226,45 @@ class PendingParticipantCollectionTest { assertEquals(expected, actual) } + @Test + fun `Given a participant is denied once, when I withRecipients, then I expect the state to be changed to PENDING`() { + val recipients = createRecipients(1) + val expected = PendingParticipantCollection.Entry( + recipient = recipients[0], + state = PendingParticipantCollection.State.PENDING, + stateChangeAt = 2.milliseconds, + denialCount = 1 + ) + + val actual = testSubject + .withRecipients(recipients) + .withDenial(recipients[0]) + .withRecipients(recipients) + .getAllPendingParticipants(0.milliseconds) + + assertEquals(expected, actual.first()) + } + + @Test + fun `Given a participant is denied twice, when I withRecipients, then I expect the state to be DENIED`() { + val recipients = createRecipients(1) + val expected = PendingParticipantCollection.Entry( + recipient = recipients[0], + state = PendingParticipantCollection.State.DENIED, + stateChangeAt = 2.milliseconds, + denialCount = 2 + ) + + val actual = testSubject + .withRecipients(recipients) + .withDenial(recipients[0]) + .withDenial(recipients[0]) + .withRecipients(recipients) + .getAllPendingParticipants(0.milliseconds) + + assertEquals(expected, actual.first()) + } + private fun createRecipients(count: Int): List { return (1..count).map { RecipientDatabaseTestUtils.createRecipient() } }