From 7f831e6806ac84090f9452df96076567ef596168 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 17 Apr 2026 14:54:49 -0300 Subject: [PATCH] Convert SafetyNumberReview dialogs to compose. --- .../error/SafetyNumberChangeRepository.java | 4 +- .../ui/error/SafetyNumberChangeViewModel.java | 2 +- .../safety/SafetyNumberBottomSheetContent.kt | 413 ++++++++++++++++++ .../safety/SafetyNumberBottomSheetEffect.kt | 16 + .../safety/SafetyNumberBottomSheetEvent.kt | 24 + .../safety/SafetyNumberBottomSheetFragment.kt | 221 ++-------- .../safety/SafetyNumberBottomSheetState.kt | 5 +- .../SafetyNumberBottomSheetViewModel.kt | 117 +++-- .../safety/SafetyNumberBucketRowItem.kt | 116 ----- .../safety/SafetyNumberRecipientRowItem.kt | 75 ---- .../SafetyNumberReviewConnectionsScreen.kt | 187 ++++++++ .../SafetyNumberReviewConnectionsFragment.kt | 160 ------- .../ComposeBottomSheetDialogFragment.kt | 23 +- .../org/signal/core/ui/compose/Fragments.kt | 8 + 14 files changed, 781 insertions(+), 590 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetContent.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetEffect.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetEvent.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBucketRowItem.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberRecipientRowItem.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberReviewConnectionsScreen.kt delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/safety/review/SafetyNumberReviewConnectionsFragment.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java index 0c5984b4aa..f36f6c868e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java @@ -46,8 +46,8 @@ public final class SafetyNumberChangeRepository { private final Context context; - public SafetyNumberChangeRepository(Context context) { - this.context = context.getApplicationContext(); + public SafetyNumberChangeRepository() { + this.context = AppDependencies.getApplication(); } @NonNull diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeViewModel.java index dccb9ec388..2aaa5e4e5c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeViewModel.java @@ -71,7 +71,7 @@ public final class SafetyNumberChangeViewModel extends ViewModel { @Override public @NonNull T create(@NonNull Class modelClass) { - SafetyNumberChangeRepository repo = new SafetyNumberChangeRepository(AppDependencies.getApplication()); + SafetyNumberChangeRepository repo = new SafetyNumberChangeRepository(); return Objects.requireNonNull(modelClass.cast(new SafetyNumberChangeViewModel(recipientIds, messageId, messageType, repo))); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetContent.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetContent.kt new file mode 100644 index 0000000000..f8ce5b4a99 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetContent.kt @@ -0,0 +1,413 @@ +package org.thoughtcrime.securesms.safety + +import android.content.Context +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.launch +import org.signal.core.ui.compose.BottomSheets +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.DropdownMenus +import org.signal.core.ui.compose.LocalFragmentManager +import org.signal.core.ui.compose.Previews +import org.signal.core.util.or +import org.signal.libsignal.protocol.IdentityKey +import org.signal.libsignal.protocol.ecc.ECPublicKey +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.avatar.AvatarImage +import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable +import org.thoughtcrime.securesms.database.IdentityTable +import org.thoughtcrime.securesms.database.model.IdentityRecord +import org.thoughtcrime.securesms.profiles.ProfileName +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.rememberRecipientField +import org.thoughtcrime.securesms.verify.VerifyIdentityFragment + +/** + * Compose content for the safety number bottom sheet. + * + * @param state Current sheet state from [SafetyNumberBottomSheetViewModel]. + * @param initialUntrustedCount Original untrusted recipient count from [SafetyNumberBottomSheetArgs], + * used for the "You have X connections" subtitle when [SafetyNumberBottomSheetState.hasLargeNumberOfUntrustedRecipients] is true. + * @param getIdentityRecord Suspending function that fetches the identity record for a recipient, + * used when the user chooses to verify a safety number. + * @param emitter Callback for user-driven events. + */ +@Composable +fun SafetyNumberBottomSheetContent( + state: SafetyNumberBottomSheetState, + initialUntrustedCount: Int, + getIdentityRecord: suspend (RecipientId) -> IdentityRecord?, + emitter: (SafetyNumberBottomSheetEvent) -> Unit +) { + val recipients = remember(state) { + if (!state.hasLargeNumberOfUntrustedRecipients) { + state.destinationToRecipientMap.values.flatten().distinct() + } else { + emptyList() + } + } + + val fragmentManager = LocalFragmentManager.current + val scope = rememberCoroutineScope() + val wrappedEmitter: (SafetyNumberBottomSheetEvent) -> Unit = remember(emitter, fragmentManager) { + { event -> + when (event) { + is SafetyNumberBottomSheetEvent.VerifySafetyNumber -> scope.launch { + val record = getIdentityRecord(event.recipientId) ?: return@launch + val fm = fragmentManager ?: error("SafetyNumberBottomSheetContent requires a FragmentManager via LocalFragmentManager.") + VerifyIdentityFragment.createDialog( + recipientId = event.recipientId, + remoteIdentity = IdentityKeyParcelable(record.identityKey), + verified = false + ).show(fm, null) + } + + else -> emitter(event) + } + } + } + + var showReviewConnections by remember { mutableStateOf(false) } + + SafetyNumberBottomSheetContentInternal( + hasLargeList = state.hasLargeNumberOfUntrustedRecipients, + isCheckupComplete = state.isCheckupComplete(), + isEmpty = state.isEmpty(), + sendAnywayFired = state.sendAnywayFired, + recipients = recipients, + initialUntrustedCount = initialUntrustedCount, + onReviewConnectionsClick = { + showReviewConnections = true + emitter(SafetyNumberBottomSheetEvent.ReviewConnections) + }, + emitter = wrappedEmitter + ) + + if (showReviewConnections) { + Dialog( + onDismissRequest = { showReviewConnections = false }, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + SafetyNumberReviewConnectionsScreen( + state = state, + onDoneClick = { showReviewConnections = false }, + emitter = wrappedEmitter + ) + } + } +} + +@Composable +private fun SafetyNumberBottomSheetContentInternal( + hasLargeList: Boolean, + isCheckupComplete: Boolean, + isEmpty: Boolean, + sendAnywayFired: Boolean, + recipients: List, + initialUntrustedCount: Int, + onReviewConnectionsClick: () -> Unit, + emitter: (SafetyNumberBottomSheetEvent) -> Unit +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxWidth() + ) { + BottomSheets.Handle() + + Spacer(modifier = Modifier.height(24.dp)) + + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.symbol_safety_number_24), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(56.dp) + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource( + id = when { + isCheckupComplete && hasLargeList -> R.string.SafetyNumberBottomSheetFragment__safety_number_checkup_complete + hasLargeList -> R.string.SafetyNumberBottomSheetFragment__safety_number_checkup + else -> R.string.SafetyNumberBottomSheetFragment__safety_number_changes + } + ), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 24.dp) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = when { + isCheckupComplete && hasLargeList -> stringResource(R.string.SafetyNumberBottomSheetFragment__all_connections_have_been_reviewed) + hasLargeList -> pluralStringResource(R.plurals.SafetyNumberBottomSheetFragment__you_have_d_connections_plural, initialUntrustedCount, initialUntrustedCount) + else -> stringResource(R.string.SafetyNumberBottomSheetFragment__the_following_people) + }, + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 24.dp) + ) + + if (isEmpty) { + Spacer(modifier = Modifier.height(48.dp)) + Text( + text = stringResource(R.string.SafetyNumberBottomSheetFragment__no_more_recipients_to_show), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 24.dp) + ) + Spacer(modifier = Modifier.height(48.dp)) + } else if (!hasLargeList) { + Spacer(modifier = Modifier.height(8.dp)) + recipients.forEach { safetyNumberRecipient -> + SafetyNumberRecipientRow(safetyNumberRecipient = safetyNumberRecipient, emitter = emitter) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = 4.dp, end = 16.dp, top = 8.dp, bottom = 16.dp) + ) { + if (hasLargeList) { + TextButton(onClick = onReviewConnectionsClick) { + Text(text = stringResource(R.string.SafetyNumberBottomSheetFragment__review_connections)) + } + } + Spacer(modifier = Modifier.weight(1f)) + Buttons.MediumTonal(enabled = !sendAnywayFired, onClick = { emitter(SafetyNumberBottomSheetEvent.SendAnyway) }) { + Text( + text = stringResource( + if (isCheckupComplete) R.string.conversation_activity__send + else R.string.SafetyNumberBottomSheetFragment__send_anyway + ) + ) + } + } + } +} + +@Composable +fun SafetyNumberRecipientRow( + safetyNumberRecipient: SafetyNumberRecipient, + emitter: (SafetyNumberBottomSheetEvent) -> Unit +) { + val context = LocalContext.current + val menuController = remember { DropdownMenus.MenuController() } + val displayName by rememberRecipientField(safetyNumberRecipient.recipient) { getDisplayName(context) } + val identifier by rememberRecipientField(safetyNumberRecipient.recipient) { e164.or(username).orElse(null) } + val isVerified = safetyNumberRecipient.identityRecord.verifiedStatus == IdentityTable.VerifiedStatus.VERIFIED + val secondaryText = remember(identifier, isVerified) { buildSecondaryText(identifier, isVerified, context) } + + Box(modifier = Modifier.fillMaxWidth()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { menuController.show() } + .padding(horizontal = 24.dp, vertical = 12.dp) + ) { + AvatarImage( + recipient = safetyNumberRecipient.recipient, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Column { + Text( + text = displayName, + style = MaterialTheme.typography.bodyLarge + ) + if (!secondaryText.isNullOrBlank()) { + Text( + text = secondaryText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + + DropdownMenus.Menu(controller = menuController) { controller -> + DropdownMenus.ItemWithIcon( + menuController = controller, + drawableResId = R.drawable.ic_safety_number_24, + stringResId = R.string.SafetyNumberBottomSheetFragment__verify_safety_number, + onClick = { emitter(SafetyNumberBottomSheetEvent.VerifySafetyNumber(safetyNumberRecipient.recipient.id)) } + ) + if (safetyNumberRecipient.distributionListMembershipCount > 0) { + DropdownMenus.ItemWithIcon( + menuController = controller, + drawableResId = R.drawable.ic_circle_x_24, + stringResId = R.string.SafetyNumberBottomSheetFragment__remove_from_story, + onClick = { emitter(SafetyNumberBottomSheetEvent.RemoveFromStory(safetyNumberRecipient.recipient.id)) } + ) + } + if (safetyNumberRecipient.distributionListMembershipCount == 0 && safetyNumberRecipient.groupMembershipCount == 0) { + DropdownMenus.ItemWithIcon( + menuController = controller, + drawableResId = R.drawable.ic_circle_x_24, + stringResId = R.string.SafetyNumberReviewConnectionsFragment__remove, + onClick = { emitter(SafetyNumberBottomSheetEvent.RemoveDestination(safetyNumberRecipient.recipient.id)) } + ) + } + } + } +} + +private fun buildSecondaryText(identifier: String?, isVerified: Boolean, context: Context): String? { + return when { + isVerified && identifier.isNullOrBlank() -> context.getString(R.string.SafetyNumberRecipientRowItem__verified) + isVerified -> context.getString(R.string.SafetyNumberRecipientRowItem__s_dot_verified, identifier) + else -> identifier + } +} + +@Suppress("UNCHECKED_CAST") +private fun Any?.unsafeCast(): T = this as T + +private fun previewSafetyRecipient( + firstName: String, + lastName: String = "", + isVerified: Boolean = false, + distributionListMembershipCount: Int = 0, + groupMembershipCount: Int = 0 +): SafetyNumberRecipient { + return SafetyNumberRecipient( + recipient = Recipient(profileName = ProfileName.fromParts(firstName, lastName)), + identityRecord = IdentityRecord( + recipientId = RecipientId.UNKNOWN, + identityKey = FakeIdentityKey(0), + verifiedStatus = if (isVerified) IdentityTable.VerifiedStatus.VERIFIED else IdentityTable.VerifiedStatus.DEFAULT, + firstUse = false, + timestamp = 0L, + nonblockingApproval = false + ), + distributionListMembershipCount = distributionListMembershipCount, + groupMembershipCount = groupMembershipCount + ) +} + +@DayNightPreviews +@Composable +private fun PreviewSmallList() { + Previews.BottomSheetContentPreview { + SafetyNumberBottomSheetContent( + state = SafetyNumberBottomSheetState( + untrustedRecipientCount = 2, + hasLargeNumberOfUntrustedRecipients = false, + destinationToRecipientMap = mapOf( + SafetyNumberBucket.ContactsBucket to listOf( + previewSafetyRecipient("Alice", "Smith"), + previewSafetyRecipient("Bob", "Chen", isVerified = true, distributionListMembershipCount = 1) + ) + ), + loadState = SafetyNumberBottomSheetState.LoadState.READY + ), + initialUntrustedCount = 2, + getIdentityRecord = { null }, + emitter = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun PreviewLargeList() { + Previews.BottomSheetContentPreview { + SafetyNumberBottomSheetContent( + state = SafetyNumberBottomSheetState( + untrustedRecipientCount = 12, + hasLargeNumberOfUntrustedRecipients = true, + loadState = SafetyNumberBottomSheetState.LoadState.READY + ), + initialUntrustedCount = 12, + getIdentityRecord = { null }, + emitter = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun PreviewCheckupComplete() { + Previews.BottomSheetContentPreview { + SafetyNumberBottomSheetContent( + state = SafetyNumberBottomSheetState( + untrustedRecipientCount = 12, + hasLargeNumberOfUntrustedRecipients = true, + loadState = SafetyNumberBottomSheetState.LoadState.DONE + ), + initialUntrustedCount = 12, + getIdentityRecord = { null }, + emitter = {} + ) + } +} + +@DayNightPreviews +@Composable +private fun PreviewEmpty() { + Previews.BottomSheetContentPreview { + SafetyNumberBottomSheetContent( + state = SafetyNumberBottomSheetState( + untrustedRecipientCount = 1, + hasLargeNumberOfUntrustedRecipients = false, + loadState = SafetyNumberBottomSheetState.LoadState.READY + ), + initialUntrustedCount = 1, + getIdentityRecord = { null }, + emitter = {} + ) + } +} + +/** + * Since ECPublicKey relies on native code that we don't have access to in + * previews, this lets us create an 'IdentityKey' that doesn't break them. + */ +private class FakeIdentityKey(private val id: Int) : IdentityKey(null.unsafeCast()) { + override fun equals(other: Any?): Boolean = other is FakeIdentityKey && other.id == id + override fun hashCode(): Int = id.hashCode() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetEffect.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetEffect.kt new file mode 100644 index 0000000000..aa724739f8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetEffect.kt @@ -0,0 +1,16 @@ +package org.thoughtcrime.securesms.safety + +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey +import org.thoughtcrime.securesms.conversation.ui.error.TrustAndVerifyResult + +/** One-shot side effects emitted by [SafetyNumberBottomSheetViewModel] for the fragment to handle. */ +sealed interface SafetyNumberBottomSheetEffect { + /** + * The trust-and-verify operation finished. The fragment should inspect [result], + * fire the appropriate [SafetyNumberBottomSheet.Callbacks] method, then dismiss. + */ + data class TrustCompleted( + val result: TrustAndVerifyResult, + val destinations: List + ) : SafetyNumberBottomSheetEffect +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetEvent.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetEvent.kt new file mode 100644 index 0000000000..39863c0e98 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetEvent.kt @@ -0,0 +1,24 @@ +package org.thoughtcrime.securesms.safety + +import org.thoughtcrime.securesms.recipients.RecipientId + +/** User-driven events emitted by the safety number bottom sheet UI. */ +sealed interface SafetyNumberBottomSheetEvent { + /** The user confirmed they want to send despite safety number changes. */ + data object SendAnyway : SafetyNumberBottomSheetEvent + + /** The user opened the full review-connections screen. */ + data object ReviewConnections : SafetyNumberBottomSheetEvent + + /** The user requested to verify the safety number for [recipientId]. */ + data class VerifySafetyNumber(val recipientId: RecipientId) : SafetyNumberBottomSheetEvent + + /** The user removed [recipientId] from all selected distribution lists. */ + data class RemoveFromStory(val recipientId: RecipientId) : SafetyNumberBottomSheetEvent + + /** The user removed [recipientId] from the send destinations. */ + data class RemoveDestination(val recipientId: RecipientId) : SafetyNumberBottomSheetEvent + + /** The user removed all recipients from the given distribution list [bucket]. */ + data class RemoveAll(val bucket: SafetyNumberBucket.DistributionListBucket) : SafetyNumberBottomSheetEvent +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetFragment.kt index 1c3a9e9815..893e2a8c84 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetFragment.kt @@ -1,40 +1,26 @@ package org.thoughtcrime.securesms.safety import android.content.DialogInterface +import android.os.Bundle import android.view.View import androidx.annotation.MainThread -import androidx.core.content.ContextCompat -import androidx.fragment.app.viewModels -import com.google.android.material.button.MaterialButton -import org.signal.core.util.DimensionUnit -import org.signal.core.util.concurrent.LifecycleDisposable +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import org.signal.core.ui.compose.ComposeBottomSheetDialogFragment import org.signal.core.util.logging.Log -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.WrapperDialogFragment -import org.thoughtcrime.securesms.components.menu.ActionItem -import org.thoughtcrime.securesms.components.settings.DSLConfiguration -import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter -import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment -import org.thoughtcrime.securesms.components.settings.DSLSettingsText -import org.thoughtcrime.securesms.components.settings.configure -import org.thoughtcrime.securesms.components.settings.models.SplashImage -import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeRepository import org.thoughtcrime.securesms.conversation.ui.error.TrustAndVerifyResult -import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable -import org.thoughtcrime.securesms.database.IdentityTable -import org.thoughtcrime.securesms.safety.review.SafetyNumberReviewConnectionsFragment import org.thoughtcrime.securesms.util.fragments.findListener -import org.thoughtcrime.securesms.util.visible -import org.thoughtcrime.securesms.verify.VerifyIdentityFragment -import org.signal.core.ui.R as CoreUiR +import org.thoughtcrime.securesms.util.viewModel /** * Displays a bottom sheet containing information about safety number changes and allows the user to * address these changes. */ -class SafetyNumberBottomSheetFragment : DSLSettingsBottomSheetFragment(layoutId = R.layout.safety_number_bottom_sheet), WrapperDialogFragment.WrapperDialogFragmentCallback { - - private lateinit var sendAnyway: MaterialButton +class SafetyNumberBottomSheetFragment : ComposeBottomSheetDialogFragment() { override val peekHeightPercentage: Float = 1f @@ -43,178 +29,53 @@ class SafetyNumberBottomSheetFragment : DSLSettingsBottomSheetFragment(layoutId SafetyNumberBottomSheet.getArgsFromBundle(requireArguments()) } - private val viewModel: SafetyNumberBottomSheetViewModel by viewModels(factoryProducer = { - SafetyNumberBottomSheetViewModel.Factory( - args, - SafetyNumberChangeRepository(requireContext()) - ) - }) + private val viewModel: SafetyNumberBottomSheetViewModel by viewModel { + SafetyNumberBottomSheetViewModel(args = args) + } - private val lifecycleDisposable = LifecycleDisposable() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) - override fun bindAdapter(adapter: DSLSettingsAdapter) { - val reviewConnections: View = requireView().findViewById(R.id.review_connections) - sendAnyway = requireView().findViewById(R.id.send_anyway) - - reviewConnections.setOnClickListener { - viewModel.setDone() - SafetyNumberReviewConnectionsFragment.show(childFragmentManager) - } - - sendAnyway.setOnClickListener { - sendAnyway.isEnabled = false - lifecycleDisposable += viewModel.trustAndVerify().subscribe { trustAndVerifyResult -> - when (trustAndVerifyResult.result) { - TrustAndVerifyResult.Result.TRUST_AND_VERIFY -> { - findListener()?.sendAnywayAfterSafetyNumberChangedInBottomSheet(viewModel.destinationSnapshot) - } - TrustAndVerifyResult.Result.TRUST_VERIFY_AND_RESEND -> { - findListener()?.onMessageResentAfterSafetyNumberChangeInBottomSheet() - } - TrustAndVerifyResult.Result.UNKNOWN -> { - Log.w(TAG, "Unknown Result") + viewLifecycleOwner.lifecycleScope.launch { + viewModel.effects.collect { effect -> + when (effect) { + is SafetyNumberBottomSheetEffect.TrustCompleted -> { + when (effect.result.result) { + TrustAndVerifyResult.Result.TRUST_AND_VERIFY -> { + findListener()?.sendAnywayAfterSafetyNumberChangedInBottomSheet(effect.destinations) + } + TrustAndVerifyResult.Result.TRUST_VERIFY_AND_RESEND -> { + findListener()?.onMessageResentAfterSafetyNumberChangeInBottomSheet() + } + TrustAndVerifyResult.Result.UNKNOWN -> Log.w(TAG, "Unknown Result") + } + dismissAllowingStateLoss() } } - - dismissAllowingStateLoss() } } + } - SplashImage.register(adapter) - SafetyNumberRecipientRowItem.register(adapter) - lifecycleDisposable.bindTo(viewLifecycleOwner) + @Composable + override fun SheetContent() { + val state by viewModel.state.collectAsState() + val emitter = remember { viewModel::onEvent } - lifecycleDisposable += viewModel.state.subscribe { state -> - reviewConnections.visible = state.hasLargeNumberOfUntrustedRecipients - - if (state.isCheckupComplete()) { - sendAnyway.setText(R.string.conversation_activity__send) - } - - adapter.submitList(getConfiguration(state).toMappingModelList()) - } + SafetyNumberBottomSheetContent( + state = state, + initialUntrustedCount = args.untrustedRecipients.size, + getIdentityRecord = viewModel::getIdentityRecord, + emitter = emitter + ) } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) - if (sendAnyway.isEnabled) { + if (!viewModel.sendAnywayFired) { findListener()?.onCanceled() } } - override fun onWrapperDialogFragmentDismissed() = Unit - - private fun getConfiguration(state: SafetyNumberBottomSheetState): DSLConfiguration { - return configure { - customPref( - SplashImage.Model( - R.drawable.ic_safety_number_24, - CoreUiR.color.signal_colorOnSurface - ) - ) - - textPref( - title = DSLSettingsText.from( - when { - state.isCheckupComplete() && state.hasLargeNumberOfUntrustedRecipients -> R.string.SafetyNumberBottomSheetFragment__safety_number_checkup_complete - state.hasLargeNumberOfUntrustedRecipients -> R.string.SafetyNumberBottomSheetFragment__safety_number_checkup - else -> R.string.SafetyNumberBottomSheetFragment__safety_number_changes - }, - DSLSettingsText.TextAppearanceModifier(CoreUiR.style.Signal_Text_TitleLarge), - DSLSettingsText.CenterModifier - ) - ) - - textPref( - title = DSLSettingsText.from( - when { - state.isCheckupComplete() && state.hasLargeNumberOfUntrustedRecipients -> getString(R.string.SafetyNumberBottomSheetFragment__all_connections_have_been_reviewed) - state.hasLargeNumberOfUntrustedRecipients -> resources.getQuantityString(R.plurals.SafetyNumberBottomSheetFragment__you_have_d_connections_plural, args.untrustedRecipients.size, args.untrustedRecipients.size) - else -> getString(R.string.SafetyNumberBottomSheetFragment__the_following_people) - }, - DSLSettingsText.TextAppearanceModifier(CoreUiR.style.Signal_Text_BodyLarge), - DSLSettingsText.CenterModifier - ) - ) - - if (state.isEmpty()) { - space(DimensionUnit.DP.toPixels(48f).toInt()) - - noPadTextPref( - title = DSLSettingsText.from( - R.string.SafetyNumberBottomSheetFragment__no_more_recipients_to_show, - DSLSettingsText.TextAppearanceModifier(CoreUiR.style.Signal_Text_BodyLarge), - DSLSettingsText.CenterModifier, - DSLSettingsText.ColorModifier(ContextCompat.getColor(requireContext(), CoreUiR.color.signal_colorOnSurfaceVariant)) - ) - ) - - space(DimensionUnit.DP.toPixels(48f).toInt()) - } - - if (!state.hasLargeNumberOfUntrustedRecipients) { - state.destinationToRecipientMap.values.flatten().distinct().forEach { - customPref( - SafetyNumberRecipientRowItem.Model( - recipient = it.recipient, - isVerified = it.identityRecord.verifiedStatus == IdentityTable.VerifiedStatus.VERIFIED, - distributionListMembershipCount = it.distributionListMembershipCount, - groupMembershipCount = it.groupMembershipCount, - getContextMenuActions = { model -> - val actions = mutableListOf() - - actions.add( - ActionItem( - iconRes = R.drawable.ic_safety_number_24, - title = getString(R.string.SafetyNumberBottomSheetFragment__verify_safety_number), - tintRes = CoreUiR.color.signal_colorOnSurface, - action = { - lifecycleDisposable += viewModel.getIdentityRecord(model.recipient.id).subscribe { record -> - VerifyIdentityFragment.createDialog( - model.recipient.id, - IdentityKeyParcelable(record.identityKey), - false - ).show(childFragmentManager, null) - } - } - ) - ) - - if (model.distributionListMembershipCount > 0) { - actions.add( - ActionItem( - iconRes = R.drawable.ic_circle_x_24, - title = getString(R.string.SafetyNumberBottomSheetFragment__remove_from_story), - tintRes = CoreUiR.color.signal_colorOnSurface, - action = { - viewModel.removeRecipientFromSelectedStories(model.recipient.id) - } - ) - ) - } - - if (model.distributionListMembershipCount == 0 && model.groupMembershipCount == 0) { - actions.add( - ActionItem( - iconRes = R.drawable.ic_circle_x_24, - title = getString(R.string.SafetyNumberReviewConnectionsFragment__remove), - tintRes = CoreUiR.color.signal_colorOnSurface, - action = { - viewModel.removeDestination(model.recipient.id) - } - ) - ) - } - - actions - } - ) - ) - } - } - } - } - companion object { private val TAG = Log.tag(SafetyNumberBottomSheetFragment::class.java) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetState.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetState.kt index 91224be386..57a4622fd8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetState.kt @@ -3,13 +3,14 @@ package org.thoughtcrime.securesms.safety import org.thoughtcrime.securesms.database.IdentityTable /** - * Screen state for SafetyNumberBottomSheetFragment and SafetyNumberReviewConnectionsFragment + * Screen state for the safety number bottom sheet. */ data class SafetyNumberBottomSheetState( val untrustedRecipientCount: Int, val hasLargeNumberOfUntrustedRecipients: Boolean, val destinationToRecipientMap: Map> = emptyMap(), - val loadState: LoadState = LoadState.INIT + val loadState: LoadState = LoadState.INIT, + val sendAnywayFired: Boolean = false ) { fun isEmpty(): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetViewModel.kt index c3ff782e4e..88362af20a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetViewModel.kt @@ -1,98 +1,121 @@ package org.thoughtcrime.securesms.safety import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.core.Maybe -import io.reactivex.rxjava3.core.Single -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.kotlin.plusAssign +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.reactive.asFlow +import kotlinx.coroutines.rx3.await +import kotlinx.coroutines.rx3.awaitSingleOrNull import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeRepository import org.thoughtcrime.securesms.conversation.ui.error.TrustAndVerifyResult import org.thoughtcrime.securesms.database.model.IdentityRecord import org.thoughtcrime.securesms.recipients.RecipientId -import org.thoughtcrime.securesms.util.rx.RxStore +/** ViewModel for [SafetyNumberBottomSheetFragment]. Manages state and trust-and-verify logic. */ class SafetyNumberBottomSheetViewModel( private val args: SafetyNumberBottomSheetArgs, private val repository: SafetyNumberBottomSheetRepository = SafetyNumberBottomSheetRepository(), - private val trustAndVerifyRepository: SafetyNumberChangeRepository + private val trustAndVerifyRepository: SafetyNumberChangeRepository = SafetyNumberChangeRepository() ) : ViewModel() { companion object { private const val MAX_RECIPIENTS_TO_DISPLAY = 5 } - private val destinationStore = RxStore(args.destinations) - val destinationSnapshot: List - get() = destinationStore.state + private val destinations = MutableStateFlow(args.destinations) - private val store = RxStore( + /** Point-in-time snapshot of send destinations, read after trust-and-verify completes. */ + val destinationSnapshot: List + get() = destinations.value + + private val internalState: MutableStateFlow = MutableStateFlow( SafetyNumberBottomSheetState( untrustedRecipientCount = args.untrustedRecipients.size, hasLargeNumberOfUntrustedRecipients = args.untrustedRecipients.size > MAX_RECIPIENTS_TO_DISPLAY ) ) + val state: StateFlow = internalState.asStateFlow() - val state: Flowable = store.stateFlowable.observeOn(AndroidSchedulers.mainThread()) + private val internalEffects = MutableSharedFlow(extraBufferCapacity = 1) + val effects = internalEffects.asSharedFlow() - private val disposables = CompositeDisposable() + val sendAnywayFired: Boolean + get() = internalState.value.sendAnywayFired init { - val bucketFlowable: Flowable>> = destinationStore.stateFlowable.switchMap { repository.getBuckets(args.untrustedRecipients, it) } - disposables += store.update(bucketFlowable) { map, state -> - state.copy( - destinationToRecipientMap = map, - untrustedRecipientCount = map.size, - loadState = if (state.loadState == SafetyNumberBottomSheetState.LoadState.INIT) SafetyNumberBottomSheetState.LoadState.READY else state.loadState - ) + viewModelScope.launch { + destinations + .flatMapLatest { repository.getBuckets(args.untrustedRecipients, it).asFlow() } + .collect { map -> + internalState.update { state -> + state.copy( + destinationToRecipientMap = map, + untrustedRecipientCount = map.size, + loadState = if (state.loadState == SafetyNumberBottomSheetState.LoadState.INIT) SafetyNumberBottomSheetState.LoadState.READY else state.loadState + ) + } + } } } fun setDone() { - store.update { it.copy(loadState = SafetyNumberBottomSheetState.LoadState.DONE) } + internalState.update { it.copy(loadState = SafetyNumberBottomSheetState.LoadState.DONE) } } - fun trustAndVerify(): Single { - val recipients: List = store.state.destinationToRecipientMap.values.flatten().distinct() - return if (args.messageId != null) { - trustAndVerifyRepository.trustOrVerifyChangedRecipientsAndResendRx(recipients, args.messageId) - } else { - trustAndVerifyRepository.trustOrVerifyChangedRecipientsRx(recipients).observeOn(AndroidSchedulers.mainThread()) + fun onEvent(event: SafetyNumberBottomSheetEvent) { + when (event) { + SafetyNumberBottomSheetEvent.SendAnyway -> { + if (internalState.value.sendAnywayFired) return + internalState.update { it.copy(sendAnywayFired = true) } + viewModelScope.launch { + val result = trustAndVerify() + internalEffects.tryEmit(SafetyNumberBottomSheetEffect.TrustCompleted(result, destinationSnapshot)) + } + } + SafetyNumberBottomSheetEvent.ReviewConnections -> setDone() + is SafetyNumberBottomSheetEvent.VerifySafetyNumber -> Unit + is SafetyNumberBottomSheetEvent.RemoveFromStory -> removeRecipientFromSelectedStories(event.recipientId) + is SafetyNumberBottomSheetEvent.RemoveDestination -> removeDestination(event.recipientId) + is SafetyNumberBottomSheetEvent.RemoveAll -> removeAll(event.bucket) } } - override fun onCleared() { - disposables.clear() - destinationStore.dispose() - store.dispose() - } - - fun getIdentityRecord(recipientId: RecipientId): Maybe { - return repository.getIdentityRecord(recipientId).observeOn(AndroidSchedulers.mainThread()) + /** Fetches the current [IdentityRecord] for [recipientId], or null if none exists. */ + suspend fun getIdentityRecord(recipientId: RecipientId): IdentityRecord? { + return repository.getIdentityRecord(recipientId).awaitSingleOrNull() } fun removeRecipientFromSelectedStories(recipientId: RecipientId) { - disposables += repository.removeFromStories(recipientId, destinationStore.state).subscribe() + viewModelScope.launch { + repository.removeFromStories(recipientId, destinations.value).await() + } } fun removeDestination(destination: RecipientId) { - destinationStore.update { list -> list.filterNot { it.recipientId == destination } } + destinations.update { list -> list.filterNot { it.recipientId == destination } } } fun removeAll(distributionListBucket: SafetyNumberBucket.DistributionListBucket) { - val toRemove = store.state.destinationToRecipientMap[distributionListBucket] ?: return - disposables += repository.removeAllFromStory(toRemove.map { it.recipient.id }, distributionListBucket.distributionListId).subscribe() + val toRemove = internalState.value.destinationToRecipientMap[distributionListBucket] ?: return + viewModelScope.launch { + repository.removeAllFromStory(toRemove.map { it.recipient.id }, distributionListBucket.distributionListId).await() + } } - class Factory( - private val args: SafetyNumberBottomSheetArgs, - private val trustAndVerifyRepository: SafetyNumberChangeRepository - ) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return modelClass.cast(SafetyNumberBottomSheetViewModel(args = args, trustAndVerifyRepository = trustAndVerifyRepository)) as T + private suspend fun trustAndVerify(): TrustAndVerifyResult { + val recipients = internalState.value.destinationToRecipientMap.values.flatten().distinct() + return if (args.messageId != null) { + trustAndVerifyRepository.trustOrVerifyChangedRecipientsAndResendRx(recipients, args.messageId).await() + } else { + trustAndVerifyRepository.trustOrVerifyChangedRecipientsRx(recipients).await() } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBucketRowItem.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBucketRowItem.kt deleted file mode 100644 index a03841db1e..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBucketRowItem.kt +++ /dev/null @@ -1,116 +0,0 @@ -package org.thoughtcrime.securesms.safety - -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import org.signal.core.util.DimensionUnit -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.menu.ActionItem -import org.thoughtcrime.securesms.components.menu.SignalContextMenu -import org.thoughtcrime.securesms.database.model.DistributionListId -import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter -import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel -import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder -import org.thoughtcrime.securesms.util.visible - -object SafetyNumberBucketRowItem { - fun register(mappingAdapter: MappingAdapter) { - mappingAdapter.registerFactory(DistributionListModel::class.java, LayoutFactory(::DistributionListViewHolder, R.layout.safety_number_bucket_row_item)) - mappingAdapter.registerFactory(GroupModel::class.java, LayoutFactory(::GroupViewHolder, R.layout.safety_number_bucket_row_item)) - mappingAdapter.registerFactory(ContactsModel::class.java, LayoutFactory(::ContactsViewHolder, R.layout.safety_number_bucket_row_item)) - } - - fun createModel( - safetyNumberBucket: SafetyNumberBucket, - actionItemsProvider: (SafetyNumberBucket) -> List - ): MappingModel<*> { - return when (safetyNumberBucket) { - SafetyNumberBucket.ContactsBucket -> ContactsModel() - is SafetyNumberBucket.DistributionListBucket -> DistributionListModel(safetyNumberBucket, actionItemsProvider) - is SafetyNumberBucket.GroupBucket -> GroupModel(safetyNumberBucket) - } - } - - private class DistributionListModel( - val distributionListBucket: SafetyNumberBucket.DistributionListBucket, - val actionItemsProvider: (SafetyNumberBucket) -> List - ) : MappingModel { - override fun areItemsTheSame(newItem: DistributionListModel): Boolean { - return distributionListBucket.distributionListId == newItem.distributionListBucket.distributionListId - } - - override fun areContentsTheSame(newItem: DistributionListModel): Boolean { - return distributionListBucket == newItem.distributionListBucket - } - } - - private class GroupModel(val groupBucket: SafetyNumberBucket.GroupBucket) : MappingModel { - override fun areItemsTheSame(newItem: GroupModel): Boolean { - return groupBucket.recipient.id == newItem.groupBucket.recipient.id - } - - override fun areContentsTheSame(newItem: GroupModel): Boolean { - return groupBucket.recipient.hasSameContent(newItem.groupBucket.recipient) - } - } - - private class ContactsModel : MappingModel { - override fun areItemsTheSame(newItem: ContactsModel): Boolean = true - - override fun areContentsTheSame(newItem: ContactsModel): Boolean = true - } - - private class DistributionListViewHolder(itemView: View) : BaseViewHolder(itemView) { - override fun getTitle(model: DistributionListModel): String { - return if (model.distributionListBucket.distributionListId == DistributionListId.MY_STORY) { - context.getString(R.string.Recipient_my_story) - } else { - model.distributionListBucket.name - } - } - - override fun bindMenuListener(model: DistributionListModel, menuView: View) { - menuView.setOnClickListener { - SignalContextMenu.Builder(menuView, menuView.rootView as ViewGroup) - .offsetX(DimensionUnit.DP.toPixels(16f).toInt()) - .offsetY(DimensionUnit.DP.toPixels(16f).toInt()) - .show(model.actionItemsProvider(model.distributionListBucket)) - } - } - } - - private class GroupViewHolder(itemView: View) : BaseViewHolder(itemView) { - override fun getTitle(model: GroupModel): String { - return model.groupBucket.recipient.getDisplayName(context) - } - - override fun bindMenuListener(model: GroupModel, menuView: View) { - menuView.visible = false - } - } - - private class ContactsViewHolder(itemView: View) : BaseViewHolder(itemView) { - override fun getTitle(model: ContactsModel): String { - return context.getString(R.string.SafetyNumberBucketRowItem__contacts) - } - - override fun bindMenuListener(model: ContactsModel, menuView: View) { - menuView.visible = false - } - } - - private abstract class BaseViewHolder>(itemView: View) : MappingViewHolder(itemView) { - - private val titleView: TextView = findViewById(R.id.safety_number_bucket_header) - private val menuView: View = findViewById(R.id.safety_number_bucket_menu) - - override fun bind(model: T) { - titleView.text = getTitle(model) - bindMenuListener(model, menuView) - } - - abstract fun getTitle(model: T): String - abstract fun bindMenuListener(model: T, menuView: View) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberRecipientRowItem.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberRecipientRowItem.kt deleted file mode 100644 index 920716d3a5..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberRecipientRowItem.kt +++ /dev/null @@ -1,75 +0,0 @@ -package org.thoughtcrime.securesms.safety - -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import org.signal.core.util.DimensionUnit -import org.signal.core.util.or -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.AvatarImageView -import org.thoughtcrime.securesms.components.menu.ActionItem -import org.thoughtcrime.securesms.components.menu.SignalContextMenu -import org.thoughtcrime.securesms.recipients.Recipient -import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter -import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel -import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder -import org.thoughtcrime.securesms.util.visible - -/** - * An untrusted recipient who can be verified or removed. - */ -object SafetyNumberRecipientRowItem { - fun register(mappingAdapter: MappingAdapter) { - mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.safety_number_recipient_row_item)) - } - - class Model( - val recipient: Recipient, - val isVerified: Boolean, - val distributionListMembershipCount: Int, - val groupMembershipCount: Int, - val getContextMenuActions: (Model) -> List - ) : MappingModel { - override fun areItemsTheSame(newItem: Model): Boolean { - return recipient.id == newItem.recipient.id - } - - override fun areContentsTheSame(newItem: Model): Boolean { - return recipient.hasSameContent(newItem.recipient) && - isVerified == newItem.isVerified && - distributionListMembershipCount == newItem.distributionListMembershipCount && - groupMembershipCount == newItem.groupMembershipCount - } - } - - class ViewHolder(itemView: View) : MappingViewHolder(itemView) { - - private val avatar: AvatarImageView = itemView.findViewById(R.id.safety_number_recipient_avatar) - private val name: TextView = itemView.findViewById(R.id.safety_number_recipient_name) - private val identifier: TextView = itemView.findViewById(R.id.safety_number_recipient_identifier) - - override fun bind(model: Model) { - avatar.setRecipient(model.recipient) - name.text = model.recipient.getDisplayName(context) - - val identifierText = model.recipient.e164.or(model.recipient.username).orElse(null) - val subLineText = when { - model.isVerified && identifierText.isNullOrBlank() -> context.getString(R.string.SafetyNumberRecipientRowItem__verified) - model.isVerified -> context.getString(R.string.SafetyNumberRecipientRowItem__s_dot_verified, identifierText) - else -> identifierText - } - - identifier.text = subLineText - identifier.visible = !subLineText.isNullOrBlank() - - itemView.setOnClickListener { - itemView.isSelected = true - SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup) - .offsetY(DimensionUnit.DP.toPixels(12f).toInt()) - .onDismiss { itemView.isSelected = false } - .show(model.getContextMenuActions(model)) - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberReviewConnectionsScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberReviewConnectionsScreen.kt new file mode 100644 index 0000000000..390a1baa6e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberReviewConnectionsScreen.kt @@ -0,0 +1,187 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.safety + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import org.signal.core.ui.compose.Buttons +import org.signal.core.ui.compose.DayNightPreviews +import org.signal.core.ui.compose.DropdownMenus +import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Scaffolds +import org.signal.core.ui.compose.SignalIcons +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.database.model.DistributionListId +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.rememberRecipientField +import org.signal.core.ui.R as CoreUiR + +/** + * Full-screen review of all recipients with safety number changes, grouped by destination bucket. + * Shown as a Compose [androidx.compose.ui.window.Dialog] inside the safety number bottom sheet. + */ +@Composable +fun SafetyNumberReviewConnectionsScreen( + state: SafetyNumberBottomSheetState, + onDoneClick: () -> Unit, + emitter: (SafetyNumberBottomSheetEvent) -> Unit +) { + val recipientCount = state.destinationToRecipientMap.values.flatten().size + + Scaffolds.Default( + onNavigationClick = onDoneClick, + navigationIconRes = CoreUiR.drawable.symbol_arrow_start_24, + navigationContentDescription = stringResource(R.string.DefaultTopAppBar__navigate_up_content_description), + title = stringResource(R.string.SafetyNumberReviewConnectionsFragment__safety_number_changes) + ) { paddingValues -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + LazyColumn( + contentPadding = PaddingValues(bottom = 76.dp), + modifier = Modifier.fillMaxSize() + ) { + item { + Text( + text = pluralStringResource( + R.plurals.SafetyNumberReviewConnectionsFragment__d_recipients_may_have, + recipientCount, + recipientCount + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp) + ) + } + + state.destinationToRecipientMap.forEach { (bucket, recipients) -> + item(key = bucketKey(bucket)) { + SafetyNumberBucketHeader(bucket = bucket, emitter = emitter) + } + items(items = recipients, key = { it.recipient.id.serialize() }) { recipient -> + SafetyNumberRecipientRow(safetyNumberRecipient = recipient, emitter = emitter) + } + } + } + + Buttons.LargeTonal( + onClick = onDoneClick, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 16.dp) + ) { + Text(text = stringResource(R.string.SafetyNumberReviewConnectionsFragment__done)) + } + } + } +} + +private fun bucketKey(bucket: SafetyNumberBucket): String { + return when (bucket) { + is SafetyNumberBucket.DistributionListBucket -> "dl_${bucket.distributionListId.serialize()}" + is SafetyNumberBucket.GroupBucket -> "group_${bucket.recipient.id.serialize()}" + SafetyNumberBucket.ContactsBucket -> "contacts" + } +} + +@Composable +private fun SafetyNumberBucketHeader( + bucket: SafetyNumberBucket, + emitter: (SafetyNumberBottomSheetEvent) -> Unit +) { + val context = LocalContext.current + val menuController = remember { DropdownMenus.MenuController() } + val groupDisplayName by rememberRecipientField( + (bucket as? SafetyNumberBucket.GroupBucket)?.recipient ?: Recipient.UNKNOWN + ) { getDisplayName(context) } + + val title = when (bucket) { + is SafetyNumberBucket.DistributionListBucket -> { + if (bucket.distributionListId == DistributionListId.MY_STORY) { + stringResource(R.string.Recipient_my_story) + } else { + bucket.name + } + } + is SafetyNumberBucket.GroupBucket -> groupDisplayName + SafetyNumberBucket.ContactsBucket -> stringResource(R.string.SafetyNumberBucketRowItem__contacts) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .padding(start = 16.dp, top = 16.dp, bottom = 12.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + modifier = Modifier + .weight(1f) + .padding(end = 16.dp) + ) + + if (bucket is SafetyNumberBucket.DistributionListBucket) { + Box { + IconButton(onClick = { menuController.show() }) { + Icon( + imageVector = SignalIcons.MoreVertical.imageVector, + contentDescription = stringResource(R.string.SafetyNumberRecipientRowItem__open_context_menu), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + DropdownMenus.Menu(controller = menuController) { controller -> + DropdownMenus.ItemWithIcon( + menuController = controller, + drawableResId = R.drawable.symbol_x_circle_24, + stringResId = R.string.SafetyNumberReviewConnectionsFragment__remove_all, + onClick = { emitter(SafetyNumberBottomSheetEvent.RemoveAll(bucket)) } + ) + } + } + } + } +} + +@DayNightPreviews +@Composable +private fun PreviewReviewConnections() { + Previews.Preview { + SafetyNumberReviewConnectionsScreen( + state = SafetyNumberBottomSheetState( + untrustedRecipientCount = 3, + hasLargeNumberOfUntrustedRecipients = true, + destinationToRecipientMap = emptyMap(), + loadState = SafetyNumberBottomSheetState.LoadState.READY + ), + onDoneClick = {}, + emitter = {} + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/review/SafetyNumberReviewConnectionsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/review/SafetyNumberReviewConnectionsFragment.kt deleted file mode 100644 index 2844d96cd0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/safety/review/SafetyNumberReviewConnectionsFragment.kt +++ /dev/null @@ -1,160 +0,0 @@ -package org.thoughtcrime.securesms.safety.review - -import android.view.View -import androidx.core.content.ContextCompat -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.viewModels -import org.signal.core.util.concurrent.LifecycleDisposable -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.WrapperDialogFragment -import org.thoughtcrime.securesms.components.menu.ActionItem -import org.thoughtcrime.securesms.components.settings.DSLConfiguration -import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment -import org.thoughtcrime.securesms.components.settings.DSLSettingsText -import org.thoughtcrime.securesms.components.settings.configure -import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable -import org.thoughtcrime.securesms.database.IdentityTable -import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheetState -import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheetViewModel -import org.thoughtcrime.securesms.safety.SafetyNumberBucket -import org.thoughtcrime.securesms.safety.SafetyNumberBucketRowItem -import org.thoughtcrime.securesms.safety.SafetyNumberRecipientRowItem -import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter -import org.thoughtcrime.securesms.verify.VerifyIdentityFragment -import org.signal.core.ui.R as CoreUiR - -/** - * Full-screen fragment which displays the list of users who have safety number changes. - * Consider this an extension of the bottom sheet. - */ -class SafetyNumberReviewConnectionsFragment : DSLSettingsFragment( - titleId = R.string.SafetyNumberReviewConnectionsFragment__safety_number_changes, - layoutId = R.layout.safety_number_review_fragment -) { - - private val viewModel: SafetyNumberBottomSheetViewModel by viewModels(ownerProducer = { - requireParentFragment().requireParentFragment() - }) - - private val lifecycleDisposable = LifecycleDisposable() - - override fun bindAdapter(adapter: MappingAdapter) { - SafetyNumberBucketRowItem.register(adapter) - SafetyNumberRecipientRowItem.register(adapter) - lifecycleDisposable.bindTo(viewLifecycleOwner) - - val done = requireView().findViewById(R.id.done) - done.setOnClickListener { - requireActivity().onBackPressed() - } - - lifecycleDisposable += viewModel.state.subscribe { state -> - adapter.submitList(getConfiguration(state).toMappingModelList()) - } - } - - private fun getConfiguration(state: SafetyNumberBottomSheetState): DSLConfiguration { - return configure { - val recipientCount = state.destinationToRecipientMap.values.flatten().size - textPref( - title = DSLSettingsText.from( - resources.getQuantityString(R.plurals.SafetyNumberReviewConnectionsFragment__d_recipients_may_have, recipientCount, recipientCount), - DSLSettingsText.TextAppearanceModifier(CoreUiR.style.Signal_Text_BodyMedium), - DSLSettingsText.ColorModifier(ContextCompat.getColor(requireContext(), CoreUiR.color.signal_colorOnSurfaceVariant)) - ) - ) - - state.destinationToRecipientMap.forEach { (bucket, recipients) -> - customPref(SafetyNumberBucketRowItem.createModel(bucket, this@SafetyNumberReviewConnectionsFragment::getActionItemsForBucket)) - - recipients.forEach { - customPref( - SafetyNumberRecipientRowItem.Model( - recipient = it.recipient, - isVerified = it.identityRecord.verifiedStatus == IdentityTable.VerifiedStatus.VERIFIED, - distributionListMembershipCount = it.distributionListMembershipCount, - groupMembershipCount = it.groupMembershipCount, - getContextMenuActions = { model -> - val actions = mutableListOf() - - actions.add( - ActionItem( - iconRes = R.drawable.ic_safety_number_24, - title = getString(R.string.SafetyNumberBottomSheetFragment__verify_safety_number), - action = { - lifecycleDisposable += viewModel.getIdentityRecord(model.recipient.id).subscribe { record -> - VerifyIdentityFragment.createDialog( - model.recipient.id, - IdentityKeyParcelable(record.identityKey), - false - ).show(childFragmentManager, null) - } - } - ) - ) - - if (model.distributionListMembershipCount > 0) { - actions.add( - ActionItem( - iconRes = R.drawable.ic_circle_x_24, - title = getString(R.string.SafetyNumberBottomSheetFragment__remove_from_story), - action = { - viewModel.removeRecipientFromSelectedStories(model.recipient.id) - } - ) - ) - } - - if (model.distributionListMembershipCount == 0 && model.groupMembershipCount == 0) { - actions.add( - ActionItem( - iconRes = R.drawable.ic_circle_x_24, - title = getString(R.string.SafetyNumberReviewConnectionsFragment__remove), - tintRes = CoreUiR.color.signal_colorOnSurface, - action = { - viewModel.removeDestination(model.recipient.id) - } - ) - ) - } - - actions - } - ) - ) - } - } - } - } - - private fun getActionItemsForBucket(bucket: SafetyNumberBucket): List { - return when (bucket) { - is SafetyNumberBucket.DistributionListBucket -> { - listOf( - ActionItem( - iconRes = R.drawable.ic_circle_x_24, - title = getString(R.string.SafetyNumberReviewConnectionsFragment__remove_all), - tintRes = CoreUiR.color.signal_colorOnSurface, - action = { - viewModel.removeAll(bucket) - } - ) - ) - } - else -> emptyList() - } - } - - class Dialog : WrapperDialogFragment() { - override fun getWrappedFragment(): Fragment { - return SafetyNumberReviewConnectionsFragment() - } - } - - companion object { - fun show(fragmentManager: FragmentManager) { - Dialog().show(fragmentManager, null) - } - } -} diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/ComposeBottomSheetDialogFragment.kt b/core/ui/src/main/java/org/signal/core/ui/compose/ComposeBottomSheetDialogFragment.kt index 699b336440..2cc6d04d09 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/ComposeBottomSheetDialogFragment.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/ComposeBottomSheetDialogFragment.kt @@ -14,13 +14,20 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.compose.ui.unit.dp import org.signal.core.ui.FixedRoundedCornerBottomSheetDialogFragment +import org.signal.core.ui.compose.LocalFragmentManager import org.signal.core.ui.compose.theme.SignalTheme +/** + * Base class for bottom sheets whose content is entirely Compose. + * Provides [LocalFragmentManager] and [SignalTheme] to the composition, and delegates + * content rendering to the abstract [SheetContent] composable. + */ abstract class ComposeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetDialogFragment() { protected open val forceDarkTheme = false @@ -34,13 +41,15 @@ abstract class ComposeBottomSheetDialogFragment : FixedRoundedCornerBottomSheetD } else { LocalConfiguration.current.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES } - SignalTheme(isDarkMode = isDark) { - Surface( - shape = RoundedCornerShape(cornerRadius.dp, cornerRadius.dp), - color = SignalTheme.colors.colorSurface1, - contentColor = MaterialTheme.colorScheme.onSurface - ) { - SheetContent() + CompositionLocalProvider(LocalFragmentManager provides childFragmentManager) { + SignalTheme(isDarkMode = isDark) { + Surface( + shape = RoundedCornerShape(cornerRadius.dp, cornerRadius.dp), + color = SignalTheme.colors.colorSurface1, + contentColor = MaterialTheme.colorScheme.onSurface + ) { + SheetContent() + } } } } diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/Fragments.kt b/core/ui/src/main/java/org/signal/core/ui/compose/Fragments.kt index f65ffd3a39..01e67a2adb 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/Fragments.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/Fragments.kt @@ -12,16 +12,24 @@ import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalInspectionMode import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager import androidx.fragment.compose.AndroidFragment import androidx.fragment.compose.FragmentState import androidx.fragment.compose.rememberFragmentState import org.signal.core.ui.compose.Fragments.Fragment +/** + * Provides the nearest [FragmentManager] to composables that need to show Fragment-based dialogs. + * Defaults to null; populated by [ComposeBottomSheetDialogFragment] and similar host fragments. + */ +val LocalFragmentManager = compositionLocalOf { null } + object Fragments { /** * Wraps an [Fragment], displaying the fragment at runtime or a placeholder in compose previews to avoid rendering errors that occur when