mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-21 17:29:32 +01:00
Implement new Safety Number Changes bottom sheeet.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
package org.thoughtcrime.securesms.safety
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.util.Preconditions
|
||||
|
||||
/**
|
||||
* Object responsible for the construction of SafetyNumberBottomSheetFragment and Arg objects.
|
||||
*/
|
||||
object SafetyNumberBottomSheet {
|
||||
|
||||
private const val TAG = "SafetyNumberBottomSheet"
|
||||
private const val ARGS = "args"
|
||||
|
||||
/**
|
||||
* Create a factory to generate a legacy dialog for the given recipient id.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun forRecipientId(recipientId: RecipientId): Factory {
|
||||
return object : Factory {
|
||||
override fun show(fragmentManager: FragmentManager) {
|
||||
SafetyNumberChangeDialog.show(fragmentManager, recipientId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a factory to generate a legacy dialog for a given recipient id to display when
|
||||
* trying to place an outgoing call.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun forCall(recipientId: RecipientId): Factory {
|
||||
return object : Factory {
|
||||
override fun show(fragmentManager: FragmentManager) {
|
||||
SafetyNumberChangeDialog.showForCall(fragmentManager, recipientId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a factory to display a legacy dialog for a given list of records when trying
|
||||
* to place a group call.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun forGroupCall(identityRecords: List<IdentityRecord>): Factory {
|
||||
return object : Factory {
|
||||
override fun show(fragmentManager: FragmentManager) {
|
||||
SafetyNumberChangeDialog.showForGroupCall(fragmentManager, identityRecords)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a factory to display a legacy dialog for a given list of recipient ids during
|
||||
* a group call
|
||||
*/
|
||||
@JvmStatic
|
||||
fun forDuringGroupCall(recipientIds: Collection<RecipientId>): Factory {
|
||||
return object : Factory {
|
||||
override fun show(fragmentManager: FragmentManager) {
|
||||
SafetyNumberChangeDialog.showForDuringGroupCall(fragmentManager, recipientIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a factory to generate a sheet for the given message record. This will try
|
||||
* to resend the message automatically when the user confirms.
|
||||
*
|
||||
* @param context Not held on to, so any context is fine.
|
||||
* @param messageRecord The message record containing failed identities.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun forMessageRecord(context: Context, messageRecord: MessageRecord): Factory {
|
||||
val args = SafetyNumberBottomSheetArgs(
|
||||
untrustedRecipients = messageRecord.identityKeyMismatches.map { it.getRecipientId(context) },
|
||||
destinations = getDestinationFromRecord(messageRecord),
|
||||
messageId = MessageId(messageRecord.id, messageRecord.isMms)
|
||||
)
|
||||
|
||||
return SheetFactory(args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a factory to generate a sheet for the given identity records and destinations.
|
||||
*
|
||||
* @param identityRecords The list of untrusted records from the thrown error
|
||||
* @param destinations The list of locations the user was trying to send content
|
||||
*/
|
||||
@JvmStatic
|
||||
fun forIdentityRecordsAndDestinations(identityRecords: List<IdentityRecord>, destinations: List<ContactSearchKey>): Factory {
|
||||
val args = SafetyNumberBottomSheetArgs(
|
||||
identityRecords.map { it.recipientId },
|
||||
destinations.filterIsInstance<ContactSearchKey.RecipientSearchKey>().map { it.requireParcelable() }
|
||||
)
|
||||
|
||||
return SheetFactory(args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a factory to generate a sheet for the given identity records and single destination.
|
||||
*
|
||||
* @param identityRecords The list of untrusted records from the thrown error
|
||||
* @param destination The location the user was trying to send content
|
||||
*/
|
||||
@JvmStatic
|
||||
fun forIdentityRecordsAndDestination(identityRecords: List<IdentityRecord>, destination: ContactSearchKey): Factory {
|
||||
val args = SafetyNumberBottomSheetArgs(
|
||||
identityRecords.map { it.recipientId },
|
||||
listOf(destination).filterIsInstance<ContactSearchKey.RecipientSearchKey>().map { it.requireParcelable() }
|
||||
)
|
||||
|
||||
return SheetFactory(args)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return The parcelized arguments inside the bundle
|
||||
* @throws IllegalArgumentException if the bundle does not contain the correct parcelized arguments.
|
||||
*/
|
||||
fun getArgsFromBundle(bundle: Bundle): SafetyNumberBottomSheetArgs {
|
||||
val args = bundle.getParcelable<SafetyNumberBottomSheetArgs>(ARGS)
|
||||
Preconditions.checkArgument(args != null)
|
||||
return args!!
|
||||
}
|
||||
|
||||
private fun getDestinationFromRecord(messageRecord: MessageRecord): List<ContactSearchKey.ParcelableRecipientSearchKey> {
|
||||
val key = if ((messageRecord as? MmsMessageRecord)?.storyType?.isStory == true) {
|
||||
ContactSearchKey.RecipientSearchKey.Story(messageRecord.recipient.id)
|
||||
} else {
|
||||
ContactSearchKey.RecipientSearchKey.KnownRecipient(messageRecord.recipient.id)
|
||||
}
|
||||
|
||||
return listOf(key.requireParcelable())
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to the normal companion object "show" methods, but with automatically provided arguments.
|
||||
*/
|
||||
interface Factory {
|
||||
fun show(fragmentManager: FragmentManager)
|
||||
}
|
||||
|
||||
private class SheetFactory(private val args: SafetyNumberBottomSheetArgs) : Factory {
|
||||
override fun show(fragmentManager: FragmentManager) {
|
||||
val dialogFragment = SafetyNumberBottomSheetFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putParcelable(ARGS, args)
|
||||
}
|
||||
}
|
||||
|
||||
dialogFragment.show(fragmentManager, TAG)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callbacks for the bottom sheet. These are optional, and are invoked by the bottom sheet itself.
|
||||
*
|
||||
* Since the bottom sheet utilizes findListener to locate the callback implementor, child fragments will
|
||||
* get precedence over their parents and activities have the least precedence.
|
||||
*/
|
||||
interface Callbacks {
|
||||
/**
|
||||
* Invoked when the user presses "send anyway" and the parent should automatically perform a resend.
|
||||
*/
|
||||
fun sendAnywayAfterSafetyNumberChangedInBottomSheet(destinations: List<ContactSearchKey.RecipientSearchKey>)
|
||||
|
||||
/**
|
||||
* Invoked when the user presses "send anyway" and a message was automatically resent.
|
||||
*/
|
||||
fun onMessageResentAfterSafetyNumberChangeInBottomSheet()
|
||||
|
||||
/**
|
||||
* Invoked when the user dismisses the sheet without performing a send.
|
||||
*/
|
||||
fun onCanceled()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package org.thoughtcrime.securesms.safety
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Fragment argument for `SafetyNumberBottomSheetFragment`
|
||||
*/
|
||||
@Parcelize
|
||||
data class SafetyNumberBottomSheetArgs(
|
||||
val untrustedRecipients: List<RecipientId>,
|
||||
val destinations: List<ContactSearchKey.ParcelableRecipientSearchKey>,
|
||||
val messageId: MessageId? = null
|
||||
) : Parcelable
|
||||
@@ -0,0 +1,218 @@
|
||||
package org.thoughtcrime.securesms.safety
|
||||
|
||||
import android.content.DialogInterface
|
||||
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.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.IdentityDatabase
|
||||
import org.thoughtcrime.securesms.safety.review.SafetyNumberReviewConnectionsFragment
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.fragments.findListener
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import org.thoughtcrime.securesms.verify.VerifyIdentityFragment
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
override val peekHeightPercentage: Float = 1f
|
||||
|
||||
@get:MainThread
|
||||
private val args: SafetyNumberBottomSheetArgs by lazy(LazyThreadSafetyMode.NONE) {
|
||||
SafetyNumberBottomSheet.getArgsFromBundle(requireArguments())
|
||||
}
|
||||
|
||||
private val viewModel: SafetyNumberBottomSheetViewModel by viewModels(factoryProducer = {
|
||||
SafetyNumberBottomSheetViewModel.Factory(
|
||||
args,
|
||||
SafetyNumberChangeRepository(requireContext())
|
||||
)
|
||||
})
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
|
||||
SplashImage.register(adapter)
|
||||
SafetyNumberRecipientRowItem.register(adapter)
|
||||
lifecycleDisposable.bindTo(viewLifecycleOwner)
|
||||
|
||||
lifecycleDisposable += viewModel.state.subscribe { state ->
|
||||
reviewConnections.visible = state.hasLargeNumberOfUntrustedRecipients
|
||||
|
||||
if (state.isCheckupComplete()) {
|
||||
sendAnyway.setText(R.string.conversation_activity__send)
|
||||
}
|
||||
|
||||
adapter.submitList(getConfiguration(state).toMappingModelList())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDismiss(dialog: DialogInterface) {
|
||||
super.onDismiss(dialog)
|
||||
if (sendAnyway.isEnabled) {
|
||||
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,
|
||||
R.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(R.style.Signal_Text_TitleLarge),
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
textPref(
|
||||
title = DSLSettingsText.from(
|
||||
when {
|
||||
state.isCheckupComplete() && state.hasLargeNumberOfUntrustedRecipients -> ""
|
||||
state.hasLargeNumberOfUntrustedRecipients -> getString(R.string.SafetyNumberBottomSheetFragment__you_have_d_connections, args.untrustedRecipients.size)
|
||||
else -> getString(R.string.SafetyNumberBottomSheetFragment__the_following_people)
|
||||
},
|
||||
DSLSettingsText.TextAppearanceModifier(R.style.Signal_Text_BodyLarge),
|
||||
DSLSettingsText.CenterModifier
|
||||
)
|
||||
)
|
||||
|
||||
if (state.isEmpty()) {
|
||||
space(DimensionUnit.DP.toPixels(96f).toInt())
|
||||
|
||||
noPadTextPref(
|
||||
title = DSLSettingsText.from(
|
||||
R.string.SafetyNumberBottomSheetFragment__no_more_recipients_to_show,
|
||||
DSLSettingsText.TextAppearanceModifier(R.style.Signal_Text_BodyLarge),
|
||||
DSLSettingsText.CenterModifier,
|
||||
DSLSettingsText.ColorModifier(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (!state.hasLargeNumberOfUntrustedRecipients) {
|
||||
state.destinationToRecipientMap.values.flatten().distinct().forEach {
|
||||
customPref(
|
||||
SafetyNumberRecipientRowItem.Model(
|
||||
recipient = it.recipient,
|
||||
isVerified = it.identityRecord.verifiedStatus == IdentityDatabase.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 = R.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 = R.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 = R.color.signal_colorOnSurface,
|
||||
action = {
|
||||
viewModel.removeDestination(model.recipient.id)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
actions
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(SafetyNumberBottomSheetFragment::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package org.thoughtcrime.securesms.safety
|
||||
|
||||
import io.reactivex.rxjava3.core.BackpressureStrategy
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Maybe
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListRecord
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* Repository for dealing with recipients with changed safety numbers.
|
||||
*/
|
||||
class SafetyNumberBottomSheetRepository {
|
||||
|
||||
/**
|
||||
* Retrieves the IdentityRecord for a given recipient from the protocol store (if present)
|
||||
*/
|
||||
fun getIdentityRecord(recipientId: RecipientId): Maybe<IdentityRecord> {
|
||||
return Single.fromCallable {
|
||||
ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecord(recipientId)
|
||||
}.flatMapMaybe {
|
||||
if (it.isPresent) {
|
||||
Maybe.just(it.get())
|
||||
} else {
|
||||
Maybe.empty()
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds out the list of UntrustedRecipients to display in either the bottom sheet or review fragment. This list will be automatically
|
||||
* republished whenever there is a recipient change, group change, or distribution list change, and is driven by Recipient.observe.
|
||||
*
|
||||
* It will automatically filter out any recipients who are no longer included in destinations as the user moves through the list and removes or
|
||||
* verifies senders.
|
||||
*/
|
||||
fun getBuckets(recipients: List<RecipientId>, destinations: List<ContactSearchKey.RecipientSearchKey>): Flowable<Map<SafetyNumberBucket, List<SafetyNumberRecipient>>> {
|
||||
val recipientObservable = getResolvedIdentities(recipients)
|
||||
val distributionListObservable = getDistributionLists(destinations)
|
||||
val groupObservable = getGroups(destinations)
|
||||
val destinationRecipientIds = destinations.map { it.recipientId }.toSet()
|
||||
|
||||
return Observable.combineLatest(recipientObservable, distributionListObservable, groupObservable) { recipientList, distributionLists, groups ->
|
||||
val map = mutableMapOf<SafetyNumberBucket, List<SafetyNumberRecipient>>()
|
||||
|
||||
recipientList.forEach {
|
||||
val distributionListMemberships = getDistributionMemberships(it.recipient, distributionLists)
|
||||
val groupMemberships = getGroupMemberships(it.recipient, groups)
|
||||
val isInContactsBucket = destinationRecipientIds.contains(it.recipient.id)
|
||||
|
||||
val safetyNumberRecipient = SafetyNumberRecipient(
|
||||
it.recipient,
|
||||
it.identityRecord.get(),
|
||||
distributionListMemberships.size,
|
||||
groupMemberships.size
|
||||
)
|
||||
|
||||
distributionListMemberships.forEach { distributionListRecord ->
|
||||
insert(map, SafetyNumberBucket.DistributionListBucket(distributionListRecord.id, distributionListRecord.name), safetyNumberRecipient)
|
||||
}
|
||||
|
||||
groupMemberships.forEach { group ->
|
||||
insert(map, SafetyNumberBucket.GroupBucket(group), safetyNumberRecipient)
|
||||
}
|
||||
|
||||
if (isInContactsBucket) {
|
||||
insert(map, SafetyNumberBucket.ContactsBucket, safetyNumberRecipient)
|
||||
}
|
||||
}
|
||||
|
||||
map.toMap()
|
||||
}.toFlowable(BackpressureStrategy.LATEST).subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the given recipient from all stories they're a member of in destinations.
|
||||
*/
|
||||
fun removeFromStories(recipientId: RecipientId, destinations: List<ContactSearchKey.RecipientSearchKey>): Completable {
|
||||
return filterForDistributionLists(destinations).flatMapCompletable { distributionRecipients ->
|
||||
Completable.fromAction {
|
||||
distributionRecipients
|
||||
.mapNotNull { SignalDatabase.distributionLists.getList(it.requireDistributionListId()) }
|
||||
.filter { it.members.contains(recipientId) }
|
||||
.forEach {
|
||||
SignalDatabase.distributionLists.excludeFromStory(recipientId, it)
|
||||
Stories.onStorySettingsChanged(it.id)
|
||||
}
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the given set of recipients from the specified distribution list
|
||||
*/
|
||||
fun removeAllFromStory(recipientIds: List<RecipientId>, distributionList: DistributionListId): Completable {
|
||||
return Completable.fromAction {
|
||||
val record = SignalDatabase.distributionLists.getList(distributionList)
|
||||
if (record != null) {
|
||||
SignalDatabase.distributionLists.excludeAllFromStory(recipientIds, record)
|
||||
Stories.onStorySettingsChanged(distributionList)
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
private fun insert(map: MutableMap<SafetyNumberBucket, List<SafetyNumberRecipient>>, bucket: SafetyNumberBucket, safetyNumberRecipient: SafetyNumberRecipient) {
|
||||
val bucketList = map.getOrDefault(bucket, emptyList())
|
||||
map[bucket] = bucketList + safetyNumberRecipient
|
||||
}
|
||||
|
||||
private fun filterForDistributionLists(destinations: List<ContactSearchKey.RecipientSearchKey>): Single<List<Recipient>> {
|
||||
return Single.fromCallable {
|
||||
val recipients = Recipient.resolvedList(destinations.map { it.recipientId })
|
||||
recipients.filter { it.isDistributionList }
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterForGroups(destinations: List<ContactSearchKey.RecipientSearchKey>): Single<List<Recipient>> {
|
||||
return Single.fromCallable {
|
||||
val recipients = Recipient.resolvedList(destinations.map { it.recipientId })
|
||||
recipients.filter { it.isGroup }
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeDistributionList(recipient: Recipient): Observable<DistributionListRecord> {
|
||||
return Recipient.observable(recipient.id).map { SignalDatabase.distributionLists.getList(it.requireDistributionListId())!! }
|
||||
}
|
||||
|
||||
private fun getDistributionLists(destinations: List<ContactSearchKey.RecipientSearchKey>): Observable<List<DistributionListRecord>> {
|
||||
val distributionListRecipients: Single<List<Recipient>> = filterForDistributionLists(destinations)
|
||||
|
||||
return distributionListRecipients.flatMapObservable { recipients ->
|
||||
if (recipients.isEmpty()) {
|
||||
Observable.just(emptyList())
|
||||
} else {
|
||||
val distributionListObservables = recipients.map { observeDistributionList(it) }
|
||||
Observable.combineLatest(distributionListObservables) {
|
||||
it.filterIsInstance<DistributionListRecord>()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getGroups(destinations: List<ContactSearchKey.RecipientSearchKey>): Observable<List<Recipient>> {
|
||||
val groupRecipients: Single<List<Recipient>> = filterForGroups(destinations)
|
||||
return groupRecipients.flatMapObservable { recipients ->
|
||||
if (recipients.isEmpty()) {
|
||||
Observable.just(emptyList())
|
||||
} else {
|
||||
val recipientObservables = recipients.map {
|
||||
Recipient.observable(it.id)
|
||||
}
|
||||
Observable.combineLatest(recipientObservables) {
|
||||
it.filterIsInstance<Recipient>()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun getResolvedIdentities(recipients: List<RecipientId>): Observable<List<ResolvedIdentity>> {
|
||||
val recipientObservables: List<Observable<ResolvedIdentity>> = recipients.map {
|
||||
Recipient.observable(it).switchMap { recipient ->
|
||||
Observable.fromCallable {
|
||||
val record = ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecord(recipient.id)
|
||||
ResolvedIdentity(recipient, record)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Observable.combineLatest(recipientObservables) { identities ->
|
||||
identities.filterIsInstance<ResolvedIdentity>().filter { it.identityRecord.isPresent }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDistributionMemberships(recipient: Recipient, distributionLists: List<DistributionListRecord>): List<DistributionListRecord> {
|
||||
return distributionLists.filter { it.members.contains(recipient.id) }
|
||||
}
|
||||
|
||||
private fun getGroupMemberships(recipient: Recipient, groups: List<Recipient>): List<Recipient> {
|
||||
return groups.filter { it.participants.contains(recipient) }
|
||||
}
|
||||
|
||||
data class ResolvedIdentity(val recipient: Recipient, val identityRecord: Optional<IdentityRecord>)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.thoughtcrime.securesms.safety
|
||||
|
||||
import org.thoughtcrime.securesms.database.IdentityDatabase
|
||||
|
||||
/**
|
||||
* Screen state for SafetyNumberBottomSheetFragment and SafetyNumberReviewConnectionsFragment
|
||||
*/
|
||||
data class SafetyNumberBottomSheetState(
|
||||
val untrustedRecipientCount: Int,
|
||||
val hasLargeNumberOfUntrustedRecipients: Boolean,
|
||||
val destinationToRecipientMap: Map<SafetyNumberBucket, List<SafetyNumberRecipient>> = emptyMap(),
|
||||
val loadState: LoadState = LoadState.INIT
|
||||
) {
|
||||
|
||||
fun isEmpty(): Boolean {
|
||||
return !hasLargeNumberOfUntrustedRecipients && destinationToRecipientMap.values.flatten().isEmpty() && loadState == LoadState.READY
|
||||
}
|
||||
|
||||
fun isCheckupComplete(): Boolean {
|
||||
return loadState == LoadState.DONE ||
|
||||
isEmpty() ||
|
||||
destinationToRecipientMap.values.flatten().all { it.identityRecord.verifiedStatus == IdentityDatabase.VerifiedStatus.VERIFIED }
|
||||
}
|
||||
|
||||
enum class LoadState {
|
||||
INIT,
|
||||
READY,
|
||||
DONE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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 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
|
||||
|
||||
class SafetyNumberBottomSheetViewModel(
|
||||
private val args: SafetyNumberBottomSheetArgs,
|
||||
private val repository: SafetyNumberBottomSheetRepository = SafetyNumberBottomSheetRepository(),
|
||||
private val trustAndVerifyRepository: SafetyNumberChangeRepository
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private const val MAX_RECIPIENTS_TO_DISPLAY = 5
|
||||
}
|
||||
|
||||
private val destinationStore = RxStore(args.destinations.map { it.asRecipientSearchKey() })
|
||||
val destinationSnapshot: List<ContactSearchKey.RecipientSearchKey>
|
||||
get() = destinationStore.state
|
||||
|
||||
private val store = RxStore(
|
||||
SafetyNumberBottomSheetState(
|
||||
untrustedRecipientCount = args.untrustedRecipients.size,
|
||||
hasLargeNumberOfUntrustedRecipients = args.untrustedRecipients.size > MAX_RECIPIENTS_TO_DISPLAY
|
||||
)
|
||||
)
|
||||
|
||||
val state: Flowable<SafetyNumberBottomSheetState> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
|
||||
|
||||
val hasLargeNumberOfUntrustedRecipients
|
||||
get() = store.state.hasLargeNumberOfUntrustedRecipients
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
init {
|
||||
if (!store.state.hasLargeNumberOfUntrustedRecipients) {
|
||||
loadRecipients()
|
||||
}
|
||||
}
|
||||
|
||||
fun setDone() {
|
||||
store.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())
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadRecipients() {
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
disposables.clear()
|
||||
}
|
||||
|
||||
fun getIdentityRecord(recipientId: RecipientId): Maybe<IdentityRecord> {
|
||||
return repository.getIdentityRecord(recipientId).observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun removeRecipientFromSelectedStories(recipientId: RecipientId) {
|
||||
disposables += repository.removeFromStories(recipientId, destinationStore.state).subscribe()
|
||||
}
|
||||
|
||||
fun removeDestination(destination: RecipientId) {
|
||||
destinationStore.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()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package org.thoughtcrime.securesms.safety
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.DistributionListId
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
sealed class SafetyNumberBucket {
|
||||
data class DistributionListBucket(val distributionListId: DistributionListId, val name: String) : SafetyNumberBucket()
|
||||
data class GroupBucket(val recipient: Recipient) : SafetyNumberBucket()
|
||||
object ContactsBucket : SafetyNumberBucket()
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.safety
|
||||
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
|
||||
/**
|
||||
* Represents a Recipient who had a safety number change. Also includes information used in
|
||||
* order to determine whether the recipient should be shown on a given screen and what menu
|
||||
* options it should have.
|
||||
*/
|
||||
data class SafetyNumberRecipient(
|
||||
val recipient: Recipient,
|
||||
val identityRecord: IdentityRecord,
|
||||
val distributionListMembershipCount: Int,
|
||||
val groupMembershipCount: Int
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
package org.thoughtcrime.securesms.safety
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
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)
|
||||
private val menu: View = itemView.findViewById(R.id.safety_number_recipient_menu)
|
||||
|
||||
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()
|
||||
menu.setOnClickListener {
|
||||
SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup)
|
||||
.show(model.getContextMenuActions(model))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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.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.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.IdentityDatabase
|
||||
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.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.verify.VerifyIdentityFragment
|
||||
|
||||
/**
|
||||
* 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: DSLSettingsAdapter) {
|
||||
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 {
|
||||
textPref(
|
||||
title = DSLSettingsText.from(
|
||||
getString(R.string.SafetyNumberReviewConnectionsFragment__d_recipients_may_have, state.destinationToRecipientMap.values.flatten().size),
|
||||
DSLSettingsText.TextAppearanceModifier(R.style.Signal_Text_BodyMedium),
|
||||
DSLSettingsText.ColorModifier(ContextCompat.getColor(requireContext(), R.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 == IdentityDatabase.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 = R.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 = R.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