Implement new Safety Number Changes bottom sheeet.

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

View File

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

View File

@@ -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<WrapperDialogFragmentCallback>()?.onWrapperDialogFragmentDismissed()
}

View File

@@ -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<Model>() {
class Model(@DrawableRes val splashImageResId: Int, @ColorRes val splashImageTintResId: Int = -1) : PreferenceModel<Model>() {
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()
}
}
}
}

View File

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

View File

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

View File

@@ -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<IdentityRecord> 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<RecipientId> changedRecipients) {
Log.d(TAG, "onSendAnywayAfterSafetyNumberChange");
initializeIdentityRecords().addListener(new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean result) {
sendMessage(null);
}
});
}
@Override
public void onMessageResentAfterSafetyNumberChange() {
public void onMessageResentAfterSafetyNumberChangeInBottomSheet() {
Log.d(TAG, "onMessageResentAfterSafetyNumberChange");
initializeIdentityRecords().addListener(new AssertedSuccessListener<Boolean>() {
@Override
@@ -3629,6 +3624,17 @@ public class ConversationParentFragment extends Fragment
material3OnScrollHelper.setColorImmediate();
}
@Override
public void sendAnywayAfterSafetyNumberChangedInBottomSheet(@NonNull List<? extends ContactSearchKey.RecipientSearchKey> destinations) {
Log.d(TAG, "onSendAnywayAfterSafetyNumberChange");
initializeIdentityRecords().addListener(new AssertedSuccessListener<Boolean>() {
@Override
public void onSuccess(Boolean result) {
sendMessage(null);
}
});
}
// Listeners
private final class DeleteCanceledVoiceNoteListener implements ListenableFuture.Listener<VoiceNoteDraft> {
@@ -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)

View File

@@ -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<IdentityRecord>) {
SafetyNumberChangeDialog.show(childFragmentManager, identityRecords)
private fun displaySafetyNumberConfirmation(identityRecords: List<IdentityRecord>, selectedContacts: List<ContactSearchKey>) {
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<RecipientId>) {
viewModel.confirmSafetySend(addMessage.text.toString(), contactSearchMediator.getSelectedContacts())
override fun sendAnywayAfterSafetyNumberChangedInBottomSheet(destinations: List<ContactSearchKey.RecipientSearchKey>) {
viewModel.confirmSafetySend(addMessage.text.toString(), destinations.toSet())
}
override fun onMessageResentAfterSafetyNumberChange() {
override fun onMessageResentAfterSafetyNumberChangeInBottomSheet() {
throw UnsupportedOperationException()
}

View File

@@ -13,7 +13,7 @@ data class MultiselectForwardState(
object Selection : Stage()
object FirstConfirmation : Stage()
object LoadingIdentities : Stage()
data class SafetyConfirmation(val identities: List<IdentityRecord>) : Stage()
data class SafetyConfirmation(val identities: List<IdentityRecord>, val selectedContacts: List<ContactSearchKey>) : Stage()
object SendPending : Stage()
object SomeFailed : Stage()
object AllFailed : Stage()

View File

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

View File

@@ -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<IdentityRecord> identityRecords) {
List<String> 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<String> 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) {

View File

@@ -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<TrustAndVerifyResult> trustOrVerifyChangedRecipientsRx(@NonNull List<SafetyNumberRecipient> 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<TrustAndVerifyResult> trustOrVerifyChangedRecipientsAndResendRx(@NonNull List<SafetyNumberRecipient> 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<TrustAndVerifyResult> trustOrVerifyChangedRecipients(@NonNull List<ChangedRecipient> changedRecipients) {
Log.d(TAG, "Trust or verify changed recipients for: " + Util.join(changedRecipients, ","));
MutableLiveData<TrustAndVerifyResult> liveData = new MutableLiveData<>();
@@ -78,6 +102,14 @@ final class SafetyNumberChangeRepository {
return new SafetyNumberChangeState(changedRecipients, messageRecord);
}
private @NonNull List<ChangedRecipient> fromSafetyNumberRecipients(@NonNull List<SafetyNumberRecipient> 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<ChangedRecipient> 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);

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ object UntrustedRecords {
return Completable.fromAction {
val untrustedRecords: List<IdentityRecord> = 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<IdentityRecord>) : Throwable()
class UntrustedRecordsException(val untrustedRecords: List<IdentityRecord>, val destinations: Set<ContactSearchKey.RecipientSearchKey>) : Throwable()
}

View File

@@ -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<ContactSearchKey.RecipientSearchKey>) {
performSend(destinations.toSet())
}
override fun onMessageResentAfterSafetyNumberChangeInBottomSheet() {
error("Unsupported, we do not hand in a message id.")
}
override fun onCanceled() = Unit
}

View File

@@ -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<ContactSearchKey.RecipientSearchKey>) {
send()
}
override fun onMessageResentAfterSafetyNumberChangeInBottomSheet() = error("Not supported here")
override fun onCanceled() {
viewModel.onSendCancelled()
}
}

View File

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

View File

@@ -0,0 +1,185 @@
package org.thoughtcrime.securesms.safety
import android.content.Context
import android.os.Bundle
import androidx.fragment.app.FragmentManager
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.util.Preconditions
/**
* Object responsible for the construction of SafetyNumberBottomSheetFragment and Arg objects.
*/
object SafetyNumberBottomSheet {
private const val TAG = "SafetyNumberBottomSheet"
private const val ARGS = "args"
/**
* Create a factory to generate a legacy dialog for the given recipient id.
*/
@JvmStatic
fun forRecipientId(recipientId: RecipientId): Factory {
return object : Factory {
override fun show(fragmentManager: FragmentManager) {
SafetyNumberChangeDialog.show(fragmentManager, recipientId)
}
}
}
/**
* Create a factory to generate a legacy dialog for a given recipient id to display when
* trying to place an outgoing call.
*/
@JvmStatic
fun forCall(recipientId: RecipientId): Factory {
return object : Factory {
override fun show(fragmentManager: FragmentManager) {
SafetyNumberChangeDialog.showForCall(fragmentManager, recipientId)
}
}
}
/**
* Create a factory to display a legacy dialog for a given list of records when trying
* to place a group call.
*/
@JvmStatic
fun forGroupCall(identityRecords: List<IdentityRecord>): Factory {
return object : Factory {
override fun show(fragmentManager: FragmentManager) {
SafetyNumberChangeDialog.showForGroupCall(fragmentManager, identityRecords)
}
}
}
/**
* Create a factory to display a legacy dialog for a given list of recipient ids during
* a group call
*/
@JvmStatic
fun forDuringGroupCall(recipientIds: Collection<RecipientId>): Factory {
return object : Factory {
override fun show(fragmentManager: FragmentManager) {
SafetyNumberChangeDialog.showForDuringGroupCall(fragmentManager, recipientIds)
}
}
}
/**
* Create a factory to generate a sheet for the given message record. This will try
* to resend the message automatically when the user confirms.
*
* @param context Not held on to, so any context is fine.
* @param messageRecord The message record containing failed identities.
*/
@JvmStatic
fun forMessageRecord(context: Context, messageRecord: MessageRecord): Factory {
val args = SafetyNumberBottomSheetArgs(
untrustedRecipients = messageRecord.identityKeyMismatches.map { it.getRecipientId(context) },
destinations = getDestinationFromRecord(messageRecord),
messageId = MessageId(messageRecord.id, messageRecord.isMms)
)
return SheetFactory(args)
}
/**
* Create a factory to generate a sheet for the given identity records and destinations.
*
* @param identityRecords The list of untrusted records from the thrown error
* @param destinations The list of locations the user was trying to send content
*/
@JvmStatic
fun forIdentityRecordsAndDestinations(identityRecords: List<IdentityRecord>, destinations: List<ContactSearchKey>): Factory {
val args = SafetyNumberBottomSheetArgs(
identityRecords.map { it.recipientId },
destinations.filterIsInstance<ContactSearchKey.RecipientSearchKey>().map { it.requireParcelable() }
)
return SheetFactory(args)
}
/**
* Create a factory to generate a sheet for the given identity records and single destination.
*
* @param identityRecords The list of untrusted records from the thrown error
* @param destination The location the user was trying to send content
*/
@JvmStatic
fun forIdentityRecordsAndDestination(identityRecords: List<IdentityRecord>, destination: ContactSearchKey): Factory {
val args = SafetyNumberBottomSheetArgs(
identityRecords.map { it.recipientId },
listOf(destination).filterIsInstance<ContactSearchKey.RecipientSearchKey>().map { it.requireParcelable() }
)
return SheetFactory(args)
}
/**
* @return The parcelized arguments inside the bundle
* @throws IllegalArgumentException if the bundle does not contain the correct parcelized arguments.
*/
fun getArgsFromBundle(bundle: Bundle): SafetyNumberBottomSheetArgs {
val args = bundle.getParcelable<SafetyNumberBottomSheetArgs>(ARGS)
Preconditions.checkArgument(args != null)
return args!!
}
private fun getDestinationFromRecord(messageRecord: MessageRecord): List<ContactSearchKey.ParcelableRecipientSearchKey> {
val key = if ((messageRecord as? MmsMessageRecord)?.storyType?.isStory == true) {
ContactSearchKey.RecipientSearchKey.Story(messageRecord.recipient.id)
} else {
ContactSearchKey.RecipientSearchKey.KnownRecipient(messageRecord.recipient.id)
}
return listOf(key.requireParcelable())
}
/**
* Similar to the normal companion object "show" methods, but with automatically provided arguments.
*/
interface Factory {
fun show(fragmentManager: FragmentManager)
}
private class SheetFactory(private val args: SafetyNumberBottomSheetArgs) : Factory {
override fun show(fragmentManager: FragmentManager) {
val dialogFragment = SafetyNumberBottomSheetFragment().apply {
arguments = Bundle().apply {
putParcelable(ARGS, args)
}
}
dialogFragment.show(fragmentManager, TAG)
}
}
/**
* Callbacks for the bottom sheet. These are optional, and are invoked by the bottom sheet itself.
*
* Since the bottom sheet utilizes findListener to locate the callback implementor, child fragments will
* get precedence over their parents and activities have the least precedence.
*/
interface Callbacks {
/**
* Invoked when the user presses "send anyway" and the parent should automatically perform a resend.
*/
fun sendAnywayAfterSafetyNumberChangedInBottomSheet(destinations: List<ContactSearchKey.RecipientSearchKey>)
/**
* Invoked when the user presses "send anyway" and a message was automatically resent.
*/
fun onMessageResentAfterSafetyNumberChangeInBottomSheet()
/**
* Invoked when the user dismisses the sheet without performing a send.
*/
fun onCanceled()
}
}

View File

@@ -0,0 +1,17 @@
package org.thoughtcrime.securesms.safety
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Fragment argument for `SafetyNumberBottomSheetFragment`
*/
@Parcelize
data class SafetyNumberBottomSheetArgs(
val untrustedRecipients: List<RecipientId>,
val destinations: List<ContactSearchKey.ParcelableRecipientSearchKey>,
val messageId: MessageId? = null
) : Parcelable

View File

@@ -0,0 +1,218 @@
package org.thoughtcrime.securesms.safety
import android.content.DialogInterface
import android.view.View
import androidx.annotation.MainThread
import androidx.core.content.ContextCompat
import androidx.fragment.app.viewModels
import com.google.android.material.button.MaterialButton
import org.signal.core.util.DimensionUnit
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.WrapperDialogFragment
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsBottomSheetFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.components.settings.models.SplashImage
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeRepository
import org.thoughtcrime.securesms.conversation.ui.error.TrustAndVerifyResult
import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable
import org.thoughtcrime.securesms.database.IdentityDatabase
import org.thoughtcrime.securesms.safety.review.SafetyNumberReviewConnectionsFragment
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.fragments.findListener
import org.thoughtcrime.securesms.util.visible
import org.thoughtcrime.securesms.verify.VerifyIdentityFragment
/**
* Displays a bottom sheet containing information about safety number changes and allows the user to
* address these changes.
*/
class SafetyNumberBottomSheetFragment : DSLSettingsBottomSheetFragment(layoutId = R.layout.safety_number_bottom_sheet), WrapperDialogFragment.WrapperDialogFragmentCallback {
private lateinit var sendAnyway: MaterialButton
override val peekHeightPercentage: Float = 1f
@get:MainThread
private val args: SafetyNumberBottomSheetArgs by lazy(LazyThreadSafetyMode.NONE) {
SafetyNumberBottomSheet.getArgsFromBundle(requireArguments())
}
private val viewModel: SafetyNumberBottomSheetViewModel by viewModels(factoryProducer = {
SafetyNumberBottomSheetViewModel.Factory(
args,
SafetyNumberChangeRepository(requireContext())
)
})
private val lifecycleDisposable = LifecycleDisposable()
override fun bindAdapter(adapter: DSLSettingsAdapter) {
val reviewConnections: View = requireView().findViewById(R.id.review_connections)
sendAnyway = requireView().findViewById(R.id.send_anyway)
reviewConnections.setOnClickListener {
viewModel.setDone()
SafetyNumberReviewConnectionsFragment.show(childFragmentManager)
}
sendAnyway.setOnClickListener {
sendAnyway.isEnabled = false
lifecycleDisposable += viewModel.trustAndVerify().subscribe { trustAndVerifyResult ->
when (trustAndVerifyResult.result) {
TrustAndVerifyResult.Result.TRUST_AND_VERIFY -> {
findListener<SafetyNumberBottomSheet.Callbacks>()?.sendAnywayAfterSafetyNumberChangedInBottomSheet(viewModel.destinationSnapshot)
}
TrustAndVerifyResult.Result.TRUST_VERIFY_AND_RESEND -> {
findListener<SafetyNumberBottomSheet.Callbacks>()?.onMessageResentAfterSafetyNumberChangeInBottomSheet()
}
TrustAndVerifyResult.Result.UNKNOWN -> {
Log.w(TAG, "Unknown Result")
}
}
dismissAllowingStateLoss()
}
}
SplashImage.register(adapter)
SafetyNumberRecipientRowItem.register(adapter)
lifecycleDisposable.bindTo(viewLifecycleOwner)
lifecycleDisposable += viewModel.state.subscribe { state ->
reviewConnections.visible = state.hasLargeNumberOfUntrustedRecipients
if (state.isCheckupComplete()) {
sendAnyway.setText(R.string.conversation_activity__send)
}
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
override fun onDismiss(dialog: DialogInterface) {
super.onDismiss(dialog)
if (sendAnyway.isEnabled) {
findListener<SafetyNumberBottomSheet.Callbacks>()?.onCanceled()
}
}
override fun onWrapperDialogFragmentDismissed() = Unit
private fun getConfiguration(state: SafetyNumberBottomSheetState): DSLConfiguration {
return configure {
customPref(
SplashImage.Model(
R.drawable.ic_safety_number_24,
R.color.signal_colorOnSurface
)
)
textPref(
title = DSLSettingsText.from(
when {
state.isCheckupComplete() && state.hasLargeNumberOfUntrustedRecipients -> R.string.SafetyNumberBottomSheetFragment__safety_number_checkup_complete
state.hasLargeNumberOfUntrustedRecipients -> R.string.SafetyNumberBottomSheetFragment__safety_number_checkup
else -> R.string.SafetyNumberBottomSheetFragment__safety_number_changes
},
DSLSettingsText.TextAppearanceModifier(R.style.Signal_Text_TitleLarge),
DSLSettingsText.CenterModifier
)
)
textPref(
title = DSLSettingsText.from(
when {
state.isCheckupComplete() && state.hasLargeNumberOfUntrustedRecipients -> ""
state.hasLargeNumberOfUntrustedRecipients -> getString(R.string.SafetyNumberBottomSheetFragment__you_have_d_connections, args.untrustedRecipients.size)
else -> getString(R.string.SafetyNumberBottomSheetFragment__the_following_people)
},
DSLSettingsText.TextAppearanceModifier(R.style.Signal_Text_BodyLarge),
DSLSettingsText.CenterModifier
)
)
if (state.isEmpty()) {
space(DimensionUnit.DP.toPixels(96f).toInt())
noPadTextPref(
title = DSLSettingsText.from(
R.string.SafetyNumberBottomSheetFragment__no_more_recipients_to_show,
DSLSettingsText.TextAppearanceModifier(R.style.Signal_Text_BodyLarge),
DSLSettingsText.CenterModifier,
DSLSettingsText.ColorModifier(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant))
)
)
}
if (!state.hasLargeNumberOfUntrustedRecipients) {
state.destinationToRecipientMap.values.flatten().distinct().forEach {
customPref(
SafetyNumberRecipientRowItem.Model(
recipient = it.recipient,
isVerified = it.identityRecord.verifiedStatus == IdentityDatabase.VerifiedStatus.VERIFIED,
distributionListMembershipCount = it.distributionListMembershipCount,
groupMembershipCount = it.groupMembershipCount,
getContextMenuActions = { model ->
val actions = mutableListOf<ActionItem>()
actions.add(
ActionItem(
iconRes = R.drawable.ic_safety_number_24,
title = getString(R.string.SafetyNumberBottomSheetFragment__verify_safety_number),
tintRes = R.color.signal_colorOnSurface,
action = {
lifecycleDisposable += viewModel.getIdentityRecord(model.recipient.id).subscribe { record ->
VerifyIdentityFragment.createDialog(
model.recipient.id,
IdentityKeyParcelable(record.identityKey),
false
).show(childFragmentManager, null)
}
}
)
)
if (model.distributionListMembershipCount > 0) {
actions.add(
ActionItem(
iconRes = R.drawable.ic_circle_x_24,
title = getString(R.string.SafetyNumberBottomSheetFragment__remove_from_story),
tintRes = R.color.signal_colorOnSurface,
action = {
viewModel.removeRecipientFromSelectedStories(model.recipient.id)
}
)
)
}
if (model.distributionListMembershipCount == 0 && model.groupMembershipCount == 0) {
actions.add(
ActionItem(
iconRes = R.drawable.ic_circle_x_24,
title = getString(R.string.SafetyNumberReviewConnectionsFragment__remove),
tintRes = R.color.signal_colorOnSurface,
action = {
viewModel.removeDestination(model.recipient.id)
}
)
)
}
actions
}
)
)
}
}
}
}
companion object {
private val TAG = Log.tag(SafetyNumberBottomSheetFragment::class.java)
}
}

View File

@@ -0,0 +1,194 @@
package org.thoughtcrime.securesms.safety
import io.reactivex.rxjava3.core.BackpressureStrategy
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.database.model.DistributionListRecord
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stories.Stories
import java.util.Optional
/**
* Repository for dealing with recipients with changed safety numbers.
*/
class SafetyNumberBottomSheetRepository {
/**
* Retrieves the IdentityRecord for a given recipient from the protocol store (if present)
*/
fun getIdentityRecord(recipientId: RecipientId): Maybe<IdentityRecord> {
return Single.fromCallable {
ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecord(recipientId)
}.flatMapMaybe {
if (it.isPresent) {
Maybe.just(it.get())
} else {
Maybe.empty()
}
}.subscribeOn(Schedulers.io())
}
/**
* Builds out the list of UntrustedRecipients to display in either the bottom sheet or review fragment. This list will be automatically
* republished whenever there is a recipient change, group change, or distribution list change, and is driven by Recipient.observe.
*
* It will automatically filter out any recipients who are no longer included in destinations as the user moves through the list and removes or
* verifies senders.
*/
fun getBuckets(recipients: List<RecipientId>, destinations: List<ContactSearchKey.RecipientSearchKey>): Flowable<Map<SafetyNumberBucket, List<SafetyNumberRecipient>>> {
val recipientObservable = getResolvedIdentities(recipients)
val distributionListObservable = getDistributionLists(destinations)
val groupObservable = getGroups(destinations)
val destinationRecipientIds = destinations.map { it.recipientId }.toSet()
return Observable.combineLatest(recipientObservable, distributionListObservable, groupObservable) { recipientList, distributionLists, groups ->
val map = mutableMapOf<SafetyNumberBucket, List<SafetyNumberRecipient>>()
recipientList.forEach {
val distributionListMemberships = getDistributionMemberships(it.recipient, distributionLists)
val groupMemberships = getGroupMemberships(it.recipient, groups)
val isInContactsBucket = destinationRecipientIds.contains(it.recipient.id)
val safetyNumberRecipient = SafetyNumberRecipient(
it.recipient,
it.identityRecord.get(),
distributionListMemberships.size,
groupMemberships.size
)
distributionListMemberships.forEach { distributionListRecord ->
insert(map, SafetyNumberBucket.DistributionListBucket(distributionListRecord.id, distributionListRecord.name), safetyNumberRecipient)
}
groupMemberships.forEach { group ->
insert(map, SafetyNumberBucket.GroupBucket(group), safetyNumberRecipient)
}
if (isInContactsBucket) {
insert(map, SafetyNumberBucket.ContactsBucket, safetyNumberRecipient)
}
}
map.toMap()
}.toFlowable(BackpressureStrategy.LATEST).subscribeOn(Schedulers.io())
}
/**
* Removes the given recipient from all stories they're a member of in destinations.
*/
fun removeFromStories(recipientId: RecipientId, destinations: List<ContactSearchKey.RecipientSearchKey>): Completable {
return filterForDistributionLists(destinations).flatMapCompletable { distributionRecipients ->
Completable.fromAction {
distributionRecipients
.mapNotNull { SignalDatabase.distributionLists.getList(it.requireDistributionListId()) }
.filter { it.members.contains(recipientId) }
.forEach {
SignalDatabase.distributionLists.excludeFromStory(recipientId, it)
Stories.onStorySettingsChanged(it.id)
}
}
}.subscribeOn(Schedulers.io())
}
/**
* Removes the given set of recipients from the specified distribution list
*/
fun removeAllFromStory(recipientIds: List<RecipientId>, distributionList: DistributionListId): Completable {
return Completable.fromAction {
val record = SignalDatabase.distributionLists.getList(distributionList)
if (record != null) {
SignalDatabase.distributionLists.excludeAllFromStory(recipientIds, record)
Stories.onStorySettingsChanged(distributionList)
}
}.subscribeOn(Schedulers.io())
}
private fun insert(map: MutableMap<SafetyNumberBucket, List<SafetyNumberRecipient>>, bucket: SafetyNumberBucket, safetyNumberRecipient: SafetyNumberRecipient) {
val bucketList = map.getOrDefault(bucket, emptyList())
map[bucket] = bucketList + safetyNumberRecipient
}
private fun filterForDistributionLists(destinations: List<ContactSearchKey.RecipientSearchKey>): Single<List<Recipient>> {
return Single.fromCallable {
val recipients = Recipient.resolvedList(destinations.map { it.recipientId })
recipients.filter { it.isDistributionList }
}
}
private fun filterForGroups(destinations: List<ContactSearchKey.RecipientSearchKey>): Single<List<Recipient>> {
return Single.fromCallable {
val recipients = Recipient.resolvedList(destinations.map { it.recipientId })
recipients.filter { it.isGroup }
}
}
private fun observeDistributionList(recipient: Recipient): Observable<DistributionListRecord> {
return Recipient.observable(recipient.id).map { SignalDatabase.distributionLists.getList(it.requireDistributionListId())!! }
}
private fun getDistributionLists(destinations: List<ContactSearchKey.RecipientSearchKey>): Observable<List<DistributionListRecord>> {
val distributionListRecipients: Single<List<Recipient>> = filterForDistributionLists(destinations)
return distributionListRecipients.flatMapObservable { recipients ->
if (recipients.isEmpty()) {
Observable.just(emptyList())
} else {
val distributionListObservables = recipients.map { observeDistributionList(it) }
Observable.combineLatest(distributionListObservables) {
it.filterIsInstance<DistributionListRecord>()
}
}
}
}
private fun getGroups(destinations: List<ContactSearchKey.RecipientSearchKey>): Observable<List<Recipient>> {
val groupRecipients: Single<List<Recipient>> = filterForGroups(destinations)
return groupRecipients.flatMapObservable { recipients ->
if (recipients.isEmpty()) {
Observable.just(emptyList())
} else {
val recipientObservables = recipients.map {
Recipient.observable(it.id)
}
Observable.combineLatest(recipientObservables) {
it.filterIsInstance<Recipient>()
}
}
}
}
private fun getResolvedIdentities(recipients: List<RecipientId>): Observable<List<ResolvedIdentity>> {
val recipientObservables: List<Observable<ResolvedIdentity>> = recipients.map {
Recipient.observable(it).switchMap { recipient ->
Observable.fromCallable {
val record = ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecord(recipient.id)
ResolvedIdentity(recipient, record)
}
}
}
return Observable.combineLatest(recipientObservables) { identities ->
identities.filterIsInstance<ResolvedIdentity>().filter { it.identityRecord.isPresent }
}
}
private fun getDistributionMemberships(recipient: Recipient, distributionLists: List<DistributionListRecord>): List<DistributionListRecord> {
return distributionLists.filter { it.members.contains(recipient.id) }
}
private fun getGroupMemberships(recipient: Recipient, groups: List<Recipient>): List<Recipient> {
return groups.filter { it.participants.contains(recipient) }
}
data class ResolvedIdentity(val recipient: Recipient, val identityRecord: Optional<IdentityRecord>)
}

View File

@@ -0,0 +1,30 @@
package org.thoughtcrime.securesms.safety
import org.thoughtcrime.securesms.database.IdentityDatabase
/**
* Screen state for SafetyNumberBottomSheetFragment and SafetyNumberReviewConnectionsFragment
*/
data class SafetyNumberBottomSheetState(
val untrustedRecipientCount: Int,
val hasLargeNumberOfUntrustedRecipients: Boolean,
val destinationToRecipientMap: Map<SafetyNumberBucket, List<SafetyNumberRecipient>> = emptyMap(),
val loadState: LoadState = LoadState.INIT
) {
fun isEmpty(): Boolean {
return !hasLargeNumberOfUntrustedRecipients && destinationToRecipientMap.values.flatten().isEmpty() && loadState == LoadState.READY
}
fun isCheckupComplete(): Boolean {
return loadState == LoadState.DONE ||
isEmpty() ||
destinationToRecipientMap.values.flatten().all { it.identityRecord.verifiedStatus == IdentityDatabase.VerifiedStatus.VERIFIED }
}
enum class LoadState {
INIT,
READY,
DONE
}
}

View File

@@ -0,0 +1,105 @@
package org.thoughtcrime.securesms.safety
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.kotlin.plusAssign
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeRepository
import org.thoughtcrime.securesms.conversation.ui.error.TrustAndVerifyResult
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.rx.RxStore
class SafetyNumberBottomSheetViewModel(
private val args: SafetyNumberBottomSheetArgs,
private val repository: SafetyNumberBottomSheetRepository = SafetyNumberBottomSheetRepository(),
private val trustAndVerifyRepository: SafetyNumberChangeRepository
) : ViewModel() {
companion object {
private const val MAX_RECIPIENTS_TO_DISPLAY = 5
}
private val destinationStore = RxStore(args.destinations.map { it.asRecipientSearchKey() })
val destinationSnapshot: List<ContactSearchKey.RecipientSearchKey>
get() = destinationStore.state
private val store = RxStore(
SafetyNumberBottomSheetState(
untrustedRecipientCount = args.untrustedRecipients.size,
hasLargeNumberOfUntrustedRecipients = args.untrustedRecipients.size > MAX_RECIPIENTS_TO_DISPLAY
)
)
val state: Flowable<SafetyNumberBottomSheetState> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
val hasLargeNumberOfUntrustedRecipients
get() = store.state.hasLargeNumberOfUntrustedRecipients
private val disposables = CompositeDisposable()
init {
if (!store.state.hasLargeNumberOfUntrustedRecipients) {
loadRecipients()
}
}
fun setDone() {
store.update { it.copy(loadState = SafetyNumberBottomSheetState.LoadState.DONE) }
}
fun trustAndVerify(): Single<TrustAndVerifyResult> {
val recipients: List<SafetyNumberRecipient> = store.state.destinationToRecipientMap.values.flatten().distinct()
return if (args.messageId != null) {
trustAndVerifyRepository.trustOrVerifyChangedRecipientsAndResendRx(recipients, args.messageId)
} else {
trustAndVerifyRepository.trustOrVerifyChangedRecipientsRx(recipients).observeOn(AndroidSchedulers.mainThread())
}
}
private fun loadRecipients() {
val bucketFlowable: Flowable<Map<SafetyNumberBucket, List<SafetyNumberRecipient>>> = destinationStore.stateFlowable.switchMap { repository.getBuckets(args.untrustedRecipients, it) }
disposables += store.update(bucketFlowable) { map, state ->
state.copy(
destinationToRecipientMap = map,
untrustedRecipientCount = map.size
)
}
}
override fun onCleared() {
disposables.clear()
}
fun getIdentityRecord(recipientId: RecipientId): Maybe<IdentityRecord> {
return repository.getIdentityRecord(recipientId).observeOn(AndroidSchedulers.mainThread())
}
fun removeRecipientFromSelectedStories(recipientId: RecipientId) {
disposables += repository.removeFromStories(recipientId, destinationStore.state).subscribe()
}
fun removeDestination(destination: RecipientId) {
destinationStore.update { list -> list.filterNot { it.recipientId == destination } }
}
fun removeAll(distributionListBucket: SafetyNumberBucket.DistributionListBucket) {
val toRemove = store.state.destinationToRecipientMap[distributionListBucket] ?: return
disposables += repository.removeAllFromStory(toRemove.map { it.recipient.id }, distributionListBucket.distributionListId).subscribe()
}
class Factory(
private val args: SafetyNumberBottomSheetArgs,
private val trustAndVerifyRepository: SafetyNumberChangeRepository
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(SafetyNumberBottomSheetViewModel(args = args, trustAndVerifyRepository = trustAndVerifyRepository)) as T
}
}
}

View File

@@ -0,0 +1,10 @@
package org.thoughtcrime.securesms.safety
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.recipients.Recipient
sealed class SafetyNumberBucket {
data class DistributionListBucket(val distributionListId: DistributionListId, val name: String) : SafetyNumberBucket()
data class GroupBucket(val recipient: Recipient) : SafetyNumberBucket()
object ContactsBucket : SafetyNumberBucket()
}

View File

@@ -0,0 +1,116 @@
package org.thoughtcrime.securesms.safety
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import org.signal.core.util.DimensionUnit
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.database.model.DistributionListId
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
object SafetyNumberBucketRowItem {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(DistributionListModel::class.java, LayoutFactory(::DistributionListViewHolder, R.layout.safety_number_bucket_row_item))
mappingAdapter.registerFactory(GroupModel::class.java, LayoutFactory(::GroupViewHolder, R.layout.safety_number_bucket_row_item))
mappingAdapter.registerFactory(ContactsModel::class.java, LayoutFactory(::ContactsViewHolder, R.layout.safety_number_bucket_row_item))
}
fun createModel(
safetyNumberBucket: SafetyNumberBucket,
actionItemsProvider: (SafetyNumberBucket) -> List<ActionItem>
): MappingModel<*> {
return when (safetyNumberBucket) {
SafetyNumberBucket.ContactsBucket -> ContactsModel()
is SafetyNumberBucket.DistributionListBucket -> DistributionListModel(safetyNumberBucket, actionItemsProvider)
is SafetyNumberBucket.GroupBucket -> GroupModel(safetyNumberBucket)
}
}
private class DistributionListModel(
val distributionListBucket: SafetyNumberBucket.DistributionListBucket,
val actionItemsProvider: (SafetyNumberBucket) -> List<ActionItem>
) : MappingModel<DistributionListModel> {
override fun areItemsTheSame(newItem: DistributionListModel): Boolean {
return distributionListBucket.distributionListId == newItem.distributionListBucket.distributionListId
}
override fun areContentsTheSame(newItem: DistributionListModel): Boolean {
return distributionListBucket == newItem.distributionListBucket
}
}
private class GroupModel(val groupBucket: SafetyNumberBucket.GroupBucket) : MappingModel<GroupModel> {
override fun areItemsTheSame(newItem: GroupModel): Boolean {
return groupBucket.recipient.id == newItem.groupBucket.recipient.id
}
override fun areContentsTheSame(newItem: GroupModel): Boolean {
return groupBucket.recipient.hasSameContent(newItem.groupBucket.recipient)
}
}
private class ContactsModel : MappingModel<ContactsModel> {
override fun areItemsTheSame(newItem: ContactsModel): Boolean = true
override fun areContentsTheSame(newItem: ContactsModel): Boolean = true
}
private class DistributionListViewHolder(itemView: View) : BaseViewHolder<DistributionListModel>(itemView) {
override fun getTitle(model: DistributionListModel): String {
return if (model.distributionListBucket.distributionListId == DistributionListId.MY_STORY) {
context.getString(R.string.Recipient_my_story)
} else {
model.distributionListBucket.name
}
}
override fun bindMenuListener(model: DistributionListModel, menuView: View) {
menuView.setOnClickListener {
SignalContextMenu.Builder(menuView, menuView.rootView as ViewGroup)
.offsetX(DimensionUnit.DP.toPixels(16f).toInt())
.offsetY(DimensionUnit.DP.toPixels(16f).toInt())
.show(model.actionItemsProvider(model.distributionListBucket))
}
}
}
private class GroupViewHolder(itemView: View) : BaseViewHolder<GroupModel>(itemView) {
override fun getTitle(model: GroupModel): String {
return model.groupBucket.recipient.getDisplayName(context)
}
override fun bindMenuListener(model: GroupModel, menuView: View) {
menuView.visible = false
}
}
private class ContactsViewHolder(itemView: View) : BaseViewHolder<ContactsModel>(itemView) {
override fun getTitle(model: ContactsModel): String {
return context.getString(R.string.SafetyNumberBucketRowItem__contacts)
}
override fun bindMenuListener(model: ContactsModel, menuView: View) {
menuView.visible = false
}
}
private abstract class BaseViewHolder<T : MappingModel<*>>(itemView: View) : MappingViewHolder<T>(itemView) {
private val titleView: TextView = findViewById(R.id.safety_number_bucket_header)
private val menuView: View = findViewById(R.id.safety_number_bucket_menu)
override fun bind(model: T) {
titleView.text = getTitle(model)
bindMenuListener(model, menuView)
}
abstract fun getTitle(model: T): String
abstract fun bindMenuListener(model: T, menuView: View)
}
}

View File

@@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.safety
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.recipients.Recipient
/**
* Represents a Recipient who had a safety number change. Also includes information used in
* order to determine whether the recipient should be shown on a given screen and what menu
* options it should have.
*/
data class SafetyNumberRecipient(
val recipient: Recipient,
val identityRecord: IdentityRecord,
val distributionListMembershipCount: Int,
val groupMembershipCount: Int
)

View File

@@ -0,0 +1,71 @@
package org.thoughtcrime.securesms.safety
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import org.signal.core.util.or
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.AvatarImageView
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.adapter.mapping.LayoutFactory
import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter
import org.thoughtcrime.securesms.util.adapter.mapping.MappingModel
import org.thoughtcrime.securesms.util.adapter.mapping.MappingViewHolder
import org.thoughtcrime.securesms.util.visible
/**
* An untrusted recipient who can be verified or removed.
*/
object SafetyNumberRecipientRowItem {
fun register(mappingAdapter: MappingAdapter) {
mappingAdapter.registerFactory(Model::class.java, LayoutFactory(::ViewHolder, R.layout.safety_number_recipient_row_item))
}
class Model(
val recipient: Recipient,
val isVerified: Boolean,
val distributionListMembershipCount: Int,
val groupMembershipCount: Int,
val getContextMenuActions: (Model) -> List<ActionItem>
) : MappingModel<Model> {
override fun areItemsTheSame(newItem: Model): Boolean {
return recipient.id == newItem.recipient.id
}
override fun areContentsTheSame(newItem: Model): Boolean {
return recipient.hasSameContent(newItem.recipient) &&
isVerified == newItem.isVerified &&
distributionListMembershipCount == newItem.distributionListMembershipCount &&
groupMembershipCount == newItem.groupMembershipCount
}
}
class ViewHolder(itemView: View) : MappingViewHolder<Model>(itemView) {
private val avatar: AvatarImageView = itemView.findViewById(R.id.safety_number_recipient_avatar)
private val name: TextView = itemView.findViewById(R.id.safety_number_recipient_name)
private val identifier: TextView = itemView.findViewById(R.id.safety_number_recipient_identifier)
private val menu: View = itemView.findViewById(R.id.safety_number_recipient_menu)
override fun bind(model: Model) {
avatar.setRecipient(model.recipient)
name.text = model.recipient.getDisplayName(context)
val identifierText = model.recipient.e164.or(model.recipient.username).orElse(null)
val subLineText = when {
model.isVerified && identifierText.isNullOrBlank() -> context.getString(R.string.SafetyNumberRecipientRowItem__verified)
model.isVerified -> context.getString(R.string.SafetyNumberRecipientRowItem__s_dot_verified, identifierText)
else -> identifierText
}
identifier.text = subLineText
identifier.visible = !subLineText.isNullOrBlank()
menu.setOnClickListener {
SignalContextMenu.Builder(itemView, itemView.rootView as ViewGroup)
.show(model.getContextMenuActions(model))
}
}
}
}

View File

@@ -0,0 +1,158 @@
package org.thoughtcrime.securesms.safety.review
import android.view.View
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.WrapperDialogFragment
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.settings.DSLConfiguration
import org.thoughtcrime.securesms.components.settings.DSLSettingsAdapter
import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment
import org.thoughtcrime.securesms.components.settings.DSLSettingsText
import org.thoughtcrime.securesms.components.settings.configure
import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable
import org.thoughtcrime.securesms.database.IdentityDatabase
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheetState
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheetViewModel
import org.thoughtcrime.securesms.safety.SafetyNumberBucket
import org.thoughtcrime.securesms.safety.SafetyNumberBucketRowItem
import org.thoughtcrime.securesms.safety.SafetyNumberRecipientRowItem
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.verify.VerifyIdentityFragment
/**
* Full-screen fragment which displays the list of users who have safety number changes.
* Consider this an extension of the bottom sheet.
*/
class SafetyNumberReviewConnectionsFragment : DSLSettingsFragment(
titleId = R.string.SafetyNumberReviewConnectionsFragment__safety_number_changes,
layoutId = R.layout.safety_number_review_fragment
) {
private val viewModel: SafetyNumberBottomSheetViewModel by viewModels(ownerProducer = {
requireParentFragment().requireParentFragment()
})
private val lifecycleDisposable = LifecycleDisposable()
override fun bindAdapter(adapter: DSLSettingsAdapter) {
SafetyNumberBucketRowItem.register(adapter)
SafetyNumberRecipientRowItem.register(adapter)
lifecycleDisposable.bindTo(viewLifecycleOwner)
val done = requireView().findViewById<View>(R.id.done)
done.setOnClickListener {
requireActivity().onBackPressed()
}
lifecycleDisposable += viewModel.state.subscribe { state ->
adapter.submitList(getConfiguration(state).toMappingModelList())
}
}
private fun getConfiguration(state: SafetyNumberBottomSheetState): DSLConfiguration {
return configure {
textPref(
title = DSLSettingsText.from(
getString(R.string.SafetyNumberReviewConnectionsFragment__d_recipients_may_have, state.destinationToRecipientMap.values.flatten().size),
DSLSettingsText.TextAppearanceModifier(R.style.Signal_Text_BodyMedium),
DSLSettingsText.ColorModifier(ContextCompat.getColor(requireContext(), R.color.signal_colorOnSurfaceVariant))
)
)
state.destinationToRecipientMap.forEach { (bucket, recipients) ->
customPref(SafetyNumberBucketRowItem.createModel(bucket, this@SafetyNumberReviewConnectionsFragment::getActionItemsForBucket))
recipients.forEach {
customPref(
SafetyNumberRecipientRowItem.Model(
recipient = it.recipient,
isVerified = it.identityRecord.verifiedStatus == IdentityDatabase.VerifiedStatus.VERIFIED,
distributionListMembershipCount = it.distributionListMembershipCount,
groupMembershipCount = it.groupMembershipCount,
getContextMenuActions = { model ->
val actions = mutableListOf<ActionItem>()
actions.add(
ActionItem(
iconRes = R.drawable.ic_safety_number_24,
title = getString(R.string.SafetyNumberBottomSheetFragment__verify_safety_number),
action = {
lifecycleDisposable += viewModel.getIdentityRecord(model.recipient.id).subscribe { record ->
VerifyIdentityFragment.createDialog(
model.recipient.id,
IdentityKeyParcelable(record.identityKey),
false
).show(childFragmentManager, null)
}
}
)
)
if (model.distributionListMembershipCount > 0) {
actions.add(
ActionItem(
iconRes = R.drawable.ic_circle_x_24,
title = getString(R.string.SafetyNumberBottomSheetFragment__remove_from_story),
action = {
viewModel.removeRecipientFromSelectedStories(model.recipient.id)
}
)
)
}
if (model.distributionListMembershipCount == 0 && model.groupMembershipCount == 0) {
actions.add(
ActionItem(
iconRes = R.drawable.ic_circle_x_24,
title = getString(R.string.SafetyNumberReviewConnectionsFragment__remove),
tintRes = R.color.signal_colorOnSurface,
action = {
viewModel.removeDestination(model.recipient.id)
}
)
)
}
actions
}
)
)
}
}
}
}
private fun getActionItemsForBucket(bucket: SafetyNumberBucket): List<ActionItem> {
return when (bucket) {
is SafetyNumberBucket.DistributionListBucket -> {
listOf(
ActionItem(
iconRes = R.drawable.ic_circle_x_24,
title = getString(R.string.SafetyNumberReviewConnectionsFragment__remove_all),
tintRes = R.color.signal_colorOnSurface,
action = {
viewModel.removeAll(bucket)
}
)
)
}
else -> emptyList()
}
}
class Dialog : WrapperDialogFragment() {
override fun getWrappedFragment(): Fragment {
return SafetyNumberReviewConnectionsFragment()
}
}
companion object {
fun show(fragmentManager: FragmentManager) {
Dialog().show(fragmentManager, null)
}
}
}

View File

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

View File

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

View File

@@ -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<WrapperDialogFragment>()?.dismiss() ?: super.onToolbarNavigationClicked()
}
class Dialog : WrapperDialogFragment() {
override fun getWrappedFragment(): Fragment {
return NavHostFragment.create(R.navigation.my_story_settings)

View File

@@ -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<RecipientId>) {
override fun sendAnywayAfterSafetyNumberChangedInBottomSheet(destinations: List<ContactSearchKey.RecipientSearchKey>) {
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")
}

View File

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