mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-03 15:11:42 +01:00
Convert SafetyNumberReview dialogs to compose.
This commit is contained in:
committed by
jeffrey-signal
parent
77a18111e1
commit
7f831e6806
@@ -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
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user