Convert SafetyNumberReview dialogs to compose.

This commit is contained in:
Alex Hart
2026-04-17 14:54:49 -03:00
committed by jeffrey-signal
parent 77a18111e1
commit 7f831e6806
14 changed files with 781 additions and 590 deletions

View File

@@ -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

View File

@@ -71,7 +71,7 @@ public final class SafetyNumberChangeViewModel extends ViewModel {
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
SafetyNumberChangeRepository repo = new SafetyNumberChangeRepository(AppDependencies.getApplication());
SafetyNumberChangeRepository repo = new SafetyNumberChangeRepository();
return Objects.requireNonNull(modelClass.cast(new SafetyNumberChangeViewModel(recipientIds, messageId, messageType, repo)));
}
}

View File

@@ -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<SafetyNumberRecipient>,
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 <T> 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<ECPublicKey>()) {
override fun equals(other: Any?): Boolean = other is FakeIdentityKey && other.id == id
override fun hashCode(): Int = id.hashCode()
}

View File

@@ -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<ContactSearchKey.RecipientSearchKey>
) : SafetyNumberBottomSheetEffect
}

View File

@@ -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
}

View File

@@ -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<SafetyNumberBottomSheet.Callbacks>()?.sendAnywayAfterSafetyNumberChangedInBottomSheet(viewModel.destinationSnapshot)
}
TrustAndVerifyResult.Result.TRUST_VERIFY_AND_RESEND -> {
findListener<SafetyNumberBottomSheet.Callbacks>()?.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<SafetyNumberBottomSheet.Callbacks>()?.sendAnywayAfterSafetyNumberChangedInBottomSheet(effect.destinations)
}
TrustAndVerifyResult.Result.TRUST_VERIFY_AND_RESEND -> {
findListener<SafetyNumberBottomSheet.Callbacks>()?.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<SafetyNumberBottomSheet.Callbacks>()?.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<ActionItem>()
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)
}

View File

@@ -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<SafetyNumberBucket, List<SafetyNumberRecipient>> = emptyMap(),
val loadState: LoadState = LoadState.INIT
val loadState: LoadState = LoadState.INIT,
val sendAnywayFired: Boolean = false
) {
fun isEmpty(): Boolean {

View File

@@ -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<ContactSearchKey.RecipientSearchKey>
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<ContactSearchKey.RecipientSearchKey>
get() = destinations.value
private val internalState: MutableStateFlow<SafetyNumberBottomSheetState> = MutableStateFlow(
SafetyNumberBottomSheetState(
untrustedRecipientCount = args.untrustedRecipients.size,
hasLargeNumberOfUntrustedRecipients = args.untrustedRecipients.size > MAX_RECIPIENTS_TO_DISPLAY
)
)
val state: StateFlow<SafetyNumberBottomSheetState> = internalState.asStateFlow()
val state: Flowable<SafetyNumberBottomSheetState> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
private val internalEffects = MutableSharedFlow<SafetyNumberBottomSheetEffect>(extraBufferCapacity = 1)
val effects = internalEffects.asSharedFlow()
private val disposables = CompositeDisposable()
val sendAnywayFired: Boolean
get() = internalState.value.sendAnywayFired
init {
val bucketFlowable: Flowable<Map<SafetyNumberBucket, List<SafetyNumberRecipient>>> = 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<TrustAndVerifyResult> {
val recipients: List<SafetyNumberRecipient> = 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<IdentityRecord> {
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 <T : ViewModel> create(modelClass: Class<T>): 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()
}
}
}

View File

@@ -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<ActionItem>
): 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<ActionItem>
) : MappingModel<DistributionListModel> {
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<GroupModel> {
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<ContactsModel> {
override fun areItemsTheSame(newItem: ContactsModel): Boolean = true
override fun areContentsTheSame(newItem: ContactsModel): Boolean = true
}
private class DistributionListViewHolder(itemView: View) : BaseViewHolder<DistributionListModel>(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<GroupModel>(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<ContactsModel>(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<T : MappingModel<*>>(itemView: View) : MappingViewHolder<T>(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)
}
}

View File

@@ -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<ActionItem>
) : MappingModel<Model> {
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<Model>(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))
}
}
}
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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<View>(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<ActionItem>()
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<ActionItem> {
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)
}
}
}