diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1b78571c6c..f500f27aff 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -300,6 +300,16 @@ + + + + = 30) { - return true; - } - - applyInsets(insets); - - return true; - } - - private void applyInsets(@NonNull Rect insets) { + public void applyInsets(@NonNull Insets insets) { Guideline statusBarGuideline = findViewById(R.id.status_bar_guideline); Guideline navigationBarGuideline = findViewById(R.id.navigation_bar_guideline); Guideline parentStartGuideline = findViewById(R.id.parent_start_guideline); @@ -83,4 +75,15 @@ public class InsetAwareConstraintLayout extends ConstraintLayout { } } } + + public interface WindowInsetsTypeProvider { + + WindowInsetsTypeProvider ALL = () -> + WindowInsetsCompat.Type.ime() | + WindowInsetsCompat.Type.systemBars() | + WindowInsetsCompat.Type.displayCutout(); + + @WindowInsetsCompat.Type.InsetsType + int getInsetsType(); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java b/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java index bf61b89eff..eec0615cbb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/KeyboardAwareLinearLayout.java @@ -219,6 +219,10 @@ public class KeyboardAwareLinearLayout extends LinearLayoutCompat { } private int getDeviceRotation() { + if (isInEditMode()) { + return Surface.ROTATION_0; + } + if (Build.VERSION.SDK_INT >= 30) { getContext().getDisplay().getRealMetrics(displayMetrics); } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt index b5e08c38c7..aa8fa3fed4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt @@ -595,6 +595,15 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter } ) } + + dividerPref() + switchPref( + title = DSLSettingsText.from("Use V2 ConversationFragment"), + isChecked = state.useConversationFragmentV2, + onClick = { + viewModel.setUseConversationFragmentV2(!state.useConversationFragmentV2) + } + ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt index 0829de176b..f924e1c8f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt @@ -21,5 +21,6 @@ data class InternalSettingsState( val delayResends: Boolean, val disableStorageService: Boolean, val canClearOnboardingState: Boolean, - val pnpInitialized: Boolean + val pnpInitialized: Boolean, + val useConversationFragmentV2: Boolean ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt index 1a93577c4d..9c0e607c95 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt @@ -104,6 +104,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito refresh() } + fun setUseConversationFragmentV2(enabled: Boolean) { + SignalStore.internalValues().setUseConversationFragmentV2(enabled) + refresh() + } + fun addSampleReleaseNote() { repository.addSampleReleaseNote() } @@ -130,7 +135,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito delayResends = SignalStore.internalValues().delayResends(), disableStorageService = SignalStore.internalValues().storageServiceDisabled(), canClearOnboardingState = SignalStore.storyValues().hasDownloadedOnboardingStory && Stories.isFeatureEnabled(), - pnpInitialized = SignalStore.misc().hasPniInitializedDevices() + pnpInitialized = SignalStore.misc().hasPniInitializedDevices(), + useConversationFragmentV2 = SignalStore.internalValues().useConversationFragmentV2() ) fun onClearOnboardingState() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java index 4e33c23dd3..bc1f3cdb3c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java @@ -58,7 +58,7 @@ public class ConversationDataSource implements PagedDataSource media; - private final StickerLocator stickerLocator; - private final boolean isBorderless; - private final int distributionType; - private final int startingPosition; - private final boolean firstTimeInSelfCreatedGroup; - private final boolean withSearchOpen; - private final Badge giftBadge; - private final long shareDataTimestamp; + public final static class Args { + private final RecipientId recipientId; + private final long threadId; + private final String draftText; + private final ArrayList media; + private final StickerLocator stickerLocator; + private final boolean isBorderless; + private final int distributionType; + private final int startingPosition; + private final boolean firstTimeInSelfCreatedGroup; + private final boolean withSearchOpen; + private final Badge giftBadge; + private final long shareDataTimestamp; + private final ConversationScreenType conversationScreenType; - static Args from(@NonNull Bundle arguments) { + public static Args from(@NonNull Bundle arguments) { Uri intentDataUri = getIntentData(arguments); if (isBubbleIntentUri(intentDataUri)) { return new Args(RecipientId.from(intentDataUri.getQueryParameter(EXTRA_RECIPIENT)), @@ -122,7 +130,8 @@ public class ConversationIntents { false, false, null, - -1L); + -1L, + ConversationScreenType.BUBBLE); } return new Args(RecipientId.from(Objects.requireNonNull(arguments.getString(EXTRA_RECIPIENT))), @@ -136,7 +145,8 @@ public class ConversationIntents { arguments.getBoolean(EXTRA_FIRST_TIME_IN_SELF_CREATED_GROUP, false), arguments.getBoolean(EXTRA_WITH_SEARCH_OPEN, false), arguments.getParcelable(EXTRA_GIFT_BADGE), - arguments.getLong(EXTRA_SHARE_DATA_TIMESTAMP, -1L)); + arguments.getLong(EXTRA_SHARE_DATA_TIMESTAMP, -1L), + ConversationScreenType.from(arguments.getInt(EXTRA_CONVERSATION_TYPE, 0))); } private Args(@NonNull RecipientId recipientId, @@ -150,7 +160,8 @@ public class ConversationIntents { boolean firstTimeInSelfCreatedGroup, boolean withSearchOpen, @Nullable Badge giftBadge, - long shareDataTimestamp) + long shareDataTimestamp, + @NonNull ConversationScreenType conversationScreenType) { this.recipientId = recipientId; this.threadId = threadId; @@ -162,8 +173,9 @@ public class ConversationIntents { this.startingPosition = startingPosition; this.firstTimeInSelfCreatedGroup = firstTimeInSelfCreatedGroup; this.withSearchOpen = withSearchOpen; - this.giftBadge = giftBadge; - this.shareDataTimestamp = shareDataTimestamp; + this.giftBadge = giftBadge; + this.shareDataTimestamp = shareDataTimestamp; + this.conversationScreenType = conversationScreenType; } public @NonNull RecipientId getRecipientId() { @@ -221,43 +233,54 @@ public class ConversationIntents { public long getShareDataTimestamp() { return shareDataTimestamp; } + + public @NonNull ConversationScreenType getConversationScreenType() { + return conversationScreenType; + } } public final static class Builder { - private final Context context; - private final Class conversationActivityClass; - private final RecipientId recipientId; - private final long threadId; + private final Context context; + private final Class conversationActivityClass; + private final RecipientId recipientId; + private final long threadId; - private String draftText; - private List media; - private StickerLocator stickerLocator; - private boolean isBorderless; - private int distributionType = ThreadTable.DistributionTypes.DEFAULT; - private int startingPosition = -1; - private Uri dataUri; - private String dataType; - private boolean firstTimeInSelfCreatedGroup; - private boolean withSearchOpen; - private Badge giftBadge; - private long shareDataTimestamp = -1L; + private String draftText; + private List media; + private StickerLocator stickerLocator; + private boolean isBorderless; + private int distributionType = ThreadTable.DistributionTypes.DEFAULT; + private int startingPosition = -1; + private Uri dataUri; + private String dataType; + private boolean firstTimeInSelfCreatedGroup; + private boolean withSearchOpen; + private Badge giftBadge; + private long shareDataTimestamp = -1L; + private ConversationScreenType conversationScreenType; private Builder(@NonNull Context context, @NonNull RecipientId recipientId, long threadId) { - this(context, ConversationActivity.class, recipientId, threadId); + this( + context, + getBaseConversationActivity(), + recipientId, + threadId + ); } private Builder(@NonNull Context context, - @NonNull Class conversationActivityClass, + @NonNull Class conversationActivityClass, @NonNull RecipientId recipientId, long threadId) { this.context = context; this.conversationActivityClass = conversationActivityClass; this.recipientId = recipientId; - this.threadId = threadId; + this.threadId = resolveThreadId(recipientId, threadId); + this.conversationScreenType = ConversationScreenType.fromActivityClass(conversationActivityClass); } public @NonNull Builder withDraftText(@Nullable String draftText) { @@ -309,7 +332,7 @@ public class ConversationIntents { this.firstTimeInSelfCreatedGroup = true; return this; } - + public Builder withGiftBadge(@NonNull Badge badge) { this.giftBadge = badge; return this; @@ -319,7 +342,7 @@ public class ConversationIntents { this.shareDataTimestamp = timestamp; return this; } - + public @NonNull Intent build() { if (stickerLocator != null && media != null) { throw new IllegalStateException("Cannot have both sticker and media array"); @@ -347,6 +370,7 @@ public class ConversationIntents { intent.putExtra(EXTRA_WITH_SEARCH_OPEN, withSearchOpen); intent.putExtra(EXTRA_GIFT_BADGE, giftBadge); intent.putExtra(EXTRA_SHARE_DATA_TIMESTAMP, shareDataTimestamp); + intent.putExtra(EXTRA_CONVERSATION_TYPE, conversationScreenType.code); if (draftText != null) { intent.putExtra(EXTRA_TEXT, draftText); @@ -371,4 +395,62 @@ public class ConversationIntents { return intent; } } + + public enum ConversationScreenType { + NORMAL(0), + BUBBLE(1), + POPUP(2); + + private final int code; + + ConversationScreenType(int code) { + this.code = code; + } + + public boolean isInBubble() { + return Objects.equals(this, BUBBLE); + } + + public boolean isNormal() { + return Objects.equals(this, NORMAL); + } + + private static @NonNull ConversationScreenType from(int code) { + for (ConversationScreenType type : values()) { + if (type.code == code) { + return type; + } + } + + return NORMAL; + } + + private static @NonNull ConversationScreenType fromActivityClass(Class activityClass) { + if (Objects.equals(activityClass, ConversationPopupActivity.class)) { + return POPUP; + } else if (Objects.equals(activityClass, BubbleConversationActivity.class)) { + return BUBBLE; + } else { + return NORMAL; + } + } + } + + private static long resolveThreadId(@NonNull RecipientId recipientId, long threadId) { + if (threadId >= 0 && SignalStore.internalValues().useConversationFragmentV2()) { + Log.w(TAG, "Getting thread id from database..."); + // TODO [alex] -- Yes, this hits the database. No, we shouldn't be doing this. + return SignalDatabase.threads().getOrCreateThreadIdFor(Recipient.resolved(recipientId)); + } else { + return threadId; + } + } + + private static Class getBaseConversationActivity() { + if (SignalStore.internalValues().useConversationFragmentV2()) { + return ConversationActivity.class; + } else { + return org.thoughtcrime.securesms.conversation.ConversationActivity.class; + } + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java index 96f224d70a..43a8069066 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java @@ -35,13 +35,13 @@ import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.schedulers.Schedulers; -class ConversationRepository { +public class ConversationRepository { private static final String TAG = Log.tag(ConversationRepository.class); private final Context context; - ConversationRepository() { + public ConversationRepository() { this.context = ApplicationDependencies.getApplication(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt new file mode 100644 index 0000000000..357bf35171 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivity.kt @@ -0,0 +1,35 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.content.Intent +import androidx.fragment.app.Fragment +import org.thoughtcrime.securesms.components.FragmentWrapperActivity +import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaController +import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner +import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme + +/** + * Wrapper activity for ConversationFragment. + */ +class ConversationActivity : FragmentWrapperActivity(), VoiceNoteMediaControllerOwner { + + private val theme = DynamicNoActionBarTheme() + override val voiceNoteMediaController = VoiceNoteMediaController(this, true) + + override fun onPreCreate() { + theme.onCreate(this) + } + + override fun onResume() { + super.onResume() + theme.onResume(this) + } + + override fun getFragment(): Fragment = ConversationFragment().apply { + arguments = intent.extras + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + error("ON NEW INTENT") + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt new file mode 100644 index 0000000000..a48700b11d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationDialogs.kt @@ -0,0 +1,23 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.content.Context +import android.content.DialogInterface +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.thoughtcrime.securesms.R + +/** + * Centralized object for displaying dialogs to the user from the + * conversation fragment. + */ +object ConversationDialogs { + /** + * Dialog which is displayed when the user attempts to start a video call + * as a non-admin in an announcement group. + */ + fun displayCannotStartGroupCallDueToPermissionsDialog(context: Context) { + MaterialAlertDialogBuilder(context).setTitle(R.string.ConversationActivity_cant_start_group_call) + .setMessage(R.string.ConversationActivity_only_admins_of_this_group_can_start_a_call) + .setPositiveButton(R.string.ok) { d: DialogInterface, w: Int -> d.dismiss() } + .show() + } +} 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 new file mode 100644 index 0000000000..df599eb4bb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -0,0 +1,634 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.net.Uri +import android.os.Bundle +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.core.app.ActivityCompat +import androidx.core.app.ActivityOptionsCompat +import androidx.core.content.ContextCompat +import androidx.core.view.ViewCompat +import androidx.fragment.app.viewModels +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.RecyclerView +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.kotlin.subscribeBy +import org.greenrobot.eventbus.EventBus +import org.signal.core.util.concurrent.LifecycleDisposable +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.LoggingFragment +import org.thoughtcrime.securesms.MainActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.ViewBinderDelegate +import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager +import org.thoughtcrime.securesms.components.voice.VoiceNoteMediaControllerOwner +import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState +import org.thoughtcrime.securesms.contactshare.Contact +import org.thoughtcrime.securesms.contactshare.ContactUtil +import org.thoughtcrime.securesms.contactshare.SharedContactDetailsActivity +import org.thoughtcrime.securesms.conversation.ConversationAdapter +import org.thoughtcrime.securesms.conversation.ConversationIntents +import org.thoughtcrime.securesms.conversation.ConversationIntents.ConversationScreenType +import org.thoughtcrime.securesms.conversation.ConversationItem +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.conversation.ConversationOptionsMenu +import org.thoughtcrime.securesms.conversation.MarkReadHelper +import org.thoughtcrime.securesms.conversation.colors.Colorizer +import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemDecoration +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart +import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupCallViewModel +import org.thoughtcrime.securesms.conversation.v2.groups.ConversationGroupViewModel +import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.databinding.V2ConversationFragmentBinding +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange +import org.thoughtcrime.securesms.invites.InviteActions +import org.thoughtcrime.securesms.linkpreview.LinkPreview +import org.thoughtcrime.securesms.longmessage.LongMessageFragment +import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.notifications.v2.ConversationId +import org.thoughtcrime.securesms.ratelimit.RecaptchaProofBottomSheetFragment +import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment +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.stickers.StickerLocator +import org.thoughtcrime.securesms.stickers.StickerPackPreviewActivity +import org.thoughtcrime.securesms.util.CommunicationActions +import org.thoughtcrime.securesms.util.ContextUtil +import org.thoughtcrime.securesms.util.DrawableUtil +import org.thoughtcrime.securesms.util.FullscreenHelper +import org.thoughtcrime.securesms.util.WindowUtil +import org.thoughtcrime.securesms.util.fragments.requireListener +import org.thoughtcrime.securesms.util.visible +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper +import org.thoughtcrime.securesms.wallpaper.ChatWallpaperDimLevelUtil +import java.util.Locale + +/** + * A single unified fragment for Conversations. + */ +class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) { + + companion object { + private val TAG = Log.tag(ConversationFragment::class.java) + } + + private val args: ConversationIntents.Args by lazy { + ConversationIntents.Args.from(requireArguments()) + } + + private val disposables = LifecycleDisposable() + private val binding by ViewBinderDelegate(V2ConversationFragmentBinding::bind) + private val viewModel: ConversationViewModel by viewModels( + factoryProducer = { + ConversationViewModel.Factory(args, ConversationRepository(requireContext())) + } + ) + + private val groupCallViewModel: ConversationGroupCallViewModel by viewModels( + factoryProducer = { + ConversationGroupCallViewModel.Factory(args.threadId) + } + ) + + private val conversationGroupViewModel: ConversationGroupViewModel by viewModels( + factoryProducer = { + ConversationGroupViewModel.Factory(args.threadId) + } + ) + + private val conversationTooltips = ConversationTooltips(this) + private lateinit var conversationOptionsMenuProvider: ConversationOptionsMenu.Provider + private lateinit var layoutManager: SmoothScrollingLinearLayoutManager + private lateinit var markReadHelper: MarkReadHelper + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + conversationOptionsMenuProvider = ConversationOptionsMenu.Provider(ConversationOptionsMenuCallback(), disposables) + markReadHelper = MarkReadHelper(ConversationId.forConversation(args.threadId), requireContext(), viewLifecycleOwner) + + FullscreenHelper(requireActivity()).showSystemUI() + + layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true) + binding.conversationItemRecycler.layoutManager = layoutManager + + val recyclerViewColorizer = RecyclerViewColorizer(binding.conversationItemRecycler) + recyclerViewColorizer.setChatColors(args.chatColors) + + val conversationToolbarOnScrollHelper = ConversationToolbarOnScrollHelper( + requireActivity(), + binding.toolbar, + viewModel::wallpaperSnapshot + ) + conversationToolbarOnScrollHelper.attach(binding.conversationItemRecycler) + + disposables.bindTo(viewLifecycleOwner) + disposables += viewModel.recipient + .firstOrError() + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy(onSuccess = { + onFirstRecipientLoad(it) + }) + + presentWallpaper(args.wallpaper) + disposables += viewModel.recipient + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy(onNext = { + recyclerViewColorizer.setChatColors(it.chatColors) + presentWallpaper(it.wallpaper) + presentConversationTitle(it) + }) + + EventBus.getDefault().registerForLifecycle(groupCallViewModel, viewLifecycleOwner) + presentGroupCallJoinButton() + } + + override fun onResume() { + super.onResume() + + WindowUtil.setLightNavigationBarFromTheme(requireActivity()) + WindowUtil.setLightStatusBarFromTheme(requireActivity()) + groupCallViewModel.peekGroupCall() + } + + private fun onFirstRecipientLoad(recipient: Recipient) { + Log.d(TAG, "onFirstRecipientLoad") + + val colorizer = Colorizer() + val adapter = ConversationAdapter( + requireContext(), + viewLifecycleOwner, + GlideApp.with(this), + Locale.getDefault(), + ConversationItemClickListener(), + recipient, + colorizer + ) + + adapter.setPagingController(viewModel.pagingController) + viewLifecycleOwner.lifecycle.addObserver(LastSeenPositionUpdater(adapter, layoutManager, viewModel)) + binding.conversationItemRecycler.adapter = adapter + + binding.conversationItemRecycler.addItemDecoration( + MultiselectItemDecoration( + requireContext() + ) { viewModel.wallpaperSnapshot } + ) + + disposables += viewModel + .conversationThreadState + .flatMap { it.items.data } + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy(onNext = { + adapter.submitList(it) + }) + + disposables += viewModel + .nameColorsMap + .observeOn(AndroidSchedulers.mainThread()) + .subscribeBy(onNext = { + colorizer.onNameColorsChanged(it) + adapter.notifyItemRangeChanged(0, adapter.itemCount) + }) + + presentActionBarMenu() + } + + private fun invalidateOptionsMenu() { + // TODO [alex] -- Handle search... is there a better way to manage this state? Maybe an event system? + conversationOptionsMenuProvider.onCreateMenu(binding.toolbar.menu, requireActivity().menuInflater) + } + + private fun presentActionBarMenu() { + invalidateOptionsMenu() + + when (args.conversationScreenType) { + ConversationScreenType.NORMAL -> presentNavigationIconForNormal() + ConversationScreenType.BUBBLE -> presentNavigationIconForBubble() + ConversationScreenType.POPUP -> Unit + } + + binding.toolbar.setOnMenuItemClickListener(conversationOptionsMenuProvider::onMenuItemSelected) + } + + private fun presentNavigationIconForNormal() { + binding.toolbar.setNavigationIcon(R.drawable.ic_arrow_left_24) + binding.toolbar.setNavigationOnClickListener { + requireActivity().finishAfterTransition() + } + } + + private fun presentNavigationIconForBubble() { + binding.toolbar.navigationIcon = DrawableUtil.tint( + ContextUtil.requireDrawable(requireContext(), R.drawable.ic_notification), + ContextCompat.getColor(requireContext(), R.color.signal_accent_primary) + ) + + binding.toolbar.setNavigationOnClickListener { + startActivity(MainActivity.clearTop(requireContext())) + } + } + + private fun presentConversationTitle(recipient: Recipient) { + binding.conversationTitleView.root.setTitle(GlideApp.with(this), recipient) + } + + private fun presentWallpaper(chatWallpaper: ChatWallpaper?) { + if (chatWallpaper != null) { + chatWallpaper.loadInto(binding.conversationWallpaper) + ChatWallpaperDimLevelUtil.applyDimLevelForNightMode(binding.conversationWallpaperDim, chatWallpaper) + } else { + binding.conversationWallpaperDim.visible = false + } + + binding.conversationWallpaper.visible = chatWallpaper != null + } + + private fun presentGroupCallJoinButton() { + binding.conversationGroupCallJoin.setOnClickListener { + handleVideoCall() + } + + disposables += groupCallViewModel.hasActiveGroupCall.subscribeBy(onNext = { + // invalidateOptionsMenu + binding.conversationGroupCallJoin.visible = it + }) + + disposables += groupCallViewModel.hasCapacity.subscribeBy(onNext = { + binding.conversationGroupCallJoin.setText( + if (it) R.string.ConversationActivity_join else R.string.ConversationActivity_full + ) + }) + } + + private fun handleVideoCall() { + val recipient: Single = viewModel.recipient.firstOrError() + val hasActiveGroupCall: Single = groupCallViewModel.hasActiveGroupCall.firstOrError() + val isNonAdminInAnnouncementGroup: Boolean = conversationGroupViewModel.isNonAdminInAnnouncementGroup() + val cannotCreateGroupCall = Single.zip(recipient, hasActiveGroupCall) { r, active -> + r to (r.isPushV2Group && !active && isNonAdminInAnnouncementGroup) + } + + disposables += cannotCreateGroupCall + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { (recipient, notAllowed) -> + if (notAllowed) { + ConversationDialogs.displayCannotStartGroupCallDueToPermissionsDialog(requireContext()) + } else { + CommunicationActions.startVideoCall(this, recipient) + } + } + } + + private fun getVoiceNoteMediaController() = requireListener().voiceNoteMediaController + + private inner class ConversationItemClickListener : ConversationAdapter.ItemClickListener { + override fun onQuoteClicked(messageRecord: MmsMessageRecord?) { + // TODO [alex] - ("Not yet implemented") + } + + override fun onLinkPreviewClicked(linkPreview: LinkPreview) { + val activity = activity ?: return + CommunicationActions.openBrowserLink(activity, linkPreview.url) + } + + override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) { + // TODO [alex] - ("Not yet implemented") + } + + override fun onMoreTextClicked(conversationRecipientId: RecipientId, messageId: Long, isMms: Boolean) { + context ?: return + LongMessageFragment.create(messageId, isMms).show(childFragmentManager, null) + } + + override fun onStickerClicked(stickerLocator: StickerLocator) { + context ?: return + startActivity(StickerPackPreviewActivity.getIntent(stickerLocator.packId, stickerLocator.packKey)) + } + + override fun onViewOnceMessageClicked(messageRecord: MmsMessageRecord) { + // TODO [alex] - ("Not yet implemented") + } + + override fun onSharedContactDetailsClicked(contact: Contact, avatarTransitionView: View) { + val activity = activity ?: return + ViewCompat.setTransitionName(avatarTransitionView, "avatar") + val bundle = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, avatarTransitionView, "avatar").toBundle() + ActivityCompat.startActivity(activity, SharedContactDetailsActivity.getIntent(activity, contact), bundle) + } + + override fun onAddToContactsClicked(contact: Contact) { + // TODO [alex] - ("Not yet implemented") + } + + override fun onMessageSharedContactClicked(choices: MutableList) { + val context = context ?: return + ContactUtil.selectRecipientThroughDialog(context, choices, Locale.getDefault()) { recipient: Recipient -> + CommunicationActions.startConversation(context, recipient, null) + } + } + + override fun onInviteSharedContactClicked(choices: MutableList) { + val context = context ?: return + ContactUtil.selectRecipientThroughDialog(context, choices, Locale.getDefault()) { recipient: Recipient -> + CommunicationActions.composeSmsThroughDefaultApp( + context, + recipient, + getString(R.string.InviteActivity_lets_switch_to_signal, getString(R.string.install_url)) + ) + } + } + + override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) { + parentFragment ?: return + ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(parentFragmentManager, null) + } + + override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) { + parentFragment ?: return + RecipientBottomSheetDialogFragment.create(recipientId, groupId).show(parentFragmentManager, "BOTTOM") + } + + override fun onMessageWithErrorClicked(messageRecord: MessageRecord) { + // TODO [alex] - ("Not yet implemented") + } + + override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) { + RecaptchaProofBottomSheetFragment.show(childFragmentManager) + } + + override fun onIncomingIdentityMismatchClicked(recipientId: RecipientId) { + SafetyNumberBottomSheet.forRecipientId(recipientId).show(parentFragmentManager) + } + + override fun onRegisterVoiceNoteCallbacks(onPlaybackStartObserver: Observer) { + getVoiceNoteMediaController() + .voiceNotePlaybackState + .observe(viewLifecycleOwner, onPlaybackStartObserver) + } + + override fun onUnregisterVoiceNoteCallbacks(onPlaybackStartObserver: Observer) { + getVoiceNoteMediaController() + .voiceNotePlaybackState + .removeObserver(onPlaybackStartObserver) + } + + override fun onVoiceNotePause(uri: Uri) { + getVoiceNoteMediaController().pausePlayback(uri) + } + + override fun onVoiceNotePlay(uri: Uri, messageId: Long, position: Double) { + getVoiceNoteMediaController().startConsecutivePlayback(uri, messageId, position) + } + + override fun onVoiceNoteSeekTo(uri: Uri, position: Double) { + getVoiceNoteMediaController().seekToPosition(uri, position) + } + + override fun onVoiceNotePlaybackSpeedChanged(uri: Uri, speed: Float) { + getVoiceNoteMediaController().setPlaybackSpeed(uri, speed) + } + + override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) { + // TODO [alex] -- ("Not yet implemented") + } + + override fun onChatSessionRefreshLearnMoreClicked() { + // TODO [alex] -- ("Not yet implemented") + } + + override fun onBadDecryptLearnMoreClicked(author: RecipientId) { + // TODO [alex] -- ("Not yet implemented") + } + + override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) { + // TODO [alex] -- ("Not yet implemented") + } + + override fun onJoinGroupCallClicked() { + // TODO [alex] -- ("Not yet implemented") + } + + override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) { + // TODO [alex] -- ("Not yet implemented") + } + + override fun onEnableCallNotificationsClicked() { + // TODO [alex] -- ("Not yet implemented") + } + + override fun onPlayInlineContent(conversationMessage: ConversationMessage?) { + // TODO [alex] - ("Not yet implemented") + } + + override fun onInMemoryMessageClicked(messageRecord: InMemoryMessageRecord) { + // TODO [alex] - ("Not yet implemented") + } + + override fun onViewGroupDescriptionChange(groupId: GroupId?, description: String, isMessageRequestAccepted: Boolean) { + // TODO [alex] - ("Not yet implemented") + } + + override fun onChangeNumberUpdateContact(recipient: Recipient) { + // TODO [alex] - ("Not yet implemented") + } + + override fun onCallToAction(action: String) { + // TODO [alex] - ("Not yet implemented") + } + + override fun onDonateClicked() { + // TODO [alex] - ("Not yet implemented") + } + + override fun onBlockJoinRequest(recipient: Recipient) { + // TODO [alex] - ("Not yet implemented") + } + + override fun onRecipientNameClicked(target: RecipientId) { + // TODO [alex] ("Not yet implemented") + } + + override fun onInviteToSignalClicked() { + val recipient = viewModel.recipientSnapshot ?: return + InviteActions.inviteUserToSignal( + requireContext(), + recipient, + {}, // TODO [alex] -- append to compose + this@ConversationFragment::startActivity + ) + } + + override fun onActivatePaymentsClicked() { + // TODO [alex] -- ("Not yet implemented") + } + + override fun onSendPaymentClicked(recipientId: RecipientId) { + // TODO [alex] -- ("Not yet implemented") + } + + override fun onScheduledIndicatorClicked(view: View, messageRecord: MessageRecord) { + // TODO [alex] -- ("Not yet implemented") + } + + override fun onUrlClicked(url: String): Boolean { + return CommunicationActions.handlePotentialGroupLinkUrl(requireActivity(), url) || + CommunicationActions.handlePotentialProxyLinkUrl(requireActivity(), url) + } + + override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) { + // TODO [alex] -- ("Not yet implemented") + } + + override fun onGiftBadgeRevealed(messageRecord: MessageRecord) { + // TODO [alex] -- ("Not yet implemented") + } + + override fun goToMediaPreview(parent: ConversationItem?, sharedElement: View?, args: MediaIntentFactory.MediaPreviewArgs?) { + // TODO [alex] -- ("Not yet implemented") + } + + override fun onItemClick(item: MultiselectPart?) { + // TODO [alex] -- ("Not yet implemented") + } + + override fun onItemLongClick(itemView: View?, item: MultiselectPart?) { + // TODO [alex] -- ("Not yet implemented") + } + } + + private inner class ConversationOptionsMenuCallback : ConversationOptionsMenu.Callback { + override fun getSnapshot(): ConversationOptionsMenu.Snapshot { + val recipient: Recipient? = viewModel.recipientSnapshot + return ConversationOptionsMenu.Snapshot( + recipient = recipient, + isPushAvailable = true, // TODO [alex] + canShowAsBubble = Observable.empty(), + isActiveGroup = recipient?.isActiveGroup == true, + isActiveV2Group = recipient?.let { it.isActiveGroup && it.isPushV2Group } == true, + isInActiveGroup = recipient?.isActiveGroup == false, + hasActiveGroupCall = groupCallViewModel.hasActiveGroupCallSnapshot, + distributionType = args.distributionType, + threadId = args.threadId, + isInMessageRequest = false, // TODO [alex] + isInBubble = args.conversationScreenType.isInBubble + ) + } + + override fun onOptionsMenuCreated(menu: Menu) { + // TODO [alex] + } + + override fun handleVideo() { + this@ConversationFragment.handleVideoCall() + } + + override fun handleDial(isSecure: Boolean) { + // TODO [alex] - ("Not yet implemented") + } + + override fun handleViewMedia() { + // TODO [alex] - ("Not yet implemented") + } + + override fun handleAddShortcut() { + // TODO [alex] - ("Not yet implemented") + } + + override fun handleSearch() { + // TODO [alex] - ("Not yet implemented") + } + + override fun handleAddToContacts() { + // TODO [alex] - ("Not yet implemented") + } + + override fun handleDisplayGroupRecipients() { + // TODO [alex] - ("Not yet implemented") + } + + override fun handleDistributionBroadcastEnabled(menuItem: MenuItem) { + // TODO [alex] - ("Not yet implemented") + } + + override fun handleDistributionConversationEnabled(menuItem: MenuItem) { + // TODO [alex] - ("Not yet implemented") + } + + override fun handleManageGroup() { + // TODO [alex] - ("Not yet implemented") + } + + override fun handleLeavePushGroup() { + // TODO [alex] - ("Not yet implemented") + } + + override fun handleInviteLink() { + // TODO [alex] - ("Not yet implemented") + } + + override fun handleMuteNotifications() { + // TODO [alex] - ("Not yet implemented") + } + + override fun handleUnmuteNotifications() { + // TODO [alex] - ("Not yet implemented") + } + + override fun handleConversationSettings() { + // TODO [alex] - ("Not yet implemented") + } + + override fun handleSelectMessageExpiration() { + // TODO [alex] - ("Not yet implemented") + } + + override fun handleCreateBubble() { + // TODO [alex] - ("Not yet implemented") + } + + override fun handleGoHome() { + // TODO [alex] - ("Not yet implemented") + } + + override fun showExpiring(recipient: Recipient) { + binding.conversationTitleView.root.showExpiring(recipient) + } + + override fun clearExpiring() { + binding.conversationTitleView.root.clearExpiring() + } + + override fun showGroupCallingTooltip() { + conversationTooltips.displayGroupCallingTooltip(requireView().findViewById(R.id.menu_video_secure)) + } + } + + private class LastSeenPositionUpdater( + val adapter: ConversationAdapter, + val layoutManager: SmoothScrollingLinearLayoutManager, + val viewModel: ConversationViewModel + ) : DefaultLifecycleObserver { + override fun onPause(owner: LifecycleOwner) { + val lastVisiblePosition = layoutManager.findLastVisibleItemPosition() + val firstVisiblePosition = layoutManager.findFirstCompletelyVisibleItemPosition() + val lastVisibleMessageTimestamp = if (firstVisiblePosition > 0 && lastVisiblePosition != RecyclerView.NO_POSITION) { + adapter.getLastVisibleConversationMessage(lastVisiblePosition)?.messageRecord?.dateReceived ?: 0L + } else { + 0L + } + + viewModel.setLastScrolled(lastVisibleMessageTimestamp) + } + } +} 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 new file mode 100644 index 0000000000..a0617d35ca --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt @@ -0,0 +1,127 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.content.Context +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.kotlin.subscribeBy +import io.reactivex.rxjava3.schedulers.Schedulers +import org.signal.core.util.concurrent.SignalExecutors +import org.signal.paging.PagedData +import org.signal.paging.PagingConfig +import org.thoughtcrime.securesms.conversation.ConversationDataSource +import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper +import org.thoughtcrime.securesms.conversation.colors.NameColor +import org.thoughtcrime.securesms.database.DatabaseObserver +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.SignalDatabase.Companion.threads +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import kotlin.math.max + +class ConversationRepository(context: Context) { + + private val applicationContext = context.applicationContext + private val oldConversationRepository = org.thoughtcrime.securesms.conversation.ConversationRepository() + + /** + * Observes the recipient tied to the given thread id, returning an error if + * the thread id does not exist or somehow does not have a recipient attached to it. + */ + fun observeRecipientForThread(threadId: Long): Observable { + return Observable.create { emitter -> + val recipientId = SignalDatabase.threads.getRecipientIdForThreadId(threadId) + + if (recipientId != null) { + val disposable = Recipient.live(recipientId).observable() + .subscribeOn(Schedulers.io()) + .subscribeBy(onNext = emitter::onNext) + + emitter.setCancellable { + disposable.dispose() + } + } else { + emitter.onError(Exception("Thread $threadId does not exist.")) + } + }.subscribeOn(Schedulers.io()) + } + + /** + * Loads the details necessary to display the conversation thread. + */ + fun getConversationThreadState(threadId: Long, requestedStartPosition: Int): Single { + return Single.create { emitter -> + val recipient = SignalDatabase.threads.getRecipientForThreadId(threadId)!! + val metadata = oldConversationRepository.getConversationData(threadId, recipient, requestedStartPosition) + val messageRequestData = metadata.messageRequestData + val startPosition = when { + metadata.shouldJumpToMessage() -> metadata.jumpToPosition + messageRequestData.isMessageRequestAccepted && metadata.shouldScrollToLastSeen() -> metadata.lastSeenPosition + messageRequestData.isMessageRequestAccepted -> metadata.lastScrolledPosition + else -> metadata.threadSize + } + val dataSource = ConversationDataSource( + applicationContext, + threadId, + messageRequestData, + metadata.showUniversalExpireTimerMessage, + metadata.threadSize + ) + val config = PagingConfig.Builder().setPageSize(25) + .setBufferPages(2) + .setStartIndex(max(startPosition, 0)) + .build() + + val threadState = ConversationThreadState( + items = PagedData.createForObservable(dataSource, config), + meta = metadata + ) + + val controller = threadState.items.controller + val messageUpdateObserver = DatabaseObserver.MessageObserver { + controller.onDataItemChanged(it) + } + val messageInsertObserver = DatabaseObserver.MessageObserver { + controller.onDataItemInserted(it, 0) + } + val conversationObserver = DatabaseObserver.Observer { + controller.onDataInvalidated() + } + + ApplicationDependencies.getDatabaseObserver().registerMessageUpdateObserver(messageUpdateObserver) + ApplicationDependencies.getDatabaseObserver().registerMessageInsertObserver(threadId, messageInsertObserver) + ApplicationDependencies.getDatabaseObserver().registerConversationObserver(threadId, conversationObserver) + + emitter.setCancellable { + ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageUpdateObserver) + ApplicationDependencies.getDatabaseObserver().unregisterObserver(messageInsertObserver) + ApplicationDependencies.getDatabaseObserver().unregisterObserver(conversationObserver) + } + + emitter.onSuccess(threadState) + } + } + + /** + * Generates the name color-map for groups. + */ + fun getNameColorsMap( + recipient: Recipient, + groupAuthorNameColorHelper: GroupAuthorNameColorHelper + ): Observable> { + return Recipient.observable(recipient.id) + .distinctUntilChanged { a, b -> a.participantIds == b.participantIds } + .map { + if (it.groupId.isPresent) { + groupAuthorNameColorHelper.getColorMap(it.requireGroupId()) + } else { + emptyMap() + } + } + .subscribeOn(Schedulers.io()) + } + + fun setLastVisibleMessageTimestamp(threadId: Long, lastVisibleMessageTimestamp: Long) { + SignalExecutors.BOUNDED.submit { threads.setLastScrolled(threadId, lastVisibleMessageTimestamp) } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationThreadState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationThreadState.kt new file mode 100644 index 0000000000..542ff542c7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationThreadState.kt @@ -0,0 +1,15 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import org.signal.paging.ObservablePagedData +import org.thoughtcrime.securesms.conversation.ConversationData +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.database.model.MessageId + +/** + * Represents the content that will be displayed in the conversation + * thread (recycler). + */ +class ConversationThreadState( + val items: ObservablePagedData, + val meta: ConversationData +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationToolbarOnScrollHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationToolbarOnScrollHelper.kt new file mode 100644 index 0000000000..d94ed66d23 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationToolbarOnScrollHelper.kt @@ -0,0 +1,37 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.app.Activity +import android.view.View +import androidx.annotation.ColorRes +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.util.Material3OnScrollHelper +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper + +/** + * Scroll helper to manage the color state of the top bar and status bar. + */ +class ConversationToolbarOnScrollHelper( + activity: Activity, + toolbarBackground: View, + private val wallpaperProvider: () -> ChatWallpaper? +) : Material3OnScrollHelper( + activity, + listOf(toolbarBackground), + emptyList() +) { + override val activeColorSet: ColorSet + get() = ColorSet(getActiveToolbarColor(wallpaperProvider() != null)) + + override val inactiveColorSet: ColorSet + get() = ColorSet(getInactiveToolbarColor(wallpaperProvider() != null)) + + @ColorRes + private fun getActiveToolbarColor(hasWallpaper: Boolean): Int { + return if (hasWallpaper) R.color.conversation_toolbar_color_wallpaper_scrolled else R.color.signal_colorSurface2 + } + + @ColorRes + private fun getInactiveToolbarColor(hasWallpaper: Boolean): Int { + return if (hasWallpaper) R.color.conversation_toolbar_color_wallpaper else R.color.signal_colorBackground + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationTooltips.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationTooltips.kt new file mode 100644 index 0000000000..24148445ea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationTooltips.kt @@ -0,0 +1,60 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import android.view.View +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.ViewModel +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.TooltipPopup +import org.thoughtcrime.securesms.keyvalue.SignalStore + +/** + * Any and all tooltips that the conversation can display, and a light amount of related presentation logic. + */ +class ConversationTooltips(fragment: Fragment) { + companion object { + private val TAG = Log.tag(ConversationTooltips::class.java) + } + + private val viewModel: TooltipViewModel by fragment.viewModels() + + /** + * Displays the tooltip notifying the user that they can begin a group call. Also + * performs the necessary record-keeping and checks to ensure we don't display it + * if we shouldn't. There is a set of callbacks which should be used to preserve + * session state for this tooltip. + * + * @param anchor The view this will be displayed underneath. If the view is not ready, we will skip. + */ + fun displayGroupCallingTooltip( + anchor: View? + ) { + if (viewModel.hasDisplayedCallingTooltip || !SignalStore.tooltips().shouldShowGroupCallingTooltip()) { + return + } + + if (anchor == null) { + Log.w(TAG, "Group calling tooltip anchor is null. Skipping tooltip.") + return + } + + viewModel.hasDisplayedCallingTooltip = true + + SignalStore.tooltips().markGroupCallSpeakerViewSeen() + TooltipPopup.forTarget(anchor) + .setBackgroundTint(ContextCompat.getColor(anchor.context, R.color.signal_accent_green)) + .setTextColor(ContextCompat.getColor(anchor.context, R.color.core_white)) + .setText(R.string.ConversationActivity__tap_here_to_start_a_group_call) + .setOnDismissListener { SignalStore.tooltips().markGroupCallingTooltipSeen() } + .show(TooltipPopup.POSITION_BELOW) + } + + /** + * ViewModel which holds different bits of session-local persistent state for different tooltips. + */ + class TooltipViewModel : ViewModel() { + var hasDisplayedCallingTooltip: Boolean = false + } +} 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 new file mode 100644 index 0000000000..4567966c18 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.kotlin.subscribeBy +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.Subject +import org.signal.paging.ProxyPagingController +import org.thoughtcrime.securesms.conversation.ConversationIntents.Args +import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper +import org.thoughtcrime.securesms.conversation.colors.NameColor +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.wallpaper.ChatWallpaper + +/** + * ConversationViewModel, which operates solely off of a thread id that never changes. + */ +class ConversationViewModel( + private val threadId: Long, + requestedStartingPosition: Int, + private val repository: ConversationRepository +) : ViewModel() { + + private val disposables = CompositeDisposable() + private val groupAuthorNameColorHelper = GroupAuthorNameColorHelper() + + private val _recipient: BehaviorSubject = BehaviorSubject.create() + val recipient: Observable = _recipient + + private val _conversationThreadState: Subject = BehaviorSubject.create() + val conversationThreadState: Observable = _conversationThreadState + + val pagingController = ProxyPagingController() + + val nameColorsMap: Observable> = _recipient.flatMap { repository.getNameColorsMap(it, groupAuthorNameColorHelper) } + + val recipientSnapshot: Recipient? + get() = _recipient.value + + val wallpaperSnapshot: ChatWallpaper? + get() = _recipient.value?.wallpaper + + init { + disposables += repository.observeRecipientForThread(threadId) + .subscribeBy(onNext = _recipient::onNext) + + disposables += repository.getConversationThreadState(threadId, requestedStartingPosition) + .subscribeBy(onSuccess = { + pagingController.set(it.items.controller) + _conversationThreadState.onNext(it) + }) + } + + override fun onCleared() { + disposables.clear() + } + + fun setLastScrolled(lastScrolledTimestamp: Long) { + repository.setLastVisibleMessageTimestamp( + threadId, + lastScrolledTimestamp + ) + } + + class Factory( + private val args: Args, + private val repository: ConversationRepository + ) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(ConversationViewModel(args.threadId, args.startingPosition, repository)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/EventBusExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/EventBusExtensions.kt new file mode 100644 index 0000000000..d7187d5cd3 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/EventBusExtensions.kt @@ -0,0 +1,34 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import org.greenrobot.eventbus.EventBus +import org.signal.core.util.logging.Log + +/** + * Set up a lifecycle aware register/deregister for the lifecycleowner. + */ +fun EventBus.registerForLifecycle(subscriber: Any, lifecycleOwner: LifecycleOwner) { + val registration = LifecycleAwareRegistration(subscriber, this) + lifecycleOwner.lifecycle.addObserver(registration) +} + +private class LifecycleAwareRegistration( + private val subscriber: Any, + private val bus: EventBus +) : DefaultLifecycleObserver { + + companion object { + private val TAG = Log.tag(LifecycleAwareRegistration::class.java) + } + + override fun onResume(owner: LifecycleOwner) { + Log.d(TAG, "Registering owner.") + bus.register(subscriber) + } + + override fun onPause(owner: LifecycleOwner) { + Log.d(TAG, "Unregistering owner.") + bus.unregister(subscriber) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupActiveState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupActiveState.kt new file mode 100644 index 0000000000..80c5e84750 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupActiveState.kt @@ -0,0 +1,11 @@ +package org.thoughtcrime.securesms.conversation.v2.groups + +/** + * Represents the 'active' state of a group. + */ +data class ConversationGroupActiveState( + val isActive: Boolean, + private val isV2: Boolean +) { + val isActiveV2: Boolean = isActive && isV2 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupCallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupCallViewModel.kt new file mode 100644 index 0000000000..ab0ac7be5a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupCallViewModel.kt @@ -0,0 +1,110 @@ +package org.thoughtcrime.securesms.conversation.v2.groups + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.kotlin.subscribeBy +import io.reactivex.rxjava3.processors.PublishProcessor +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.Subject +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.events.GroupCallPeekEvent +import org.thoughtcrime.securesms.recipients.Recipient + +/** + * ViewModel which manages state associated with group calls. + */ +class ConversationGroupCallViewModel(threadId: Long) : ViewModel() { + + companion object { + private val TAG = Log.tag(ConversationGroupCallViewModel::class.java) + } + + private val _isGroupActive: Subject = BehaviorSubject.createDefault(false) + private val _hasOngoingGroupCall: Subject = BehaviorSubject.createDefault(false) + private val _hasCapacity: Subject = BehaviorSubject.createDefault(false) + private val _hasActiveGroupCall: BehaviorSubject = BehaviorSubject.create() + private val _recipient: BehaviorSubject = BehaviorSubject.create() + private val _groupCallPeekEventProcessor: PublishProcessor = PublishProcessor.create() + private val _peekRequestProcessor: PublishProcessor = PublishProcessor.create() + private val disposables = CompositeDisposable() + + val hasActiveGroupCall: Observable = _hasActiveGroupCall.observeOn(AndroidSchedulers.mainThread()) + val hasCapacity: Observable = _hasCapacity.observeOn(AndroidSchedulers.mainThread()) + + val hasActiveGroupCallSnapshot: Boolean + get() = _hasActiveGroupCall.value == true + + init { + disposables += Observable + .combineLatest(_isGroupActive, _hasActiveGroupCall) { a, b -> a && b } + .subscribeBy(onNext = _hasActiveGroupCall::onNext) + + disposables += Single + .fromCallable { SignalDatabase.threads.getRecipientForThreadId(threadId)!! } + .subscribeOn(Schedulers.io()) + .filter { it.isPushV2Group } + .flatMapObservable { Recipient.live(it.id).observable() } + .subscribeBy(onNext = _recipient::onNext) + + disposables += _recipient + .map { it.isActiveGroup } + .distinctUntilChanged() + .subscribeBy(onNext = _isGroupActive::onNext) + + disposables += _recipient + .firstOrError() + .subscribeBy(onSuccess = { + peekGroupCall() + }) + + disposables += _groupCallPeekEventProcessor + .onBackpressureLatest() + .switchMap { event -> + _recipient.firstElement().map { it.id }.filter { it == event.groupRecipientId }.map { event }.toFlowable() + } + .subscribeBy(onNext = { + Log.i(TAG, "update UI with call event: ongoing call: " + it.isOngoing + " hasCapacity: " + it.callHasCapacity()) + _hasOngoingGroupCall.onNext(it.isOngoing) + _hasCapacity.onNext(it.callHasCapacity()) + }) + + disposables += _peekRequestProcessor + .onBackpressureLatest() + .switchMap { + _recipient.firstOrError().map { it.id }.toFlowable() + } + .subscribeBy(onNext = { recipientId -> + Log.i(TAG, "peek call for $recipientId") + ApplicationDependencies.getSignalCallManager().peekGroupCall(recipientId) + }) + } + + override fun onCleared() { + disposables.clear() + } + + @Subscribe(threadMode = ThreadMode.MAIN, sticky = true) + fun onGroupCallPeekEvent(groupCallPeekEvent: GroupCallPeekEvent) { + _groupCallPeekEventProcessor.onNext(groupCallPeekEvent) + } + + fun peekGroupCall() { + _peekRequestProcessor.onNext(Unit) + } + + class Factory(private val threadId: Long) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(ConversationGroupCallViewModel(threadId)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupMemberLevel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupMemberLevel.kt new file mode 100644 index 0000000000..b9f47aab6a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupMemberLevel.kt @@ -0,0 +1,12 @@ +package org.thoughtcrime.securesms.conversation.v2.groups + +import org.thoughtcrime.securesms.database.GroupTable + +/** + * @param groupTableMemberLevel Self membership level + * @param isAnnouncementGroup Whether the group is an announcement group. + */ +data class ConversationGroupMemberLevel( + val groupTableMemberLevel: GroupTable.MemberLevel, + val isAnnouncementGroup: Boolean +) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupReviewState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupReviewState.kt new file mode 100644 index 0000000000..09c3a20f5b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupReviewState.kt @@ -0,0 +1,22 @@ +package org.thoughtcrime.securesms.conversation.v2.groups + +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.recipients.Recipient + +/** + * Represents detected duplicate recipients that should be displayed + * to the user as a warning. + * + * @param groupId The groupId for the conversation + * @param recipient The first recipient in the list of duplicates + * @param count The number of duplicates + */ +data class ConversationGroupReviewState( + val groupId: GroupId.V2?, + val recipient: Recipient, + val count: Int +) { + companion object { + val EMPTY = ConversationGroupReviewState(null, Recipient.UNKNOWN, 0) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupViewModel.kt new file mode 100644 index 0000000000..e913b7131f --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/groups/ConversationGroupViewModel.kt @@ -0,0 +1,113 @@ +package org.thoughtcrime.securesms.conversation.v2.groups + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.kotlin.plusAssign +import io.reactivex.rxjava3.kotlin.subscribeBy +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.subjects.Subject +import org.thoughtcrime.securesms.database.GroupTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.GroupRecord +import org.thoughtcrime.securesms.groups.GroupsV1MigrationUtil +import org.thoughtcrime.securesms.profiles.spoofing.ReviewUtil +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * Manages group state and actions for conversations. + */ +class ConversationGroupViewModel( + private val threadId: Long +) : ViewModel() { + + private val disposables = CompositeDisposable() + private val _recipient: Subject = BehaviorSubject.create() + private val _groupRecord: Subject = BehaviorSubject.create() + private val _groupActiveState: Subject = BehaviorSubject.create() + private val _memberLevel: BehaviorSubject = BehaviorSubject.create() + private val _actionableRequestingMembersCount: Subject = BehaviorSubject.create() + private val _gv1MigrationSuggestions: Subject> = BehaviorSubject.create() + private val _reviewState: Subject = BehaviorSubject.create() + + init { + disposables += Single + .fromCallable { SignalDatabase.threads.getRecipientForThreadId(threadId)!! } + .subscribeOn(Schedulers.io()) + .filter { it.isGroup } + .flatMapObservable { Recipient.observable(it.id) } + .subscribeBy(onNext = _recipient::onNext) + + disposables += _recipient + .switchMap { + Observable.fromCallable { + SignalDatabase.groups.getGroup(it.id).get() + } + } + .subscribeBy(onNext = _groupRecord::onNext) + + val duplicates = _groupRecord.map { + if (it.isV2Group) { + ReviewUtil.getDuplicatedRecipients(it.id.requireV2()).map { it.recipient } + } else { + emptyList() + } + } + + disposables += Observable.combineLatest(_groupRecord, duplicates) { record, dupes -> + if (dupes.isEmpty()) { + ConversationGroupReviewState.EMPTY + } else { + ConversationGroupReviewState(record.id.requireV2(), dupes[0], dupes.size) + } + }.subscribeBy(onNext = _reviewState::onNext) + + disposables += _groupRecord.subscribe { groupRecord -> + _groupActiveState.onNext(ConversationGroupActiveState(groupRecord.isActive, groupRecord.isV2Group)) + _memberLevel.onNext(ConversationGroupMemberLevel(groupRecord.memberLevel(Recipient.self()), groupRecord.isAnnouncementGroup)) + _actionableRequestingMembersCount.onNext(getActionableRequestingMembersCount(groupRecord)) + _gv1MigrationSuggestions.onNext(getGv1MigrationSuggestions(groupRecord)) + } + } + + override fun onCleared() { + disposables.clear() + } + + fun isNonAdminInAnnouncementGroup(): Boolean { + val memberLevel = _memberLevel.value ?: return false + return memberLevel.groupTableMemberLevel != GroupTable.MemberLevel.ADMINISTRATOR && memberLevel.isAnnouncementGroup + } + + private fun getActionableRequestingMembersCount(groupRecord: GroupRecord): Int { + return if (groupRecord.isV2Group && groupRecord.memberLevel(Recipient.self()) == GroupTable.MemberLevel.ADMINISTRATOR) { + groupRecord.requireV2GroupProperties() + .decryptedGroup + .requestingMembersCount + } else { + 0 + } + } + + private fun getGv1MigrationSuggestions(groupRecord: GroupRecord): List { + return if (!groupRecord.isActive || !groupRecord.isV2Group || groupRecord.isPendingMember(Recipient.self())) { + emptyList() + } else { + groupRecord.unmigratedV1Members + .filterNot { groupRecord.members.contains(it) } + .map { Recipient.resolved(it) } + .filter { GroupsV1MigrationUtil.isAutoMigratable(it) } + .map { it.id } + } + } + + class Factory(private val threadId: Long) : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return modelClass.cast(ConversationGroupViewModel(threadId)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java index 2fc8af4174..caa828a851 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.java @@ -29,6 +29,7 @@ public final class InternalValues extends SignalStoreValues { public static final String DISABLE_STORAGE_SERVICE = "internal.disable_storage_service"; public static final String FORCE_WEBSOCKET_MODE = "internal.force_websocket_mode"; public static final String LAST_SCROLL_POSITION = "internal.last_scroll_position"; + public static final String CONVERSATION_FRAGMENT_V2 = "internal.conversation_fragment_v2"; InternalValues(KeyValueStore store) { super(store); @@ -189,4 +190,12 @@ public final class InternalValues extends SignalStoreValues { public int getLastScrollPosition() { return getInteger(LAST_SCROLL_POSITION, 0); } + + public void setUseConversationFragmentV2(boolean useConversationFragmentV2) { + putBoolean(CONVERSATION_FRAGMENT_V2, useConversationFragmentV2); + } + + public boolean useConversationFragmentV2() { + return FeatureFlags.internalUser() && getBoolean(CONVERSATION_FRAGMENT_V2, false); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java index 893e62c6f5..761f1452ee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java @@ -142,6 +142,7 @@ public class CommunicationActions { @Override protected void onPostExecute(@Nullable Long threadId) { + // TODO [alex] -- ThreadID should *always* exist ConversationIntents.Builder builder = ConversationIntents.createBuilder(context, recipient.getId(), threadId != null ? threadId : -1); if (!TextUtils.isEmpty(text)) { builder.withDraftText(text); diff --git a/app/src/main/res/layout/conversation_title_view.xml b/app/src/main/res/layout/conversation_title_view.xml index f4cecdcab0..e9b8ae4607 100644 --- a/app/src/main/res/layout/conversation_title_view.xml +++ b/app/src/main/res/layout/conversation_title_view.xml @@ -1,7 +1,6 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +