Merge V2 Conversation Fragment behind an internal setting.

This commit is contained in:
Alex Hart 2023-04-14 12:46:18 -03:00 committed by Cody Henthorne
parent 5959545ae9
commit 3090a8521c
27 changed files with 1603 additions and 68 deletions

View file

@ -300,6 +300,16 @@
</intent-filter>
</activity>
<activity android:name=".conversation.v2.ConversationActivity"
android:windowSoftInputMode="stateUnchanged"
android:launchMode="singleTask"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
android:parentActivityName=".MainActivity">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value="org.thoughtcrime.securesms.MainActivity" />
</activity>
<activity android:name=".conversation.ConversationActivity"
android:windowSoftInputMode="stateUnchanged"
android:launchMode="singleTask"

View file

@ -1,10 +1,6 @@
package org.thoughtcrime.securesms.components;
import android.annotation.TargetApi;
import android.content.Context;
import android.graphics.Insets;
import android.graphics.Rect;
import android.os.Build;
import android.util.AttributeSet;
import android.view.WindowInsets;
@ -12,12 +8,17 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.Guideline;
import androidx.core.graphics.Insets;
import androidx.core.view.WindowInsetsCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.ViewUtil;
public class InsetAwareConstraintLayout extends ConstraintLayout {
private WindowInsetsTypeProvider windowInsetsTypeProvider = WindowInsetsTypeProvider.ALL;
private Insets insets;
public InsetAwareConstraintLayout(@NonNull Context context) {
super(context);
}
@ -30,30 +31,21 @@ public class InsetAwareConstraintLayout extends ConstraintLayout {
super(context, attrs, defStyleAttr);
}
public void setWindowInsetsTypeProvider(@NonNull WindowInsetsTypeProvider windowInsetsTypeProvider) {
this.windowInsetsTypeProvider = windowInsetsTypeProvider;
requestLayout();
}
@Override
public WindowInsets onApplyWindowInsets(WindowInsets insets) {
if (Build.VERSION.SDK_INT < 30) {
return super.onApplyWindowInsets(insets);
}
Insets windowInsets = insets.getInsets(WindowInsets.Type.systemBars() | WindowInsets.Type.ime() | WindowInsets.Type.displayCutout());
applyInsets(new Rect(windowInsets.left, windowInsets.top, windowInsets.right, windowInsets.bottom));
WindowInsetsCompat windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(insets);
Insets newInsets = windowInsetsCompat.getInsets(windowInsetsTypeProvider.getInsetsType());
applyInsets(newInsets);
return super.onApplyWindowInsets(insets);
}
@Override
protected boolean fitSystemWindows(Rect insets) {
if (Build.VERSION.SDK_INT >= 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();
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -58,7 +58,7 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
/** Used once for the initial fetch, then cleared. */
private int baseSize;
ConversationDataSource(@NonNull Context context, long threadId, @NonNull MessageRequestData messageRequestData, boolean showUniversalExpireTimerUpdate, int baseSize) {
public ConversationDataSource(@NonNull Context context, long threadId, @NonNull MessageRequestData messageRequestData, boolean showUniversalExpireTimerUpdate, int baseSize) {
this.context = context;
this.threadId = threadId;
this.messageRequestData = messageRequestData;

View file

@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.conversation;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
@ -8,9 +9,13 @@ import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.badges.models.Badge;
import org.thoughtcrime.securesms.conversation.colors.ChatColors;
import org.thoughtcrime.securesms.conversation.v2.ConversationActivity;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mediasend.Media;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@ -23,6 +28,7 @@ import java.util.List;
import java.util.Objects;
public class ConversationIntents {
private static final String TAG = Log.tag(ConversationIntents.class);
private static final String BUBBLE_AUTHORITY = "bubble";
private static final String NOTIFICATION_CUSTOM_SCHEME = "custom";
@ -38,6 +44,7 @@ public class ConversationIntents {
private static final String EXTRA_WITH_SEARCH_OPEN = "with_search_open";
private static final String EXTRA_GIFT_BADGE = "gift_badge";
private static final String EXTRA_SHARE_DATA_TIMESTAMP = "share_data_timestamp";
private static final String EXTRA_CONVERSATION_TYPE = "conversation_type";
private static final String INTENT_DATA = "intent_data";
private static final String INTENT_TYPE = "intent_type";
@ -94,21 +101,22 @@ public class ConversationIntents {
return uri != null && Objects.equals(uri.getScheme(), NOTIFICATION_CUSTOM_SCHEME);
}
final static class Args {
private final RecipientId recipientId;
private final long threadId;
private final String draftText;
private final ArrayList<Media> 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> 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<? extends ConversationActivity> conversationActivityClass;
private final RecipientId recipientId;
private final long threadId;
private final Context context;
private final Class<? extends Activity> conversationActivityClass;
private final RecipientId recipientId;
private final long threadId;
private String draftText;
private List<Media> 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> 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<? extends ConversationActivity> conversationActivityClass,
@NonNull Class<? extends Activity> 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<? extends Activity> 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<? extends Activity> getBaseConversationActivity() {
if (SignalStore.internalValues().useConversationFragmentV2()) {
return ConversationActivity.class;
} else {
return org.thoughtcrime.securesms.conversation.ConversationActivity.class;
}
}
}

View file

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

View file

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

View file

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

View file

@ -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<Recipient> = viewModel.recipient.firstOrError()
val hasActiveGroupCall: Single<Boolean> = 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<VoiceNoteMediaControllerOwner>().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<Recipient>) {
val context = context ?: return
ContactUtil.selectRecipientThroughDialog(context, choices, Locale.getDefault()) { recipient: Recipient ->
CommunicationActions.startConversation(context, recipient, null)
}
}
override fun onInviteSharedContactClicked(choices: MutableList<Recipient>) {
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<VoiceNotePlaybackState>) {
getVoiceNoteMediaController()
.voiceNotePlaybackState
.observe(viewLifecycleOwner, onPlaybackStartObserver)
}
override fun onUnregisterVoiceNoteCallbacks(onPlaybackStartObserver: Observer<VoiceNotePlaybackState>) {
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)
}
}
}

View file

@ -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<Recipient> {
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<ConversationThreadState> {
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<Map<RecipientId, NameColor>> {
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) }
}
}

View file

@ -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<MessageId, ConversationMessage>,
val meta: ConversationData
)

View file

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

View file

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

View file

@ -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<Recipient> = BehaviorSubject.create()
val recipient: Observable<Recipient> = _recipient
private val _conversationThreadState: Subject<ConversationThreadState> = BehaviorSubject.create()
val conversationThreadState: Observable<ConversationThreadState> = _conversationThreadState
val pagingController = ProxyPagingController<MessageId>()
val nameColorsMap: Observable<Map<RecipientId, NameColor>> = _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 <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ConversationViewModel(args.threadId, args.startingPosition, repository)) as T
}
}
}

View file

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

View file

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

View file

@ -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<Boolean> = BehaviorSubject.createDefault(false)
private val _hasOngoingGroupCall: Subject<Boolean> = BehaviorSubject.createDefault(false)
private val _hasCapacity: Subject<Boolean> = BehaviorSubject.createDefault(false)
private val _hasActiveGroupCall: BehaviorSubject<Boolean> = BehaviorSubject.create()
private val _recipient: BehaviorSubject<Recipient> = BehaviorSubject.create()
private val _groupCallPeekEventProcessor: PublishProcessor<GroupCallPeekEvent> = PublishProcessor.create()
private val _peekRequestProcessor: PublishProcessor<Unit> = PublishProcessor.create()
private val disposables = CompositeDisposable()
val hasActiveGroupCall: Observable<Boolean> = _hasActiveGroupCall.observeOn(AndroidSchedulers.mainThread())
val hasCapacity: Observable<Boolean> = _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 <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ConversationGroupCallViewModel(threadId)) as T
}
}
}

View file

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

View file

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

View file

@ -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<Recipient> = BehaviorSubject.create()
private val _groupRecord: Subject<GroupRecord> = BehaviorSubject.create()
private val _groupActiveState: Subject<ConversationGroupActiveState> = BehaviorSubject.create()
private val _memberLevel: BehaviorSubject<ConversationGroupMemberLevel> = BehaviorSubject.create()
private val _actionableRequestingMembersCount: Subject<Int> = BehaviorSubject.create()
private val _gv1MigrationSuggestions: Subject<List<RecipientId>> = BehaviorSubject.create()
private val _reviewState: Subject<ConversationGroupReviewState> = 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<RecipientId> {
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 <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ConversationGroupViewModel(threadId)) as T
}
}
}

View file

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

View file

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

View file

@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.conversation.ConversationTitleView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/conversation_title_view"
android:layout_width="match_parent"

View file

@ -0,0 +1,100 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.components.InsetAwareConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<include layout="@layout/system_ui_guidelines" />
<ImageView
android:id="@+id/conversation_wallpaper"
android:layout_width="0dp"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="centerCrop"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline" />
<View
android:id="@+id/conversation_wallpaper_dim"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
android:visibility="gone"
tools:alpha="0.2f"
tools:visibility="visible" />
<org.thoughtcrime.securesms.conversation.mutiselect.MultiselectRecyclerView
android:id="@+id/conversation_item_recycler"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@+id/conversation_bottom_panel_barrier"
app:layout_constraintTop_toTopOf="parent"
tools:itemCount="20"
tools:listitem="@layout/conversation_item_sent_text_only" />
<org.thoughtcrime.securesms.util.views.DarkOverflowToolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="@dimen/signal_m3_toolbar_height"
android:background="@color/transparent"
android:clipChildren="false"
android:clipToPadding="false"
android:minHeight="@dimen/signal_m3_toolbar_height"
android:theme="?attr/actionBarStyle"
app:contentInsetStart="46dp"
app:contentInsetStartWithNavigation="0dp"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline"
app:layout_constraintTop_toTopOf="@id/status_bar_guideline">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="horizontal">
<include
android:id="@+id/conversation_title_view"
layout="@layout/conversation_title_view"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<com.google.android.material.button.MaterialButton
android:id="@+id/conversation_group_call_join"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:fontFamily="sans-serif-medium"
android:text="@string/ConversationActivity_join"
android:textAllCaps="false"
android:textColor="@color/core_white"
android:visibility="gone"
app:backgroundTint="@color/core_ultramarine"
app:cornerRadius="@dimen/material_button_full_round_corner_radius"
app:icon="@drawable/ic_video_solid_18"
app:iconGravity="textStart"
app:iconTint="@color/core_white"
tools:visibility="visible" />
</LinearLayout>
</org.thoughtcrime.securesms.util.views.DarkOverflowToolbar>
<androidx.constraintlayout.widget.Barrier
android:id="@+id/conversation_bottom_panel_barrier"
app:barrierDirection="top"
app:constraint_referenced_ids="conversation_input_panel"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<include
android:id="@+id/conversation_input_panel"
layout="@layout/conversation_input_panel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="@id/navigation_bar_guideline" />
</org.thoughtcrime.securesms.components.InsetAwareConstraintLayout>