Implement new Safety Number Changes bottom sheeet.

This commit is contained in:
Alex Hart
2022-07-11 12:54:30 -03:00
parent b0dc7fe6df
commit 7a0f4fafe2
42 changed files with 1787 additions and 121 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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