From 7a0f4fafe25f761a74a3315f9db0d7539b9e43ec Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Mon, 11 Jul 2022 12:54:30 -0300 Subject: [PATCH] Implement new Safety Number Changes bottom sheeet. --- .../SafetyNumberChangeDialogPreviewer.kt | 32 ++- .../SafetyNumberBottomSheetRepositoryTest.kt | 139 +++++++++++ .../securesms/testing/SignalActivityRule.kt | 11 +- .../securesms/WebRtcCallActivity.java | 8 +- .../components/WrapperDialogFragment.kt | 14 ++ .../components/settings/models/SplashImage.kt | 12 +- .../contacts/paged/ContactSearchKey.kt | 6 +- .../conversation/ConversationFragment.java | 4 +- .../ConversationParentFragment.java | 40 ++-- .../forward/MultiselectForwardFragment.kt | 18 +- .../forward/MultiselectForwardState.kt | 2 +- .../forward/MultiselectForwardViewModel.kt | 9 +- .../ui/error/SafetyNumberChangeDialog.java | 36 +-- .../error/SafetyNumberChangeRepository.java | 40 +++- .../database/DistributionListDatabase.kt | 36 +++ .../securesms/database/model/MessageId.kt | 6 +- .../mediasend/v2/MediaSelectionActivity.kt | 6 +- .../mediasend/v2/UntrustedRecords.kt | 6 +- .../v2/text/TextStoryPostCreationFragment.kt | 18 +- .../v2/text/send/TextStoryPostSendFragment.kt | 21 +- .../MessageDetailsFragment.java | 10 +- .../safety/SafetyNumberBottomSheet.kt | 185 +++++++++++++++ .../safety/SafetyNumberBottomSheetArgs.kt | 17 ++ .../safety/SafetyNumberBottomSheetFragment.kt | 218 ++++++++++++++++++ .../SafetyNumberBottomSheetRepository.kt | 194 ++++++++++++++++ .../safety/SafetyNumberBottomSheetState.kt | 30 +++ .../SafetyNumberBottomSheetViewModel.kt | 105 +++++++++ .../securesms/safety/SafetyNumberBucket.kt | 10 + .../safety/SafetyNumberBucketRowItem.kt | 116 ++++++++++ .../securesms/safety/SafetyNumberRecipient.kt | 16 ++ .../safety/SafetyNumberRecipientRowItem.kt | 71 ++++++ .../SafetyNumberReviewConnectionsFragment.kt | 158 +++++++++++++ .../stories/landing/StoriesLandingFragment.kt | 6 +- .../securesms/stories/my/MyStoriesFragment.kt | 6 +- .../settings/my/MyStorySettingsFragment.kt | 5 - .../reply/group/StoryGroupReplyFragment.kt | 27 ++- .../verify/VerifyIdentityFragment.kt | 34 ++- .../res/layout/safety_number_bottom_sheet.xml | 60 +++++ .../layout/safety_number_bucket_row_item.xml | 43 ++++ .../safety_number_recipient_row_item.xml | 62 +++++ .../layout/safety_number_review_fragment.xml | 33 +++ app/src/main/res/values/strings.xml | 38 +++ 42 files changed, 1787 insertions(+), 121 deletions(-) create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetRepositoryTest.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheet.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetArgs.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetFragment.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetRepository.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetState.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBucket.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBucketRowItem.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberRecipient.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberRecipientRowItem.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/safety/review/SafetyNumberReviewConnectionsFragment.kt create mode 100644 app/src/main/res/layout/safety_number_bottom_sheet.xml create mode 100644 app/src/main/res/layout/safety_number_bucket_row_item.xml create mode 100644 app/src/main/res/layout/safety_number_recipient_row_item.xml create mode 100644 app/src/main/res/layout/safety_number_review_fragment.xml diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/SafetyNumberChangeDialogPreviewer.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/SafetyNumberChangeDialogPreviewer.kt index 061d0c5e9f..e6b520e668 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/SafetyNumberChangeDialogPreviewer.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/SafetyNumberChangeDialogPreviewer.kt @@ -5,11 +5,13 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.database.IdentityDatabase import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.profiles.ProfileName import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet import org.thoughtcrime.securesms.testing.SignalActivityRule /** @@ -18,7 +20,7 @@ import org.thoughtcrime.securesms.testing.SignalActivityRule @RunWith(AndroidJUnit4::class) class SafetyNumberChangeDialogPreviewer { - @get:Rule val harness = SignalActivityRule() + @get:Rule val harness = SignalActivityRule(othersCount = 10) @Test fun testShowLongName() { @@ -31,7 +33,31 @@ class SafetyNumberChangeDialogPreviewer { val scenario: ActivityScenario = harness.launchActivity { putExtra("recipient_id", other.id.serialize()) } scenario.onActivity { - SafetyNumberChangeDialog.show(it.supportFragmentManager, other.id) + SafetyNumberBottomSheet.forRecipientId(other.id).show(it.supportFragmentManager) + } + + // Uncomment to make dialog stay on screen, otherwise will show/dismiss immediately + // ThreadUtil.sleep(15000) + } + + @Test + fun testShowLargeSheet() { + val othersRecipients = harness.others.map { Recipient.resolved(it) } + othersRecipients.forEach { other -> + SignalDatabase.recipients.setProfileName(other.id, ProfileName.fromParts("My", "Name")) + + harness.setVerified(other, IdentityDatabase.VerifiedStatus.VERIFIED) + harness.changeIdentityKey(other) + } + + val scenario: ActivityScenario = harness.launchActivity { putExtra("recipient_id", harness.others.first().serialize()) } + scenario.onActivity { conversationActivity -> + SafetyNumberBottomSheet + .forIdentityRecordsAndDestinations( + identityRecords = ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(othersRecipients).identityRecords, + destinations = othersRecipients.map { ContactSearchKey.RecipientSearchKey.KnownRecipient(it.id) } + ) + .show(conversationActivity.supportFragmentManager) } // Uncomment to make dialog stay on screen, otherwise will show/dismiss immediately diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetRepositoryTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetRepositoryTest.kt new file mode 100644 index 0000000000..487582b778 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetRepositoryTest.kt @@ -0,0 +1,139 @@ +package org.thoughtcrime.securesms.safety + +import io.reactivex.rxjava3.plugins.RxJavaPlugins +import io.reactivex.rxjava3.schedulers.TestScheduler +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.testing.SignalActivityRule + +class SafetyNumberBottomSheetRepositoryTest { + + @get:Rule val harness = SignalActivityRule(othersCount = 10) + + private val testScheduler = TestScheduler() + private val subjectUnderTest = SafetyNumberBottomSheetRepository() + + @Before + fun setUp() { + RxJavaPlugins.setInitIoSchedulerHandler { testScheduler } + RxJavaPlugins.setIoSchedulerHandler { testScheduler } + } + + @Test + fun givenIOnlyHave1to1Destinations_whenIGetBuckets_thenIOnlyHaveContactsBucketContainingAllRecipients() { + val recipients = harness.others + val destinations = harness.others.map { ContactSearchKey.RecipientSearchKey.KnownRecipient(it) } + + val result = subjectUnderTest.getBuckets(recipients, destinations).test() + + testScheduler.triggerActions() + + result.assertValueAt(1) { map -> + assertMatch(map, mapOf(SafetyNumberBucket.ContactsBucket to harness.others)) + } + } + + @Test + fun givenIOnlyHaveASingle1to1Destination_whenIGetBuckets_thenIOnlyHaveContactsBucketContainingAllRecipients() { + // GIVEN + val recipients = harness.others + val destination = harness.others.take(1).map { ContactSearchKey.RecipientSearchKey.KnownRecipient(it) } + + // WHEN + val result = subjectUnderTest.getBuckets(recipients, destination).test(1) + testScheduler.triggerActions() + + // THEN + result.assertValue { map -> + assertMatch(map, mapOf(SafetyNumberBucket.ContactsBucket to harness.others.take(1))) + } + } + + @Test + fun givenIHaveADistributionListDestination_whenIGetBuckets_thenIOnlyHaveDistributionListDestinationWithCorrespondingMembers() { + // GIVEN + val distributionListMembers = harness.others.take(5) + val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!! + val destinationKey = ContactSearchKey.RecipientSearchKey.Story(SignalDatabase.distributionLists.getRecipientId(distributionList)!!) + + // WHEN + val result = subjectUnderTest.getBuckets(harness.others, listOf(destinationKey)).test(1) + testScheduler.triggerActions() + + // THEN + result.assertValue { map -> + assertMatch( + map, + mapOf( + SafetyNumberBucket.DistributionListBucket(distributionList, "ListA") to harness.others.take(5) + ) + ) + } + } + + @Test + fun givenIHaveADistributionListDestinationAndIGetBuckets_whenIRemoveFromStories_thenIOnlyHaveDistributionListDestinationWithCorrespondingMembers() { + // GIVEN + val distributionListMembers = harness.others.take(5) + val toRemove = distributionListMembers.last() + val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!! + val destinationKey = ContactSearchKey.RecipientSearchKey.Story(SignalDatabase.distributionLists.getRecipientId(distributionList)!!) + val testSubscriber = subjectUnderTest.getBuckets(distributionListMembers, listOf(destinationKey)).test(2) + testScheduler.triggerActions() + + // WHEN + subjectUnderTest.removeFromStories(toRemove, listOf(destinationKey)).subscribe() + testSubscriber.request(1) + testScheduler.triggerActions() + testSubscriber.awaitCount(3) + + // THEN + testSubscriber.assertValueAt(2) { map -> + assertMatch( + map, + mapOf( + SafetyNumberBucket.DistributionListBucket(distributionList, "ListA") to distributionListMembers.dropLast(1) + ) + ) + } + } + + @Test + fun givenIHaveADistributionListDestinationAndIGetBuckets_whenIRemoveAllFromStory_thenINoLongerHaveEntryForThatBucket() { + // GIVEN + val distributionListMembers = harness.others.take(5) + val distributionList = SignalDatabase.distributionLists.createList("ListA", distributionListMembers)!! + val destinationKey = ContactSearchKey.RecipientSearchKey.Story(SignalDatabase.distributionLists.getRecipientId(distributionList)!!) + val testSubscriber = subjectUnderTest.getBuckets(distributionListMembers, listOf(destinationKey)).test(2) + testScheduler.triggerActions() + + // WHEN + subjectUnderTest.removeAllFromStory(distributionListMembers, distributionList).subscribe() + testSubscriber.request(1) + testScheduler.triggerActions() + testSubscriber.awaitCount(3) + + // THEN + testSubscriber.assertValueAt(2) { map -> + assertMatch(map, mapOf()) + } + } + + private fun assertMatch( + resultMap: Map>, + idMap: Map> + ): Boolean { + assertEquals("Result and ID Maps had different key sets", idMap.keys, resultMap.keys) + + resultMap.forEach { (bucket, members) -> + assertEquals("Mismatch in Bucket $bucket", idMap[bucket], members.map { it.recipient.id }) + } + + return true + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/SignalActivityRule.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/SignalActivityRule.kt index 7b5e74fbfb..d2f5805680 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/testing/SignalActivityRule.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/testing/SignalActivityRule.kt @@ -30,6 +30,7 @@ import org.whispersystems.signalservice.api.profiles.SignalServiceProfile import org.whispersystems.signalservice.api.push.ACI import org.whispersystems.signalservice.api.push.SignalServiceAddress import org.whispersystems.signalservice.internal.push.VerifyAccountResponse +import java.lang.IllegalArgumentException import java.util.UUID /** @@ -38,7 +39,7 @@ import java.util.UUID * * To use: `@get:Rule val harness = SignalActivityRule()` */ -class SignalActivityRule : ExternalResource() { +class SignalActivityRule(private val othersCount: Int = 4) : ExternalResource() { val application: Application = ApplicationDependencies.getApplication() @@ -88,9 +89,13 @@ class SignalActivityRule : ExternalResource() { private fun setupOthers(): List { val others = mutableListOf() - for (i in 0..4) { + if (othersCount !in 0 until 1000) { + throw IllegalArgumentException("$othersCount must be between 0 and 1000") + } + + for (i in 0 until othersCount) { val aci = ACI.from(UUID.randomUUID()) - val recipientId = RecipientId.from(SignalServiceAddress(aci, "+1555555101$i")) + val recipientId = RecipientId.from(SignalServiceAddress(aci, "+15555551%03d".format(i))) SignalDatabase.recipients.setProfileName(recipientId, ProfileName.fromParts("Buddy", "#$i")) SignalDatabase.recipients.setProfileKeyIfAbsent(recipientId, ProfileKeyUtil.createNew()) SignalDatabase.recipients.setCapabilities(recipientId, SignalServiceProfile.Capabilities(true, true, true, true, true, true, true)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java index ac838c6c9c..1bcf952aa7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/WebRtcCallActivity.java @@ -70,6 +70,7 @@ import org.thoughtcrime.securesms.messagerequests.CalleeMustAcceptMessageRequest import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet; import org.thoughtcrime.securesms.service.webrtc.SignalCallManager; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.util.EllapsedTimeFormatter; @@ -344,7 +345,8 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan startCall(((WebRtcCallViewModel.Event.StartCall) event).isVideoCall()); return; } else if (event instanceof WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) { - SafetyNumberChangeDialog.showForGroupCall(getSupportFragmentManager(), ((WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) event).getIdentityRecords()); + SafetyNumberBottomSheet.forGroupCall(((WebRtcCallViewModel.Event.ShowGroupCallSafetyNumberChange) event).getIdentityRecords()) + .show(getSupportFragmentManager()); return; } else if (event instanceof WebRtcCallViewModel.Event.SwitchToSpeaker) { callScreen.switchToSpeakerView(); @@ -561,7 +563,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan handleTerminate(recipient, HangupMessage.Type.NORMAL); } - SafetyNumberChangeDialog.showForCall(getSupportFragmentManager(), recipient.getId()); + SafetyNumberBottomSheet.forCall(recipient.getId()).show(getSupportFragmentManager()); } public void handleSafetyNumberChangeEvent(@NonNull WebRtcCallViewModel.SafetyNumberChangeEvent safetyNumberChangeEvent) { @@ -570,7 +572,7 @@ public class WebRtcCallActivity extends BaseActivity implements SafetyNumberChan GroupCallSafetyNumberChangeNotificationUtil.showNotification(this, viewModel.getRecipient().get()); } else { GroupCallSafetyNumberChangeNotificationUtil.cancelNotification(this, viewModel.getRecipient().get()); - SafetyNumberChangeDialog.showForDuringGroupCall(getSupportFragmentManager(), safetyNumberChangeEvent.getRecipientIds()); + SafetyNumberBottomSheet.forDuringGroupCall(safetyNumberChangeEvent.getRecipientIds()).show(getSupportFragmentManager()); } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/WrapperDialogFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/WrapperDialogFragment.kt index 92ab7b0600..bcb12ed926 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/WrapperDialogFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/WrapperDialogFragment.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components import android.content.DialogInterface import android.os.Bundle import android.view.View +import androidx.activity.OnBackPressedCallback import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import org.thoughtcrime.securesms.R @@ -40,6 +41,15 @@ abstract class WrapperDialogFragment : DialogFragment(R.layout.fragment_containe } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + requireActivity().onBackPressedDispatcher.addCallback( + viewLifecycleOwner, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + onHandleBackPressed() + } + } + ) + if (savedInstanceState == null) { childFragmentManager.beginTransaction() .replace(R.id.fragment_container, getWrappedFragment()) @@ -47,6 +57,10 @@ abstract class WrapperDialogFragment : DialogFragment(R.layout.fragment_containe } } + open fun onHandleBackPressed() { + dismissAllowingStateLoss() + } + override fun onDismiss(dialog: DialogInterface) { findListener()?.onWrapperDialogFragmentDismissed() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/SplashImage.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/SplashImage.kt index c3321f8429..1d0f7cf260 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/SplashImage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/models/SplashImage.kt @@ -2,7 +2,9 @@ package org.thoughtcrime.securesms.components.settings.models import android.view.View import android.widget.ImageView +import androidx.annotation.ColorRes import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.settings.PreferenceModel import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory @@ -18,9 +20,9 @@ object SplashImage { mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.splash_image)) } - class Model(@DrawableRes val splashImageResId: Int) : PreferenceModel() { + class Model(@DrawableRes val splashImageResId: Int, @ColorRes val splashImageTintResId: Int = -1) : PreferenceModel() { override fun areItemsTheSame(newItem: Model): Boolean { - return newItem.splashImageResId == splashImageResId + return newItem.splashImageResId == splashImageResId && newItem.splashImageTintResId == splashImageTintResId } } @@ -30,6 +32,12 @@ object SplashImage { override fun bind(model: Model) { splashImageView.setImageResource(model.splashImageResId) + + if (model.splashImageTintResId != -1) { + splashImageView.setColorFilter(ContextCompat.getColor(context, model.splashImageTintResId)) + } else { + splashImageView.clearColorFilter() + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt index abe3f36851..ae29c0e8ca 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/paged/ContactSearchKey.kt @@ -17,7 +17,7 @@ sealed class ContactSearchKey { */ open fun requireShareContact(): ShareContact = error("This key cannot be converted into a ShareContact") - open fun requireParcelable(): Parcelable = error("This key cannot be parcelized") + open fun requireParcelable(): ParcelableRecipientSearchKey = error("This key cannot be parcelized") sealed class RecipientSearchKey : ContactSearchKey() { @@ -29,7 +29,7 @@ sealed class ContactSearchKey { return ShareContact(recipientId) } - override fun requireParcelable(): Parcelable { + override fun requireParcelable(): ParcelableRecipientSearchKey { return ParcelableRecipientSearchKey(ParcelableType.STORY, recipientId) } @@ -44,7 +44,7 @@ sealed class ContactSearchKey { return ShareContact(recipientId) } - override fun requireParcelable(): Parcelable { + override fun requireParcelable(): ParcelableRecipientSearchKey { return ParcelableRecipientSearchKey(ParcelableType.KNOWN_RECIPIENT, recipientId) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index f7c4c629f5..3197a58806 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -158,6 +158,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment; import org.thoughtcrime.securesms.revealable.ViewOnceMessageActivity; import org.thoughtcrime.securesms.revealable.ViewOnceUtil; +import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.sms.OutgoingTextMessage; import org.thoughtcrime.securesms.stickers.StickerLocator; @@ -1874,7 +1875,8 @@ public class ConversationFragment extends LoggingFragment implements Multiselect @Override public void onIncomingIdentityMismatchClicked(@NonNull RecipientId recipientId) { - SafetyNumberChangeDialog.show(getParentFragmentManager(), recipientId); + SafetyNumberBottomSheet.forRecipientId(recipientId) + .show(getParentFragmentManager()); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java index 677cb6a9b6..421c6cc7c8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -152,6 +152,7 @@ import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState; import org.thoughtcrime.securesms.components.voice.VoiceNotePlayerView; import org.thoughtcrime.securesms.contacts.ContactAccessor; import org.thoughtcrime.securesms.contacts.ContactAccessor.ContactData; +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey; import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery; import org.thoughtcrime.securesms.contactshare.Contact; import org.thoughtcrime.securesms.contactshare.ContactShareEditActivity; @@ -161,7 +162,6 @@ import org.thoughtcrime.securesms.conversation.ConversationGroupViewModel.GroupA import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory; import org.thoughtcrime.securesms.conversation.drafts.DraftRepository; import org.thoughtcrime.securesms.conversation.drafts.DraftViewModel; -import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog; import org.thoughtcrime.securesms.conversation.ui.groupcall.GroupCallViewModel; import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel; import org.thoughtcrime.securesms.crypto.ReentrantSessionLock; @@ -263,6 +263,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.recipients.ui.disappearingmessages.RecipientDisappearingMessagesActivity; import org.thoughtcrime.securesms.registration.RegistrationNavigationActivity; +import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet; import org.thoughtcrime.securesms.search.MessageResult; import org.thoughtcrime.securesms.service.KeyCachingService; import org.thoughtcrime.securesms.sms.MessageSender; @@ -348,7 +349,7 @@ public class ConversationParentFragment extends Fragment AttachmentKeyboard.Callback, ConversationReactionOverlay.OnReactionSelectedListener, ReactWithAnyEmojiBottomSheetDialogFragment.Callback, - SafetyNumberChangeDialog.Callback, + SafetyNumberBottomSheet.Callbacks, ReactionsBottomSheetDialogFragment.Callback, MediaKeyboard.MediaKeyboardListener, EmojiEventListener, @@ -1594,22 +1595,16 @@ public class ConversationParentFragment extends Fragment private void handleRecentSafetyNumberChange() { List records = identityRecords.getUnverifiedRecords(); records.addAll(identityRecords.getUntrustedRecords()); - SafetyNumberChangeDialog.show(getChildFragmentManager(), records); + SafetyNumberBottomSheet + .forIdentityRecordsAndDestination( + records, + new ContactSearchKey.RecipientSearchKey.KnownRecipient(recipient.getId()) + ) + .show(getChildFragmentManager()); } @Override - public void onSendAnywayAfterSafetyNumberChange(@NonNull List changedRecipients) { - Log.d(TAG, "onSendAnywayAfterSafetyNumberChange"); - initializeIdentityRecords().addListener(new AssertedSuccessListener() { - @Override - public void onSuccess(Boolean result) { - sendMessage(null); - } - }); - } - - @Override - public void onMessageResentAfterSafetyNumberChange() { + public void onMessageResentAfterSafetyNumberChangeInBottomSheet() { Log.d(TAG, "onMessageResentAfterSafetyNumberChange"); initializeIdentityRecords().addListener(new AssertedSuccessListener() { @Override @@ -3629,6 +3624,17 @@ public class ConversationParentFragment extends Fragment material3OnScrollHelper.setColorImmediate(); } + @Override + public void sendAnywayAfterSafetyNumberChangedInBottomSheet(@NonNull List destinations) { + Log.d(TAG, "onSendAnywayAfterSafetyNumberChange"); + initializeIdentityRecords().addListener(new AssertedSuccessListener() { + @Override + public void onSuccess(Boolean result) { + sendMessage(null); + } + }); + } + // Listeners private final class DeleteCanceledVoiceNoteListener implements ListenableFuture.Listener { @@ -3888,7 +3894,9 @@ public class ConversationParentFragment extends Fragment @Override public void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord) { if (messageRecord.isIdentityMismatchFailure()) { - SafetyNumberChangeDialog.show(requireContext(), getChildFragmentManager(), messageRecord); + SafetyNumberBottomSheet + .forMessageRecord(requireContext(), messageRecord) + .show(getChildFragmentManager()); } else if (messageRecord.hasFailedWithNetworkFailures()) { new MaterialAlertDialogBuilder(requireContext()) .setMessage(R.string.conversation_activity__message_could_not_be_sent) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt index 8e51c0c822..ffcdbcdfb2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardFragment.kt @@ -34,12 +34,12 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator import org.thoughtcrime.securesms.contacts.paged.ContactSearchState -import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog import org.thoughtcrime.securesms.database.model.IdentityRecord import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseGroupStoryBottomSheet import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet import org.thoughtcrime.securesms.sharing.MultiShareArgs import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter import org.thoughtcrime.securesms.sharing.ShareSelectionMappingModel @@ -74,7 +74,7 @@ import org.thoughtcrime.securesms.util.visible */ class MultiselectForwardFragment : Fragment(R.layout.multiselect_forward_fragment), - SafetyNumberChangeDialog.Callback, + SafetyNumberBottomSheet.Callbacks, ChooseStoryTypeBottomSheet.Callback, WrapperDialogFragment.WrapperDialogFragmentCallback, ChooseInitialMyStoryMembershipBottomSheetDialogFragment.Callback { @@ -204,7 +204,7 @@ class MultiselectForwardFragment : when (it.stage) { MultiselectForwardState.Stage.Selection -> {} MultiselectForwardState.Stage.FirstConfirmation -> displayFirstSendConfirmation() - is MultiselectForwardState.Stage.SafetyConfirmation -> displaySafetyNumberConfirmation(it.stage.identities) + is MultiselectForwardState.Stage.SafetyConfirmation -> displaySafetyNumberConfirmation(it.stage.identities, it.stage.selectedContacts) MultiselectForwardState.Stage.LoadingIdentities -> {} MultiselectForwardState.Stage.SendPending -> { handler?.removeCallbacksAndMessages(null) @@ -288,8 +288,10 @@ class MultiselectForwardFragment : viewModel.send(addMessage.text.toString(), contactSearchMediator.getSelectedContacts()) } - private fun displaySafetyNumberConfirmation(identityRecords: List) { - SafetyNumberChangeDialog.show(childFragmentManager, identityRecords) + private fun displaySafetyNumberConfirmation(identityRecords: List, selectedContacts: List) { + SafetyNumberBottomSheet + .forIdentityRecordsAndDestinations(identityRecords, selectedContacts) + .show(childFragmentManager) } private fun dismissWithSuccess(@PluralsRes toastTextResId: Int) { @@ -332,11 +334,11 @@ class MultiselectForwardFragment : callback.exitFlow() } - override fun onSendAnywayAfterSafetyNumberChange(changedRecipients: MutableList) { - viewModel.confirmSafetySend(addMessage.text.toString(), contactSearchMediator.getSelectedContacts()) + override fun sendAnywayAfterSafetyNumberChangedInBottomSheet(destinations: List) { + viewModel.confirmSafetySend(addMessage.text.toString(), destinations.toSet()) } - override fun onMessageResentAfterSafetyNumberChange() { + override fun onMessageResentAfterSafetyNumberChangeInBottomSheet() { throw UnsupportedOperationException() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardState.kt index a74bb7e19f..766130252f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardState.kt @@ -13,7 +13,7 @@ data class MultiselectForwardState( object Selection : Stage() object FirstConfirmation : Stage() object LoadingIdentities : Stage() - data class SafetyConfirmation(val identities: List) : Stage() + data class SafetyConfirmation(val identities: List, val selectedContacts: List) : Stage() object SendPending : Stage() object SomeFailed : Stage() object AllFailed : Stage() diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt index 59d629ff3b..92a6110faa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/mutiselect/forward/MultiselectForwardViewModel.kt @@ -44,7 +44,14 @@ class MultiselectForwardViewModel( if (identityRecords.isEmpty()) { performSend(additionalMessage, selectedContacts) } else { - store.update { it.copy(stage = MultiselectForwardState.Stage.SafetyConfirmation(identityRecords)) } + store.update { state -> + state.copy( + stage = MultiselectForwardState.Stage.SafetyConfirmation( + identityRecords, + selectedContacts.filterIsInstance() + ) + ) + } } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java index 67d237e36e..73cc7802fa 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeDialog.java @@ -3,7 +3,6 @@ package org.thoughtcrime.securesms.conversation.ui.error; import android.annotation.SuppressLint; import android.app.Activity; import android.app.Dialog; -import android.content.Context; import android.content.DialogInterface; import android.os.Bundle; import android.view.LayoutInflater; @@ -28,9 +27,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.model.IdentityRecord; -import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.verify.VerifyIdentityActivity; @@ -64,37 +61,6 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa fragment.show(fragmentManager, SAFETY_NUMBER_DIALOG); } - public static void show(@NonNull FragmentManager fragmentManager, @NonNull List identityRecords) { - List ids = Stream.of(identityRecords) - .filterNot(IdentityRecord::isFirstUse) - .map(record -> record.getRecipientId().serialize()) - .distinct() - .toList(); - - Bundle arguments = new Bundle(); - arguments.putStringArray(RECIPIENT_IDS_EXTRA, ids.toArray(new String[0])); - arguments.putInt(CONTINUE_TEXT_RESOURCE_EXTRA, R.string.safety_number_change_dialog__send_anyway); - SafetyNumberChangeDialog fragment = new SafetyNumberChangeDialog(); - fragment.setArguments(arguments); - fragment.show(fragmentManager, SAFETY_NUMBER_DIALOG); - } - - public static void show(@NonNull Context context, @NonNull FragmentManager fragmentManager, @NonNull MessageRecord messageRecord) { - List ids = Stream.of(messageRecord.getIdentityKeyMismatches()) - .map(mismatch -> mismatch.getRecipientId(context).serialize()) - .distinct() - .toList(); - - Bundle arguments = new Bundle(); - arguments.putStringArray(RECIPIENT_IDS_EXTRA, ids.toArray(new String[0])); - arguments.putLong(MESSAGE_ID_EXTRA, messageRecord.getId()); - arguments.putString(MESSAGE_TYPE_EXTRA, messageRecord.isMms() ? MmsSmsDatabase.MMS_TRANSPORT : MmsSmsDatabase.SMS_TRANSPORT); - arguments.putInt(CONTINUE_TEXT_RESOURCE_EXTRA, R.string.safety_number_change_dialog__send_anyway); - SafetyNumberChangeDialog fragment = new SafetyNumberChangeDialog(); - fragment.setArguments(arguments); - fragment.show(fragmentManager, SAFETY_NUMBER_DIALOG); - } - public static void showForCall(@NonNull FragmentManager fragmentManager, @NonNull RecipientId recipientId) { Bundle arguments = new Bundle(); arguments.putStringArray(RECIPIENT_IDS_EXTRA, new String[] { recipientId.serialize() }); @@ -140,7 +106,7 @@ public final class SafetyNumberChangeDialog extends DialogFragment implements Sa fragment.show(fragmentManager, SAFETY_NUMBER_DIALOG); } - private SafetyNumberChangeDialog() { } + private SafetyNumberChangeDialog() {} @Override public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java index 827e199bcb..2702d332e5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/error/SafetyNumberChangeRepository.java @@ -22,10 +22,12 @@ import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.NoSuchMessageException; import org.thoughtcrime.securesms.database.SignalDatabase; 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.dependencies.ApplicationDependencies; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.safety.SafetyNumberRecipient; import org.thoughtcrime.securesms.sms.MessageSender; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.signalservice.api.SignalSessionLock; @@ -35,17 +37,39 @@ import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.stream.Collectors; -final class SafetyNumberChangeRepository { +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.schedulers.Schedulers; + +public final class SafetyNumberChangeRepository { private static final String TAG = Log.tag(SafetyNumberChangeRepository.class); private final Context context; - SafetyNumberChangeRepository(Context context) { + public SafetyNumberChangeRepository(Context context) { this.context = context.getApplicationContext(); } + @NonNull + public Single trustOrVerifyChangedRecipientsRx(@NonNull List safetyNumberRecipients) { + Log.d(TAG, "Trust or verify changed recipients for: " + Util.join(safetyNumberRecipients, ",")); + return Single.fromCallable(() -> trustOrVerifyChangedRecipientsInternal(fromSafetyNumberRecipients(safetyNumberRecipients))) + .subscribeOn(Schedulers.io()); + } + + @NonNull + public Single trustOrVerifyChangedRecipientsAndResendRx(@NonNull List safetyNumberRecipients, @NonNull MessageId messageId) { + Log.d(TAG, "Trust or verify changed recipients and resend message: " + messageId + " for: " + Util.join(safetyNumberRecipients, ",")); + return Single.fromCallable(() -> { + MessageRecord messageRecord = messageId.isMms() ? SignalDatabase.mms().getMessageRecord(messageId.getId()) + : SignalDatabase.sms().getMessageRecord(messageId.getId()); + + return trustOrVerifyChangedRecipientsAndResendInternal(fromSafetyNumberRecipients(safetyNumberRecipients), messageRecord); + }).subscribeOn(Schedulers.io()); + } + @NonNull LiveData trustOrVerifyChangedRecipients(@NonNull List changedRecipients) { Log.d(TAG, "Trust or verify changed recipients for: " + Util.join(changedRecipients, ",")); MutableLiveData liveData = new MutableLiveData<>(); @@ -78,6 +102,14 @@ final class SafetyNumberChangeRepository { return new SafetyNumberChangeState(changedRecipients, messageRecord); } + private @NonNull List fromSafetyNumberRecipients(@NonNull List safetyNumberRecipients) { + return safetyNumberRecipients.stream().map(this::fromSafetyNumberRecipient).collect(Collectors.toList()); + } + + private @NonNull ChangedRecipient fromSafetyNumberRecipient(@NonNull SafetyNumberRecipient safetyNumberRecipient) { + return new ChangedRecipient(safetyNumberRecipient.getRecipient(), safetyNumberRecipient.getIdentityRecord()); + } + @WorkerThread private @Nullable MessageRecord getMessageRecord(Long messageId, String messageType) { try { @@ -99,7 +131,7 @@ final class SafetyNumberChangeRepository { private TrustAndVerifyResult trustOrVerifyChangedRecipientsInternal(@NonNull List changedRecipients) { SignalIdentityKeyStore identityStore = ApplicationDependencies.getProtocolStore().aci().identities(); - try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) { + try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) { for (ChangedRecipient changedRecipient : changedRecipients) { IdentityRecord identityRecord = changedRecipient.getIdentityRecord(); @@ -126,7 +158,7 @@ final class SafetyNumberChangeRepository { Log.d(TAG, "No changed recipients to process, will still process message record"); } - try(SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) { + try (SignalSessionLock.Lock unused = ReentrantSessionLock.INSTANCE.acquire()) { for (ChangedRecipient changedRecipient : changedRecipients) { SignalProtocolAddress mismatchAddress = changedRecipient.getRecipient().requireServiceId().toProtocolAddress(SignalServiceAddress.DEFAULT_DEVICE_ID); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListDatabase.kt index b6d0a75950..638482a59f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DistributionListDatabase.kt @@ -488,6 +488,13 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si .run() } + fun removeAllMembers(listId: DistributionListId, privacyMode: DistributionListPrivacyMode) { + writableDatabase + .delete(MembershipTable.TABLE_NAME) + .where("${MembershipTable.LIST_ID} = ? AND ${MembershipTable.PRIVACY_MODE} = ?", listId.serialize(), privacyMode.serialize()) + .run() + } + fun remapRecipient(oldId: RecipientId, newId: RecipientId) { val values = ContentValues().apply { put(MembershipTable.RECIPIENT_ID, newId.serialize()) @@ -644,4 +651,33 @@ class DistributionListDatabase constructor(context: Context?, databaseHelper: Si private fun createUniqueNameForUnknownDistributionId(): String { return "DELETED-${UUID.randomUUID()}" } + + fun excludeFromStory(recipientId: RecipientId, record: DistributionListRecord) { + excludeAllFromStory(listOf(recipientId), record) + } + + fun excludeAllFromStory(recipientIds: List, record: DistributionListRecord) { + writableDatabase.withinTransaction { + when (record.privacyMode) { + DistributionListPrivacyMode.ONLY_WITH -> { + recipientIds.forEach { + removeMemberFromList(record.id, record.privacyMode, it) + } + } + DistributionListPrivacyMode.ALL_EXCEPT -> { + recipientIds.forEach { + addMemberToList(record.id, record.privacyMode, it) + } + } + DistributionListPrivacyMode.ALL -> { + removeAllMembers(record.id, DistributionListPrivacyMode.ALL_EXCEPT) + setPrivacyMode(record.id, DistributionListPrivacyMode.ALL_EXCEPT) + + recipientIds.forEach { + addMemberToList(record.id, DistributionListPrivacyMode.ALL_EXCEPT, it) + } + } + } + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt index e7155aa781..d4c1dceb36 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageId.kt @@ -1,13 +1,17 @@ package org.thoughtcrime.securesms.database.model +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + /** * Represents a pair of values that can be used to find a message. Because we have two tables, * that means this has both the primary key and a boolean indicating which table it's in. */ +@Parcelize data class MessageId( val id: Long, @get:JvmName("isMms") val mms: Boolean -) { +) : Parcelable { fun serialize(): String { return "$id|$mms" } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt index 525788858f..c3fd7fb89b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionActivity.kt @@ -31,7 +31,6 @@ import org.thoughtcrime.securesms.contacts.paged.ContactSearchState import org.thoughtcrime.securesms.conversation.MessageSendType import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFullScreenDialogFragment import org.thoughtcrime.securesms.conversation.mutiselect.forward.SearchConfigurationProvider -import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil @@ -41,6 +40,7 @@ import org.thoughtcrime.securesms.mediasend.v2.review.MediaReviewFragment import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationViewModel import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendRepository import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet import org.thoughtcrime.securesms.stories.Stories import org.thoughtcrime.securesms.util.navigation.safeNavigate import org.thoughtcrime.securesms.util.visible @@ -245,7 +245,9 @@ class MediaSelectionActivity : override fun onSendError(error: Throwable) { if (error is UntrustedRecords.UntrustedRecordsException) { Log.w(TAG, "Send failed due to untrusted identities.") - SafetyNumberChangeDialog.show(supportFragmentManager, error.untrustedRecords) + SafetyNumberBottomSheet + .forIdentityRecordsAndDestinations(error.untrustedRecords, error.destinations.toList()) + .show(supportFragmentManager) } else { setResult(RESULT_CANCELED) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/UntrustedRecords.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/UntrustedRecords.kt index 03013212f6..807653266a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/UntrustedRecords.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/UntrustedRecords.kt @@ -16,7 +16,7 @@ object UntrustedRecords { return Completable.fromAction { val untrustedRecords: List = checkForBadIdentityRecordsSync(contactSearchKeys) if (untrustedRecords.isNotEmpty()) { - throw UntrustedRecordsException(untrustedRecords) + throw UntrustedRecordsException(untrustedRecords, contactSearchKeys) } }.subscribeOn(Schedulers.io()) } @@ -39,8 +39,8 @@ object UntrustedRecords { } .flatten() - return ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(recipients).untrustedRecords + return ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(recipients).identityRecords } - class UntrustedRecordsException(val untrustedRecords: List) : Throwable() + class UntrustedRecordsException(val untrustedRecords: List, val destinations: Set) : Throwable() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt index 588e111c56..71c2e2562d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/TextStoryPostCreationFragment.kt @@ -14,18 +14,18 @@ import androidx.navigation.fragment.findNavController import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey -import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog import org.thoughtcrime.securesms.linkpreview.LinkPreviewRepository import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel import org.thoughtcrime.securesms.mediasend.v2.HudCommand import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendRepository import org.thoughtcrime.securesms.mediasend.v2.text.send.TextStoryPostSendResult +import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet import org.thoughtcrime.securesms.stories.StoryTextPostView import org.thoughtcrime.securesms.util.LifecycleDisposable import org.thoughtcrime.securesms.util.navigation.safeNavigate -class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creation_fragment), TextStoryPostTextEntryFragment.Callback { +class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creation_fragment), TextStoryPostTextEntryFragment.Callback, SafetyNumberBottomSheet.Callbacks { private lateinit var scene: ConstraintLayout private lateinit var backgroundButton: AppCompatImageView @@ -166,9 +166,21 @@ class TextStoryPostCreationFragment : Fragment(R.layout.stories_text_post_creati } is TextStoryPostSendResult.UntrustedRecordsError -> { send.isClickable = true - SafetyNumberChangeDialog.show(childFragmentManager, result.untrustedRecords) + SafetyNumberBottomSheet + .forIdentityRecordsAndDestinations(result.untrustedRecords, contacts.toList()) + .show(childFragmentManager) } } } } + + override fun sendAnywayAfterSafetyNumberChangedInBottomSheet(destinations: List) { + performSend(destinations.toSet()) + } + + override fun onMessageResentAfterSafetyNumberChangeInBottomSheet() { + error("Unsupported, we do not hand in a message id.") + } + + override fun onCanceled() = Unit } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt index fad87ec51c..2a6e6e214b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/text/send/TextStoryPostSendFragment.kt @@ -18,12 +18,12 @@ import org.thoughtcrime.securesms.components.WrapperDialogFragment import org.thoughtcrime.securesms.contacts.paged.ContactSearchConfiguration import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.contacts.paged.ContactSearchMediator -import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModel import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseGroupStoryBottomSheet import org.thoughtcrime.securesms.mediasend.v2.stories.ChooseStoryTypeBottomSheet import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationViewModel import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet import org.thoughtcrime.securesms.sharing.ShareSelectionAdapter import org.thoughtcrime.securesms.sharing.ShareSelectionMappingModel import org.thoughtcrime.securesms.stories.Stories @@ -39,7 +39,8 @@ class TextStoryPostSendFragment : Fragment(R.layout.stories_send_text_post_fragment), ChooseStoryTypeBottomSheet.Callback, WrapperDialogFragment.WrapperDialogFragmentCallback, - ChooseInitialMyStoryMembershipBottomSheetDialogFragment.Callback { + ChooseInitialMyStoryMembershipBottomSheetDialogFragment.Callback, + SafetyNumberBottomSheet.Callbacks { private lateinit var shareListWrapper: View private lateinit var shareSelectionRecyclerView: RecyclerView @@ -93,8 +94,10 @@ class TextStoryPostSendFragment : send() } - disposables += viewModel.untrustedIdentities.subscribe { - SafetyNumberChangeDialog.show(childFragmentManager, it) + disposables += viewModel.untrustedIdentities.subscribe { records -> + SafetyNumberBottomSheet + .forIdentityRecordsAndDestinations(records, contactSearchMediator.getSelectedContacts().toList()) + .show(childFragmentManager) } searchField.doAfterTextChanged { @@ -197,4 +200,14 @@ class TextStoryPostSendFragment : contactSearchMediator.setKeysSelected(setOf(ContactSearchKey.RecipientSearchKey.Story(recipientId))) contactSearchMediator.refresh() } + + override fun sendAnywayAfterSafetyNumberChangedInBottomSheet(destinations: List) { + send() + } + + override fun onMessageResentAfterSafetyNumberChangeInBottomSheet() = error("Not supported here") + + override fun onCanceled() { + viewModel.onSendCancelled() + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.java index 26c9f874f8..6bc527c10e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsFragment.java @@ -15,7 +15,6 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.components.FullScreenDialogFragment; import org.thoughtcrime.securesms.conversation.colors.Colorizer; import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer; -import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog; import org.thoughtcrime.securesms.database.MmsSmsDatabase; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController; @@ -26,6 +25,7 @@ import org.thoughtcrime.securesms.messagedetails.MessageDetailsViewModel.Factory import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet; import org.thoughtcrime.securesms.util.Material3OnScrollHelper; import java.util.ArrayList; @@ -88,8 +88,8 @@ public final class MessageDetailsFragment extends FullScreenDialogFragment { } private void initializeList(@NonNull View view) { - RecyclerView list = view.findViewById(R.id.message_details_list); - View toolbarShadow = view.findViewById(R.id.toolbar_shadow); + RecyclerView list = view.findViewById(R.id.message_details_list); + View toolbarShadow = view.findViewById(R.id.toolbar_shadow); colorizer = new Colorizer(); adapter = new MessageDetailsAdapter(getViewLifecycleOwner(), glideRequests, colorizer, this::onErrorClicked); @@ -159,7 +159,9 @@ public final class MessageDetailsFragment extends FullScreenDialogFragment { } private void onErrorClicked(@NonNull MessageRecord messageRecord) { - SafetyNumberChangeDialog.show(requireContext(), getChildFragmentManager(), messageRecord); + SafetyNumberBottomSheet + .forMessageRecord(requireContext(), messageRecord) + .show(getChildFragmentManager()); } public interface Callback { diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheet.kt new file mode 100644 index 0000000000..f555251552 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheet.kt @@ -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): 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): 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, destinations: List): Factory { + val args = SafetyNumberBottomSheetArgs( + identityRecords.map { it.recipientId }, + destinations.filterIsInstance().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, destination: ContactSearchKey): Factory { + val args = SafetyNumberBottomSheetArgs( + identityRecords.map { it.recipientId }, + listOf(destination).filterIsInstance().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(ARGS) + Preconditions.checkArgument(args != null) + return args!! + } + + private fun getDestinationFromRecord(messageRecord: MessageRecord): List { + 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) + + /** + * 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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetArgs.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetArgs.kt new file mode 100644 index 0000000000..7f7d96b420 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetArgs.kt @@ -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, + val destinations: List, + val messageId: MessageId? = null +) : Parcelable diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetFragment.kt new file mode 100644 index 0000000000..d5a433699f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetFragment.kt @@ -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()?.sendAnywayAfterSafetyNumberChangedInBottomSheet(viewModel.destinationSnapshot) + } + TrustAndVerifyResult.Result.TRUST_VERIFY_AND_RESEND -> { + findListener()?.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()?.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() + + 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) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetRepository.kt new file mode 100644 index 0000000000..3c585a2f22 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetRepository.kt @@ -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 { + 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, destinations: List): Flowable>> { + 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>() + + 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): 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, 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>, bucket: SafetyNumberBucket, safetyNumberRecipient: SafetyNumberRecipient) { + val bucketList = map.getOrDefault(bucket, emptyList()) + map[bucket] = bucketList + safetyNumberRecipient + } + + private fun filterForDistributionLists(destinations: List): Single> { + return Single.fromCallable { + val recipients = Recipient.resolvedList(destinations.map { it.recipientId }) + recipients.filter { it.isDistributionList } + } + } + + private fun filterForGroups(destinations: List): Single> { + return Single.fromCallable { + val recipients = Recipient.resolvedList(destinations.map { it.recipientId }) + recipients.filter { it.isGroup } + } + } + + private fun observeDistributionList(recipient: Recipient): Observable { + return Recipient.observable(recipient.id).map { SignalDatabase.distributionLists.getList(it.requireDistributionListId())!! } + } + + private fun getDistributionLists(destinations: List): Observable> { + val distributionListRecipients: Single> = filterForDistributionLists(destinations) + + return distributionListRecipients.flatMapObservable { recipients -> + if (recipients.isEmpty()) { + Observable.just(emptyList()) + } else { + val distributionListObservables = recipients.map { observeDistributionList(it) } + Observable.combineLatest(distributionListObservables) { + it.filterIsInstance() + } + } + } + } + + private fun getGroups(destinations: List): Observable> { + val groupRecipients: Single> = 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() + } + } + } + } + + private fun getResolvedIdentities(recipients: List): Observable> { + val recipientObservables: List> = 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().filter { it.identityRecord.isPresent } + } + } + + private fun getDistributionMemberships(recipient: Recipient, distributionLists: List): List { + return distributionLists.filter { it.members.contains(recipient.id) } + } + + private fun getGroupMemberships(recipient: Recipient, groups: List): List { + return groups.filter { it.participants.contains(recipient) } + } + + data class ResolvedIdentity(val recipient: Recipient, val identityRecord: Optional) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetState.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetState.kt new file mode 100644 index 0000000000..49ce66b80e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetState.kt @@ -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> = 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetViewModel.kt new file mode 100644 index 0000000000..13757ae349 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBottomSheetViewModel.kt @@ -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 + get() = destinationStore.state + + private val store = RxStore( + SafetyNumberBottomSheetState( + untrustedRecipientCount = args.untrustedRecipients.size, + hasLargeNumberOfUntrustedRecipients = args.untrustedRecipients.size > MAX_RECIPIENTS_TO_DISPLAY + ) + ) + + val state: Flowable = 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 { + val recipients: List = store.state.destinationToRecipientMap.values.flatten().distinct() + return if (args.messageId != null) { + trustAndVerifyRepository.trustOrVerifyChangedRecipientsAndResendRx(recipients, args.messageId) + } else { + trustAndVerifyRepository.trustOrVerifyChangedRecipientsRx(recipients).observeOn(AndroidSchedulers.mainThread()) + } + } + + private fun loadRecipients() { + val bucketFlowable: Flowable>> = 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 { + 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 create(modelClass: Class): T { + return modelClass.cast(SafetyNumberBottomSheetViewModel(args = args, trustAndVerifyRepository = trustAndVerifyRepository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBucket.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBucket.kt new file mode 100644 index 0000000000..2db48270ac --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBucket.kt @@ -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() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBucketRowItem.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBucketRowItem.kt new file mode 100644 index 0000000000..a03841db1e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberBucketRowItem.kt @@ -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 + ): MappingModel<*> { + return when (safetyNumberBucket) { + SafetyNumberBucket.ContactsBucket -> ContactsModel() + is SafetyNumberBucket.DistributionListBucket -> DistributionListModel(safetyNumberBucket, actionItemsProvider) + is SafetyNumberBucket.GroupBucket -> GroupModel(safetyNumberBucket) + } + } + + private class DistributionListModel( + val distributionListBucket: SafetyNumberBucket.DistributionListBucket, + val actionItemsProvider: (SafetyNumberBucket) -> List + ) : MappingModel { + override fun areItemsTheSame(newItem: DistributionListModel): Boolean { + return distributionListBucket.distributionListId == newItem.distributionListBucket.distributionListId + } + + override fun areContentsTheSame(newItem: DistributionListModel): Boolean { + return distributionListBucket == newItem.distributionListBucket + } + } + + private class GroupModel(val groupBucket: SafetyNumberBucket.GroupBucket) : MappingModel { + override fun areItemsTheSame(newItem: GroupModel): Boolean { + return groupBucket.recipient.id == newItem.groupBucket.recipient.id + } + + override fun areContentsTheSame(newItem: GroupModel): Boolean { + return groupBucket.recipient.hasSameContent(newItem.groupBucket.recipient) + } + } + + private class ContactsModel : MappingModel { + override fun areItemsTheSame(newItem: ContactsModel): Boolean = true + + override fun areContentsTheSame(newItem: ContactsModel): Boolean = true + } + + private class DistributionListViewHolder(itemView: View) : BaseViewHolder(itemView) { + override fun getTitle(model: DistributionListModel): String { + return if (model.distributionListBucket.distributionListId == DistributionListId.MY_STORY) { + context.getString(R.string.Recipient_my_story) + } else { + model.distributionListBucket.name + } + } + + override fun bindMenuListener(model: DistributionListModel, menuView: View) { + menuView.setOnClickListener { + SignalContextMenu.Builder(menuView, menuView.rootView as ViewGroup) + .offsetX(DimensionUnit.DP.toPixels(16f).toInt()) + .offsetY(DimensionUnit.DP.toPixels(16f).toInt()) + .show(model.actionItemsProvider(model.distributionListBucket)) + } + } + } + + private class GroupViewHolder(itemView: View) : BaseViewHolder(itemView) { + override fun getTitle(model: GroupModel): String { + return model.groupBucket.recipient.getDisplayName(context) + } + + override fun bindMenuListener(model: GroupModel, menuView: View) { + menuView.visible = false + } + } + + private class ContactsViewHolder(itemView: View) : BaseViewHolder(itemView) { + override fun getTitle(model: ContactsModel): String { + return context.getString(R.string.SafetyNumberBucketRowItem__contacts) + } + + override fun bindMenuListener(model: ContactsModel, menuView: View) { + menuView.visible = false + } + } + + private abstract class BaseViewHolder>(itemView: View) : MappingViewHolder(itemView) { + + private val titleView: TextView = findViewById(R.id.safety_number_bucket_header) + private val menuView: View = findViewById(R.id.safety_number_bucket_menu) + + override fun bind(model: T) { + titleView.text = getTitle(model) + bindMenuListener(model, menuView) + } + + abstract fun getTitle(model: T): String + abstract fun bindMenuListener(model: T, menuView: View) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberRecipient.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberRecipient.kt new file mode 100644 index 0000000000..0e2d24da0c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberRecipient.kt @@ -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 +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberRecipientRowItem.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberRecipientRowItem.kt new file mode 100644 index 0000000000..a78e8fae9b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/SafetyNumberRecipientRowItem.kt @@ -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 + ) : MappingModel { + override fun areItemsTheSame(newItem: Model): Boolean { + return recipient.id == newItem.recipient.id + } + + override fun areContentsTheSame(newItem: Model): Boolean { + return recipient.hasSameContent(newItem.recipient) && + isVerified == newItem.isVerified && + distributionListMembershipCount == newItem.distributionListMembershipCount && + groupMembershipCount == newItem.groupMembershipCount + } + } + + class ViewHolder(itemView: View) : MappingViewHolder(itemView) { + + private val avatar: AvatarImageView = itemView.findViewById(R.id.safety_number_recipient_avatar) + private val name: TextView = itemView.findViewById(R.id.safety_number_recipient_name) + private val identifier: TextView = itemView.findViewById(R.id.safety_number_recipient_identifier) + 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)) + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/safety/review/SafetyNumberReviewConnectionsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/safety/review/SafetyNumberReviewConnectionsFragment.kt new file mode 100644 index 0000000000..0494dc6fcb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/safety/review/SafetyNumberReviewConnectionsFragment.kt @@ -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(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() + + 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 { + 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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt index b5145ff596..a42a87ae17 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt @@ -32,13 +32,13 @@ import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.conversation.ConversationIntents import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs -import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.StoryViewState import org.thoughtcrime.securesms.main.Material3OnScrollHelperBinder import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet import org.thoughtcrime.securesms.stories.StoryTextPostModel import org.thoughtcrime.securesms.stories.StoryViewerArgs import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu @@ -211,7 +211,9 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l startActivityIfAble(Intent(requireContext(), MyStoriesActivity::class.java)) } else if (model.data.primaryStory.messageRecord.isOutgoing && model.data.primaryStory.messageRecord.isFailed) { if (model.data.primaryStory.messageRecord.isIdentityMismatchFailure) { - SafetyNumberChangeDialog.show(requireContext(), childFragmentManager, model.data.primaryStory.messageRecord) + SafetyNumberBottomSheet + .forMessageRecord(requireContext(), model.data.primaryStory.messageRecord) + .show(childFragmentManager) } else { StoryDialogs.resendStory(requireContext()) { lifecycleDisposable += viewModel.resend(model.data.primaryStory.messageRecord).subscribe() diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt index 6a48367780..26eeb0c55f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt @@ -15,10 +15,10 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs -import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet import org.thoughtcrime.securesms.stories.StoryTextPostModel import org.thoughtcrime.securesms.stories.StoryViewerArgs import org.thoughtcrime.securesms.stories.dialogs.StoryContextMenu @@ -80,7 +80,9 @@ class MyStoriesFragment : DSLSettingsFragment( onClick = { it, preview -> if (it.distributionStory.messageRecord.isOutgoing && it.distributionStory.messageRecord.isFailed) { if (it.distributionStory.messageRecord.isIdentityMismatchFailure) { - SafetyNumberChangeDialog.show(requireContext(), childFragmentManager, it.distributionStory.messageRecord) + SafetyNumberBottomSheet + .forMessageRecord(requireContext(), it.distributionStory.messageRecord) + .show(childFragmentManager) } else { StoryDialogs.resendStory(requireContext()) { lifecycleDisposable += viewModel.resend(it.distributionStory.messageRecord).subscribe() diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsFragment.kt index de3e7c4f71..6a3467a899 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/settings/my/MyStorySettingsFragment.kt @@ -16,7 +16,6 @@ import org.thoughtcrime.securesms.components.settings.DSLSettingsText import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.database.model.DistributionListPrivacyMode import org.thoughtcrime.securesms.util.LifecycleDisposable -import org.thoughtcrime.securesms.util.fragments.findListener import org.thoughtcrime.securesms.util.navigation.safeNavigate class MyStorySettingsFragment : DSLSettingsFragment( @@ -111,10 +110,6 @@ class MyStorySettingsFragment : DSLSettingsFragment( } } - override fun onToolbarNavigationClicked() { - findListener()?.dismiss() ?: super.onToolbarNavigationClicked() - } - class Dialog : WrapperDialogFragment() { override fun getWrappedFragment(): Fragment { return NavHostFragment.create(R.navigation.my_story_settings) diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt index 9f7b7689cc..da10ebe33c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/reply/group/StoryGroupReplyFragment.kt @@ -24,9 +24,9 @@ import org.thoughtcrime.securesms.components.emoji.MediaKeyboard import org.thoughtcrime.securesms.components.mention.MentionAnnotation import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.components.settings.configure +import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.conversation.MarkReadHelper import org.thoughtcrime.securesms.conversation.colors.Colorizer -import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerFragment import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel import org.thoughtcrime.securesms.database.model.Mention @@ -43,6 +43,7 @@ import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDial import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment +import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet import org.thoughtcrime.securesms.sms.MessageSender import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerChild import org.thoughtcrime.securesms.stories.viewer.reply.StoryViewsAndRepliesPagerParent @@ -67,7 +68,7 @@ class StoryGroupReplyFragment : StoryReplyComposer.Callback, EmojiKeyboardCallback, ReactWithAnyEmojiBottomSheetDialogFragment.Callback, - SafetyNumberChangeDialog.Callback { + SafetyNumberBottomSheet.Callbacks { companion object { private val TAG = Log.tag(StoryGroupReplyFragment::class.java) @@ -311,7 +312,9 @@ class StoryGroupReplyFragment : } if (messageRecord.isIdentityMismatchFailure) { - SafetyNumberChangeDialog.show(requireContext(), childFragmentManager, messageRecord) + SafetyNumberBottomSheet + .forMessageRecord(requireContext(), messageRecord) + .show(childFragmentManager) } else if (messageRecord.hasFailedWithNetworkFailures()) { MaterialAlertDialogBuilder(requireContext()) .setMessage(R.string.conversation_activity__message_could_not_be_sent) @@ -370,7 +373,9 @@ class StoryGroupReplyFragment : if (error is UntrustedRecords.UntrustedRecordsException) { resendReaction = emoji - SafetyNumberChangeDialog.show(childFragmentManager, error.untrustedRecords) + SafetyNumberBottomSheet + .forIdentityRecordsAndDestination(error.untrustedRecords, ContactSearchKey.RecipientSearchKey.Story(groupRecipientId)) + .show(childFragmentManager) } else { Log.w(TAG, "Failed to send reply", error) val context = context @@ -463,14 +468,16 @@ class StoryGroupReplyFragment : lifecycleDisposable += StoryGroupReplySender.sendReply(requireContext(), storyId, body, mentions) .observeOn(AndroidSchedulers.mainThread()) .subscribeBy( - onError = { - if (it is UntrustedRecords.UntrustedRecordsException) { + onError = { throwable -> + if (throwable is UntrustedRecords.UntrustedRecordsException) { resendBody = body resendMentions = mentions - SafetyNumberChangeDialog.show(childFragmentManager, it.untrustedRecords) + SafetyNumberBottomSheet + .forIdentityRecordsAndDestination(throwable.untrustedRecords, ContactSearchKey.RecipientSearchKey.Story(groupRecipientId)) + .show(childFragmentManager) } else { - Log.w(TAG, "Failed to send reply", it) + Log.w(TAG, "Failed to send reply", throwable) val context = context if (context != null) { Toast.makeText(context, R.string.message_details_recipient__failed_to_send, Toast.LENGTH_SHORT).show() @@ -480,7 +487,7 @@ class StoryGroupReplyFragment : ) } - override fun onSendAnywayAfterSafetyNumberChange(changedRecipients: MutableList) { + override fun sendAnywayAfterSafetyNumberChangedInBottomSheet(destinations: List) { val resendBody = resendBody val resendReaction = resendReaction if (resendBody != null) { @@ -490,7 +497,7 @@ class StoryGroupReplyFragment : } } - override fun onMessageResentAfterSafetyNumberChange() { + override fun onMessageResentAfterSafetyNumberChangeInBottomSheet() { Log.i(TAG, "Message resent") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyIdentityFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyIdentityFragment.kt index e6d8fdcf72..eff57957c7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyIdentityFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyIdentityFragment.kt @@ -4,10 +4,12 @@ import android.Manifest import android.os.Bundle import android.view.View import android.widget.Toast +import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import org.signal.core.util.ThreadUtil import org.signal.qr.kitkat.ScanListener import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.WrapperDialogFragment import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.permissions.Permissions @@ -20,6 +22,14 @@ import org.thoughtcrime.securesms.util.ServiceUtil */ class VerifyIdentityFragment : Fragment(R.layout.fragment_container), ScanListener, VerifyDisplayFragment.Callback { + class Dialog : WrapperDialogFragment() { + override fun getWrappedFragment(): Fragment { + return VerifyIdentityFragment().apply { + arguments = this@Dialog.requireArguments() + } + } + } + companion object { private const val EXTRA_RECIPIENT = "extra.recipient.id" private const val EXTRA_IDENTITY = "extra.recipient.identity" @@ -32,11 +42,25 @@ class VerifyIdentityFragment : Fragment(R.layout.fragment_container), ScanListen verified: Boolean ): VerifyIdentityFragment { return VerifyIdentityFragment().apply { - arguments = Bundle().apply { - putParcelable(EXTRA_RECIPIENT, recipientId) - putParcelable(EXTRA_IDENTITY, remoteIdentity) - putBoolean(EXTRA_VERIFIED, verified) - } + arguments = bundleOf( + EXTRA_RECIPIENT to recipientId, + EXTRA_IDENTITY to remoteIdentity, + EXTRA_VERIFIED to verified + ) + } + } + + fun createDialog( + recipientId: RecipientId, + remoteIdentity: IdentityKeyParcelable, + verified: Boolean + ): Dialog { + return Dialog().apply { + arguments = bundleOf( + EXTRA_RECIPIENT to recipientId, + EXTRA_IDENTITY to remoteIdentity, + EXTRA_VERIFIED to verified + ) } } } diff --git a/app/src/main/res/layout/safety_number_bottom_sheet.xml b/app/src/main/res/layout/safety_number_bottom_sheet.xml new file mode 100644 index 0000000000..d34de31080 --- /dev/null +++ b/app/src/main/res/layout/safety_number_bottom_sheet.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/safety_number_bucket_row_item.xml b/app/src/main/res/layout/safety_number_bucket_row_item.xml new file mode 100644 index 0000000000..4825f13c2d --- /dev/null +++ b/app/src/main/res/layout/safety_number_bucket_row_item.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/safety_number_recipient_row_item.xml b/app/src/main/res/layout/safety_number_recipient_row_item.xml new file mode 100644 index 0000000000..6ec73f9620 --- /dev/null +++ b/app/src/main/res/layout/safety_number_recipient_row_item.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/safety_number_review_fragment.xml b/app/src/main/res/layout/safety_number_review_fragment.xml new file mode 100644 index 0000000000..6b4bc2ac9a --- /dev/null +++ b/app/src/main/res/layout/safety_number_review_fragment.xml @@ -0,0 +1,33 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fc21dd56c2..91a62f4cc4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4934,6 +4934,44 @@ Swipe right to exit Got it + + Open context menu + + %1$s ยท Verified + + Verified + + Safety number changes + + The following people may have reinstalled Signal or changed devices. Tap a recipient to confirm the new safety number. This is optional. + + Safety number checkup + + Safety number checkup complete + + You have %1$d connections who may have reinstalled Signal or changed devices. Before sharing your story with them review their safety numbers or consider removing them from your story. + + Verify safety number + + Remove from story + + Send anyway + + Review connections + + No more recipients to show + + Done + + Safety number changes + + %1$d recipients may have reinstalled Signal or changed devices. Tap a recipient to confirm the new safety number. This is optional. + + Contacts + + Remove all + + Remove My Story Privacy