Add new navigation and pane support.

This commit is contained in:
Alex Hart
2025-09-18 16:22:06 -03:00
committed by Jeffrey Starke
parent 146a5f5701
commit fd999be41a
28 changed files with 1272 additions and 731 deletions

View File

@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.calls.links
import android.app.Dialog
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -35,11 +36,18 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.util.BreakIteratorCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsViewModel
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.window.WindowSizeClass
class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
@@ -66,61 +74,109 @@ class EditCallLinkNameDialogFragment : ComposeDialogFragment() {
@Preview
@Composable
override fun DialogContent() {
var callName by remember {
mutableStateOf(
TextFieldValue(
text = argName,
selection = TextRange(argName.length)
)
EditCallLinkNameScreen(
initialNameValue = argName,
onSaveClick = {
setFragmentResult(RESULT_KEY, bundleOf(RESULT_KEY to it))
dismiss()
},
onNavigationClick = {
dismiss()
}
)
}
}
@Composable
fun EditCallLinkNameScreen(
roomId: CallLinkRoomId
) {
val viewModel: CallLinkDetailsViewModel = viewModel {
CallLinkDetailsViewModel(roomId)
}
val backPressedDispatcherOwner = LocalOnBackPressedDispatcherOwner.current
val lifecycleScope = LocalLifecycleOwner.current.lifecycleScope
EditCallLinkNameScreen(
initialNameValue = viewModel.nameSnapshot,
onSaveClick = {
lifecycleScope.launch {
viewModel.setName(it)
backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed()
}
},
onNavigationClick = {
backPressedDispatcherOwner?.onBackPressedDispatcher?.onBackPressed()
},
showNavigationIcon = !WindowSizeClass.rememberWindowSizeClass().isSplitPane()
)
}
@Composable
private fun EditCallLinkNameScreen(
initialNameValue: String,
onSaveClick: (String) -> Unit,
onNavigationClick: () -> Unit,
showNavigationIcon: Boolean = true
) {
var callName by remember {
mutableStateOf(
TextFieldValue(
text = initialNameValue,
selection = TextRange(initialNameValue.length)
)
}
)
}
Scaffolds.Settings(
title = stringResource(id = R.string.EditCallLinkNameDialogFragment__edit_call_name),
onNavigationClick = this::dismiss,
navigationIcon = ImageVector.vectorResource(id = R.drawable.symbol_arrow_start_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { paddingValues ->
val focusRequester = remember { FocusRequester() }
val breakIterator = remember { BreakIteratorCompat.getInstance() }
Scaffolds.Settings(
title = stringResource(id = R.string.EditCallLinkNameDialogFragment__edit_call_name),
onNavigationClick = onNavigationClick,
navigationIcon = if (showNavigationIcon) {
ImageVector.vectorResource(id = R.drawable.symbol_arrow_start_24)
} else {
null
},
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { paddingValues ->
val focusRequester = remember { FocusRequester() }
val breakIterator = remember { BreakIteratorCompat.getInstance() }
Surface(modifier = Modifier.padding(paddingValues)) {
Column(
modifier = Modifier
.padding(
horizontal = dimensionResource(id = org.signal.core.ui.R.dimen.gutter)
)
.padding(top = 20.dp, bottom = 16.dp)
) {
TextField(
value = callName,
label = {
Text(text = stringResource(id = R.string.EditCallLinkNameDialogFragment__call_name))
},
onValueChange = {
callName = it.copy(text = breakIterator.apply { setText(it.text) }.take(32).toString())
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
Surface(modifier = Modifier.padding(paddingValues)) {
Column(
modifier = Modifier
.padding(
horizontal = dimensionResource(id = org.signal.core.ui.R.dimen.gutter)
)
Spacer(modifier = Modifier.weight(1f))
Buttons.MediumTonal(
onClick = {
setFragmentResult(RESULT_KEY, bundleOf(RESULT_KEY to callName.text))
dismiss()
},
modifier = Modifier.align(End)
) {
Text(text = stringResource(id = R.string.EditCallLinkNameDialogFragment__save))
}
.padding(top = 20.dp, bottom = 16.dp)
) {
TextField(
value = callName,
label = {
Text(text = stringResource(id = R.string.EditCallLinkNameDialogFragment__call_name))
},
onValueChange = {
callName = it.copy(text = breakIterator.apply { setText(it.text) }.take(32).toString())
},
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
)
Spacer(modifier = Modifier.weight(1f))
Buttons.MediumTonal(
onClick = {
onSaveClick(callName.text)
},
modifier = Modifier.align(End)
) {
Text(text = stringResource(id = R.string.EditCallLinkNameDialogFragment__save))
}
}
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
}
}

View File

@@ -1,5 +1,5 @@
/**
* Copyright 2023 Signal Messenger, LLC
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
@@ -7,22 +7,68 @@ package org.thoughtcrime.securesms.calls.links.details
import android.content.Context
import android.content.Intent
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.NavHostFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FragmentWrapperActivity
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.remember
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentActivity
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.getParcelableExtraCompat
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationRouter
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.util.viewModel
class CallLinkDetailsActivity : FragmentWrapperActivity() {
override fun getFragment(): Fragment = NavHostFragment.create(R.navigation.call_link_details, intent.extras!!.getBundle(BUNDLE))
class CallLinkDetailsActivity : FragmentActivity() {
companion object {
private const val BUNDLE = "bundle"
private const val ARG_ROOM_ID = "room.id"
fun createIntent(context: Context, callLinkRoomId: CallLinkRoomId): Intent {
return Intent(context, CallLinkDetailsActivity::class.java)
.putExtra(BUNDLE, CallLinkDetailsFragmentArgs.Builder(callLinkRoomId).build().toBundle())
.putExtra(ARG_ROOM_ID, callLinkRoomId)
}
}
private val roomId: CallLinkRoomId
get() = intent.getParcelableExtraCompat(ARG_ROOM_ID, CallLinkRoomId::class.java)!!
private val viewModel: CallLinkDetailsViewModel by viewModel {
CallLinkDetailsViewModel(roomId)
}
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
setContent {
SignalTheme {
CallLinkDetailsScreen(
roomId = roomId,
viewModel = viewModel,
router = remember { Router() }
)
}
}
}
private inner class Router : MainNavigationRouter {
override fun goTo(location: MainNavigationDetailLocation) {
when (location) {
is MainNavigationDetailLocation.Calls.EditCallLinkName -> {
EditCallLinkNameDialogFragment().apply {
arguments = bundleOf(EditCallLinkNameDialogFragment.ARG_NAME to viewModel.nameSnapshot)
}.show(supportFragmentManager, null)
}
else -> error("Unsupported route $location")
}
}
override fun goTo(location: MainNavigationListLocation) = Unit
}
}

View File

@@ -1,363 +0,0 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
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.core.app.ActivityCompat
import androidx.core.app.ShareCompat
import androidx.fragment.app.viewModels
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkState.Restrictions
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.YouAreAlreadyInACallSnackbar
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment
import org.thoughtcrime.securesms.calls.links.SignalCallRow
import org.thoughtcrime.securesms.compose.ComposeFragment
import org.thoughtcrime.securesms.database.CallLinkTable
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.sharing.v2.ShareActivity
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.Util
import java.time.Instant
/**
* Provides detailed info about a call link and allows the owner of that link
* to modify call properties.
*/
class CallLinkDetailsFragment : ComposeFragment(), CallLinkDetailsCallback {
companion object {
private val TAG = Log.tag(CallLinkDetailsFragment::class.java)
}
private val args: CallLinkDetailsFragmentArgs by navArgs()
private val viewModel: CallLinkDetailsViewModel by viewModels(factoryProducer = {
CallLinkDetailsViewModel.Factory(args.roomId)
})
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 FragmentContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
val showAlreadyInACall by viewModel.showAlreadyInACall.collectAsStateWithLifecycle(false)
CallLinkDetails(
state,
showAlreadyInACall,
this
)
}
override fun onNavigationClicked() {
ActivityCompat.finishAfterTransition(requireActivity())
}
override fun onJoinClicked() {
val recipientSnapshot = viewModel.recipientSnapshot
if (recipientSnapshot != null) {
CommunicationActions.startVideoCall(this, recipientSnapshot) {
viewModel.showAlreadyInACall(true)
}
}
}
override fun onEditNameClicked() {
val name = viewModel.nameSnapshot
findNavController().navigate(
CallLinkDetailsFragmentDirections.actionCallLinkDetailsFragmentToEditCallLinkNameDialogFragment(name)
)
}
override fun onShareClicked() {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(requireContext())
.setText(CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
.setType(mimeType)
.createChooserIntent()
try {
startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
}
}
override fun onCopyClicked() {
Util.copyToClipboard(requireContext(), CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
Toast.makeText(requireContext(), R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
}
override fun onShareLinkViaSignalClicked() {
startActivity(
ShareActivity.sendSimpleText(
requireContext(),
getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
)
)
}
override fun onDeleteClicked() {
viewModel.setDisplayRevocationDialog(true)
}
override fun onDeleteConfirmed() {
viewModel.setDisplayRevocationDialog(false)
lifecycleDisposable += viewModel.delete().observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
when (it) {
is UpdateCallLinkResult.Delete -> ActivityCompat.finishAfterTransition(requireActivity())
is UpdateCallLinkResult.CallLinkIsInUse -> {
Log.w(TAG, "Failed to delete in-use call link.")
toastCouldNotDeleteCallLink()
}
else -> {
Log.w(TAG, "Failed to delete call link. $it")
toastFailure()
}
}
}, onError = handleError("onDeleteClicked"))
}
override fun onDeleteCanceled() {
viewModel.setDisplayRevocationDialog(false)
}
override fun onApproveAllMembersChanged(checked: Boolean) {
lifecycleDisposable += viewModel.setApproveAllMembers(checked).observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
if (it is UpdateCallLinkResult.Failure) {
Log.w(TAG, "Failed to change restrictions. $it")
if (it.status == 409.toShort()) {
toastCallLinkInUse()
} else {
toastFailure()
}
}
}, onError = handleError("onApproveAllMembersChanged"))
}
private fun setName(name: String) {
lifecycleDisposable += viewModel.setName(name).observeOn(AndroidSchedulers.mainThread()).subscribeBy(onSuccess = {
if (it !is UpdateCallLinkResult.Update) {
Log.w(TAG, "Failed to set name. $it")
toastFailure()
}
}, onError = handleError("setName"))
}
private fun handleError(method: String): (throwable: Throwable) -> Unit {
return {
Log.w(TAG, "Failure during $method", it)
toastFailure()
}
}
private fun toastCallLinkInUse() {
Snackbar.make(requireView(), R.string.CallLinkDetailsFragment__couldnt_update_admin_approval, Snackbar.LENGTH_LONG).show()
}
private fun toastFailure() {
Snackbar.make(requireView(), R.string.CallLinkDetailsFragment__couldnt_save_changes, Snackbar.LENGTH_LONG).show()
}
private fun toastCouldNotDeleteCallLink() {
Snackbar.make(requireView(), R.string.CallLinkDetailsFragment__couldnt_delete_call_link, Snackbar.LENGTH_LONG).show()
}
}
private interface CallLinkDetailsCallback {
fun onNavigationClicked()
fun onJoinClicked()
fun onEditNameClicked()
fun onShareClicked()
fun onCopyClicked()
fun onShareLinkViaSignalClicked()
fun onDeleteClicked()
fun onDeleteConfirmed()
fun onDeleteCanceled()
fun onApproveAllMembersChanged(checked: Boolean)
}
@Preview
@Composable
private fun CallLinkDetailsPreview() {
val callLink = remember {
val credentials = CallLinkCredentials(
byteArrayOf(1, 2, 3, 4),
byteArrayOf(0, 1, 2, 3),
byteArrayOf(3, 4, 5, 6)
)
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = CallLinkRoomId.fromBytes(byteArrayOf(1, 2, 3, 4)),
credentials = credentials,
state = SignalCallLinkState(
name = "Call Name",
revoked = false,
restrictions = Restrictions.NONE,
expiration = Instant.MAX
),
deletionTimestamp = 0L
)
}
SignalTheme(false) {
CallLinkDetails(
CallLinkDetailsState(
false,
false,
callLink
),
true,
object : CallLinkDetailsCallback {
override fun onDeleteConfirmed() = Unit
override fun onDeleteCanceled() = Unit
override fun onNavigationClicked() = Unit
override fun onJoinClicked() = Unit
override fun onEditNameClicked() = Unit
override fun onShareClicked() = Unit
override fun onCopyClicked() = Unit
override fun onShareLinkViaSignalClicked() = Unit
override fun onDeleteClicked() = Unit
override fun onApproveAllMembersChanged(checked: Boolean) = Unit
}
)
}
}
@Composable
private fun CallLinkDetails(
state: CallLinkDetailsState,
showAlreadyInACall: Boolean,
callback: CallLinkDetailsCallback
) {
Scaffolds.Settings(
title = stringResource(id = R.string.CallLinkDetailsFragment__call_details),
snackbarHost = {
YouAreAlreadyInACallSnackbar(showAlreadyInACall)
},
onNavigationClick = callback::onNavigationClicked,
navigationIcon = ImageVector.vectorResource(id = R.drawable.symbol_arrow_start_24)
) { paddingValues ->
if (state.callLink == null) {
return@Settings
}
Column(
modifier = Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())
) {
SignalCallRow(
callLink = state.callLink,
callLinkPeekInfo = state.peekInfo,
onJoinClicked = callback::onJoinClicked,
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
)
if (state.callLink.credentials?.adminPassBytes != null) {
Rows.TextRow(
text = stringResource(
id = if (state.callLink.state.name.isEmpty()) {
R.string.CreateCallLinkBottomSheetDialogFragment__add_call_name
} else {
R.string.CreateCallLinkBottomSheetDialogFragment__edit_call_name
}
),
onClick = callback::onEditNameClicked
)
Rows.ToggleRow(
checked = state.callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
text = stringResource(id = R.string.CallLinkDetailsFragment__require_admin_approval),
onCheckChanged = callback::onApproveAllMembersChanged,
isLoading = state.isLoadingAdminApprovalChange
)
Dividers.Default()
}
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link_via_signal),
icon = ImageVector.vectorResource(id = R.drawable.symbol_forward_24),
onClick = callback::onShareLinkViaSignalClicked
)
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__copy_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_copy_android_24),
onClick = callback::onCopyClicked
)
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24),
onClick = callback::onShareClicked
)
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__delete_call_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_trash_24),
foregroundTint = MaterialTheme.colorScheme.error,
onClick = callback::onDeleteClicked
)
}
if (state.displayRevocationDialog) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.CallLinkDetailsFragment__delete_link),
body = stringResource(id = R.string.CallLinkDetailsFragment__this_link_will_no_longer_work),
confirm = stringResource(id = R.string.delete),
dismiss = stringResource(id = android.R.string.cancel),
onConfirm = callback::onDeleteConfirmed,
onDismiss = callback::onDeleteCanceled
)
}
}
}

View File

@@ -0,0 +1,357 @@
/**
* Copyright 2023 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.calls.links.details
import android.content.ActivityNotFoundException
import android.content.Intent
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
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.core.app.ShareCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.Dialogs
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.Snackbars
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.ringrtc.CallLinkState.Restrictions
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.YouAreAlreadyInACallSnackbar
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.SignalCallRow
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationRouter
import org.thoughtcrime.securesms.main.MainNavigationViewModel
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.sharing.v2.ShareActivity
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.window.WindowSizeClass
import java.time.Instant
@Composable
fun CallLinkDetailsScreen(
roomId: CallLinkRoomId,
viewModel: CallLinkDetailsViewModel = viewModel {
CallLinkDetailsViewModel(roomId)
},
router: MainNavigationRouter = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalContext.current as ComponentActivity) {
error("Should already be created.")
}
) {
val activity = LocalContext.current as FragmentActivity
val callback = remember {
DefaultCallLinkDetailsCallback(
activity = activity,
viewModel = viewModel,
router = router
)
}
val state by viewModel.state.collectAsStateWithLifecycle(activity)
val showAlreadyInACall by viewModel.showAlreadyInACall.collectAsStateWithLifecycle(initialValue = false, lifecycleOwner = activity)
CallLinkDetailsScreen(
state = state,
showAlreadyInACall = showAlreadyInACall,
callback = callback,
showNavigationIcon = !WindowSizeClass.rememberWindowSizeClass().isSplitPane()
)
}
class DefaultCallLinkDetailsCallback(
private val activity: FragmentActivity,
private val viewModel: CallLinkDetailsViewModel,
private val router: MainNavigationRouter
) : CallLinkDetailsCallback {
private val lifecycleDisposable = LifecycleDisposable()
init {
lifecycleDisposable.bindTo(activity)
}
override fun onNavigationClicked() {
activity.onBackPressedDispatcher.onBackPressed()
}
override fun onJoinClicked() {
val recipientSnapshot = viewModel.recipientSnapshot
if (recipientSnapshot != null) {
CommunicationActions.startVideoCall(activity, recipientSnapshot) {
viewModel.showAlreadyInACall(true)
}
}
}
override fun onEditNameClicked() {
router.goTo(MainNavigationDetailLocation.Calls.EditCallLinkName(callLinkRoomId = viewModel.recipientSnapshot!!.requireCallLinkRoomId()))
}
override fun onShareClicked() {
val mimeType = Intent.normalizeMimeType("text/plain")
val shareIntent = ShareCompat.IntentBuilder(activity)
.setText(CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
.setType(mimeType)
.createChooserIntent()
try {
activity.startActivity(shareIntent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(activity, R.string.CreateCallLinkBottomSheetDialogFragment__failed_to_open_share_sheet, Toast.LENGTH_LONG).show()
}
}
override fun onCopyClicked() {
Util.copyToClipboard(activity, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
Toast.makeText(activity, R.string.CreateCallLinkBottomSheetDialogFragment__copied_to_clipboard, Toast.LENGTH_LONG).show()
}
override fun onShareLinkViaSignalClicked() {
activity.startActivity(
ShareActivity.sendSimpleText(
activity,
activity.getString(R.string.CreateCallLink__use_this_link_to_join_a_signal_call, CallLinks.url(viewModel.rootKeySnapshot, viewModel.epochSnapshot))
)
)
}
override fun onDeleteClicked() {
viewModel.setDisplayRevocationDialog(true)
}
override fun onDeleteConfirmed() {
viewModel.setDisplayRevocationDialog(false)
activity.lifecycleScope.launch {
if (viewModel.delete()) {
router.goTo(MainNavigationListLocation.CALLS)
router.goTo(MainNavigationDetailLocation.Empty)
}
}
}
override fun onDeleteCanceled() {
viewModel.setDisplayRevocationDialog(false)
}
override fun onApproveAllMembersChanged(checked: Boolean) {
activity.lifecycleScope.launch {
viewModel.setApproveAllMembers(checked)
}
}
}
interface CallLinkDetailsCallback {
fun onNavigationClicked() = Unit
fun onJoinClicked() = Unit
fun onEditNameClicked() = Unit
fun onShareClicked() = Unit
fun onCopyClicked() = Unit
fun onShareLinkViaSignalClicked() = Unit
fun onDeleteClicked() = Unit
fun onDeleteConfirmed() = Unit
fun onDeleteCanceled() = Unit
fun onApproveAllMembersChanged(checked: Boolean) = Unit
object Empty : CallLinkDetailsCallback
}
@Composable
fun CallLinkDetailsScreen(
state: CallLinkDetailsState,
showAlreadyInACall: Boolean,
callback: CallLinkDetailsCallback,
showNavigationIcon: Boolean = true
) {
Scaffolds.Settings(
title = stringResource(id = R.string.CallLinkDetailsFragment__call_details),
snackbarHost = {
YouAreAlreadyInACallSnackbar(showAlreadyInACall)
FailureSnackbar(failureSnackbar = state.failureSnackbar)
},
onNavigationClick = callback::onNavigationClicked,
navigationIcon = if (showNavigationIcon) {
ImageVector.vectorResource(id = R.drawable.symbol_arrow_start_24)
} else {
null
}
) { paddingValues ->
if (state.callLink == null) {
return@Settings
}
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.fillMaxHeight()
) {
item {
SignalCallRow(
callLink = state.callLink,
callLinkPeekInfo = state.peekInfo,
onJoinClicked = callback::onJoinClicked,
modifier = Modifier.padding(top = 16.dp, bottom = 12.dp)
)
}
if (state.callLink.credentials?.adminPassBytes != null) {
item {
Rows.TextRow(
text = stringResource(
id = if (state.callLink.state.name.isEmpty()) {
R.string.CreateCallLinkBottomSheetDialogFragment__add_call_name
} else {
R.string.CreateCallLinkBottomSheetDialogFragment__edit_call_name
}
),
onClick = callback::onEditNameClicked
)
}
item {
Rows.ToggleRow(
checked = state.callLink.state.restrictions == Restrictions.ADMIN_APPROVAL,
text = stringResource(id = R.string.CallLinkDetailsFragment__require_admin_approval),
onCheckChanged = callback::onApproveAllMembersChanged,
isLoading = state.isLoadingAdminApprovalChange
)
}
item {
Dividers.Default()
}
}
item {
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__share_link_via_signal),
icon = ImageVector.vectorResource(id = R.drawable.symbol_forward_24),
onClick = callback::onShareLinkViaSignalClicked
)
}
item {
Rows.TextRow(
text = stringResource(id = R.string.CreateCallLinkBottomSheetDialogFragment__copy_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_copy_android_24),
onClick = callback::onCopyClicked
)
}
item {
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__share_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_link_24),
onClick = callback::onShareClicked
)
}
item {
Rows.TextRow(
text = stringResource(id = R.string.CallLinkDetailsFragment__delete_call_link),
icon = ImageVector.vectorResource(id = R.drawable.symbol_trash_24),
foregroundTint = MaterialTheme.colorScheme.error,
onClick = callback::onDeleteClicked
)
}
}
if (state.displayRevocationDialog) {
Dialogs.SimpleAlertDialog(
title = stringResource(R.string.CallLinkDetailsFragment__delete_link),
body = stringResource(id = R.string.CallLinkDetailsFragment__this_link_will_no_longer_work),
confirm = stringResource(id = R.string.delete),
dismiss = stringResource(id = android.R.string.cancel),
onConfirm = callback::onDeleteConfirmed,
onDismiss = callback::onDeleteCanceled
)
}
}
}
@Composable
private fun FailureSnackbar(
failureSnackbar: CallLinkDetailsState.FailureSnackbar?,
modifier: Modifier = Modifier
) {
val message: String? = when (failureSnackbar) {
CallLinkDetailsState.FailureSnackbar.COULD_NOT_DELETE_CALL_LINK -> stringResource(R.string.CallLinkDetailsFragment__couldnt_delete_call_link)
CallLinkDetailsState.FailureSnackbar.COULD_NOT_SAVE_CHANGES -> stringResource(R.string.CallLinkDetailsFragment__couldnt_save_changes)
CallLinkDetailsState.FailureSnackbar.COULD_NOT_UPDATE_ADMIN_APPROVAL -> stringResource(R.string.CallLinkDetailsFragment__couldnt_update_admin_approval)
null -> null
}
val hostState = remember { SnackbarHostState() }
Snackbars.Host(hostState, modifier = modifier)
LaunchedEffect(message) {
if (message != null) {
hostState.showSnackbar(message)
}
}
}
@Preview
@Composable
private fun CallLinkDetailsScreenPreview() {
val callLink = remember {
val credentials = CallLinkCredentials(
byteArrayOf(1, 2, 3, 4),
byteArrayOf(0, 1, 2, 3),
byteArrayOf(3, 4, 5, 6)
)
CallLinkTable.CallLink(
recipientId = RecipientId.UNKNOWN,
roomId = CallLinkRoomId.fromBytes(byteArrayOf(1, 2, 3, 4)),
credentials = credentials,
state = SignalCallLinkState(
name = "Call Name",
revoked = false,
restrictions = Restrictions.NONE,
expiration = Instant.MAX
),
deletionTimestamp = 0L
)
}
SignalTheme(false) {
CallLinkDetailsScreen(
CallLinkDetailsState(
false,
false,
callLink
),
true,
CallLinkDetailsCallback.Empty
)
}
}

View File

@@ -5,14 +5,19 @@
package org.thoughtcrime.securesms.calls.links.details
import androidx.compose.runtime.Immutable
import org.thoughtcrime.securesms.database.CallLinkTable
import org.thoughtcrime.securesms.service.webrtc.CallLinkPeekInfo
@Immutable
data class CallLinkDetailsState(
val displayRevocationDialog: Boolean = false,
val isLoadingAdminApprovalChange: Boolean = false,
val callLink: CallLinkTable.CallLink? = null,
val peekInfo: CallLinkPeekInfo? = null
)
val peekInfo: CallLinkPeekInfo? = null,
val failureSnackbar: FailureSnackbar? = null
) {
enum class FailureSnackbar {
COULD_NOT_DELETE_CALL_LINK,
COULD_NOT_SAVE_CHANGES,
COULD_NOT_UPDATE_ADMIN_APPROVAL
}
}

View File

@@ -7,16 +7,16 @@ package org.thoughtcrime.securesms.calls.links.details
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
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 io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.BehaviorSubject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import org.signal.core.util.logging.Log
import org.signal.ringrtc.CallLinkState
import org.thoughtcrime.securesms.calls.links.CallLinks
import org.thoughtcrime.securesms.calls.links.UpdateCallLinkRepository
@@ -24,12 +24,20 @@ import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import org.thoughtcrime.securesms.service.webrtc.links.UpdateCallLinkResult
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@OptIn(ExperimentalCoroutinesApi::class)
class CallLinkDetailsViewModel(
callLinkRoomId: CallLinkRoomId,
repository: CallLinkDetailsRepository = CallLinkDetailsRepository(),
private val mutationRepository: UpdateCallLinkRepository = UpdateCallLinkRepository()
) : ViewModel() {
companion object {
private val TAG = Log.tag(CallLinkDetailsViewModel::class)
}
private val disposables = CompositeDisposable()
private val _state: MutableStateFlow<CallLinkDetailsState> = MutableStateFlow(CallLinkDetailsState())
@@ -54,7 +62,6 @@ class CallLinkDetailsViewModel(
disposables += repository.refreshCallLinkState(callLinkRoomId)
disposables += CallLinks.watchCallLink(callLinkRoomId)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy { callLink ->
_state.update { it.copy(callLink = callLink) }
}
@@ -75,7 +82,6 @@ class CallLinkDetailsViewModel(
.toObservable()
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy { callLinkPeekInfo ->
_state.update { it.copy(peekInfo = callLinkPeekInfo) }
}
@@ -94,26 +100,102 @@ class CallLinkDetailsViewModel(
_state.update { it.copy(displayRevocationDialog = displayRevocationDialog) }
}
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)
.doOnSubscribe {
_state.update { it.copy(isLoadingAdminApprovalChange = true) }
}
.doFinally {
_state.update { it.copy(isLoadingAdminApprovalChange = false) }
suspend fun setApproveAllMembers(approveAllMembers: Boolean) {
val result = suspendCoroutine { continuation ->
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
disposables += mutationRepository
.setCallRestrictions(credentials, if (approveAllMembers) CallLinkState.Restrictions.ADMIN_APPROVAL else CallLinkState.Restrictions.NONE)
.doOnSubscribe {
_state.update { it.copy(isLoadingAdminApprovalChange = true) }
}
.doFinally {
_state.update { it.copy(isLoadingAdminApprovalChange = false) }
}
.subscribeBy(
onSuccess = { continuation.resume(Result.success(it)) },
onError = { continuation.resume(Result.failure(it)) }
)
}.getOrNull()
if (result == null) {
handleError("setApproveAllMembers")
return
}
if (result is UpdateCallLinkResult.Failure) {
Log.w(TAG, "Failed to change restrictions. $result")
if (result.status == 409.toShort()) {
toastCallLinkInUse()
} else {
toastFailure()
}
}
}
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)
suspend fun setName(name: String) {
val result = suspendCoroutine { continuation ->
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
disposables += mutationRepository.setCallName(credentials, name)
.subscribeBy(
onSuccess = { continuation.resume(Result.success(it)) },
onError = { continuation.resume(Result.failure(it)) }
)
}.getOrNull()
if (result == null) {
handleError("setName")
} else {
if (result !is UpdateCallLinkResult.Update) {
Log.w(TAG, "Failed to set name. $name")
toastFailure()
}
}
}
fun delete(): Single<UpdateCallLinkResult> {
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
return mutationRepository.deleteCallLink(credentials)
suspend fun delete(): Boolean {
val result = suspendCoroutine { continuation ->
val credentials = _state.value.callLink?.credentials ?: error("User cannot change the name of this call.")
disposables += mutationRepository.deleteCallLink(credentials)
.subscribeBy(
onSuccess = { continuation.resume(Result.success(it)) },
onError = { continuation.resume(Result.failure(it)) }
)
}.getOrNull()
when (result) {
null -> handleError("delete")
is UpdateCallLinkResult.Delete -> return true
is UpdateCallLinkResult.CallLinkIsInUse -> {
Log.w(TAG, "Failed to delete in-use call link.")
toastCouldNotDeleteCallLink()
}
else -> {
Log.w(TAG, "Failed to delete call link. $result")
toastFailure()
}
}
return false
}
private fun handleError(method: String): (throwable: Throwable) -> Unit {
return {
Log.w(TAG, "Failure during $method", it)
toastFailure()
}
}
private fun toastCallLinkInUse() {
_state.update { it.copy(failureSnackbar = CallLinkDetailsState.FailureSnackbar.COULD_NOT_UPDATE_ADMIN_APPROVAL) }
}
private fun toastFailure() {
_state.update { it.copy(failureSnackbar = CallLinkDetailsState.FailureSnackbar.COULD_NOT_SAVE_CHANGES) }
}
private fun toastCouldNotDeleteCallLink() {
_state.update { it.copy(failureSnackbar = CallLinkDetailsState.FailureSnackbar.COULD_NOT_DELETE_CALL_LINK) }
}
class Factory(private val callLinkRoomId: CallLinkRoomId) : ViewModelProvider.Factory {

View File

@@ -11,12 +11,16 @@ import androidx.compose.material3.SnackbarDuration
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.kotlin.Flowables
import io.reactivex.rxjava3.kotlin.subscribeBy
import kotlinx.coroutines.launch
import org.signal.core.util.DimensionUnit
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.concurrent.addTo
@@ -41,6 +45,7 @@ import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterLerp
import org.thoughtcrime.securesms.conversationlist.chatfilter.FilterPullState
import org.thoughtcrime.securesms.databinding.CallLogFragmentBinding
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.main.MainNavigationDetailLocation
import org.thoughtcrime.securesms.main.MainNavigationListLocation
import org.thoughtcrime.securesms.main.MainNavigationViewModel
import org.thoughtcrime.securesms.main.MainToolbarMode
@@ -78,6 +83,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
private lateinit var callLogActionMode: CallLogActionMode
private val conversationUpdateTick: ConversationUpdateTick = ConversationUpdateTick(this::onTimestampTick)
private var callLogAdapter: CallLogAdapter? = null
private val backPressedCallback = OnBackPressed()
private lateinit var signalBottomActionBarController: SignalBottomActionBarController
@@ -165,16 +171,14 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
initializePullToFilter(scrollToPositionDelegate)
initializeTapToScrollToTop(scrollToPositionDelegate)
requireActivity().onBackPressedDispatcher.addCallback(
viewLifecycleOwner,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (!closeSearchIfOpen()) {
mainNavigationViewModel.onChatsSelected()
}
requireActivity().onBackPressedDispatcher.addCallback(backPressedCallback)
viewLifecycleOwner.lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.RESUMED) {
mainToolbarViewModel.state.collect {
backPressedCallback.isEnabled = it.mode == MainToolbarMode.SEARCH
}
}
)
}
if (resources.getWindowSizeClass().isCompact()) {
ViewUtil.setBottomMargin(binding.bottomActionBar, ViewUtil.getNavigationBarHeight(binding.bottomActionBar))
@@ -316,7 +320,7 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
if (viewModel.selectionStateSnapshot.isNotEmpty(binding.recycler.adapter!!.itemCount)) {
viewModel.toggleSelected(callLogRow.id)
} else {
startActivity(CallLinkDetailsActivity.createIntent(requireContext(), callLogRow.record.roomId))
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Calls.CallLinkDetails(callLogRow.record.roomId))
}
}
@@ -482,6 +486,12 @@ class CallLogFragment : Fragment(R.layout.call_log_fragment), CallLogAdapter.Cal
}
}
private inner class OnBackPressed : OnBackPressedCallback(enabled = false) {
override fun handleOnBackPressed() {
closeSearchIfOpen()
}
}
interface Callback {
fun onMultiSelectStarted()
fun onMultiSelectFinished()