From fd999be41a44583873bc7d4331b80066ed50bd13 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 18 Sep 2025 16:22:06 -0300 Subject: [PATCH] Add new navigation and pane support. --- .../thoughtcrime/securesms/MainActivity.kt | 89 +++-- .../thoughtcrime/securesms/MainNavigator.java | 4 +- .../securesms/badges/models/Badge.kt | 5 +- .../links/EditCallLinkNameDialogFragment.kt | 152 +++++--- .../links/details/CallLinkDetailsActivity.kt | 68 +++- .../links/details/CallLinkDetailsFragment.kt | 363 ------------------ .../links/details/CallLinkDetailsScreen.kt | 357 +++++++++++++++++ .../links/details/CallLinkDetailsState.kt | 13 +- .../links/details/CallLinkDetailsViewModel.kt | 120 +++++- .../securesms/calls/log/CallLogFragment.kt | 28 +- .../conversation/ConversationArgs.kt | 56 +++ .../conversation/ConversationIntents.java | 241 ++++-------- .../conversation/drafts/DraftRepository.kt | 4 +- .../conversation/v2/ConversationFragment.kt | 5 +- .../v2/ShareDataTimestampViewModel.kt | 4 +- .../ConversationListFragment.java | 10 +- .../ConversationListFragmentExtensions.kt | 3 +- .../securesms/main/CallsNavHost.kt | 108 ++++++ .../securesms/main/ChatsNavHost.kt | 99 +++++ .../securesms/main/MainActivityComponents.kt | 56 +++ .../main/MainNavigationDetailLocation.kt | 48 ++- .../securesms/main/MainNavigationRouter.kt | 12 + .../securesms/main/MainNavigationViewModel.kt | 37 +- .../securesms/main/StoriesNavHost.kt | 35 ++ .../service/webrtc/links/CallLinkRoomId.kt | 2 + .../securesms/stickers/StickerLocator.kt | 2 + .../main/res/navigation/call_link_details.xml | 35 -- .../org/signal/core/ui/compose/Scaffolds.kt | 47 ++- 28 files changed, 1272 insertions(+), 731 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsScreen.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationArgs.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/CallsNavHost.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/MainActivityComponents.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationRouter.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/main/StoriesNavHost.kt delete mode 100644 app/src/main/res/navigation/call_link_details.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index c8e02ff2c8..20d4d58018 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -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!!))) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java b/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java index 701a5b01d7..1e47176372 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MainNavigator.java @@ -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); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt b/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt index af7de8a678..683c87ea6c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/badges/models/Badge.kt @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/EditCallLinkNameDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/EditCallLinkNameDialogFragment.kt index 83636dde78..377c91fc8f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/EditCallLinkNameDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/EditCallLinkNameDialogFragment.kt @@ -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() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt index d9ecb688f7..c958c9437c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsActivity.kt @@ -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 + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt deleted file mode 100644 index 7cffe2c405..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsFragment.kt +++ /dev/null @@ -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 - ) - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsScreen.kt new file mode 100644 index 0000000000..af595a9b8c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsScreen.kt @@ -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(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 + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsState.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsState.kt index 08bd847ba4..f7239621e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsState.kt @@ -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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt index ac713cb19a..34c92584be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/links/details/CallLinkDetailsViewModel.kt @@ -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 = 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 { - 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 { - 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 { - 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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt index 9f5b8b0767..e9015c9a02 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/log/CallLogFragment.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationArgs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationArgs.kt new file mode 100644 index 0000000000..4bd3022cf8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationArgs.kt @@ -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?, + 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java index 348bf8434a..d25b1e0075 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java @@ -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; - 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, - @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 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"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt index 44f6c35d04..1d850504f9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index a47602aa65..ceafdd59a8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ShareDataTimestampViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ShareDataTimestampViewModel.kt index 22f91ccece..a2cb7dde54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ShareDataTimestampViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ShareDataTimestampViewModel.kt @@ -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 } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java index abaf5738c0..115f9d8f76 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragment.java @@ -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); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragmentExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragmentExtensions.kt index d1b37d52d2..ef72f06639 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragmentExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversationlist/ConversationListFragmentExtensions.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/CallsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/main/CallsNavHost.kt new file mode 100644 index 0000000000..2e2bef6636 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/CallsNavHost.kt @@ -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(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( + typeMap = mapOf( + typeOf() to JsonSerializableNavType(CallLinkRoomId.serializer()) + ) + ) { + val route = it.toRoute() + + LaunchedEffect(route) { + mainNavigationViewModel.goTo(route) + } + + MainActivityDetailContainer(contentLayoutData) { + CallLinkDetailsScreen(roomId = route.callLinkRoomId) + } + } + + composable( + typeMap = mapOf( + typeOf() to JsonSerializableNavType(CallLinkRoomId.serializer()) + ) + ) { + val parent = navHostController.getBackStackEntry(startDestination) + val route = it.toRoute() + + LaunchedEffect(route) { + mainNavigationViewModel.goTo(route) + } + + MainActivityDetailContainer(contentLayoutData) { + CompositionLocalProvider(LocalViewModelStoreOwner provides parent) { + EditCallLinkNameScreen(roomId = route.callLinkRoomId) + } + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt new file mode 100644 index 0000000000..b386b259c9 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/ChatsNavHost.kt @@ -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(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( + typeMap = mapOf( + typeOf() to JsonSerializableNavType(ConversationArgs.serializer()) + ) + ) { + val route = it.toRoute() + 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() + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityComponents.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityComponents.kt new file mode 100644 index 0000000000..02855c1f62 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainActivityComponents.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt index a01b8ef8d4..38d7f32fcf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationDetailLocation.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationRouter.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationRouter.kt new file mode 100644 index 0000000000..745586326d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationRouter.kt @@ -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) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt index 65616429dc..98e1951b8b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/main/MainNavigationViewModel.kt @@ -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? = null @@ -46,7 +46,8 @@ class MainNavigationViewModel( private val internalDetailLocation = MutableStateFlow(initialDetailLocation) val detailLocation: StateFlow = internalDetailLocation val detailLocationObservable: Observable = 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 = 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 + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/main/StoriesNavHost.kt b/app/src/main/java/org/thoughtcrime/securesms/main/StoriesNavHost.kt new file mode 100644 index 0000000000..635c2e126a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/main/StoriesNavHost.kt @@ -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 } } + ) { + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/CallLinkRoomId.kt b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/CallLinkRoomId.kt index d6ff220261..f5d5a13b20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/CallLinkRoomId.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/service/webrtc/links/CallLinkRoomId.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerLocator.kt b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerLocator.kt index 47749a43c5..2427debb94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerLocator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stickers/StickerLocator.kt @@ -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 diff --git a/app/src/main/res/navigation/call_link_details.xml b/app/src/main/res/navigation/call_link_details.xml deleted file mode 100644 index adae17d69a..0000000000 --- a/app/src/main/res/navigation/call_link_details.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - - - \ No newline at end of file diff --git a/core-ui/src/main/java/org/signal/core/ui/compose/Scaffolds.kt b/core-ui/src/main/java/org/signal/core/ui/compose/Scaffolds.kt index 28efc732ae..39ef06cd18 100644 --- a/core-ui/src/main/java/org/signal/core/ui/compose/Scaffolds.kt +++ b/core-ui/src/main/java/org/signal/core/ui/compose/Scaffolds.kt @@ -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") + } + } + } +}