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

@@ -18,12 +18,12 @@ import android.view.ViewTreeObserver
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
@@ -51,7 +51,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.fragment.app.DialogFragment
@@ -62,6 +61,7 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.navigation.compose.rememberNavController
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
@@ -78,6 +78,7 @@ import org.signal.donations.StripeApi
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsActivity
import org.thoughtcrime.securesms.calls.log.CallLogFilter
import org.thoughtcrime.securesms.calls.log.CallLogFragment
import org.thoughtcrime.securesms.calls.new.NewCallActivity
@@ -91,7 +92,6 @@ import org.thoughtcrime.securesms.components.settings.app.notifications.manual.N
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController
import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.conversation.v2.MotionEventRelay
import org.thoughtcrime.securesms.conversation.v2.ShareDataTimestampViewModel
import org.thoughtcrime.securesms.conversationlist.ConversationListArchiveFragment
@@ -104,6 +104,9 @@ import org.thoughtcrime.securesms.devicetransfer.olddevice.OldDeviceExitActivity
import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.lock.v2.CreateSvrPinActivity
import org.thoughtcrime.securesms.main.CallsNavHost
import org.thoughtcrime.securesms.main.ChatsNavHost
import org.thoughtcrime.securesms.main.EmptyDetailScreen
import org.thoughtcrime.securesms.main.MainBottomChrome
import org.thoughtcrime.securesms.main.MainBottomChromeCallback
import org.thoughtcrime.securesms.main.MainBottomChromeState
@@ -122,6 +125,7 @@ import org.thoughtcrime.securesms.main.MainToolbarViewModel
import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder
import org.thoughtcrime.securesms.main.NavigationBarSpacerCompat
import org.thoughtcrime.securesms.main.SnackbarState
import org.thoughtcrime.securesms.main.StoriesNavHost
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.megaphone.Megaphone
@@ -312,8 +316,11 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
}
val isNavigationVisible = remember(mainToolbarState.mode) {
mainToolbarState.mode == MainToolbarMode.FULL
val isNavigationVisible = mainToolbarState.mode == MainToolbarMode.FULL
val isBackHandlerEnabled = mainToolbarState.destination != MainNavigationListLocation.CHATS
BackHandler(enabled = isBackHandlerEnabled) {
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
}
val mainBottomChromeState = remember(mainToolbarState.destination, snackbar, mainToolbarState.mode, megaphone) {
@@ -347,17 +354,13 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
val mutableInteractionSource = remember { MutableInteractionSource() }
LaunchedEffect(mainNavigationDetailLocation) {
println("Detail Location Changed pane:${wrappedNavigator.currentDestination?.pane}")
if (paneExpansionState.currentAnchor == listOnlyAnchor && wrappedNavigator.currentDestination?.pane == ThreePaneScaffoldRole.Primary) {
println("Animate detail")
paneExpansionState.animateTo(detailOnlyAnchor)
}
}
LaunchedEffect(mainNavigationState.currentListLocation) {
println("List Location Changed pane:${wrappedNavigator.currentDestination?.pane}")
if (paneExpansionState.currentAnchor == detailOnlyAnchor && wrappedNavigator.currentDestination?.pane == ThreePaneScaffoldRole.Secondary) {
println("Animate list")
paneExpansionState.animateTo(listOnlyAnchor)
}
}
@@ -462,35 +465,33 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
}
},
detailContent = {
when (val destination = mainNavigationDetailLocation) {
is MainNavigationDetailLocation.Conversation -> {
val fragmentState = key(destination) { rememberFragmentState() }
AndroidFragment(
clazz = ConversationFragment::class.java,
fragmentState = fragmentState,
arguments = requireNotNull(destination.intent.extras) { "Handed null Conversation intent arguments." },
modifier = Modifier
.padding(end = contentLayoutData.detailPaddingEnd)
.clip(contentLayoutData.shape)
.background(color = MaterialTheme.colorScheme.surface)
.fillMaxSize()
when (val location = mainNavigationDetailLocation) {
MainNavigationDetailLocation.Empty -> {
EmptyDetailScreen(contentLayoutData)
}
is MainNavigationDetailLocation.Chats -> {
ChatsNavHost(
currentDestination = location,
contentLayoutData = contentLayoutData
)
}
MainNavigationDetailLocation.Empty -> {
Box(
modifier = Modifier
.padding(end = contentLayoutData.detailPaddingEnd)
.clip(contentLayoutData.shape)
.background(color = MaterialTheme.colorScheme.surface)
.fillMaxSize()
) {
Image(
painter = painterResource(R.drawable.ic_signal_logo_large),
contentDescription = null,
modifier = Modifier.align(Alignment.Center)
)
}
is MainNavigationDetailLocation.Calls -> {
CallsNavHost(
currentDestination = location,
contentLayoutData = contentLayoutData
)
}
is MainNavigationDetailLocation.Stories -> {
val storiesNavigationController = rememberNavController()
StoriesNavHost(
navHostController = storiesNavigationController,
startDestination = location,
contentLayoutData = contentLayoutData
)
}
}
},
@@ -551,8 +552,20 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
return remember(scaffoldNavigator, coroutine) {
mainNavigationViewModel.wrapNavigator(coroutine, scaffoldNavigator) { detailLocation ->
when (detailLocation) {
is MainNavigationDetailLocation.Conversation -> {
startActivity(detailLocation.intent)
is MainNavigationDetailLocation.Chats.Conversation -> {
startActivity(
ConversationIntents.createBuilderSync(this, detailLocation.conversationArgs.recipientId, detailLocation.conversationArgs.threadId)
.withArgs(detailLocation.conversationArgs)
.build()
)
}
is MainNavigationDetailLocation.Calls.CallLinkDetails -> {
startActivity(CallLinkDetailsActivity.createIntent(this, detailLocation.callLinkRoomId))
}
is MainNavigationDetailLocation.Calls.EditCallLinkName -> {
error("Unexpected subroute EditCallLinkName.")
}
MainNavigationDetailLocation.Empty -> Unit
@@ -775,7 +788,7 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
if (ConversationIntents.isConversationIntent(intent)) {
Log.d(TAG, "Got conversation intent. Navigating to conversation.")
mainNavigationViewModel.goTo(MainNavigationListLocation.CHATS)
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Conversation(intent))
mainNavigationViewModel.goTo(MainNavigationDetailLocation.Chats.Conversation(ConversationIntents.readArgsFromBundle(intent.extras!!)))
}
}

View File

@@ -45,8 +45,8 @@ public class MainNavigator {
Disposable disposable = ConversationIntents.createBuilder(activity, recipientId, threadId)
.map(builder -> builder.withDistributionType(distributionType)
.withStartingPosition(startingPosition)
.build())
.subscribe(intent -> viewModel.goTo(new MainNavigationDetailLocation.Conversation(intent)));
.toConversationArgs())
.subscribe(args -> viewModel.goTo(new MainNavigationDetailLocation.Chats.Conversation(args)));
lifecycleDisposable.add(disposable);
}

View File

@@ -12,9 +12,11 @@ import com.bumptech.glide.load.Key
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.badges.load.BadgeSpriteTransformation
import org.thoughtcrime.securesms.components.settings.PreferenceModel
import org.thoughtcrime.securesms.serialization.UriSerializer
import org.thoughtcrime.securesms.util.ThemeUtil
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
@@ -28,12 +30,13 @@ typealias OnBadgeClicked = (Badge, Boolean, Boolean) -> Unit
*/
@Stable
@Parcelize
@Serializable
data class Badge(
val id: String,
val category: Category,
val name: String,
val description: String,
val imageUrl: Uri,
@Serializable(with = UriSerializer::class) val imageUrl: Uri,
val imageDensity: String,
val expirationTimestamp: Long,
val visible: Boolean,

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()

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation
import android.net.Uri
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import org.thoughtcrime.securesms.badges.models.Badge
import org.thoughtcrime.securesms.conversation.ConversationIntents.ConversationScreenType
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mms.SlideFactory
import org.thoughtcrime.securesms.recipients.Recipient.Companion.resolved
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.serialization.UriSerializer
import org.thoughtcrime.securesms.stickers.StickerLocator
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper
@Serializable
@Parcelize
data class ConversationArgs(
val recipientId: RecipientId,
@JvmField val threadId: Long,
val draftText: String?,
@Serializable(with = UriSerializer::class) val draftMedia: Uri?,
val draftContentType: String?,
val media: List<Media?>?,
val stickerLocator: StickerLocator?,
val isBorderless: Boolean,
val distributionType: Int,
val startingPosition: Int,
val isFirstTimeInSelfCreatedGroup: Boolean,
val isWithSearchOpen: Boolean,
val giftBadge: Badge?,
val shareDataTimestamp: Long,
val conversationScreenType: ConversationScreenType
) : Parcelable {
@IgnoredOnParcel
val draftMediaType: SlideFactory.MediaType? = SlideFactory.MediaType.from(draftContentType)
@IgnoredOnParcel
val wallpaper: ChatWallpaper?
get() = resolved(recipientId).wallpaper
@IgnoredOnParcel
val chatColors: ChatColors
get() = resolved(recipientId).chatColors
fun canInitializeFromDatabase(): Boolean {
return draftText == null && (draftMedia == null || ConversationIntents.isBubbleIntentUri(draftMedia) || ConversationIntents.isNotificationIntentUri(draftMedia)) && draftMediaType == null
}
}

View File

@@ -11,16 +11,13 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.conversation.v2.ConversationActivity;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.mms.SlideFactory;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.stickers.StickerLocator;
import org.thoughtcrime.securesms.wallpaper.ChatWallpaper;
import org.whispersystems.signalservice.api.util.Preconditions;
import java.util.ArrayList;
@@ -99,6 +96,12 @@ public class ConversationIntents {
return new Builder(context, ConversationActivity.class, recipientId, threadId, ConversationScreenType.NORMAL);
}
public static @NonNull Builder createBuilderSync(@NonNull Context context, @NonNull ConversationArgs conversationArgs) {
Preconditions.checkArgument(conversationArgs.threadId > 0, "threadId is invalid");
return new Builder(context, ConversationActivity.class, conversationArgs.getRecipientId(), conversationArgs.threadId, ConversationScreenType.NORMAL)
.withArgs(conversationArgs);
}
static @Nullable Uri getIntentData(@NonNull Bundle bundle) {
return bundle.getParcelable(INTENT_DATA);
}
@@ -132,170 +135,41 @@ public class ConversationIntents {
return ACTION.equals(intent.getAction());
}
public final static class Args {
private final RecipientId recipientId;
private final long threadId;
private final String draftText;
private final Uri draftMedia;
private final String draftContentType;
private final SlideFactory.MediaType draftMediaType;
private final ArrayList<Media> media;
private final StickerLocator stickerLocator;
private final boolean isBorderless;
private final int distributionType;
private final int startingPosition;
private final boolean firstTimeInSelfCreatedGroup;
private final boolean withSearchOpen;
private final Badge giftBadge;
private final long shareDataTimestamp;
private final ConversationScreenType conversationScreenType;
public static Args from(@NonNull Bundle arguments) {
Uri intentDataUri = getIntentData(arguments);
if (isBubbleIntentUri(intentDataUri)) {
return new Args(RecipientId.from(intentDataUri.getQueryParameter(EXTRA_RECIPIENT)),
Long.parseLong(intentDataUri.getQueryParameter(EXTRA_THREAD_ID)),
null,
null,
null,
null,
null,
false,
ThreadTable.DistributionTypes.DEFAULT,
-1,
false,
false,
null,
-1L,
ConversationScreenType.BUBBLE);
}
return new Args(RecipientId.from(Objects.requireNonNull(arguments.getString(EXTRA_RECIPIENT))),
arguments.getLong(EXTRA_THREAD_ID, -1),
arguments.getString(EXTRA_TEXT),
ConversationIntents.getIntentData(arguments),
ConversationIntents.getIntentType(arguments),
arguments.getParcelableArrayList(EXTRA_MEDIA),
arguments.getParcelable(EXTRA_STICKER),
arguments.getBoolean(EXTRA_BORDERLESS, false),
arguments.getInt(EXTRA_DISTRIBUTION_TYPE, ThreadTable.DistributionTypes.DEFAULT),
arguments.getInt(EXTRA_STARTING_POSITION, -1),
arguments.getBoolean(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, false),
arguments.getBoolean(EXTRA_WITH_SEARCH_OPEN, false),
arguments.getParcelable(EXTRA_GIFT_BADGE),
arguments.getLong(EXTRA_SHARE_DATA_TIMESTAMP, -1L),
ConversationScreenType.from(arguments.getInt(EXTRA_CONVERSATION_TYPE, 0)));
public static ConversationArgs readArgsFromBundle(@NonNull Bundle arguments) {
Uri intentDataUri = getIntentData(arguments);
if (isBubbleIntentUri(intentDataUri)) {
return new ConversationArgs(RecipientId.from(intentDataUri.getQueryParameter(EXTRA_RECIPIENT)),
Long.parseLong(intentDataUri.getQueryParameter(EXTRA_THREAD_ID)),
null,
null,
null,
null,
null,
false,
ThreadTable.DistributionTypes.DEFAULT,
-1,
false,
false,
null,
-1L,
ConversationScreenType.BUBBLE);
}
private Args(@NonNull RecipientId recipientId,
long threadId,
@Nullable String draftText,
@Nullable Uri draftMedia,
@Nullable String draftContentType,
@Nullable ArrayList<Media> media,
@Nullable StickerLocator stickerLocator,
boolean isBorderless,
int distributionType,
int startingPosition,
boolean firstTimeInSelfCreatedGroup,
boolean withSearchOpen,
@Nullable Badge giftBadge,
long shareDataTimestamp,
@NonNull ConversationScreenType conversationScreenType)
{
this.recipientId = recipientId;
this.threadId = threadId;
this.draftText = draftText;
this.draftMedia = draftMedia;
this.draftContentType = draftContentType;
this.media = media;
this.stickerLocator = stickerLocator;
this.isBorderless = isBorderless;
this.distributionType = distributionType;
this.startingPosition = startingPosition;
this.firstTimeInSelfCreatedGroup = firstTimeInSelfCreatedGroup;
this.withSearchOpen = withSearchOpen;
this.giftBadge = giftBadge;
this.shareDataTimestamp = shareDataTimestamp;
this.conversationScreenType = conversationScreenType;
this.draftMediaType = SlideFactory.MediaType.from(draftContentType);
}
public @NonNull RecipientId getRecipientId() {
return recipientId;
}
public long getThreadId() {
return threadId;
}
public @Nullable String getDraftText() {
return draftText;
}
public @Nullable Uri getDraftMedia() {
return draftMedia;
}
public @Nullable String getDraftContentType() {
return draftContentType;
}
public @Nullable SlideFactory.MediaType getDraftMediaType() {
return draftMediaType;
}
public @Nullable ArrayList<Media> getMedia() {
return media;
}
public @Nullable StickerLocator getStickerLocator() {
return stickerLocator;
}
public int getDistributionType() {
return distributionType;
}
public int getStartingPosition() {
return startingPosition;
}
public boolean isBorderless() {
return isBorderless;
}
public boolean isFirstTimeInSelfCreatedGroup() {
return firstTimeInSelfCreatedGroup;
}
public @Nullable ChatWallpaper getWallpaper() {
return Recipient.resolved(recipientId).getWallpaper();
}
public @NonNull ChatColors getChatColors() {
return Recipient.resolved(recipientId).getChatColors();
}
public boolean isWithSearchOpen() {
return withSearchOpen;
}
public @Nullable Badge getGiftBadge() {
return giftBadge;
}
public long getShareDataTimestamp() {
return shareDataTimestamp;
}
public @NonNull ConversationScreenType getConversationScreenType() {
return conversationScreenType;
}
public boolean canInitializeFromDatabase() {
return draftText == null && (draftMedia == null || ConversationIntents.isBubbleIntentUri(draftMedia) || ConversationIntents.isNotificationIntentUri(draftMedia)) && draftMediaType == null;
}
return new ConversationArgs(RecipientId.from(Objects.requireNonNull(arguments.getString(EXTRA_RECIPIENT))),
arguments.getLong(EXTRA_THREAD_ID, -1),
arguments.getString(EXTRA_TEXT),
ConversationIntents.getIntentData(arguments),
ConversationIntents.getIntentType(arguments),
arguments.getParcelableArrayList(EXTRA_MEDIA),
arguments.getParcelable(EXTRA_STICKER),
arguments.getBoolean(EXTRA_BORDERLESS, false),
arguments.getInt(EXTRA_DISTRIBUTION_TYPE, ThreadTable.DistributionTypes.DEFAULT),
arguments.getInt(EXTRA_STARTING_POSITION, -1),
arguments.getBoolean(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, false),
arguments.getBoolean(EXTRA_WITH_SEARCH_OPEN, false),
arguments.getParcelable(EXTRA_GIFT_BADGE),
arguments.getLong(EXTRA_SHARE_DATA_TIMESTAMP, -1L),
ConversationScreenType.from(arguments.getInt(EXTRA_CONVERSATION_TYPE, 0)));
}
public final static class Builder {
@@ -331,6 +205,23 @@ public class ConversationIntents {
this.conversationScreenType = conversationScreenType;
}
public @NonNull Builder withArgs(@NonNull ConversationArgs args) {
draftText = args.getDraftText();
media = args.getMedia();
stickerLocator = args.getStickerLocator();
isBorderless = args.isBorderless();
distributionType = args.getDistributionType();
startingPosition = args.getStartingPosition();
dataType = args.getDraftContentType();
dataUri = args.getDraftMedia();
firstTimeInSelfCreatedGroup = args.isFirstTimeInSelfCreatedGroup();
withSearchOpen = args.isWithSearchOpen();
giftBadge = args.getGiftBadge();
shareDataTimestamp = args.getShareDataTimestamp();
return this;
}
public @NonNull Builder withDraftText(@Nullable String draftText) {
this.draftText = draftText;
return this;
@@ -391,6 +282,26 @@ public class ConversationIntents {
return this;
}
public @NonNull ConversationArgs toConversationArgs() {
return new ConversationArgs(
recipientId,
threadId,
draftText,
dataUri,
dataType,
media,
stickerLocator,
isBorderless,
distributionType,
startingPosition,
firstTimeInSelfCreatedGroup,
withSearchOpen,
giftBadge,
shareDataTimestamp,
conversationScreenType
);
}
public @NonNull Intent build() {
if (stickerLocator != null && media != null) {
throw new IllegalStateException("Cannot have both sticker and media array");

View File

@@ -13,7 +13,7 @@ import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.location.SignalPlace
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory
import org.thoughtcrime.securesms.conversation.MessageStyler
@@ -53,7 +53,7 @@ class DraftRepository(
private val threadTable: ThreadTable = SignalDatabase.threads,
private val draftTable: DraftTable = SignalDatabase.drafts,
private val saveDraftsExecutor: Executor = SerialMonoLifoExecutor(SignalExecutors.BOUNDED),
private val conversationArguments: ConversationIntents.Args? = null
private val conversationArguments: ConversationArgs? = null
) {
companion object {

View File

@@ -154,6 +154,7 @@ import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity
import org.thoughtcrime.securesms.conversation.AttachmentKeyboardButton
import org.thoughtcrime.securesms.conversation.BadDecryptLearnMoreDialog
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
import org.thoughtcrime.securesms.conversation.ConversationData
import org.thoughtcrime.securesms.conversation.ConversationHeaderView
@@ -390,8 +391,8 @@ class ConversationFragment :
private const val IS_SCROLLED_TO_BOTTOM_THRESHOLD: Int = 2
}
private val args: ConversationIntents.Args by lazy {
ConversationIntents.Args.from(requireArguments())
private val args: ConversationArgs by lazy {
ConversationIntents.readArgsFromBundle(requireArguments())
}
private val conversationRecipientRepository: ConversationRecipientRepository by lazy {

View File

@@ -9,7 +9,7 @@ import android.content.Intent
import android.os.Bundle
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.util.delegate
/**
@@ -33,7 +33,7 @@ class ShareDataTimestampViewModel(
}
}
fun setTimestampFromConversationArgs(args: ConversationIntents.Args) {
fun setTimestampFromConversationArgs(args: ConversationArgs) {
timestamp = args.shareDataTimestamp
}
}

View File

@@ -18,7 +18,6 @@ package org.thoughtcrime.securesms.conversationlist;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.res.ColorStateList;
import android.content.res.Configuration;
import android.content.res.Resources;
@@ -115,7 +114,7 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchData;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator;
import org.thoughtcrime.securesms.contacts.paged.ContactSearchState;
import org.thoughtcrime.securesms.conversation.ConversationIntents;
import org.thoughtcrime.securesms.conversation.ConversationArgs;
import org.thoughtcrime.securesms.conversation.ConversationUpdateTick;
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterRequest;
import org.thoughtcrime.securesms.conversationlist.chatfilter.ConversationFilterSource;
@@ -417,10 +416,9 @@ public class ConversationListFragment extends MainFragment implements Conversati
lifecycleDisposable.add(mainNavigationViewModel.getDetailLocationObservable()
.subscribeOn(AndroidSchedulers.mainThread())
.subscribe(location -> {
if (location instanceof MainNavigationDetailLocation.Conversation) {
Intent intent = ((MainNavigationDetailLocation.Conversation) location).getIntent();
ConversationIntents.Args args = ConversationIntents.Args.from(Objects.requireNonNull(intent.getExtras()));
long threadId = args.getThreadId();
if (location instanceof MainNavigationDetailLocation.Chats.Conversation) {
ConversationArgs args = ((MainNavigationDetailLocation.Chats.Conversation) location).getConversationArgs();
long threadId = args.threadId;
defaultAdapter.setActiveThreadId(threadId);
}

View File

@@ -37,8 +37,9 @@ fun Fragment.listenToEventBusWhileResumed(
.collectLatest {
if (resources.getWindowSizeClass().isCompact()) {
when (it) {
is MainNavigationDetailLocation.Conversation -> unsubscribe()
is MainNavigationDetailLocation.Chats.Conversation -> unsubscribe()
MainNavigationDetailLocation.Empty -> subscribe()
else -> Unit
}
} else {
subscribe()

View File

@@ -0,0 +1,108 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import org.signal.core.ui.compose.Animations.navHostSlideInTransition
import org.signal.core.ui.compose.Animations.navHostSlideOutTransition
import org.thoughtcrime.securesms.calls.links.EditCallLinkNameScreen
import org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsScreen
import org.thoughtcrime.securesms.serialization.JsonSerializableNavType
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
import kotlin.reflect.typeOf
/**
* A navigation host for the calls detail pane of [org.thoughtcrime.securesms.MainActivity].
*
* @param currentDestination The current calls destination to navigate to, containing routing information
* @param contentLayoutData Layout configuration data for responsive UI rendering
*/
@Composable
fun CallsNavHost(
currentDestination: MainNavigationDetailLocation.Calls,
contentLayoutData: MainContentLayoutData
) {
val navHostController = key(currentDestination.controllerKey) {
rememberNavController()
}
val startDestination = remember(currentDestination.controllerKey) {
MainNavigationDetailLocation.Calls.CallLinkDetails(currentDestination.controllerKey)
}
LaunchedEffect(navHostController) {
navHostController.enableOnBackPressed(true)
}
LaunchedEffect(currentDestination) {
if (currentDestination != startDestination) {
navHostController.navigate(currentDestination)
}
}
val mainNavigationViewModel = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalContext.current as ComponentActivity) {
error("Should already exist.")
}
NavHost(
navController = navHostController,
startDestination = startDestination,
enterTransition = { navHostSlideInTransition { it } },
exitTransition = { navHostSlideOutTransition { -it } },
popEnterTransition = { navHostSlideInTransition { -it } },
popExitTransition = { navHostSlideOutTransition { it } },
modifier = Modifier.fillMaxSize()
) {
composable<MainNavigationDetailLocation.Calls.CallLinkDetails>(
typeMap = mapOf(
typeOf<CallLinkRoomId>() to JsonSerializableNavType(CallLinkRoomId.serializer())
)
) {
val route = it.toRoute<MainNavigationDetailLocation.Calls.CallLinkDetails>()
LaunchedEffect(route) {
mainNavigationViewModel.goTo(route)
}
MainActivityDetailContainer(contentLayoutData) {
CallLinkDetailsScreen(roomId = route.callLinkRoomId)
}
}
composable<MainNavigationDetailLocation.Calls.EditCallLinkName>(
typeMap = mapOf(
typeOf<CallLinkRoomId>() to JsonSerializableNavType(CallLinkRoomId.serializer())
)
) {
val parent = navHostController.getBackStackEntry(startDestination)
val route = it.toRoute<MainNavigationDetailLocation.Calls.EditCallLinkName>()
LaunchedEffect(route) {
mainNavigationViewModel.goTo(route)
}
MainActivityDetailContainer(contentLayoutData) {
CompositionLocalProvider(LocalViewModelStoreOwner provides parent) {
EditCallLinkNameScreen(roomId = route.callLinkRoomId)
}
}
}
}
}

View File

@@ -0,0 +1,99 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import androidx.activity.ComponentActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.fragment.compose.AndroidFragment
import androidx.fragment.compose.rememberFragmentState
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import org.signal.core.ui.compose.Animations.navHostSlideInTransition
import org.signal.core.ui.compose.Animations.navHostSlideOutTransition
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.conversation.ConversationIntents
import org.thoughtcrime.securesms.conversation.v2.ConversationFragment
import org.thoughtcrime.securesms.serialization.JsonSerializableNavType
import kotlin.reflect.typeOf
/**
* A navigation host for the chats detail pane of [org.thoughtcrime.securesms.MainActivity].
*
* @param currentDestination The current calls destination to navigate to, containing routing information
* @param contentLayoutData Layout configuration data for responsive UI rendering
*/
@Composable
fun ChatsNavHost(
currentDestination: MainNavigationDetailLocation.Chats,
contentLayoutData: MainContentLayoutData
) {
val navHostController: NavHostController = key(currentDestination.controllerKey) {
rememberNavController()
}
val startDestination = remember(currentDestination.controllerKey) {
currentDestination as? MainNavigationDetailLocation.Chats.Conversation ?: error("Unsupported start destination.")
}
LaunchedEffect(currentDestination) {
if (currentDestination != startDestination) {
navHostController.navigate(currentDestination)
}
}
val mainNavigationViewModel = viewModel<MainNavigationViewModel>(viewModelStoreOwner = LocalContext.current as ComponentActivity) {
error("Should already exist.")
}
NavHost(
navController = navHostController,
startDestination = startDestination,
enterTransition = { navHostSlideInTransition { it } },
exitTransition = { navHostSlideOutTransition { -it } },
popEnterTransition = { navHostSlideInTransition { -it } },
popExitTransition = { navHostSlideOutTransition { it } },
modifier = Modifier.fillMaxSize()
) {
composable<MainNavigationDetailLocation.Chats.Conversation>(
typeMap = mapOf(
typeOf<ConversationArgs>() to JsonSerializableNavType(ConversationArgs.serializer())
)
) {
val route = it.toRoute<MainNavigationDetailLocation.Chats.Conversation>()
val fragmentState = key(route) { rememberFragmentState() }
val context = LocalContext.current
LaunchedEffect(route) {
mainNavigationViewModel.goTo(route)
}
AndroidFragment(
clazz = ConversationFragment::class.java,
fragmentState = fragmentState,
arguments = requireNotNull(ConversationIntents.createBuilderSync(context, route.conversationArgs).build().extras) { "Handed null Conversation intent arguments." },
modifier = Modifier
.padding(end = contentLayoutData.detailPaddingEnd)
.clip(contentLayoutData.shape)
.background(color = MaterialTheme.colorScheme.surface)
.fillMaxSize()
)
}
}
}

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import org.thoughtcrime.securesms.R
@Composable
fun EmptyDetailScreen(
contentLayoutData: MainContentLayoutData
) {
Box(
modifier = Modifier
.padding(end = contentLayoutData.detailPaddingEnd)
.clip(contentLayoutData.shape)
.background(color = MaterialTheme.colorScheme.surface)
.fillMaxSize()
) {
Icon(
painter = painterResource(R.drawable.ic_signal_logo_large),
contentDescription = null,
tint = Color(0x58607152),
modifier = Modifier.align(Alignment.Center)
)
}
}
@Composable
fun MainActivityDetailContainer(
contentLayoutData: MainContentLayoutData,
content: @Composable () -> Unit
) {
Box(
modifier = Modifier
.padding(end = contentLayoutData.detailPaddingEnd)
.clip(contentLayoutData.shape)
.background(color = MaterialTheme.colorScheme.surface)
.fillMaxSize()
) {
content()
}
}

View File

@@ -5,15 +5,59 @@
package org.thoughtcrime.securesms.main
import android.content.Intent
import android.os.Parcelable
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import org.thoughtcrime.securesms.conversation.ConversationArgs
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId
/**
* Describes which content to display in the detail view.
*/
@Parcelize
sealed interface MainNavigationDetailLocation : Parcelable {
@Serializable
data object Empty : MainNavigationDetailLocation
data class Conversation(val intent: Intent) : MainNavigationDetailLocation
@Parcelize
sealed interface Chats : MainNavigationDetailLocation {
val controllerKey: RecipientId
@Serializable
data class Conversation(val conversationArgs: ConversationArgs) : Chats {
@Transient
@IgnoredOnParcel
override val controllerKey: RecipientId = conversationArgs.recipientId
}
}
/**
* Content which can be displayed while the user is navigating the Calls tab.
*/
@Parcelize
sealed interface Calls : MainNavigationDetailLocation {
val controllerKey: CallLinkRoomId
@Serializable
data class CallLinkDetails(val callLinkRoomId: CallLinkRoomId) : Calls {
@Transient
@IgnoredOnParcel
override val controllerKey: CallLinkRoomId = callLinkRoomId
}
@Serializable
data class EditCallLinkName(val callLinkRoomId: CallLinkRoomId) : Calls {
@Transient
@IgnoredOnParcel
override val controllerKey: CallLinkRoomId = callLinkRoomId
}
}
@Parcelize
sealed interface Stories : MainNavigationDetailLocation
}

View File

@@ -0,0 +1,12 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
interface MainNavigationRouter {
fun goTo(location: MainNavigationDetailLocation)
fun goTo(location: MainNavigationListLocation)
}

View File

@@ -33,7 +33,7 @@ import org.thoughtcrime.securesms.stories.Stories
class MainNavigationViewModel(
initialListLocation: MainNavigationListLocation = MainNavigationListLocation.CHATS,
initialDetailLocation: MainNavigationDetailLocation = MainNavigationDetailLocation.Empty
) : ViewModel() {
) : ViewModel(), MainNavigationRouter {
private val megaphoneRepository = AppDependencies.megaphoneRepository
private var navigator: ThreePaneScaffoldNavigator<Any>? = null
@@ -46,7 +46,8 @@ class MainNavigationViewModel(
private val internalDetailLocation = MutableStateFlow(initialDetailLocation)
val detailLocation: StateFlow<MainNavigationDetailLocation> = internalDetailLocation
val detailLocationObservable: Observable<MainNavigationDetailLocation> = internalDetailLocation.asObservable()
var latestConversationLocation: MainNavigationDetailLocation.Conversation? = null
var latestConversationLocation: MainNavigationDetailLocation.Chats.Conversation? = null
var latestCallsLocation: MainNavigationDetailLocation.Calls? = null
private val internalMegaphone = MutableStateFlow(Megaphone.NONE)
val megaphone: StateFlow<Megaphone> = internalMegaphone
@@ -117,7 +118,7 @@ class MainNavigationViewModel(
* Navigates to the requested location. If the navigator is not present, this functionally sets our
* "default" location to that specified, and we will route the user there when the navigator is set.
*/
fun goTo(location: MainNavigationDetailLocation) {
override fun goTo(location: MainNavigationDetailLocation) {
if (!SignalStore.internal.largeScreenUi) {
goToLegacyDetailLocation?.invoke(location)
return
@@ -137,10 +138,15 @@ class MainNavigationViewModel(
ThreePaneScaffoldRole.Secondary
}
is MainNavigationDetailLocation.Conversation -> {
is MainNavigationDetailLocation.Chats.Conversation -> {
latestConversationLocation = location
ThreePaneScaffoldRole.Primary
}
is MainNavigationDetailLocation.Calls -> {
latestCallsLocation = location
ThreePaneScaffoldRole.Primary
}
}
navigatorScope?.launch {
@@ -161,19 +167,28 @@ class MainNavigationViewModel(
}
}
fun goTo(location: MainNavigationListLocation) {
override fun goTo(location: MainNavigationListLocation) {
if (navigator == null) {
earlyNavigationListLocationRequested = location
return
}
if (location != MainNavigationListLocation.CHATS) {
internalDetailLocation.update {
MainNavigationDetailLocation.Empty
when (location) {
MainNavigationListLocation.CHATS -> {
internalDetailLocation.update {
latestConversationLocation ?: MainNavigationDetailLocation.Empty
}
}
} else {
internalDetailLocation.update {
latestConversationLocation ?: MainNavigationDetailLocation.Empty
MainNavigationListLocation.ARCHIVE -> Unit
MainNavigationListLocation.CALLS -> {
internalDetailLocation.update {
latestCallsLocation ?: MainNavigationDetailLocation.Empty
}
}
MainNavigationListLocation.STORIES -> {
internalDetailLocation.update {
MainNavigationDetailLocation.Empty
}
}
}

View File

@@ -0,0 +1,35 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.main
import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import org.signal.core.ui.compose.Animations.navHostSlideInTransition
import org.signal.core.ui.compose.Animations.navHostSlideOutTransition
/**
* A navigation host for the stories detail pane of [org.thoughtcrime.securesms.MainActivity].
*
* @param currentDestination The current calls destination to navigate to, containing routing information
* @param contentLayoutData Layout configuration data for responsive UI rendering
*/
@Composable
fun StoriesNavHost(
navHostController: NavHostController,
startDestination: MainNavigationDetailLocation.Stories,
contentLayoutData: MainContentLayoutData
) {
NavHost(
navController = navHostController,
startDestination = startDestination,
enterTransition = { navHostSlideInTransition { it } },
exitTransition = { navHostSlideOutTransition { -it } },
popEnterTransition = { navHostSlideInTransition { -it } },
popExitTransition = { navHostSlideOutTransition { it } }
) {
}
}

View File

@@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.service.webrtc.links
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.signal.core.util.Base64
@@ -14,6 +15,7 @@ import org.signal.core.util.Hex
import org.signal.core.util.Serializer
import org.signal.ringrtc.CallLinkRootKey
@Serializable
@Parcelize
class CallLinkRoomId private constructor(private val roomId: ByteArray) : Parcelable {
fun serialize(): String = DatabaseSerializer.serialize(this)

View File

@@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.stickers
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
@Serializable
@Parcelize
class StickerLocator(
@JvmField

View File

@@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/call_link_details"
app:startDestination="@id/callLinkDetailsFragment">
<fragment
android:id="@+id/callLinkDetailsFragment"
android:name="org.thoughtcrime.securesms.calls.links.details.CallLinkDetailsFragment"
android:label="call_link_details">
<action
android:id="@+id/action_callLinkDetailsFragment_to_editCallLinkNameDialogFragment"
app:destination="@id/editCallLinkNameDialogFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_close_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<argument
android:name="room_id"
app:argType="org.thoughtcrime.securesms.service.webrtc.links.CallLinkRoomId"
app:nullable="false" />
</fragment>
<dialog
android:id="@+id/editCallLinkNameDialogFragment"
android:name="org.thoughtcrime.securesms.calls.links.EditCallLinkNameDialogFragment"
android:label="edit_call_link_name_dialog">
<argument
android:name="name"
app:argType="string"
app:nullable="false" />
</dialog>
</navigation>

View File

@@ -42,8 +42,8 @@ object Scaffolds {
fun Settings(
title: String,
onNavigationClick: () -> Unit,
navigationIcon: ImageVector,
modifier: Modifier = Modifier,
navigationIcon: ImageVector? = null,
navigationContentDescription: String? = null,
titleContent: @Composable (Float, String) -> Unit = { _, title ->
Text(text = title, style = MaterialTheme.typography.titleLarge)
@@ -80,7 +80,7 @@ object Scaffolds {
title: String,
titleContent: @Composable (Float, String) -> Unit,
onNavigationClick: () -> Unit,
navigationIcon: ImageVector,
navigationIcon: ImageVector?,
navigationContentDescription: String? = null,
actions: @Composable RowScope.() -> Unit = {},
scrollBehavior: TopAppBarScrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
@@ -90,14 +90,16 @@ object Scaffolds {
titleContent(scrollBehavior.state.contentOffset, title)
},
navigationIcon = {
IconButton(
onClick = onNavigationClick,
Modifier.padding(end = 16.dp)
) {
Icon(
imageVector = navigationIcon,
contentDescription = navigationContentDescription
)
if (navigationIcon != null) {
IconButton(
onClick = onNavigationClick,
Modifier.padding(end = 16.dp)
) {
Icon(
imageVector = navigationIcon,
contentDescription = navigationContentDescription
)
}
}
},
scrollBehavior = scrollBehavior,
@@ -134,3 +136,28 @@ private fun SettingsScaffoldPreview() {
}
}
}
@Preview
@Composable
private fun SettingsScaffoldNoNavIconPreview() {
SignalTheme(isDarkMode = false) {
Scaffolds.Settings(
"Settings Scaffold",
onNavigationClick = {},
actions = {
IconButton(onClick = {}) {
Icon(Icons.Default.Settings, contentDescription = null)
}
}
) { paddingValues ->
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
Text("Content")
}
}
}
}