Ensure call links UX is still available post new calling features.

This commit is contained in:
Cody Henthorne 2023-12-20 11:06:46 -05:00 committed by Clark Chen
parent b55a9f253e
commit 624f863da4
15 changed files with 287 additions and 411 deletions

View file

@ -57,14 +57,12 @@ import org.signal.core.util.logging.Log;
import org.signal.libsignal.protocol.IdentityKey;
import org.thoughtcrime.securesms.components.TooltipPopup;
import org.thoughtcrime.securesms.components.sensors.DeviceOrientationMonitor;
import org.thoughtcrime.securesms.components.webrtc.CallLinkInfoSheet;
import org.thoughtcrime.securesms.components.webrtc.CallLinkProfileKeySender;
import org.thoughtcrime.securesms.components.webrtc.CallOverflowPopupWindow;
import org.thoughtcrime.securesms.components.webrtc.CallParticipantsListUpdatePopupWindow;
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.controls.ControlsAndInfoViewModel;
import org.thoughtcrime.securesms.components.webrtc.GroupCallSafetyNumberChangeNotificationUtil;
import org.thoughtcrime.securesms.components.webrtc.InCallStatus;
import org.thoughtcrime.securesms.components.webrtc.PendingParticipantsBottomSheet;
@ -76,6 +74,7 @@ import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel;
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls;
import org.thoughtcrime.securesms.components.webrtc.WifiToCellularPopupWindow;
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoController;
import org.thoughtcrime.securesms.components.webrtc.controls.ControlsAndInfoViewModel;
import org.thoughtcrime.securesms.components.webrtc.participantslist.CallParticipantsListDialog;
import org.thoughtcrime.securesms.components.webrtc.requests.CallLinkIncomingRequestSheet;
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
@ -84,7 +83,6 @@ import org.thoughtcrime.securesms.events.WebRtcViewModel;
import org.thoughtcrime.securesms.messagerequests.CalleeMustAcceptMessageRequestActivity;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet;
@ -198,7 +196,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
initializeViewModel(isLandscapeEnabled);
initializePictureInPictureParams();
controlsAndInfo = new ControlsAndInfoController(callScreen, callOverflowPopupWindow, viewModel, controlsAndInfoViewModel);
controlsAndInfo = new ControlsAndInfoController(this, callScreen, callOverflowPopupWindow, viewModel, controlsAndInfoViewModel);
controlsAndInfo.addVisibilityListener(new FadeCallback());
fullscreenHelper.showAndHideWithSystemUI(getWindow(), findViewById(R.id.webrtc_call_view_toolbar_text), findViewById(R.id.webrtc_call_view_toolbar_no_text));
@ -868,6 +866,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
viewModel.setRecipient(event.getRecipient());
callScreen.setRecipient(event.getRecipient());
controlsAndInfoViewModel.setRecipient(event.getRecipient());
switch (event.getState()) {
case CALL_PRE_JOIN:
@ -1102,13 +1101,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan
@Override
public void onCallInfoClicked() {
LiveRecipient liveRecipient = viewModel.getRecipient();
if (liveRecipient.get().isCallLink()) {
CallLinkInfoSheet.show(getSupportFragmentManager(), liveRecipient.get().requireCallLinkRoomId());
} else {
controlsAndInfo.showCallInfo();
}
controlsAndInfo.showCallInfo();
}
@Override

View file

@ -119,7 +119,8 @@ fun SignalCallRow(
.align(CenterVertically)
) {
Text(
text = callLink.state.name.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) }
text = callLink.state.name.ifEmpty { stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__signal_call) },
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = callUrl,

View file

@ -92,6 +92,7 @@ class CreateCallLinkBottomSheetDialogFragment : ComposeBottomSheetDialogFragment
Text(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__create_call_link),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)

View file

@ -9,7 +9,6 @@ import androidx.compose.runtime.Immutable
import org.thoughtcrime.securesms.database.CallLinkTable
@Immutable
@Deprecated("Merge with ControlsAndInfoState")
data class CallLinkDetailsState(
val displayRevocationDialog: Boolean = false,
val callLink: CallLinkTable.CallLink? = null

View file

@ -22,7 +22,6 @@ import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
@Deprecated("Merge with ControlsAndInfoViewModel")
class CallLinkDetailsViewModel(
callLinkRoomId: CallLinkRoomId,
repository: CallLinkDetailsRepository = CallLinkDetailsRepository(),

View file

@ -1,372 +0,0 @@
/*
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.components.webrtc
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.app.ShareCompat
import androidx.core.os.BundleCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import androidx.lifecycle.toLiveData
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.collections.immutable.toImmutableList
import org.signal.core.ui.BottomSheets
import org.signal.core.ui.Dividers
import org.signal.core.ui.Rows
import org.signal.core.ui.theme.SignalTheme
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.concurrent.addTo
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkState.Restrictions
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragmentArgs
import org.thoughtcrime.securesms.calls.links.SignalCallRow
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.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkCredentials
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.SignalCallLinkState
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.util.BottomSheetUtil
/**
* Displays information about the in-progress CallLink call from
* within WebRtcActivity. If the user is able to modify call link
* state, provides options to do so.
*/
@Deprecated("Merge with CallInfoView")
class CallLinkInfoSheet : ComposeBottomSheetDialogFragment() {
companion object {
private val TAG = Log.tag(CallLinkInfoSheet::class.java)
private const val CALL_LINK_ROOM_ID = "call_link_room_id"
@JvmStatic
fun show(fragmentManager: FragmentManager, callLinkRoomId: CallLinkRoomId) {
CallLinkInfoSheet().apply {
arguments = bundleOf(CALL_LINK_ROOM_ID to callLinkRoomId)
}.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
override val forceDarkTheme = true
private val webRtcCallViewModel: WebRtcCallViewModel by activityViewModels()
private val callLinkDetailsViewModel: CallLinkDetailsViewModel by viewModels(factoryProducer = {
CallLinkDetailsViewModel.Factory(BundleCompat.getParcelable(requireArguments(), CALL_LINK_ROOM_ID, CallLinkRoomId::class.java)!!)
})
private val lifecycleDisposable = LifecycleDisposable()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
lifecycleDisposable.bindTo(viewLifecycleOwner)
parentFragmentManager.setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, viewLifecycleOwner) { resultKey, bundle ->
if (bundle.containsKey(resultKey)) {
setName(bundle.getString(resultKey)!!)
}
}
}
@Composable
override fun SheetContent() {
val callLinkDetailsState by callLinkDetailsViewModel.state
val participants: List<CallParticipant> by webRtcCallViewModel.callParticipantsState
.toFlowable(BackpressureStrategy.LATEST)
.map { state -> state.allRemoteParticipants }
.toLiveData()
.observeAsState(emptyList())
val onEditNameClicked: () -> Unit = remember(callLinkDetailsState) {
{
EditCallLinkNameDialogFragment().apply {
arguments = EditCallLinkNameDialogFragmentArgs.Builder(callLinkDetailsState.callLink?.state?.name ?: "").build().toBundle()
}.show(parentFragmentManager, null)
}
}
val callLink = callLinkDetailsState.callLink
if (callLink != null) {
Sheet(
callLink = callLink,
participants = participants,
onShareLinkClicked = this::shareLink,
onEditNameClicked = onEditNameClicked,
onToggleAdminApprovalClicked = this::onApproveAllMembersChanged,
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())
.subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to change restrictions. $it")
toastFailure()
}
}, onError = handleError("onApproveAllMembersChanged"))
.addTo(lifecycleDisposable)
}
private fun shareLink() {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(CallLinks.url(callLinkDetailsViewModel.rootKeySnapshot))
.setType(mimeType)
.createChooserIntent()
try {
startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
}
}
private fun setName(name: String) {
callLinkDetailsViewModel.setName(name)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(
onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to set name. $it")
toastFailure()
}
},
onError = handleError("setName")
)
.addTo(lifecycleDisposable)
}
private fun handleError(method: String): (throwable: Throwable) -> Unit {
return {
Log.w(TAG, "Failure during $method", it)
toastFailure()
}
}
private fun toastFailure() {
Toast.makeText(requireContext(), R.string.CallLinkDetailsFragment__couldnt_save_changes, Toast.LENGTH_LONG).show()
}
}
@Preview
@Composable
private fun SheetPreview() {
SignalTheme(isDarkMode = true) {
Surface {
Sheet(
callLink = CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = CallLinkRoomId.fromBytes(byteArrayOf(1, 2, 3, 4, 5)),
credentials = CallLinkCredentials(
linkKeyBytes = byteArrayOf(1, 2, 3, 4, 5),
adminPassBytes = byteArrayOf(1, 2, 3, 4, 5)
),
state = SignalCallLinkState()
),
participants = listOf(CallParticipant(recipient = Recipient.UNKNOWN)).toImmutableList(),
onShareLinkClicked = {},
onEditNameClicked = {},
onToggleAdminApprovalClicked = {},
onBlock = {}
)
}
}
}
@Composable
private fun Sheet(
callLink: CallLinkTable.CallLink,
participants: List<CallParticipant>,
onShareLinkClicked: () -> Unit,
onEditNameClicked: () -> Unit,
onToggleAdminApprovalClicked: (Boolean) -> 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),
icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24),
iconModifier = Modifier
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = CircleShape
)
.size(42.dp)
.padding(9.dp),
modifier = Modifier
.defaultMinSize(minHeight = 64.dp)
.clickable(onClick = onShareLinkClicked)
)
}
items(participants, { it.callParticipantId }, { null }) {
CallLinkMemberRow(
callParticipant = it,
isSelfAdmin = callLink.credentials?.adminPassBytes != null,
onBlockClicked = onBlock
)
}
if (callLink.credentials?.adminPassBytes != null) {
item {
Dividers.Default()
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__add_call_name),
modifier = Modifier.clickable(onClick = onEditNameClicked)
)
Rows.ToggleRow(
checked = callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
text = stringResource(id = R.string.CallLinkDetailsFragment__approve_all_members),
onCheckChanged = onToggleAdminApprovalClicked
)
}
}
}
}
@Preview
@Composable
private fun CallLinkMemberRowPreview() {
SignalTheme(isDarkMode = true) {
Surface {
CallLinkMemberRow(
CallParticipant(recipient = Recipient.UNKNOWN),
isSelfAdmin = true,
{}
)
}
}
}
@Composable
private fun CallLinkMemberRow(
callParticipant: CallParticipant,
isSelfAdmin: Boolean,
onBlockClicked: (CallParticipant) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(Rows.defaultPadding())
) {
val recipient by ((if (LocalInspectionMode.current) Observable.just(Recipient.UNKNOWN) else Recipient.observable(callParticipant.recipient.id)))
.toFlowable(BackpressureStrategy.LATEST)
.toLiveData()
.observeAsState(initial = callParticipant.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(recipient)
}
}
Spacer(modifier = Modifier.width(24.dp))
Text(
text = recipient.getShortDisplayName(LocalContext.current),
modifier = Modifier
.weight(1f)
.align(Alignment.CenterVertically)
)
if (isSelfAdmin && !recipient.isSelf) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_minus_circle_24),
contentDescription = null,
modifier = Modifier
.clickable(onClick = { onBlockClicked(callParticipant) })
.align(Alignment.CenterVertically)
)
}
}
}

View file

@ -840,6 +840,12 @@ public class WebRtcCallView extends InsetAwareConstraintLayout {
ConstraintSet.TOP,
ViewUtil.dpToPx(layoutPositions.reactionBottomMargin));
constraintSet.connect(pendingParticipantsViewStub.getId(),
ConstraintSet.BOTTOM,
layoutPositions.reactionBottomViewId,
ConstraintSet.TOP,
ViewUtil.dpToPx(layoutPositions.reactionBottomMargin));
constraintSet.applyTo(this);
}

View file

@ -29,6 +29,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rxjava3.subscribeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -46,9 +47,12 @@ import androidx.lifecycle.toLiveData
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Observable
import org.signal.core.ui.Dividers
import org.signal.core.ui.Rows
import org.signal.core.ui.theme.SignalTheme
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.SignalCallRow
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
@ -65,7 +69,12 @@ import org.thoughtcrime.securesms.recipients.Recipient
object CallInfoView {
@Composable
fun View(webRtcCallViewModel: WebRtcCallViewModel, controlsAndInfoViewModel: ControlsAndInfoViewModel, modifier: Modifier) {
fun View(
webRtcCallViewModel: WebRtcCallViewModel,
controlsAndInfoViewModel: ControlsAndInfoViewModel,
callbacks: Callbacks,
modifier: Modifier
) {
val participantsState: ParticipantsState by webRtcCallViewModel.callParticipantsState
.toFlowable(BackpressureStrategy.LATEST)
.map { state ->
@ -85,14 +94,35 @@ object CallInfoView {
val controlAndInfoState: ControlAndInfoState by controlsAndInfoViewModel.state
val onEditNameClicked: () -> Unit = remember(controlAndInfoState) {
{
callbacks.onEditNameClicked(controlAndInfoState.callLink?.state?.name ?: "")
}
}
SignalTheme(
isDarkMode = true
) {
Surface {
CallInfo(participantsState = participantsState, controlAndInfoState = controlAndInfoState, modifier = modifier)
CallInfo(
participantsState = participantsState,
controlAndInfoState = controlAndInfoState,
onShareLinkClicked = callbacks::onShareLinkClicked,
onEditNameClicked = onEditNameClicked,
onToggleAdminApprovalClicked = callbacks::onToggleAdminApprovalClicked,
onBlock = callbacks::onBlock,
modifier = modifier
)
}
}
}
interface Callbacks {
fun onShareLinkClicked()
fun onEditNameClicked(name: String)
fun onToggleAdminApprovalClicked(checked: Boolean)
fun onBlock(callParticipant: CallParticipant)
}
}
@Preview
@ -103,7 +133,11 @@ private fun CallInfoPreview() {
val remoteParticipants = listOf(CallParticipant(recipient = Recipient.UNKNOWN))
CallInfo(
participantsState = ParticipantsState(remoteParticipants = remoteParticipants, raisedHands = remoteParticipants.map { GroupCallRaiseHandEvent(it.recipient, System.currentTimeMillis()) }),
controlAndInfoState = ControlAndInfoState()
controlAndInfoState = ControlAndInfoState(),
onShareLinkClicked = { },
onEditNameClicked = { },
onToggleAdminApprovalClicked = { },
onBlock = { }
)
}
}
@ -113,6 +147,10 @@ private fun CallInfoPreview() {
private fun CallInfo(
participantsState: ParticipantsState,
controlAndInfoState: ControlAndInfoState,
onShareLinkClicked: () -> Unit,
onEditNameClicked: () -> Unit,
onToggleAdminApprovalClicked: (Boolean) -> Unit,
onBlock: (CallParticipant) -> Unit,
modifier: Modifier = Modifier
) {
val listState = rememberLazyListState()
@ -134,6 +172,29 @@ private fun CallInfo(
)
}
if (controlAndInfoState.callLink != null) {
item {
SignalCallRow(callLink = controlAndInfoState.callLink, onJoinClicked = null)
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24),
iconModifier = Modifier
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = CircleShape
)
.size(42.dp)
.padding(9.dp),
onClick = onShareLinkClicked,
modifier = Modifier
.defaultMinSize(minHeight = 64.dp)
)
Dividers.Default()
}
}
if (participantsState.raisedHands.isNotEmpty()) {
item {
Box(
@ -157,21 +218,30 @@ private fun CallInfo(
) {
HandRaisedRow(recipient = it.sender)
}
item {
Dividers.Default()
}
}
item {
Box(
modifier = Modifier
.padding(horizontal = 24.dp)
.defaultMinSize(minHeight = 52.dp)
.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
Text(
text = getCallSheetLabel(participantsState),
style = MaterialTheme.typography.titleSmall
)
var includeAdminControlsDivider = true
if (controlAndInfoState.callLink == null || participantsState.isOngoing()) {
item {
Box(
modifier = Modifier
.padding(horizontal = 24.dp)
.defaultMinSize(minHeight = 52.dp)
.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
Text(
text = getCallSheetLabel(participantsState),
style = MaterialTheme.typography.titleSmall
)
}
}
} else {
includeAdminControlsDivider = false
}
if (!participantsState.inCallLobby || participantsState.isOngoing()) {
@ -182,8 +252,8 @@ private fun CallInfo(
) {
CallParticipantRow(
callParticipant = it,
isSelfAdmin = false,
onBlockClicked = {}
isSelfAdmin = controlAndInfoState.isSelfAdmin() && !participantsState.inCallLobby,
onBlockClicked = onBlock
)
}
} else if (participantsState.isGroupCall()) {
@ -197,7 +267,7 @@ private fun CallInfo(
isSelfAdmin = false
)
}
} else {
} else if (controlAndInfoState.callLink == null) {
item {
CallParticipantRow(
initialRecipient = participantsState.callRecipient,
@ -213,6 +283,24 @@ private fun CallInfo(
}
}
if (controlAndInfoState.callLink?.credentials?.adminPassBytes != null) {
item {
if (includeAdminControlsDivider) {
Dividers.Default()
}
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__add_call_name),
onClick = onEditNameClicked
)
Rows.ToggleRow(
checked = controlAndInfoState.callLink.state.restrictions == CallLinkState.Restrictions.ADMIN_APPROVAL,
text = stringResource(id = R.string.CallLinkDetailsFragment__approve_all_members),
onCheckChanged = onToggleAdminApprovalClicked
)
}
}
item {
Spacer(modifier = Modifier.size(48.dp))
}

View file

@ -6,8 +6,14 @@
package org.thoughtcrime.securesms.components.webrtc.controls
import androidx.compose.runtime.Immutable
import org.thoughtcrime.securesms.database.CallLinkTable
@Immutable
data class ControlAndInfoState(
val callLink: CallLinkTable.CallLink? = null,
val resetScrollState: Long = 0
)
) {
fun isSelfAdmin(): Boolean {
return callLink?.credentials?.adminPassBytes != null
}
}

View file

@ -5,10 +5,13 @@
package org.thoughtcrime.securesms.components.webrtc.controls
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.res.ColorStateList
import android.os.Handler
import android.view.View
import android.widget.FrameLayout
import android.widget.Toast
import androidx.annotation.IdRes
import androidx.annotation.Px
import androidx.compose.ui.Modifier
@ -20,6 +23,7 @@ import androidx.constraintlayout.widget.ConstraintLayout
import androidx.constraintlayout.widget.ConstraintSet
import androidx.constraintlayout.widget.Guideline
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.app.ShareCompat
import androidx.core.content.ContextCompat
import androidx.transition.AutoTransition
import androidx.transition.TransitionManager
@ -27,18 +31,30 @@ import androidx.transition.TransitionSet
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback
import com.google.android.material.bottomsheet.BottomSheetBehaviorHack
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable
import com.google.android.material.shape.ShapeAppearanceModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.kotlin.addTo
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.dp
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.WebRtcCallActivity
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragmentArgs
import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout
import org.thoughtcrime.securesms.components.webrtc.CallOverflowPopupWindow
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallView
import org.thoughtcrime.securesms.components.webrtc.WebRtcCallViewModel
import org.thoughtcrime.securesms.components.webrtc.WebRtcControls
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.events.CallParticipant
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import org.thoughtcrime.securesms.util.padding
import org.thoughtcrime.securesms.util.visible
import kotlin.math.max
@ -48,13 +64,16 @@ import kotlin.time.Duration.Companion.seconds
* Brain for rendering the call controls and info within a bottom sheet.
*/
class ControlsAndInfoController(
private val webRtcCallActivity: WebRtcCallActivity,
private val webRtcCallView: WebRtcCallView,
private val overflowPopupWindow: CallOverflowPopupWindow,
private val viewModel: WebRtcCallViewModel,
private val controlsAndInfoViewModel: ControlsAndInfoViewModel
) : Disposable {
) : CallInfoView.Callbacks, Disposable {
companion object {
private val TAG = Log.tag(ControlsAndInfoController::class.java)
private const val CONTROL_FADE_OUT_START = 0f
private const val CONTROL_FADE_OUT_DONE = 0.23f
private const val INFO_FADE_IN_START = CONTROL_FADE_OUT_DONE
@ -96,7 +115,7 @@ class ControlsAndInfoController(
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val nestedScrollInterop = rememberNestedScrollInteropConnection()
CallInfoView.View(viewModel, controlsAndInfoViewModel, Modifier.nestedScroll(nestedScrollInterop))
CallInfoView.View(viewModel, controlsAndInfoViewModel, this@ControlsAndInfoController, Modifier.nestedScroll(nestedScrollInterop))
}
}
@ -116,7 +135,7 @@ class ControlsAndInfoController(
.setTopRightCorner(CornerFamily.ROUNDED, 18.dp.toFloat())
.build()
).apply {
fillColor = ColorStateList.valueOf(ContextCompat.getColor(webRtcCallView.context, R.color.signal_colorSurface))
fillColor = ColorStateList.valueOf(ContextCompat.getColor(webRtcCallActivity, R.color.signal_colorSurface))
}
behavior.isHideable = true
@ -181,6 +200,14 @@ class ControlsAndInfoController(
overflowPopupWindow.setOnDismissListener {
hide(delay = HIDE_CONTROL_DELAY)
}
webRtcCallActivity
.supportFragmentManager
.setFragmentResultListener(EditCallLinkNameDialogFragment.RESULT_KEY, webRtcCallActivity) { resultKey, bundle ->
if (bundle.containsKey(resultKey)) {
setName(bundle.getString(resultKey)!!)
}
}
}
fun onControlTopChanged(composeViewSize: Int = raiseHandComposeView.height) {
@ -340,6 +367,77 @@ class ControlsAndInfoController(
return disposables.isDisposed
}
override fun onShareLinkClicked() {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(webRtcCallActivity)
.setText(CallLinks.url(controlsAndInfoViewModel.rootKeySnapshot))
.setType(mimeType)
.createChooserIntent()
try {
webRtcCallActivity.startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(webRtcCallActivity, R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
}
}
override fun onEditNameClicked(name: String) {
EditCallLinkNameDialogFragment().apply {
arguments = EditCallLinkNameDialogFragmentArgs.Builder(name).build().toBundle()
}.show(webRtcCallActivity.supportFragmentManager, null)
}
override fun onToggleAdminApprovalClicked(checked: Boolean) {
controlsAndInfoViewModel.setApproveAllMembers(checked)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to change restrictions. $it")
toastFailure()
}
}, onError = handleError("onApproveAllMembersChanged"))
.addTo(disposables)
}
override fun onBlock(callParticipant: CallParticipant) {
MaterialAlertDialogBuilder(webRtcCallActivity)
.setNegativeButton(android.R.string.cancel, null)
.setMessage(webRtcCallView.resources.getString(R.string.CallLinkInfoSheet__remove_s_from_the_call, callParticipant.recipient.getShortDisplayName(webRtcCallActivity)))
.setPositiveButton(R.string.CallLinkInfoSheet__remove) { _, _ ->
ApplicationDependencies.getSignalCallManager().removeFromCallLink(callParticipant)
}
.setNeutralButton(R.string.CallLinkInfoSheet__block_from_call) { _, _ ->
ApplicationDependencies.getSignalCallManager().blockFromCallLink(callParticipant)
}
.show()
}
private fun setName(name: String) {
controlsAndInfoViewModel.setName(name)
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy(
onSuccess = {
if (it !is UpdateCallLinkResult.Success) {
Log.w(TAG, "Failed to set name. $it")
toastFailure()
}
},
onError = handleError("setName")
)
.addTo(disposables)
}
private fun handleError(method: String): (throwable: Throwable) -> Unit {
return {
Log.w(TAG, "Failure during $method", it)
toastFailure()
}
}
private fun toastFailure() {
Toast.makeText(webRtcCallActivity, R.string.CallLinkDetailsFragment__couldnt_save_changes, Toast.LENGTH_LONG).show()
}
private fun ConstraintSet.setControlConstraints(@IdRes viewId: Int, visible: Boolean, @Px horizontalMargins: Int) {
setVisibility(viewId, if (visible) View.VISIBLE else View.GONE)
setMargin(viewId, ConstraintSet.START, horizontalMargins)

View file

@ -8,15 +8,66 @@ package org.thoughtcrime.securesms.components.webrtc.controls
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsRepository
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
/**
* Provides a view model communicating with the Controls and Info view, [CallInfoView].
*/
class ControlsAndInfoViewModel : ViewModel() {
class ControlsAndInfoViewModel(
private val repository: CallLinkDetailsRepository = CallLinkDetailsRepository(),
private val mutationRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
) : ViewModel() {
private val disposables = CompositeDisposable()
private var callRecipientId: RecipientId? = null
private val _state = mutableStateOf(ControlAndInfoState())
val state: State<ControlAndInfoState> = _state
val rootKeySnapshot: ByteArray
get() = state.value.callLink?.credentials?.linkKeyBytes ?: error("Call link not loaded yet.")
fun setRecipient(recipient: Recipient) {
if (recipient.isCallLink && callRecipientId != recipient.id) {
callRecipientId = recipient.id
disposables += repository.refreshCallLinkState(recipient.requireCallLinkRoomId())
disposables += CallLinks.watchCallLink(recipient.requireCallLinkRoomId()).subscribeBy {
_state.value = _state.value.copy(callLink = it)
}
}
}
override fun onCleared() {
super.onCleared()
disposables.dispose()
}
fun resetScrollState() {
_state.value = _state.value.copy(resetScrollState = System.currentTimeMillis())
}
fun setApproveAllMembers(approveAllMembers: Boolean): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.setCallRestrictions(credentials, if (approveAllMembers) CallLinkState.Restrictions.ADMIN_APPROVAL else CallLinkState.Restrictions.NONE)
}
fun setName(name: String): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.setCallName(credentials, name)
}
class Factory : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ControlsAndInfoViewModel()) as T
}
}
}

View file

@ -13,7 +13,7 @@ sealed interface UpdateCallLinkResult {
val state: SignalCallLinkState
) : UpdateCallLinkResult
class Failure(
data class Failure(
val status: Short
) : UpdateCallLinkResult

View file

@ -15,6 +15,10 @@ public class Stub<T extends View> {
this.viewStub = viewStub;
}
public int getId() {
return (viewStub != null) ? viewStub.getId() : view.getId();
}
public T get() {
if (view == null) {
//noinspection unchecked

View file

@ -373,7 +373,7 @@
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_constraintBottom_toTopOf="@id/call_screen_above_controls_guideline"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />

View file

@ -76,6 +76,7 @@ object Rows {
fun ToggleRow(
checked: Boolean,
text: String,
textColor: Color = MaterialTheme.colorScheme.onSurface,
onCheckChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
@ -86,6 +87,7 @@ object Rows {
) {
Text(
text = text,
color = textColor,
modifier = Modifier
.weight(1f)
.align(CenterVertically)