diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/identity/UnverifiedBannerView.java b/app/src/main/java/org/thoughtcrime/securesms/components/identity/UnverifiedBannerView.java index 5ff794fac3..78c934f083 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/identity/UnverifiedBannerView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/identity/UnverifiedBannerView.java @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.components.identity; import android.content.Context; -import android.os.Build; import android.util.AttributeSet; import android.view.LayoutInflater; import android.view.View; @@ -12,7 +11,6 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; @@ -24,9 +22,10 @@ public class UnverifiedBannerView extends LinearLayout { private static final String TAG = Log.tag(UnverifiedBannerView.class); - private View container; - private TextView text; - private ImageView closeButton; + private View container; + private TextView text; + private ImageView closeButton; + private OnHideListener onHideListener; public UnverifiedBannerView(Context context) { super(context); @@ -38,13 +37,11 @@ public class UnverifiedBannerView extends LinearLayout { initialize(); } - @RequiresApi(api = Build.VERSION_CODES.HONEYCOMB) public UnverifiedBannerView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); initialize(); } - @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP) public UnverifiedBannerView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); initialize(); @@ -82,16 +79,27 @@ public class UnverifiedBannerView extends LinearLayout { }); } + public void setOnHideListener(@Nullable OnHideListener onHideListener) { + this.onHideListener = onHideListener; + } + public void hide() { + if (onHideListener != null && onHideListener.onHide()) { + return; + } + setVisibility(View.GONE); } public interface DismissListener { - public void onDismissed(List unverifiedIdentities); + void onDismissed(List unverifiedIdentities); } public interface ClickListener { - public void onClicked(List unverifiedIdentities); + void onClicked(List unverifiedIdentities); } + public interface OnHideListener { + boolean onHide(); + } } 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 3b8a936c70..a0b1ce5d54 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -4393,7 +4393,7 @@ public class ConversationParentFragment extends Fragment AlertDialog.Builder builder = new MaterialAlertDialogBuilder(requireContext()); builder.setIcon(R.drawable.ic_warning); - builder.setTitle("No longer verified"); + builder.setTitle(R.string.ConversationFragment__no_longer_verified); builder.setItems(unverifiedNames, (dialog, which) -> { startActivity(VerifyIdentityActivity.newIntent(requireContext(), unverifiedIdentities.get(which), false)); }); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java index 536fcc0eff..cfd3251162 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationTitleView.java @@ -71,7 +71,6 @@ public class ConversationTitleView extends ConstraintLayout { ViewUtil.setTextViewGravityStart(this.subtitle, getContext()); } - @Override protected @NonNull Parcelable onSaveInstanceState() { Bundle bundle = new Bundle(); @@ -94,7 +93,6 @@ public class ConversationTitleView extends ConstraintLayout { } } - public void showExpiring(@NonNull Recipient recipient) { isSelf = recipient.isSelf(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt index ca6141c314..28cb682473 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationBannerView.kt @@ -6,8 +6,10 @@ package org.thoughtcrime.securesms.conversation.v2 import android.content.Context +import android.transition.ChangeBounds import android.transition.Slide import android.transition.TransitionManager +import android.transition.TransitionSet import android.util.AttributeSet import android.view.Gravity import android.view.LayoutInflater @@ -15,8 +17,12 @@ import android.view.View import androidx.appcompat.widget.LinearLayoutCompat import androidx.core.transition.addListener import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView import org.thoughtcrime.securesms.components.reminder.Reminder import org.thoughtcrime.securesms.components.reminder.ReminderView +import org.thoughtcrime.securesms.database.identity.IdentityRecordList +import org.thoughtcrime.securesms.database.model.IdentityRecord +import org.thoughtcrime.securesms.util.IdentityUtil /** * Responsible for showing the various "banner" views at the top of a conversation @@ -37,7 +43,7 @@ class ConversationBannerView @JvmOverloads constructor( private val inflater: LayoutInflater by lazy { LayoutInflater.from(context) } private var reminderView: ReminderView? = null - private var currentView: View? = null + private var unverifiedBannerView: UnverifiedBannerView? = null var listener: Listener? = null @@ -45,8 +51,9 @@ class ConversationBannerView @JvmOverloads constructor( orientation = VERTICAL } - fun showAsReminder(reminder: Reminder) { + fun showReminder(reminder: Reminder) { reminderView = show( + position = -1, existingView = reminderView, create = { ReminderView(context) }, bind = { @@ -63,39 +70,60 @@ class ConversationBannerView @JvmOverloads constructor( } } setOnHideListener { - removeIfNotNull(reminderView) - reminderView = null + clearReminder() true } } ) } - fun clear() { - removeAllViews() + fun clearReminder() { + removeIfNotNull(reminderView) reminderView = null - currentView = null } - private fun show(existingView: V?, create: () -> V, bind: V.() -> Unit = {}): V { - if (existingView != currentView) { - removeIfNotNull(currentView) - } + fun showUnverifiedBanner(identityRecords: IdentityRecordList) { + unverifiedBannerView = show( + position = 0, + existingView = null, + create = { UnverifiedBannerView(context) }, + bind = { + setOnHideListener { + clearUnverifiedBanner() + true + } + display( + IdentityUtil.getUnverifiedBannerDescription(context, identityRecords.unverifiedRecipients)!!, + identityRecords.unverifiedRecords, + { listener?.onUnverifiedBannerClicked(identityRecords.unverifiedRecords) }, + { listener?.onUnverifiedBannerDismissed(identityRecords.unverifiedRecords) } + ) + } + ) + } + fun clearUnverifiedBanner() { + removeIfNotNull(unverifiedBannerView) + unverifiedBannerView = null + } + + private fun show(position: Int, existingView: V?, create: () -> V, bind: V.() -> Unit = {}): V { val view: V = if (existingView != null) { existingView } else { val newView: V = create() TransitionManager.beginDelayedTransition(this, Slide(Gravity.TOP)) - addView(newView, defaultLayoutParams()) + if (position in 0..childCount) { + addView(newView, position, defaultLayoutParams()) + } else { + addView(newView, defaultLayoutParams()) + } newView } view.bind() - currentView = view - return view } @@ -105,13 +133,29 @@ class ConversationBannerView @JvmOverloads constructor( private fun removeIfNotNull(view: View?) { if (view != null) { - val transition = Slide(Gravity.TOP).apply { + val slideTransition = Slide(Gravity.TOP).apply { addListener( onEnd = { layoutParams = layoutParams.apply { height = LayoutParams.WRAP_CONTENT } } ) } + + val changeTransition = ChangeBounds().apply { + if (reminderView != null) { + addTarget(reminderView) + } + + if (unverifiedBannerView != null) { + addTarget(unverifiedBannerView) + } + } + + val transition = TransitionSet().apply { + addTransition(slideTransition) + addTransition(changeTransition) + } + layoutParams = layoutParams.apply { height = this@ConversationBannerView.height } TransitionManager.beginDelayedTransition(this, transition) removeView(view) @@ -124,5 +168,7 @@ class ConversationBannerView @JvmOverloads constructor( fun reviewJoinRequestsAction() fun gv1SuggestionsAction(actionId: Int) fun changeBubbleSettingAction(disableSetting: Boolean) + fun onUnverifiedBannerClicked(unverifiedIdentities: List) + fun onUnverifiedBannerDismissed(unverifiedIdentities: List) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index f30e4899d7..5c4aefccc5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -60,6 +60,8 @@ import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.kotlin.subscribeBy import io.reactivex.rxjava3.schedulers.Schedulers import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode import org.signal.core.util.Result import org.signal.core.util.ThreadUtil import org.signal.core.util.concurrent.LifecycleDisposable @@ -125,6 +127,7 @@ import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSe import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupCallViewModel import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupViewModel import org.thoughtcrime.securesms.conversation.v2.keyboard.AttachmentKeyboardFragment +import org.thoughtcrime.securesms.database.model.IdentityRecord import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageId @@ -198,6 +201,7 @@ import org.thoughtcrime.securesms.util.hasGiftBadge import org.thoughtcrime.securesms.util.viewModel import org.thoughtcrime.securesms.util.views.Stub import org.thoughtcrime.securesms.util.visible +import org.thoughtcrime.securesms.verify.VerifyIdentityActivity import org.thoughtcrime.securesms.wallpaper.ChatWallpaper import org.thoughtcrime.securesms.wallpaper.ChatWallpaperDimLevelUtil import java.util.Locale @@ -345,6 +349,9 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) WindowUtil.setLightNavigationBarFromTheme(requireActivity()) WindowUtil.setLightStatusBarFromTheme(requireActivity()) + + EventBus.getDefault().register(this) + groupCallViewModel.peekGroupCall() if (!args.conversationScreenType.isInBubble) { @@ -352,12 +359,20 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) } motionEventRelay.setDrain(MotionEventRelayDrain()) + + viewModel.updateIdentityRecords() } override fun onPause() { super.onPause() ApplicationDependencies.getMessageNotifier().clearVisibleThread() motionEventRelay.setDrain(null) + EventBus.getDefault().unregister(this) + } + + override fun onStop() { + super.onStop() + EventBus.getDefault().unregister(this) } private fun observeConversationThread() { @@ -485,12 +500,30 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) .reminder .subscribeBy { reminder -> if (reminder.isPresent) { - binding.conversationBanner.showAsReminder(reminder.get()) + binding.conversationBanner.showReminder(reminder.get()) } else { - binding.conversationBanner.clear() + binding.conversationBanner.clearReminder() } } .addTo(disposables) + + viewModel + .identityRecords + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy { presentIdentityRecordsState(it) } + .addTo(disposables) + } + + private fun presentIdentityRecordsState(identityRecordsState: IdentityRecordsState) { + if (!identityRecordsState.isGroup) { + binding.conversationTitleView.root.setVerified(identityRecordsState.isVerified) + } + + if (identityRecordsState.isUnverified) { + binding.conversationBanner.showUnverifiedBanner(identityRecordsState.identityRecords) + } else { + binding.conversationBanner.clearUnverifiedBanner() + } } private fun presentInputReadyState(inputReadyState: InputReadyState) { @@ -589,7 +622,14 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) return } - binding.conversationTitleView.root.setTitle(GlideApp.with(this), recipient) + val titleView = binding.conversationTitleView.root + + titleView.setTitle(GlideApp.with(this), recipient) + if (recipient.expiresInSeconds > 0) { + titleView.showExpiring(recipient) + } else { + titleView.clearExpiring() + } } private fun presentWallpaper(chatWallpaper: ChatWallpaper?) { @@ -1714,13 +1754,8 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) // TODO [alex] - ("Not yet implemented") } - override fun showExpiring(recipient: Recipient) { - binding.conversationTitleView.root.showExpiring(recipient) - } - - override fun clearExpiring() { - binding.conversationTitleView.root.clearExpiring() - } + override fun showExpiring(recipient: Recipient) = Unit + override fun clearExpiring() = Unit override fun showGroupCallingTooltip() { conversationTooltips.displayGroupCallingTooltip(requireView().findViewById(R.id.menu_video_secure)) @@ -1841,6 +1876,26 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) startActivity(intent) } } + + override fun onUnverifiedBannerClicked(unverifiedIdentities: List) { + if (unverifiedIdentities.size == 1) { + startActivity(VerifyIdentityActivity.newIntent(requireContext(), unverifiedIdentities[0], false)) + } else { + val unverifiedNames = unverifiedIdentities + .map { Recipient.resolved(it.recipientId).getDisplayName(requireContext()) } + .toTypedArray() + + MaterialAlertDialogBuilder(requireContext()) + .setIcon(R.drawable.ic_warning) + .setTitle(R.string.ConversationFragment__no_longer_verified) + .setItems(unverifiedNames) { _, which: Int -> startActivity(VerifyIdentityActivity.newIntent(requireContext(), unverifiedIdentities[which], false)) } + .show() + } + } + + override fun onUnverifiedBannerDismissed(unverifiedIdentities: List) { + viewModel.resetVerifiedStatusToDefault(unverifiedIdentities) + } } //endregion @@ -2100,4 +2155,13 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) } //endregion + + //region Event Bus + + @Subscribe(threadMode = ThreadMode.POSTING) + fun onIdentityRecordUpdate(event: IdentityRecord?) { + viewModel.updateIdentityRecords() + } + + //endregion } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt index e27e37d453..71af861d7a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt @@ -28,9 +28,14 @@ import org.thoughtcrime.securesms.components.reminder.UnauthorizedReminder import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper import org.thoughtcrime.securesms.conversation.colors.NameColor import org.thoughtcrime.securesms.conversation.v2.data.ConversationDataSource +import org.thoughtcrime.securesms.crypto.ReentrantSessionLock +import org.thoughtcrime.securesms.database.GroupTable +import org.thoughtcrime.securesms.database.IdentityTable.VerifiedStatus +import org.thoughtcrime.securesms.database.RecipientTable import org.thoughtcrime.securesms.database.RxDatabaseObserver import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.model.GroupRecord +import org.thoughtcrime.securesms.database.model.IdentityRecord import org.thoughtcrime.securesms.database.model.Mention import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.Quote @@ -226,6 +231,36 @@ class ConversationRepository( } } + @Suppress("IfThenToElvis") + fun getIdentityRecords(recipient: Recipient, groupRecord: GroupRecord?): Single { + return Single.fromCallable { + val recipients = if (groupRecord == null) { + listOf(recipient) + } else { + groupRecord.requireV2GroupProperties().getMemberRecipients(GroupTable.MemberSet.FULL_MEMBERS_EXCLUDING_SELF) + } + + val records = ApplicationDependencies.getProtocolStore().aci().identities().getIdentityRecords(recipients) + val isVerified = recipient.registered == RecipientTable.RegisteredState.REGISTERED && + Recipient.self().isRegistered && + records.isVerified && + !recipient.isSelf + + IdentityRecordsState(isVerified, records, isGroup = groupRecord != null) + }.subscribeOn(Schedulers.io()) + } + + fun resetVerifiedStatusToDefault(unverifiedIdentities: List): Completable { + return Completable.fromCallable { + ReentrantSessionLock.INSTANCE.acquire().use { + val identityStore = ApplicationDependencies.getProtocolStore().aci().identities() + for ((recipientId, identityKey) in unverifiedIdentities) { + identityStore.setVerified(recipientId, identityKey, VerifiedStatus.DEFAULT) + } + } + }.subscribeOn(Schedulers.io()) + } + data class MessageCounts( val unread: Int, val mentions: Int diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 2415ab51e5..1aae371cf1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper import org.thoughtcrime.securesms.conversation.colors.NameColor import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey import org.thoughtcrime.securesms.database.DatabaseObserver +import org.thoughtcrime.securesms.database.model.IdentityRecord import org.thoughtcrime.securesms.database.model.Mention import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord @@ -95,9 +96,11 @@ class ConversationViewModel( get() = hasMessageRequestStateSubject.value ?: false private val refreshReminder: Subject = PublishSubject.create() - val reminder: Observable> + private val refreshIdentityRecords: Subject = PublishSubject.create() + val identityRecords: Observable + init { disposables += recipient .subscribeBy { @@ -170,6 +173,14 @@ class ConversationViewModel( .subscribeOn(Schedulers.io()) .flatMapMaybe { groupRecord -> repository.getReminder(groupRecord.orNull()) } .observeOn(AndroidSchedulers.mainThread()) + + identityRecords = Observable.combineLatest( + refreshIdentityRecords.startWithItem(Unit).observeOn(Schedulers.io()), + recipient, + recipientRepository.groupRecord + ) { _, r, g -> Pair(r, g) } + .flatMapSingle { (r, g) -> repository.getIdentityRecords(r, g.orNull()) } + .distinctUntilChanged() } override fun onCleared() { @@ -229,4 +240,15 @@ class ConversationViewModel( bodyRanges = bodyRanges ).observeOn(AndroidSchedulers.mainThread()) } + + fun resetVerifiedStatusToDefault(unverifiedIdentities: List) { + disposables += repository.resetVerifiedStatusToDefault(unverifiedIdentities) + .subscribe { + refreshIdentityRecords.onNext(Unit) + } + } + + fun updateIdentityRecords() { + refreshIdentityRecords.onNext(Unit) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/IdentityRecordsState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/IdentityRecordsState.kt new file mode 100644 index 0000000000..0383491183 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/IdentityRecordsState.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import org.thoughtcrime.securesms.database.identity.IdentityRecordList + +/** + * Current state for all participants identity keys in a conversation excluding self. + */ +data class IdentityRecordsState( + val isVerified: Boolean, + val identityRecords: IdentityRecordList, + val isGroup: Boolean +) { + val isUnverified: Boolean = identityRecords.isUnverified +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/identity/IdentityRecordList.java b/app/src/main/java/org/thoughtcrime/securesms/database/identity/IdentityRecordList.java index 09ced38ec8..7e33c1f310 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/identity/IdentityRecordList.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/identity/IdentityRecordList.java @@ -10,6 +10,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.concurrent.TimeUnit; public final class IdentityRecordList { @@ -145,4 +146,16 @@ public final class IdentityRecordList { System.currentTimeMillis() - identityRecord.getTimestamp() < untrustedWindowMillis; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + final IdentityRecordList that = (IdentityRecordList) o; + return Objects.equals(identityRecords, that.identityRecords); + } + + @Override + public int hashCode() { + return Objects.hash(identityRecords); + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a9bf5234e0..4b93273e01 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -503,6 +503,9 @@ Navigate back. Open Signal + + No longer verified + Clear filter