Merge V2 Conversation Fragment behind an internal setting.
This commit is contained in:
parent
5959545ae9
commit
3090a8521c
27 changed files with 1603 additions and 68 deletions
|
@ -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"
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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"
|
||||
|
|
100
app/src/main/res/layout/v2_conversation_fragment.xml
Normal file
100
app/src/main/res/layout/v2_conversation_fragment.xml
Normal 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>
|
Loading…
Add table
Reference in a new issue