Compare commits

..

41 Commits

Author SHA1 Message Date
Greyson Parrelli
cbecd2a2fc Bump version to 4.68.2 2020-07-31 16:47:55 -04:00
Greyson Parrelli
3772dd40ac Updated language translations. 2020-07-31 16:46:01 -04:00
Alex Hart
f69a0f0261 Refine reaction details fragment. 2020-07-31 16:49:52 -03:00
Alex Hart
cb323ffb84 Fix reaction overlay toolbar and status bar. 2020-07-31 15:51:41 -03:00
Alex Hart
0db73e71a0 Remove sticky header on list reinitailization.
When we forward a message or share into the app, it is possible that we are going to reuse the same activity. In this case, when the adapter was reinitialized, we were just adding a new ItemDecoration every time.

This fix checks if we've already added one and removes it if necessary, just like the last seen decorator.
2020-07-31 14:26:31 -03:00
Alex Hart
eeb0c838db Fix masking when attachment keyboard is visible. 2020-07-31 11:34:46 -03:00
Greyson Parrelli
dc48ee5aed Bump version to 4.68.1 2020-07-30 23:32:20 -04:00
Greyson Parrelli
c0acfa57a9 Updated language translations. 2020-07-30 23:32:19 -04:00
Greyson Parrelli
3e166ef927 Fix issue where group updates were mis-rendered. 2020-07-30 23:32:19 -04:00
Greyson Parrelli
4942d83de5 Properly render reset session update messages. 2020-07-30 23:32:19 -04:00
Alex Hart
4c30b39e71 Add section to recent reactions page listing emoji already applied to message. 2020-07-30 23:32:19 -04:00
Alex Hart
e55f4fe6b6 Save preference on emoji send. 2020-07-30 22:26:59 -04:00
Greyson Parrelli
aff74cffa0 Fix crash with UnknownSenderView.
The listener was being called on a background thread, but it was doing
UI work.
2020-07-30 13:31:51 -04:00
Alex Hart
8b29bb8664 Fix info icon in light mode. 2020-07-30 10:48:45 -03:00
Greyson Parrelli
3cee57b6c2 Bump version to 4.68.0 2020-07-29 23:54:46 -04:00
Greyson Parrelli
857f4a4fc8 Updated language translations. 2020-07-29 23:54:09 -04:00
Jim Gustafson
a942293a74 RingRTC v2.4.0 Release Integration.
Co-authored-by: Peter Thatcher <peter@signal.org>
2020-07-29 23:43:06 -04:00
Greyson Parrelli
550b121990 Prevent UUID-only contacts from being added to GV1 groups. 2020-07-29 23:43:06 -04:00
Alex Hart
cc84901a49 Add dropshadow to emoji variation popup. 2020-07-29 23:43:06 -04:00
Alex Hart
9d3764c5d9 Reactions UX polish. 2020-07-29 23:43:06 -04:00
Greyson Parrelli
0950235ccd Fix typo in RemappedRecords. 2020-07-29 23:19:21 -04:00
Greyson Parrelli
8ed7fc894e Improve handling of partially bi-directional text. 2020-07-29 23:19:21 -04:00
Greyson Parrelli
e504ffa225 Clean up conversation list data loading sequence.
- The Paging library was giving us empty paged lists when loading was
invalidated, but only *sometimes*. This library, man. Fixed it by
ignoring invalid lists, which you'd think the library would do for us...
- Noticed we were doing a ton of list refreshes because of how we were
listening to archive count. Switched from combine to switchMap.
- Noticed that we could become double-subscribed to LiveDatas in the
ConversationListFragment if you went to archived. Fixed by observing on
the fragment's view lifecycle.

Fixes #9803
2020-07-29 23:19:21 -04:00
Cody Henthorne
9c63b37bb4 Refactor use of MessageRecord to increase flexibility of ConversationAdapter. 2020-07-29 23:19:21 -04:00
Greyson Parrelli
5c110ca359 Remove UUIDs from GV1 membership lists. 2020-07-29 23:19:21 -04:00
Cody Henthorne
1ab61beeb9 Add initial Mentions UI/UX for picker and compose edit. 2020-07-28 15:20:20 -04:00
Alan Evans
8e45a546c9 Fix NPE on Group multi-invite. 2020-07-28 15:20:20 -04:00
Alan Evans
745a7f76ea Change position of GroupsV2 leave update message. 2020-07-28 15:20:20 -04:00
Alan Evans
8cb9ab3204 Fetch newly found profiles on Groups V2 inline. 2020-07-28 15:20:20 -04:00
Alan Evans
12533d1414 Ensure profile key is up to date on Group V2 conversation open. 2020-07-28 15:20:20 -04:00
Alan Evans
bd1c164d57 Live group update messages on conversation list and conversation. 2020-07-28 15:20:20 -04:00
Greyson Parrelli
7446c2096d Don't ellipsize multi-line text in conversation list.
Instead, basically convert newlines to spaces.
2020-07-28 15:19:52 -04:00
Greyson Parrelli
8ce5c4b885 Cleanup naming of RecipientDatabase GLOB search. 2020-07-28 15:19:52 -04:00
Alan Evans
ab76112f5f Prevent leading and trailing whitespace in group names. 2020-07-28 15:19:52 -04:00
Alan Evans
9c54e39eae Adjust scope of Groups V2 feature flag. 2020-07-28 15:19:52 -04:00
Greyson Parrelli
61eab44474 Bump version to 4.67.3 2020-07-27 18:04:05 -04:00
Greyson Parrelli
f6285ec710 Updated language translations. 2020-07-27 18:02:31 -04:00
Alex Hart
ed878ec4b4 Add more generic SMS verification code pattern. 2020-07-27 17:57:56 -04:00
Greyson Parrelli
e38d41d67a Reduce the number of cats in giphy sticker search results. 2020-07-27 15:25:26 -04:00
Greyson Parrelli
3d237d72bd Fix issue where feature flag fetches weren't limited. 2020-07-27 15:25:01 -04:00
Cody Henthorne
8044d2390c Fix bug causing profile updates to unarchive threads. 2020-07-27 13:32:38 -04:00
212 changed files with 4548 additions and 1460 deletions

View File

@@ -80,8 +80,8 @@ protobuf {
}
}
def canonicalVersionCode = 679
def canonicalVersionName = "4.67.2"
def canonicalVersionCode = 683
def canonicalVersionName = "4.68.2"
def postFixSize = 10
def abiPostFix = ['universal' : 0,
@@ -304,7 +304,7 @@ dependencies {
implementation 'org.signal:argon2:13.1@aar'
implementation 'org.signal:ringrtc-android:2.3.1'
implementation 'org.signal:ringrtc-android:2.4.1'
implementation "me.leolin:ShortcutBadger:1.1.16"
implementation 'se.emilsjolander:stickylistheaders:2.7.0'
@@ -359,10 +359,10 @@ dependencies {
testImplementation 'junit:junit:4.12'
testImplementation 'org.assertj:assertj-core:3.11.1'
testImplementation 'org.mockito:mockito-core:1.9.5'
testImplementation 'org.powermock:powermock-api-mockito:1.6.1'
testImplementation 'org.powermock:powermock-module-junit4:1.6.1'
testImplementation 'org.powermock:powermock-module-junit4-rule:1.6.1'
testImplementation 'org.powermock:powermock-classloading-xstream:1.6.1'
testImplementation 'org.powermock:powermock-api-mockito:1.6.5'
testImplementation 'org.powermock:powermock-module-junit4:1.6.5'
testImplementation 'org.powermock:powermock-module-junit4-rule:1.6.5'
testImplementation 'org.powermock:powermock-classloading-xstream:1.6.5'
testImplementation 'androidx.test:core:1.2.0'
testImplementation ('org.robolectric:robolectric:4.2') {

View File

@@ -132,7 +132,7 @@ public class ApplicationContext extends MultiDexApplication implements DefaultLi
NotificationChannels.create(this);
RefreshPreKeysJob.scheduleIfNecessary();
StorageSyncHelper.scheduleRoutineSync();
RetrieveProfileJob.enqueueRoutineFetchIfNeccessary(this);
RetrieveProfileJob.enqueueRoutineFetchIfNecessary(this);
RegistrationUtil.maybeMarkRegistrationComplete(this);
ProcessLifecycleOwner.get().getLifecycle().addObserver(this);

View File

@@ -5,6 +5,7 @@ import androidx.annotation.Nullable;
import android.view.View;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
@@ -21,17 +22,17 @@ import java.util.Locale;
import java.util.Set;
public interface BindableConversationItem extends Unbindable {
void bind(@NonNull MessageRecord messageRecord,
void bind(@NonNull ConversationMessage messageRecord,
@NonNull Optional<MessageRecord> previousMessageRecord,
@NonNull Optional<MessageRecord> nextMessageRecord,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@NonNull Set<MessageRecord> batchSelected,
@NonNull Recipient recipients,
@Nullable String searchQuery,
boolean pulseHighlight);
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@NonNull Set<ConversationMessage> batchSelected,
@NonNull Recipient recipients,
@Nullable String searchQuery,
boolean pulseHighlight);
MessageRecord getMessageRecord();
ConversationMessage getConversationMessage();
void setEventListener(@Nullable EventListener listener);
@@ -45,7 +46,7 @@ public interface BindableConversationItem extends Unbindable {
void onAddToContactsClicked(@NonNull Contact contact);
void onMessageSharedContactClicked(@NonNull List<Recipient> choices);
void onInviteSharedContactClicked(@NonNull List<Recipient> choices);
void onReactionClicked(long messageId, boolean isMms);
void onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms);
void onGroupMemberAvatarClicked(@NonNull RecipientId recipientId, @NonNull GroupId groupId);
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
}

View File

@@ -113,7 +113,9 @@ public abstract class ContactSelectionActivity extends PassphraseRequiredActivit
}
@Override
public void onContactSelected(Optional<RecipientId> recipientId, String number) {}
public boolean onContactSelected(Optional<RecipientId> recipientId, String number) {
return true;
}
@Override
public void onContactDeselected(Optional<RecipientId> recipientId, String number) {}

View File

@@ -473,11 +473,15 @@ public final class ContactSelectionListFragment extends LoggingFragment
if (uuid.isPresent()) {
Recipient recipient = Recipient.externalUsername(requireContext(), uuid.get(), contact.getNumber());
SelectedContact selected = SelectedContact.forUsername(recipient.getId(), contact.getNumber());
markContactSelected(selected);
cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
if (onContactSelectedListener != null) {
onContactSelectedListener.onContactSelected(Optional.of(recipient.getId()), null);
if (onContactSelectedListener.onContactSelected(Optional.of(recipient.getId()), null)) {
markContactSelected(selected);
cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
} else {
markContactSelected(selected);
cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
} else {
new AlertDialog.Builder(requireContext())
@@ -488,11 +492,14 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
});
} else {
markContactSelected(selectedContact);
cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
if (onContactSelectedListener != null) {
onContactSelectedListener.onContactSelected(contact.getRecipientId(), contact.getNumber());
if (onContactSelectedListener.onContactSelected(contact.getRecipientId(), contact.getNumber())) {
markContactSelected(selectedContact);
cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
} else {
markContactSelected(selectedContact);
cursorRecyclerViewAdapter.notifyItemChanged(recyclerView.getChildAdapterPosition(contact), ContactSelectionListAdapter.PAYLOAD_SELECTION_CHANGE);
}
}
} else {
@@ -624,7 +631,8 @@ public final class ContactSelectionListFragment extends LoggingFragment
}
public interface OnContactSelectedListener {
void onContactSelected(Optional<RecipientId> recipientId, String number);
/** @return True if the contact is allowed to be selected, otherwise false. */
boolean onContactSelected(Optional<RecipientId> recipientId, String number);
void onContactDeselected(Optional<RecipientId> recipientId, String number);
}

View File

@@ -121,8 +121,9 @@ public class InviteActivity extends PassphraseRequiredActivity implements Contac
}
@Override
public void onContactSelected(Optional<RecipientId> recipientId, String number) {
public boolean onContactSelected(Optional<RecipientId> recipientId, String number) {
updateSmsButtonText();
return true;
}
@Override

View File

@@ -60,7 +60,7 @@ public class NewConversationActivity extends ContactSelectionActivity
}
@Override
public void onContactSelected(Optional<RecipientId> recipientId, String number) {
public boolean onContactSelected(Optional<RecipientId> recipientId, String number) {
if (recipientId.isPresent()) {
launch(Recipient.resolved(recipientId.get()));
} else {
@@ -92,6 +92,8 @@ public class NewConversationActivity extends ContactSelectionActivity
launch(Recipient.external(this, number));
}
}
return true;
}
private void launch(Recipient recipient) {

View File

@@ -2,40 +2,50 @@ package org.thoughtcrime.securesms.components;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Canvas;
import android.os.Build;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.core.view.inputmethod.EditorInfoCompat;
import androidx.core.view.inputmethod.InputConnectionCompat;
import androidx.core.view.inputmethod.InputContentInfoCompat;
import androidx.core.os.BuildCompat;
import android.text.Editable;
import android.text.InputType;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.TextUtils.TruncateAt;
import android.text.method.QwertyKeyListener;
import android.text.style.RelativeSizeSpan;
import android.util.AttributeSet;
import org.thoughtcrime.securesms.logging.Log;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.os.BuildCompat;
import androidx.core.view.inputmethod.EditorInfoCompat;
import androidx.core.view.inputmethod.InputConnectionCompat;
import androidx.core.view.inputmethod.InputContentInfoCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.TransportOption;
import org.thoughtcrime.securesms.components.emoji.EmojiEditText;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.mention.MentionRendererDelegate;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import java.util.UUID;
public class ComposeText extends EmojiEditText {
private CharSequence hint;
private SpannableString subHint;
private CharSequence hint;
private SpannableString subHint;
private MentionRendererDelegate mentionRendererDelegate;
@Nullable private InputPanel.MediaListener mediaListener;
@Nullable private CursorPositionChangedListener cursorPositionChangedListener;
@Nullable private MentionQueryChangedListener mentionQueryChangedListener;
public ComposeText(Context context) {
super(context);
@@ -75,11 +85,33 @@ public class ComposeText extends EmojiEditText {
protected void onSelectionChanged(int selStart, int selEnd) {
super.onSelectionChanged(selStart, selEnd);
if (FeatureFlags.mentions()) {
if (selStart == selEnd) {
doAfterCursorChange();
} else {
updateQuery("");
}
}
if (cursorPositionChangedListener != null) {
cursorPositionChangedListener.onCursorPositionChanged(selStart, selEnd);
}
}
@Override
protected void onDraw(Canvas canvas) {
if (FeatureFlags.mentions() && getText() != null && getLayout() != null) {
int checkpoint = canvas.save();
canvas.translate(getTotalPaddingLeft(), getTotalPaddingTop());
try {
mentionRendererDelegate.draw(canvas, getText(), getLayout());
} finally {
canvas.restoreToCount(checkpoint);
}
}
super.onDraw(canvas);
}
private CharSequence ellipsizeToWidth(CharSequence text) {
return TextUtils.ellipsize(text,
getPaint(),
@@ -119,6 +151,10 @@ public class ComposeText extends EmojiEditText {
this.cursorPositionChangedListener = listener;
}
public void setMentionQueryChangedListener(@Nullable MentionQueryChangedListener listener) {
this.mentionQueryChangedListener = listener;
}
private boolean isLandscape() {
return getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
}
@@ -169,9 +205,89 @@ public class ComposeText extends EmojiEditText {
if (TextSecurePreferences.isIncognitoKeyboardEnabled(getContext())) {
setImeOptions(getImeOptions() | 16777216);
}
if (FeatureFlags.mentions()) {
mentionRendererDelegate = new MentionRendererDelegate(getContext());
}
}
private void doAfterCursorChange() {
Editable text = getText();
if (text != null && enoughToFilter(text)) {
performFiltering(text);
} else {
updateQuery("");
}
}
private void performFiltering(@NonNull Editable text) {
int end = getSelectionEnd();
int start = findQueryStart(text, end);
CharSequence query = text.subSequence(start, end);
updateQuery(query);
}
private void updateQuery(@NonNull CharSequence query) {
if (mentionQueryChangedListener != null) {
mentionQueryChangedListener.onQueryChanged(query);
}
}
private boolean enoughToFilter(@NonNull Editable text) {
int end = getSelectionEnd();
if (end < 0) {
return false;
}
return end - findQueryStart(text, end) >= 1;
}
public void replaceTextWithMention(@NonNull String displayName, @NonNull UUID uuid) {
Editable text = getText();
if (text == null) {
return;
}
clearComposingText();
int end = getSelectionEnd();
int start = findQueryStart(text, end) - 1;
String original = TextUtils.substring(text, start, end);
QwertyKeyListener.markAsReplaced(text, start, end, original);
text.replace(start, end, createReplacementToken(displayName, uuid));
}
private @NonNull CharSequence createReplacementToken(@NonNull CharSequence text, @NonNull UUID uuid) {
SpannableStringBuilder builder = new SpannableStringBuilder("@");
if (text instanceof Spanned) {
SpannableString spannableString = new SpannableString(text + " ");
TextUtils.copySpansFrom((Spanned) text, 0, text.length(), Object.class, spannableString, 0);
builder.append(spannableString);
} else {
builder.append(text).append(" ");
}
builder.setSpan(MentionAnnotation.mentionAnnotationForUuid(uuid), 0, builder.length() - 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
return builder;
}
private int findQueryStart(@NonNull CharSequence text, int inputCursorPosition) {
if (inputCursorPosition == 0) {
return inputCursorPosition;
}
int delimiterSearchIndex = inputCursorPosition - 1;
while (delimiterSearchIndex >= 0 && (text.charAt(delimiterSearchIndex) != '@' && text.charAt(delimiterSearchIndex) != ' ')) {
delimiterSearchIndex--;
}
if (delimiterSearchIndex >= 0 && text.charAt(delimiterSearchIndex) == '@') {
return delimiterSearchIndex + 1;
}
return inputCursorPosition;
}
@RequiresApi(api = Build.VERSION_CODES.HONEYCOMB_MR2)
private static class CommitContentListener implements InputConnectionCompat.OnCommitContentListener {
private static final String TAG = CommitContentListener.class.getSimpleName();
@@ -207,4 +323,8 @@ public class ComposeText extends EmojiEditText {
public interface CursorPositionChangedListener {
void onCursorPositionChanged(int start, int end);
}
public interface MentionQueryChangedListener {
void onQueryChanged(CharSequence query);
}
}

View File

@@ -79,7 +79,6 @@ public class MaskView extends View {
target.getDrawingRect(drawingRect);
activityContentView.offsetDescendantRectToMyCoords(target, drawingRect);
drawingRect.bottom = Math.min(drawingRect.bottom, getBottom() - getPaddingBottom());
drawingRect.top += targetParentTranslationY;
drawingRect.bottom += targetParentTranslationY;
@@ -88,6 +87,7 @@ public class MaskView extends View {
target.draw(maskCanvas);
canvas.clipRect(drawingRect.left, Math.max(drawingRect.top, getTop() + getPaddingTop()), drawingRect.right, Math.min(drawingRect.bottom, getBottom() - getPaddingBottom()));
canvas.drawBitmap(mask, 0, drawingRect.top, maskPaint);
mask.recycle();

View File

@@ -12,6 +12,7 @@ import android.widget.ImageView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter.VariationSelectorListener;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.util.ResUtil;
import org.thoughtcrime.securesms.util.ThemeUtil;
@@ -48,6 +49,7 @@ public class EmojiKeyboardProvider implements MediaKeyboardProvider,
@Override
public void onEmojiSelected(String emoji) {
recentModel.onCodePointSelected(emoji);
SignalStore.emojiValues().setPreferredVariation(emoji);
if (emojiEventListener != null) {
emojiEventListener.onEmojiSelected(emoji);

View File

@@ -10,6 +10,7 @@ import android.widget.PopupWindow;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider.EmojiEventListener;
import org.thoughtcrime.securesms.util.ThemeUtil;
import java.util.List;
@@ -27,7 +28,7 @@ public class EmojiVariationSelectorPopup extends PopupWindow {
this.listener = listener;
this.list = (ViewGroup) getContentView().findViewById(R.id.emoji_variation_container);
setBackgroundDrawable(null);
setBackgroundDrawable(ThemeUtil.getThemedDrawable(context, R.attr.emoji_variation_selector_background));
setOutsideTouchable(true);
if (Build.VERSION.SDK_INT >= 21) {

View File

@@ -4,6 +4,8 @@ import android.content.Context;
import android.content.SharedPreferences;
import android.os.AsyncTask;
import android.preference.PreferenceManager;
import androidx.annotation.MainThread;
import androidx.annotation.NonNull;
import com.annimon.stream.Stream;
@@ -13,6 +15,7 @@ import com.fasterxml.jackson.databind.type.TypeFactory;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.JsonUtils;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import java.io.IOException;
import java.util.ArrayList;
@@ -73,6 +76,7 @@ public class RecentEmojiPageModel implements EmojiPageModel {
return true;
}
@MainThread
public void onCodePointSelected(String emoji) {
recentlyUsed.remove(emoji);
recentlyUsed.add(emoji);
@@ -84,22 +88,16 @@ public class RecentEmojiPageModel implements EmojiPageModel {
}
final LinkedHashSet<String> latestRecentlyUsed = new LinkedHashSet<>(recentlyUsed);
new AsyncTask<Void, Void, Void>() {
@Override
protected Void doInBackground(Void... params) {
try {
String serialized = JsonUtils.toJson(latestRecentlyUsed);
prefs.edit()
.putString(preferenceName, serialized)
.apply();
} catch (IOException e) {
Log.w(TAG, e);
}
return null;
SignalExecutors.BOUNDED.execute(() -> {
try {
String serialized = JsonUtils.toJson(latestRecentlyUsed);
prefs.edit()
.putString(preferenceName, serialized)
.apply();
} catch (IOException e) {
Log.w(TAG, e);
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
});
}
private String[] toReversePrimitiveArray(@NonNull LinkedHashSet<String> emojiSet) {

View File

@@ -0,0 +1,26 @@
package org.thoughtcrime.securesms.components.mention;
import android.text.Annotation;
import androidx.annotation.NonNull;
import java.util.UUID;
/**
* Factory for creating mention annotation spans.
*
* Note: This wraps creating an Android standard {@link Annotation} so it can leverage the built in
* span parceling for copy/paste. Do not extend Annotation or this will be lost.
*/
public final class MentionAnnotation {
public static final String MENTION_ANNOTATION = "mention";
private MentionAnnotation() {
}
public static Annotation mentionAnnotationForUuid(@NonNull UUID uuid) {
return new Annotation(MENTION_ANNOTATION, uuid.toString());
}
}

View File

@@ -0,0 +1,133 @@
package org.thoughtcrime.securesms.components.mention;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
import android.text.Layout;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.util.LayoutUtil;
/**
* Handles actually drawing the mention backgrounds for a TextView.
* <p>
* Ported and modified from https://github.com/googlearchive/android-text/tree/master/RoundedBackground-Kotlin
*/
public abstract class MentionRenderer {
protected final int horizontalPadding;
protected final int verticalPadding;
public MentionRenderer(int horizontalPadding, int verticalPadding) {
this.horizontalPadding = horizontalPadding;
this.verticalPadding = verticalPadding;
}
public abstract void draw(@NonNull Canvas canvas, @NonNull Layout layout, int startLine, int endLine, int startOffset, int endOffset);
protected int getLineTop(@NonNull Layout layout, int line) {
return LayoutUtil.getLineTopWithoutPadding(layout, line) - verticalPadding;
}
protected int getLineBottom(@NonNull Layout layout, int line) {
return LayoutUtil.getLineBottomWithoutPadding(layout, line) + verticalPadding;
}
public static final class SingleLineMentionRenderer extends MentionRenderer {
private final Drawable drawable;
public SingleLineMentionRenderer(int horizontalPadding, int verticalPadding, @NonNull Drawable drawable) {
super(horizontalPadding, verticalPadding);
this.drawable = drawable;
}
@Override
public void draw(@NonNull Canvas canvas, @NonNull Layout layout, int startLine, int endLine, int startOffset, int endOffset) {
int lineTop = getLineTop(layout, startLine);
int lineBottom = getLineBottom(layout, startLine);
int left = Math.min(startOffset, endOffset);
int right = Math.max(startOffset, endOffset);
drawable.setBounds(left, lineTop, right, lineBottom);
drawable.draw(canvas);
}
}
public static final class MultiLineMentionRenderer extends MentionRenderer {
private final Drawable drawableLeft;
private final Drawable drawableMid;
private final Drawable drawableRight;
public MultiLineMentionRenderer(int horizontalPadding, int verticalPadding,
@NonNull Drawable drawableLeft,
@NonNull Drawable drawableMid,
@NonNull Drawable drawableRight)
{
super(horizontalPadding, verticalPadding);
this.drawableLeft = drawableLeft;
this.drawableMid = drawableMid;
this.drawableRight = drawableRight;
}
@Override
public void draw(@NonNull Canvas canvas, @NonNull Layout layout, int startLine, int endLine, int startOffset, int endOffset) {
int paragraphDirection = layout.getParagraphDirection(startLine);
float lineEndOffset;
if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) {
lineEndOffset = layout.getLineLeft(startLine) - horizontalPadding;
} else {
lineEndOffset = layout.getLineRight(startLine) + horizontalPadding;
}
int lineBottom = getLineBottom(layout, startLine);
int lineTop = getLineTop(layout, startLine);
drawStart(canvas, startOffset, lineTop, (int) lineEndOffset, lineBottom);
for (int line = startLine + 1; line < endLine; line++) {
int left = (int) layout.getLineLeft(line) - horizontalPadding;
int right = (int) layout.getLineRight(line) + horizontalPadding;
lineTop = getLineTop(layout, line);
lineBottom = getLineBottom(layout, line);
drawableMid.setBounds(left, lineTop, right, lineBottom);
drawableMid.draw(canvas);
}
float lineStartOffset;
if (paragraphDirection == Layout.DIR_RIGHT_TO_LEFT) {
lineStartOffset = layout.getLineRight(startLine) + horizontalPadding;
} else {
lineStartOffset = layout.getLineLeft(startLine) - horizontalPadding;
}
lineBottom = getLineBottom(layout, endLine);
lineTop = getLineTop(layout, endLine);
drawEnd(canvas, (int) lineStartOffset, lineTop, endOffset, lineBottom);
}
private void drawStart(@NonNull Canvas canvas, int start, int top, int end, int bottom) {
if (start > end) {
drawableRight.setBounds(end, top, start, bottom);
drawableRight.draw(canvas);
} else {
drawableLeft.setBounds(start, top, end, bottom);
drawableLeft.draw(canvas);
}
}
private void drawEnd(@NonNull Canvas canvas, int start, int top, int end, int bottom) {
if (start > end) {
drawableLeft.setBounds(end, top, start, bottom);
drawableLeft.draw(canvas);
} else {
drawableRight.setBounds(start, top, end, bottom);
drawableRight.draw(canvas);
}
}
}
}

View File

@@ -0,0 +1,78 @@
package org.thoughtcrime.securesms.components.mention;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
import android.text.Annotation;
import android.text.Layout;
import android.text.Spanned;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
/**
* Encapsulates the logic for determining the type of mention rendering needed (single vs multi-line) and then
* passing that information to the appropriate {@link MentionRenderer}.
* <p></p>
* Ported and modified from https://github.com/googlearchive/android-text/tree/master/RoundedBackground-Kotlin
*/
public class MentionRendererDelegate {
private final MentionRenderer single;
private final MentionRenderer multi;
private final int horizontalPadding;
public MentionRendererDelegate(@NonNull Context context) {
//noinspection ConstantConditions
this(ViewUtil.dpToPx(2),
ViewUtil.dpToPx(2),
ContextCompat.getDrawable(context, R.drawable.mention_text_bg),
ContextCompat.getDrawable(context, R.drawable.mention_text_bg_left),
ContextCompat.getDrawable(context, R.drawable.mention_text_bg_mid),
ContextCompat.getDrawable(context, R.drawable.mention_text_bg_right),
ThemeUtil.getThemedColor(context, R.attr.conversation_mention_background_color));
}
public MentionRendererDelegate(int horizontalPadding,
int verticalPadding,
@NonNull Drawable drawable,
@NonNull Drawable drawableLeft,
@NonNull Drawable drawableMid,
@NonNull Drawable drawableEnd,
@ColorInt int tint)
{
this.horizontalPadding = horizontalPadding;
single = new MentionRenderer.SingleLineMentionRenderer(horizontalPadding,
verticalPadding,
DrawableUtil.tint(drawable, tint));
multi = new MentionRenderer.MultiLineMentionRenderer(horizontalPadding,
verticalPadding,
DrawableUtil.tint(drawableLeft, tint),
DrawableUtil.tint(drawableMid, tint),
DrawableUtil.tint(drawableEnd, tint));
}
public void draw(@NonNull Canvas canvas, @NonNull Spanned text, @NonNull Layout layout) {
Annotation[] spans = text.getSpans(0, text.length(), Annotation.class);
for (Annotation span : spans) {
if (MentionAnnotation.MENTION_ANNOTATION.equals(span.getKey())) {
int spanStart = text.getSpanStart(span);
int spanEnd = text.getSpanEnd(span);
int startLine = layout.getLineForOffset(spanStart);
int endLine = layout.getLineForOffset(spanEnd);
int startOffset = (int) (layout.getPrimaryHorizontal(spanStart) + -1 * layout.getParagraphDirection(startLine) * horizontalPadding);
int endOffset = (int) (layout.getPrimaryHorizontal(spanEnd) + layout.getParagraphDirection(endLine) * horizontalPadding);
MentionRenderer renderer = (startLine == endLine) ? single : multi;
renderer.draw(canvas, layout, startLine, endLine, startOffset, endOffset);
}
}
}
}

View File

@@ -125,6 +125,7 @@ import org.thoughtcrime.securesms.contactshare.ContactUtil;
import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher;
import org.thoughtcrime.securesms.conversation.ConversationGroupViewModel.GroupActiveState;
import org.thoughtcrime.securesms.conversation.ui.error.SafetyNumberChangeDialog;
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.crypto.SecurityEvent;
import org.thoughtcrime.securesms.database.DatabaseFactory;
@@ -149,6 +150,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.events.ReminderUpdateEvent;
import org.thoughtcrime.securesms.giph.ui.GiphyActivity;
import org.thoughtcrime.securesms.groups.GroupChangeException;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupManager;
import org.thoughtcrime.securesms.groups.ui.GroupChangeFailureReason;
import org.thoughtcrime.securesms.groups.ui.GroupChangeResult;
@@ -158,6 +160,7 @@ import org.thoughtcrime.securesms.groups.ui.managegroup.ManageGroupActivity;
import org.thoughtcrime.securesms.insights.InsightsLauncher;
import org.thoughtcrime.securesms.invites.InviteReminderModel;
import org.thoughtcrime.securesms.invites.InviteReminderRepository;
import org.thoughtcrime.securesms.jobs.GroupV2UpdateSelfProfileKeyJob;
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob;
@@ -198,6 +201,7 @@ import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.profiles.GroupShareProfileView;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.reactions.ReactionsBottomSheetDialogFragment;
import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiBottomSheetDialogFragment;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -224,6 +228,7 @@ import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.DynamicDarkToolbarTheme;
import org.thoughtcrime.securesms.util.DynamicLanguage;
import org.thoughtcrime.securesms.util.DynamicTheme;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageUtil;
@@ -277,7 +282,8 @@ public class ConversationActivity extends PassphraseRequiredActivity
AttachmentKeyboard.Callback,
ConversationReactionOverlay.OnReactionSelectedListener,
ReactWithAnyEmojiBottomSheetDialogFragment.Callback,
SafetyNumberChangeDialog.Callback
SafetyNumberChangeDialog.Callback,
ReactionsBottomSheetDialogFragment.Callback
{
private static final int SHORTCUT_ICON_SIZE = Build.VERSION.SDK_INT >= 26 ? ViewUtil.dpToPx(72) : ViewUtil.dpToPx(48 + 16 * 2);
@@ -339,6 +345,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
private InputPanel inputPanel;
private View panelParent;
private View noLongerMemberBanner;
private Stub<View> mentionsSuggestions;
private LinkPreviewViewModel linkPreviewViewModel;
private ConversationSearchViewModel searchViewModel;
@@ -412,6 +419,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
initializeStickerObserver();
initializeViewModel();
initializeGroupViewModel();
if (FeatureFlags.mentions()) initializeMentionsViewModel();
initializeEnabledCheck();
initializeSecurity(recipient.get().isRegistered(), isDefaultSms).addListener(new AssertedSuccessListener<Boolean>() {
@Override
@@ -504,7 +512,12 @@ public class ConversationActivity extends PassphraseRequiredActivity
calculateCharactersRemaining();
if (recipientSnapshot.getGroupId().isPresent() && recipientSnapshot.getGroupId().get().isV2()) {
ApplicationDependencies.getJobManager().add(new RequestGroupV2InfoJob(recipientSnapshot.getGroupId().get().requireV2()));
GroupId.V2 groupId = recipientSnapshot.getGroupId().get().requireV2();
ApplicationDependencies.getJobManager()
.startChain(new RequestGroupV2InfoJob(groupId))
.then(new GroupV2UpdateSelfProfileKeyJob(groupId))
.enqueue();
}
ApplicationDependencies.getMessageNotifier().setVisibleThread(threadId);
@@ -1693,6 +1706,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
searchNav = ViewUtil.findById(this, R.id.conversation_search_nav);
messageRequestBottomView = ViewUtil.findById(this, R.id.conversation_activity_message_request_bottom_bar);
reactionOverlay = ViewUtil.findById(this, R.id.conversation_reaction_scrubber);
mentionsSuggestions = ViewUtil.findStubById(this, R.id.conversation_mention_suggestions_stub);
ImageButton quickCameraToggle = ViewUtil.findById(this, R.id.quick_camera_toggle);
ImageButton inlineAttachmentButton = ViewUtil.findById(this, R.id.inline_attachment_button);
@@ -1857,6 +1871,28 @@ public class ConversationActivity extends PassphraseRequiredActivity
groupViewModel.getGroupActiveState().observe(this, unused -> invalidateOptionsMenu());
}
private void initializeMentionsViewModel() {
MentionsPickerViewModel mentionsViewModel = ViewModelProviders.of(this, new MentionsPickerViewModel.Factory()).get(MentionsPickerViewModel.class);
recipient.observe(this, mentionsViewModel::onRecipientChange);
composeText.setMentionQueryChangedListener(query -> {
if (getRecipient().isGroup()) {
if (!mentionsSuggestions.resolved()) {
mentionsSuggestions.get();
}
mentionsViewModel.onQueryChange(query);
}
});
mentionsViewModel.getSelectedRecipient().observe(this, recipient -> {
String replacementDisplayName = recipient.getDisplayName(this);
if (replacementDisplayName.equals(recipient.getDisplayUsername())) {
replacementDisplayName = recipient.getUsername().or(replacementDisplayName);
}
composeText.replaceTextWithMention(replacementDisplayName, recipient.requireUuid());
});
}
private void showStickerIntroductionTooltip() {
TextSecurePreferences.setMediaKeyboardMode(this, MediaKeyboardMode.STICKER);
inputPanel.setMediaKeyboardToggleMode(true);
@@ -2729,6 +2765,11 @@ public class ConversationActivity extends PassphraseRequiredActivity
typingTextWatcher.setEnabled(true);
}
@Override
public void onReactionsDialogDismissed() {
reactionOverlay.hideMask();
}
// Listeners
private class QuickCameraToggleListener implements OnClickListener {
@@ -2893,7 +2934,7 @@ public class ConversationActivity extends PassphraseRequiredActivity
{
reactionOverlay.setOnToolbarItemClickedListener(toolbarListener);
reactionOverlay.setOnHideListener(onHideListener);
reactionOverlay.show(this, maskTarget, messageRecord, panelParent.getMeasuredHeight());
reactionOverlay.show(this, maskTarget, messageRecord, inputAreaHeight());
}
@Override
@@ -2916,6 +2957,11 @@ public class ConversationActivity extends PassphraseRequiredActivity
}
}
@Override
public void handleReactionDetails(@NonNull View maskTarget) {
reactionOverlay.showMask(maskTarget, titleView.getMeasuredHeight(), inputAreaHeight());
}
@Override
public void onCursorChanged() {
if (!reactionOverlay.isShowing()) {
@@ -3013,6 +3059,19 @@ public class ConversationActivity extends PassphraseRequiredActivity
updateLinkPreviewState();
}
private int inputAreaHeight() {
int height = panelParent.getMeasuredHeight();
if (attachmentKeyboardStub.resolved()) {
View keyboard = attachmentKeyboardStub.get();
if (keyboard.getVisibility() == View.VISIBLE) {
return height + keyboard.getMeasuredHeight();
}
}
return height;
}
private void onMessageRequestDeleteClicked(@NonNull MessageRequestViewModel requestModel) {
Recipient recipient = requestModel.getRecipient().getValue();
if (recipient == null) {

View File

@@ -65,8 +65,8 @@ import java.util.Set;
* manager, so position 0 is at the bottom of the screen. That's why the "header" is at the bottom,
* the "footer" is at the top, and we refer to the "next" record as having a lower index.
*/
public class ConversationAdapter<V extends View & BindableConversationItem>
extends PagedListAdapter<MessageRecord, RecyclerView.ViewHolder>
public class ConversationAdapter
extends PagedListAdapter<ConversationMessage, RecyclerView.ViewHolder>
implements StickyHeaderDecoration.StickyHeaderAdapter<ConversationAdapter.StickyHeaderViewHolder>
{
@@ -89,16 +89,16 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
private final Locale locale;
private final Recipient recipient;
private final Set<MessageRecord> selected;
private final List<MessageRecord> fastRecords;
private final Set<Long> releasedFastRecords;
private final Calendar calendar;
private final MessageDigest digest;
private final Set<ConversationMessage> selected;
private final List<ConversationMessage> fastRecords;
private final Set<Long> releasedFastRecords;
private final Calendar calendar;
private final MessageDigest digest;
private String searchQuery;
private MessageRecord recordToPulseHighlight;
private View headerView;
private View footerView;
private String searchQuery;
private ConversationMessage recordToPulseHighlight;
private View headerView;
private View footerView;
ConversationAdapter(@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@@ -130,7 +130,8 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
return MESSAGE_TYPE_FOOTER;
}
MessageRecord messageRecord = getItem(position);
ConversationMessage conversationMessage = getItem(position);
MessageRecord messageRecord = (conversationMessage != null) ? conversationMessage.getMessageRecord() : null;
if (messageRecord == null) {
return MESSAGE_TYPE_PLACEHOLDER;
@@ -153,16 +154,13 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
return FOOTER_ID;
}
MessageRecord record = getItem(position);
ConversationMessage message = getItem(position);
if (record == null) {
if (message == null) {
return -1;
}
String unique = (record.isMms() ? "MMS::" : "SMS::") + record.getId();
byte[] bytes = digest.digest(unique.getBytes());
return Conversions.byteArrayToLong(bytes);
return message.getUniqueId(digest);
}
@Override
@@ -175,22 +173,23 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
case MESSAGE_TYPE_UPDATE:
long start = System.currentTimeMillis();
V itemView = CachedInflater.from(parent.getContext()).inflate(getLayoutForViewType(viewType), parent, false);
View itemView = CachedInflater.from(parent.getContext()).inflate(getLayoutForViewType(viewType), parent, false);
BindableConversationItem bindable = (BindableConversationItem) itemView;
itemView.setOnClickListener(view -> {
if (clickListener != null) {
clickListener.onItemClick(itemView.getMessageRecord());
clickListener.onItemClick(bindable.getConversationMessage());
}
});
itemView.setOnLongClickListener(view -> {
if (clickListener != null) {
clickListener.onItemLongClick(itemView, itemView.getMessageRecord());
clickListener.onItemLongClick(itemView, bindable.getConversationMessage());
}
return true;
});
itemView.setEventListener(clickListener);
bindable.setEventListener(clickListener);
Log.d(TAG, String.format(Locale.US, "Inflate time: %d ms for View type: %d", System.currentTimeMillis() - start, viewType));
return new ConversationViewHolder(itemView);
@@ -215,23 +214,23 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
case MESSAGE_TYPE_OUTGOING_MULTIMEDIA:
case MESSAGE_TYPE_UPDATE:
ConversationViewHolder conversationViewHolder = (ConversationViewHolder) holder;
MessageRecord messageRecord = Objects.requireNonNull(getItem(position));
ConversationMessage conversationMessage = Objects.requireNonNull(getItem(position));
int adapterPosition = holder.getAdapterPosition();
MessageRecord previousRecord = adapterPosition < getItemCount() - 1 && !isFooterPosition(adapterPosition + 1) ? getItem(adapterPosition + 1) : null;
MessageRecord nextRecord = adapterPosition > 0 && !isHeaderPosition(adapterPosition - 1) ? getItem(adapterPosition - 1) : null;
ConversationMessage previousMessage = adapterPosition < getItemCount() - 1 && !isFooterPosition(adapterPosition + 1) ? getItem(adapterPosition + 1) : null;
ConversationMessage nextMessage = adapterPosition > 0 && !isHeaderPosition(adapterPosition - 1) ? getItem(adapterPosition - 1) : null;
conversationViewHolder.getView().bind(messageRecord,
Optional.fromNullable(previousRecord),
Optional.fromNullable(nextRecord),
glideRequests,
locale,
selected,
recipient,
searchQuery,
messageRecord == recordToPulseHighlight);
conversationViewHolder.getBindable().bind(conversationMessage,
Optional.fromNullable(previousMessage != null ? previousMessage.getMessageRecord() : null),
Optional.fromNullable(nextMessage != null ? nextMessage.getMessageRecord() : null),
glideRequests,
locale,
selected,
recipient,
searchQuery,
conversationMessage == recordToPulseHighlight);
if (messageRecord == recordToPulseHighlight) {
if (conversationMessage == recordToPulseHighlight) {
recordToPulseHighlight = null;
}
break;
@@ -245,13 +244,13 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
}
@Override
public void submitList(@Nullable PagedList<MessageRecord> pagedList) {
public void submitList(@Nullable PagedList<ConversationMessage> pagedList) {
cleanFastRecords();
super.submitList(pagedList);
}
@Override
protected @Nullable MessageRecord getItem(int position) {
protected @Nullable ConversationMessage getItem(int position) {
position = hasHeader() ? position - 1 : position;
if (position < fastRecords.size()) {
@@ -272,7 +271,7 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
@Override
public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) {
if (holder instanceof ConversationViewHolder) {
((ConversationViewHolder) holder).getView().unbind();
((ConversationViewHolder) holder).getBindable().unbind();
} else if (holder instanceof HeaderFooterViewHolder) {
((HeaderFooterViewHolder) holder).unbind();
}
@@ -285,11 +284,11 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
if (position >= getItemCount()) return -1;
if (position < 0) return -1;
MessageRecord record = getItem(position);
ConversationMessage conversationMessage = getItem(position);
if (record == null) return -1;
if (conversationMessage == null) return -1;
calendar.setTime(new Date(record.getDateSent()));
calendar.setTime(new Date(conversationMessage.getMessageRecord().getDateSent()));
return Util.hashCode(calendar.get(Calendar.YEAR), calendar.get(Calendar.DAY_OF_YEAR));
}
@@ -300,8 +299,8 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
@Override
public void onBindHeaderViewHolder(StickyHeaderViewHolder viewHolder, int position) {
MessageRecord messageRecord = Objects.requireNonNull(getItem(position));
viewHolder.setText(DateUtils.getRelativeDate(viewHolder.itemView.getContext(), locale, messageRecord.getDateReceived()));
ConversationMessage conversationMessage = Objects.requireNonNull(getItem(position));
viewHolder.setText(DateUtils.getRelativeDate(viewHolder.itemView.getContext(), locale, conversationMessage.getMessageRecord().getDateReceived()));
}
void onBindLastSeenViewHolder(StickyHeaderViewHolder viewHolder, int position) {
@@ -328,12 +327,12 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
if (position >= getItemCount()) return 0;
if (position < 0) return 0;
MessageRecord messageRecord = getItem(position);
ConversationMessage conversationMessage = getItem(position);
if (messageRecord == null || messageRecord.isOutgoing()) {
if (conversationMessage == null || conversationMessage.getMessageRecord().isOutgoing()) {
return 0;
} else {
return messageRecord.getDateReceived();
return conversationMessage.getMessageRecord().getDateReceived();
}
}
@@ -403,8 +402,8 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
* for a database change.
*/
@MainThread
void addFastRecord(MessageRecord record) {
fastRecords.add(0, record);
void addFastRecord(ConversationMessage conversationMessage) {
fastRecords.add(0, conversationMessage);
notifyDataSetChanged();
}
@@ -422,7 +421,7 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
/**
* Returns set of records that are selected in multi-select mode.
*/
Set<MessageRecord> getSelectedItems() {
Set<ConversationMessage> getSelectedItems() {
return new HashSet<>(selected);
}
@@ -436,11 +435,11 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
/**
* Toggles the selected state of a record in multi-select mode.
*/
void toggleSelection(MessageRecord record) {
if (selected.contains(record)) {
selected.remove(record);
void toggleSelection(ConversationMessage conversationMessage) {
if (selected.contains(conversationMessage)) {
selected.remove(conversationMessage);
} else {
selected.add(record);
selected.add(conversationMessage);
}
}
@@ -464,11 +463,11 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
Util.assertMainThread();
synchronized (releasedFastRecords) {
Iterator<MessageRecord> recordIterator = fastRecords.iterator();
while (recordIterator.hasNext()) {
long id = recordIterator.next().getId();
Iterator<ConversationMessage> messageIterator = fastRecords.iterator();
while (messageIterator.hasNext()) {
long id = messageIterator.next().getMessageRecord().getId();
if (releasedFastRecords.contains(id)) {
recordIterator.remove();
messageIterator.remove();
releasedFastRecords.remove(id);
}
}
@@ -510,18 +509,17 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
}
}
public @Nullable MessageRecord getLastVisibleMessageRecord(int position) {
public @Nullable ConversationMessage getLastVisibleConversationMessage(int position) {
return getItem(position - ((hasFooter() && position == getItemCount() - 1) ? 1 : 0));
}
static class ConversationViewHolder extends RecyclerView.ViewHolder {
public <V extends View & BindableConversationItem> ConversationViewHolder(final @NonNull V itemView) {
public ConversationViewHolder(final @NonNull View itemView) {
super(itemView);
}
public <V extends View & BindableConversationItem> V getView() {
//noinspection unchecked
return (V)itemView;
public BindableConversationItem getBindable() {
return (BindableConversationItem) itemView;
}
}
@@ -530,7 +528,7 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
StickyHeaderViewHolder(View itemView) {
super(itemView);
textView = ViewUtil.findById(itemView, R.id.text);
textView = itemView.findViewById(R.id.text);
}
StickyHeaderViewHolder(TextView textView) {
@@ -571,21 +569,21 @@ public class ConversationAdapter<V extends View & BindableConversationItem>
}
}
private static class DiffCallback extends DiffUtil.ItemCallback<MessageRecord> {
private static class DiffCallback extends DiffUtil.ItemCallback<ConversationMessage> {
@Override
public boolean areItemsTheSame(@NonNull MessageRecord oldItem, @NonNull MessageRecord newItem) {
return oldItem.isMms() == newItem.isMms() && oldItem.getId() == newItem.getId();
public boolean areItemsTheSame(@NonNull ConversationMessage oldItem, @NonNull ConversationMessage newItem) {
return oldItem.getMessageRecord().isMms() == newItem.getMessageRecord().isMms() && oldItem.getMessageRecord().getId() == newItem.getMessageRecord().getId();
}
@Override
public boolean areContentsTheSame(@NonNull MessageRecord oldItem, @NonNull MessageRecord newItem) {
public boolean areContentsTheSame(@NonNull ConversationMessage oldItem, @NonNull ConversationMessage newItem) {
// Corner rounding is not part of the model, so we can't use this yet
return false;
}
}
interface ItemClickListener extends BindableConversationItem.EventListener {
void onItemClick(MessageRecord item);
void onItemLongClick(View maskTarget, MessageRecord item);
void onItemClick(ConversationMessage item);
void onItemLongClick(View maskTarget, ConversationMessage item);
}
}

View File

@@ -7,12 +7,13 @@ import androidx.annotation.NonNull;
import androidx.paging.DataSource;
import androidx.paging.PositionalDataSource;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.database.DatabaseContentProviders;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MmsSmsDatabase;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.paging.Invalidator;
import org.thoughtcrime.securesms.util.paging.SizeFixResult;
@@ -24,7 +25,7 @@ import java.util.concurrent.Executor;
/**
* Core data source for loading an individual conversation.
*/
class ConversationDataSource extends PositionalDataSource<MessageRecord> {
class ConversationDataSource extends PositionalDataSource<ConversationMessage> {
private static final String TAG = Log.tag(ConversationDataSource.class);
@@ -57,7 +58,7 @@ class ConversationDataSource extends PositionalDataSource<MessageRecord> {
}
@Override
public void loadInitial(@NonNull LoadInitialParams params, @NonNull LoadInitialCallback<MessageRecord> callback) {
public void loadInitial(@NonNull LoadInitialParams params, @NonNull LoadInitialCallback<ConversationMessage> callback) {
long start = System.currentTimeMillis();
MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context);
@@ -76,14 +77,19 @@ class ConversationDataSource extends PositionalDataSource<MessageRecord> {
if (!isInvalid()) {
SizeFixResult<MessageRecord> result = SizeFixResult.ensureMultipleOfPageSize(records, params.requestedStartPosition, params.pageSize, totalCount);
callback.onResult(result.getItems(), params.requestedStartPosition, result.getTotal());
}
List<ConversationMessage> items = Stream.of(result.getItems())
.map(ConversationMessage::new)
.toList();
Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms | thread: " + threadId + ", start: " + params.requestedStartPosition + ", size: " + params.requestedLoadSize + (isInvalid() ? " -- invalidated" : ""));
callback.onResult(items, params.requestedStartPosition, result.getTotal());
Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms | thread: " + threadId + ", start: " + params.requestedStartPosition + ", requestedSize: " + params.requestedLoadSize + ", actualSize: " + result.getItems().size() + ", totalCount: " + result.getTotal());
} else {
Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms | thread: " + threadId + ", start: " + params.requestedStartPosition + ", requestedSize: " + params.requestedLoadSize + ", totalCount: " + totalCount + " -- invalidated");
}
}
@Override
public void loadRange(@NonNull LoadRangeParams params, @NonNull LoadRangeCallback<MessageRecord> callback) {
public void loadRange(@NonNull LoadRangeParams params, @NonNull LoadRangeCallback<ConversationMessage> callback) {
long start = System.currentTimeMillis();
MmsSmsDatabase db = DatabaseFactory.getMmsSmsDatabase(context);
@@ -96,12 +102,15 @@ class ConversationDataSource extends PositionalDataSource<MessageRecord> {
}
}
callback.onResult(records);
List<ConversationMessage> items = Stream.of(records)
.map(ConversationMessage::new)
.toList();
callback.onResult(items);
Log.d(TAG, "[Update] " + (System.currentTimeMillis() - start) + " ms | thread: " + threadId + ", start: " + params.startPosition + ", size: " + params.loadSize + (isInvalid() ? " -- invalidated" : ""));
}
static class Factory extends DataSource.Factory<Integer, MessageRecord> {
static class Factory extends DataSource.Factory<Integer, ConversationMessage> {
private final Context context;
private final long threadId;
@@ -114,7 +123,7 @@ class ConversationDataSource extends PositionalDataSource<MessageRecord> {
}
@Override
public @NonNull DataSource<Integer, MessageRecord> create() {
public @NonNull DataSource<Integer, ConversationMessage> create() {
return new ConversationDataSource(context, threadId, invalidator);
}
}

View File

@@ -55,6 +55,7 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.OnScrollListener;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import com.google.android.collect.Sets;
@@ -152,6 +153,7 @@ public class ConversationFragment extends LoggingFragment {
private Locale locale;
private RecyclerView list;
private RecyclerView.ItemDecoration lastSeenDecoration;
private RecyclerView.ItemDecoration stickyHeaderDecoration;
private ViewSwitcher topLoadMoreView;
private ViewSwitcher bottomLoadMoreView;
private ConversationTypingView typingView;
@@ -210,8 +212,8 @@ public class ConversationFragment extends LoggingFragment {
typingView = (ConversationTypingView) inflater.inflate(R.layout.conversation_typing_view, container, false);
new ConversationItemSwipeCallback(
messageRecord -> actionMode == null &&
MenuState.canReplyToMessage(MenuState.isActionMessage(messageRecord), messageRecord, messageRequestViewModel.shouldShowMessageRequest()),
conversationMessage -> actionMode == null &&
MenuState.canReplyToMessage(MenuState.isActionMessage(conversationMessage.getMessageRecord()), conversationMessage.getMessageRecord(), messageRequestViewModel.shouldShowMessageRequest()),
this::handleReplyMessage
).attachToRecyclerView(list);
@@ -288,9 +290,9 @@ public class ConversationFragment extends LoggingFragment {
final long lastVisibleMessageTimestamp;
if (firstVisiblePosition > 0 && lastVisiblePosition != RecyclerView.NO_POSITION) {
MessageRecord message = getListAdapter().getLastVisibleMessageRecord(lastVisiblePosition);
ConversationMessage message = getListAdapter().getLastVisibleConversationMessage(lastVisiblePosition);
lastVisibleMessageTimestamp = message != null ? message.getDateReceived() : 0;
lastVisibleMessageTimestamp = message != null ? message.getMessageRecord().getDateReceived() : 0;
} else {
lastVisibleMessageTimestamp = 0;
}
@@ -434,7 +436,7 @@ public class ConversationFragment extends LoggingFragment {
Log.d(TAG, "Initializing adapter for " + recipient.getId());
ConversationAdapter adapter = new ConversationAdapter(GlideApp.with(this), locale, selectionClickListener, this.recipient.get());
list.setAdapter(adapter);
list.addItemDecoration(new StickyHeaderDecoration(adapter, false, false));
setStickyHeaderDecoration(adapter);
ConversationAdapter.initializePool(list.getRecycledViewPool());
adapter.registerAdapterDataObserver(snapToTopDataObserver);
@@ -519,14 +521,14 @@ public class ConversationFragment extends LoggingFragment {
}
private void setCorrectMenuVisibility(@NonNull Menu menu) {
Set<MessageRecord> messageRecords = getListAdapter().getSelectedItems();
Set<ConversationMessage> messages = getListAdapter().getSelectedItems();
if (actionMode != null && messageRecords.size() == 0) {
if (actionMode != null && messages.size() == 0) {
actionMode.finish();
return;
}
MenuState menuState = MenuState.getMenuState(messageRecords, messageRequestViewModel.shouldShowMessageRequest());
MenuState menuState = MenuState.getMenuState(Stream.of(messages).map(ConversationMessage::getMessageRecord).collect(Collectors.toSet()), messageRequestViewModel.shouldShowMessageRequest());
menu.findItem(R.id.menu_context_forward).setVisible(menuState.shouldShowForwardAction());
menu.findItem(R.id.menu_context_reply).setVisible(menuState.shouldShowReplyAction());
@@ -544,8 +546,8 @@ public class ConversationFragment extends LoggingFragment {
return (SmoothScrollingLinearLayoutManager) list.getLayoutManager();
}
private MessageRecord getSelectedMessageRecord() {
Set<MessageRecord> messageRecords = getListAdapter().getSelectedItems();
private ConversationMessage getSelectedConversationMessage() {
Set<ConversationMessage> messageRecords = getListAdapter().getSelectedItems();
if (messageRecords.size() == 1) return messageRecords.iterator().next();
else throw new AssertionError();
@@ -572,6 +574,15 @@ public class ConversationFragment extends LoggingFragment {
}
}
public void setStickyHeaderDecoration(@NonNull ConversationAdapter adapter) {
if (stickyHeaderDecoration != null) {
list.removeItemDecoration(stickyHeaderDecoration);
}
stickyHeaderDecoration = new StickyHeaderDecoration(adapter, false, false);
list.addItemDecoration(stickyHeaderDecoration);
}
public void setLastSeen(long lastSeen) {
if (lastSeenDecoration != null) {
list.removeItemDecoration(lastSeenDecoration);
@@ -581,8 +592,8 @@ public class ConversationFragment extends LoggingFragment {
list.addItemDecoration(lastSeenDecoration);
}
private void handleCopyMessage(final Set<MessageRecord> messageRecords) {
List<MessageRecord> messageList = new LinkedList<>(messageRecords);
private void handleCopyMessage(final Set<ConversationMessage> conversationMessages) {
List<MessageRecord> messageList = Stream.of(conversationMessages).map(ConversationMessage::getMessageRecord).toList();
Collections.sort(messageList, new Comparator<MessageRecord>() {
@Override
public int compare(MessageRecord lhs, MessageRecord rhs) {
@@ -611,7 +622,8 @@ public class ConversationFragment extends LoggingFragment {
clipboard.setText(result);
}
private void handleDeleteMessages(final Set<MessageRecord> messageRecords) {
private void handleDeleteMessages(final Set<ConversationMessage> conversationMessages) {
Set<MessageRecord> messageRecords = Stream.of(conversationMessages).map(ConversationMessage::getMessageRecord).collect(Collectors.toSet());
if (FeatureFlags.remoteDelete()) {
buildRemoteDeleteConfirmationDialog(messageRecords).show();
} else {
@@ -725,11 +737,12 @@ public class ConversationFragment extends LoggingFragment {
}
}
private void handleDisplayDetails(MessageRecord message) {
startActivity(MessageDetailsActivity.getIntentForMessageDetails(requireContext(), message, recipient.getId(), threadId));
private void handleDisplayDetails(ConversationMessage message) {
startActivity(MessageDetailsActivity.getIntentForMessageDetails(requireContext(), message.getMessageRecord(), recipient.getId(), threadId));
}
private void handleForwardMessage(MessageRecord message) {
private void handleForwardMessage(ConversationMessage conversationMessage) {
MessageRecord message = conversationMessage.getMessageRecord();
if (message.isViewOnce()) {
throw new AssertionError("Cannot forward a view-once message.");
}
@@ -812,13 +825,13 @@ public class ConversationFragment extends LoggingFragment {
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, message);
}
private void handleReplyMessage(final MessageRecord message) {
private void handleReplyMessage(final ConversationMessage message) {
if (getActivity() != null) {
//noinspection ConstantConditions
((AppCompatActivity) getActivity()).getSupportActionBar().collapseActionView();
}
listener.handleReplyMessage(message);
listener.handleReplyMessage(message.getMessageRecord());
}
private void handleSaveAttachment(final MediaMmsMessageRecord message) {
@@ -858,7 +871,7 @@ public class ConversationFragment extends LoggingFragment {
if (getListAdapter() != null) {
clearHeaderIfNotTyping(getListAdapter());
setLastSeen(0);
getListAdapter().addFastRecord(messageRecord);
getListAdapter().addFastRecord(new ConversationMessage(messageRecord));
list.post(() -> list.scrollToPosition(0));
}
@@ -871,7 +884,7 @@ public class ConversationFragment extends LoggingFragment {
if (getListAdapter() != null) {
clearHeaderIfNotTyping(getListAdapter());
setLastSeen(0);
getListAdapter().addFastRecord(messageRecord);
getListAdapter().addFastRecord(new ConversationMessage(messageRecord));
list.post(() -> list.scrollToPosition(0));
}
@@ -1011,6 +1024,7 @@ public class ConversationFragment extends LoggingFragment {
void onCursorChanged();
void onListVerticalTranslationChanged(float translationY);
void onMessageWithErrorClicked(@NonNull MessageRecord messageRecord);
void handleReactionDetails(@NonNull View maskTarget);
}
private class ConversationScrollListener extends OnScrollListener {
@@ -1085,9 +1099,9 @@ public class ConversationFragment extends LoggingFragment {
private class ConversationFragmentItemClickListener implements ItemClickListener {
@Override
public void onItemClick(MessageRecord messageRecord) {
public void onItemClick(ConversationMessage conversationMessage) {
if (actionMode != null) {
((ConversationAdapter) list.getAdapter()).toggleSelection(messageRecord);
((ConversationAdapter) list.getAdapter()).toggleSelection(conversationMessage);
list.getAdapter().notifyDataSetChanged();
if (getListAdapter().getSelectedItems().size() == 0) {
@@ -1100,10 +1114,12 @@ public class ConversationFragment extends LoggingFragment {
}
@Override
public void onItemLongClick(View maskTarget, MessageRecord messageRecord) {
public void onItemLongClick(View maskTarget, ConversationMessage conversationMessage) {
if (actionMode != null) return;
MessageRecord messageRecord = conversationMessage.getMessageRecord();
if (messageRecord.isSecure() &&
!messageRecord.isRemoteDelete() &&
!messageRecord.isUpdate() &&
@@ -1113,12 +1129,12 @@ public class ConversationFragment extends LoggingFragment {
{
isReacting = true;
list.setLayoutFrozen(true);
listener.handleReaction(maskTarget, messageRecord, new ReactionsToolbarListener(messageRecord), () -> {
listener.handleReaction(maskTarget, messageRecord, new ReactionsToolbarListener(conversationMessage), () -> {
isReacting = false;
list.setLayoutFrozen(false);
});
} else {
((ConversationAdapter) list.getAdapter()).toggleSelection(messageRecord);
((ConversationAdapter) list.getAdapter()).toggleSelection(conversationMessage);
list.getAdapter().notifyDataSetChanged();
actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback);
@@ -1259,9 +1275,10 @@ public class ConversationFragment extends LoggingFragment {
}
@Override
public void onReactionClicked(long messageId, boolean isMms) {
public void onReactionClicked(@NonNull View reactionTarget, long messageId, boolean isMms) {
if (getContext() == null) return;
listener.handleReactionDetails(reactionTarget);
ReactionsBottomSheetDialogFragment.create(messageId, isMms).show(requireFragmentManager(), null);
}
@@ -1287,8 +1304,8 @@ public class ConversationFragment extends LoggingFragment {
}
}
private void handleEnterMultiSelect(@NonNull MessageRecord messageRecord) {
((ConversationAdapter) list.getAdapter()).toggleSelection(messageRecord);
private void handleEnterMultiSelect(@NonNull ConversationMessage conversationMessage) {
((ConversationAdapter) list.getAdapter()).toggleSelection(conversationMessage);
list.getAdapter().notifyDataSetChanged();
actionMode = ((AppCompatActivity)getActivity()).startSupportActionMode(actionModeCallback);
@@ -1342,23 +1359,23 @@ public class ConversationFragment extends LoggingFragment {
private class ReactionsToolbarListener implements Toolbar.OnMenuItemClickListener {
private final MessageRecord messageRecord;
private final ConversationMessage conversationMessage;
private ReactionsToolbarListener(@NonNull MessageRecord messageRecord) {
this.messageRecord = messageRecord;
private ReactionsToolbarListener(@NonNull ConversationMessage conversationMessage) {
this.conversationMessage = conversationMessage;
}
@Override
public boolean onMenuItemClick(MenuItem item) {
switch (item.getItemId()) {
case R.id.action_info: handleDisplayDetails(messageRecord); return true;
case R.id.action_delete: handleDeleteMessages(Sets.newHashSet(messageRecord)); return true;
case R.id.action_copy: handleCopyMessage(Sets.newHashSet(messageRecord)); return true;
case R.id.action_reply: handleReplyMessage(messageRecord); return true;
case R.id.action_multiselect: handleEnterMultiSelect(messageRecord); return true;
case R.id.action_forward: handleForwardMessage(messageRecord); return true;
case R.id.action_download: handleSaveAttachment((MediaMmsMessageRecord) messageRecord); return true;
default: return false;
case R.id.action_info: handleDisplayDetails(conversationMessage); return true;
case R.id.action_delete: handleDeleteMessages(Sets.newHashSet(conversationMessage)); return true;
case R.id.action_copy: handleCopyMessage(Sets.newHashSet(conversationMessage)); return true;
case R.id.action_reply: handleReplyMessage(conversationMessage); return true;
case R.id.action_multiselect: handleEnterMultiSelect(conversationMessage); return true;
case R.id.action_forward: handleForwardMessage(conversationMessage); return true;
case R.id.action_download: handleSaveAttachment((MediaMmsMessageRecord) conversationMessage.getMessageRecord()); return true;
default: return false;
}
}
}
@@ -1417,24 +1434,24 @@ public class ConversationFragment extends LoggingFragment {
actionMode.finish();
return true;
case R.id.menu_context_details:
handleDisplayDetails(getSelectedMessageRecord());
handleDisplayDetails(getSelectedConversationMessage());
actionMode.finish();
return true;
case R.id.menu_context_forward:
handleForwardMessage(getSelectedMessageRecord());
handleForwardMessage(getSelectedConversationMessage());
actionMode.finish();
return true;
case R.id.menu_context_resend:
handleResendMessage(getSelectedMessageRecord());
handleResendMessage(getSelectedConversationMessage().getMessageRecord());
actionMode.finish();
return true;
case R.id.menu_context_save_attachment:
handleSaveAttachment((MediaMmsMessageRecord)getSelectedMessageRecord());
handleSaveAttachment((MediaMmsMessageRecord) getSelectedConversationMessage().getMessageRecord());
actionMode.finish();
return true;
case R.id.menu_context_reply:
maybeShowSwipeToReplyTooltip();
handleReplyMessage(getSelectedMessageRecord());
handleReplyMessage(getSelectedConversationMessage());
actionMode.finish();
return true;
}

View File

@@ -139,11 +139,12 @@ public class ConversationItem extends LinearLayout implements BindableConversati
private static final Rect SWIPE_RECT = new Rect();
private MessageRecord messageRecord;
private Locale locale;
private boolean groupThread;
private LiveRecipient recipient;
private GlideRequests glideRequests;
private ConversationMessage conversationMessage;
private MessageRecord messageRecord;
private Locale locale;
private boolean groupThread;
private LiveRecipient recipient;
private GlideRequests glideRequests;
protected ConversationItemBodyBubble bodyBubble;
protected View reply;
@@ -160,7 +161,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
private ViewGroup container;
protected ReactionsConversationView reactionsView;
private @NonNull Set<MessageRecord> batchSelected = new HashSet<>();
private @NonNull Set<ConversationMessage> batchSelected = new HashSet<>();
private @NonNull Outliner outliner = new Outliner();
private LiveRecipient conversationRecipient;
private Stub<ConversationItemThumbnail> mediaThumbnailStub;
@@ -234,22 +235,23 @@ public class ConversationItem extends LinearLayout implements BindableConversati
}
@Override
public void bind(@NonNull MessageRecord messageRecord,
public void bind(@NonNull ConversationMessage conversationMessage,
@NonNull Optional<MessageRecord> previousMessageRecord,
@NonNull Optional<MessageRecord> nextMessageRecord,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@NonNull Set<MessageRecord> batchSelected,
@NonNull Recipient conversationRecipient,
@Nullable String searchQuery,
boolean pulseHighlight)
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@NonNull Set<ConversationMessage> batchSelected,
@NonNull Recipient conversationRecipient,
@Nullable String searchQuery,
boolean pulseHighlight)
{
if (this.recipient != null) this.recipient.removeForeverObserver(this);
if (this.conversationRecipient != null) this.conversationRecipient.removeForeverObserver(this);
conversationRecipient = conversationRecipient.resolve();
this.messageRecord = messageRecord;
this.conversationMessage = conversationMessage;
this.messageRecord = conversationMessage.getMessageRecord();
this.locale = locale;
this.glideRequests = glideRequests;
this.batchSelected = batchSelected;
@@ -263,7 +265,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
setGutterSizes(messageRecord, groupThread);
setMessageShape(messageRecord, previousMessageRecord, nextMessageRecord, groupThread);
setMediaAttributes(messageRecord, previousMessageRecord, nextMessageRecord, conversationRecipient, groupThread);
setInteractionState(messageRecord, pulseHighlight);
setInteractionState(conversationMessage, pulseHighlight);
setBodyText(messageRecord, searchQuery);
setBubbleState(messageRecord);
setStatusIcons(messageRecord);
@@ -381,8 +383,8 @@ public class ConversationItem extends LinearLayout implements BindableConversati
}
}
public MessageRecord getMessageRecord() {
return messageRecord;
public ConversationMessage getConversationMessage() {
return conversationMessage;
}
/// MessageRecord Attribute Parsers
@@ -424,8 +426,8 @@ public class ConversationItem extends LinearLayout implements BindableConversati
}
}
private void setInteractionState(MessageRecord messageRecord, boolean pulseHighlight) {
if (batchSelected.contains(messageRecord)) {
private void setInteractionState(ConversationMessage conversationMessage, boolean pulseHighlight) {
if (batchSelected.contains(conversationMessage)) {
setBackgroundResource(R.drawable.conversation_item_background);
setSelected(true);
} else if (pulseHighlight) {
@@ -437,19 +439,19 @@ public class ConversationItem extends LinearLayout implements BindableConversati
}
if (mediaThumbnailStub.resolved()) {
mediaThumbnailStub.get().setFocusable(!shouldInterceptClicks(messageRecord) && batchSelected.isEmpty());
mediaThumbnailStub.get().setClickable(!shouldInterceptClicks(messageRecord) && batchSelected.isEmpty());
mediaThumbnailStub.get().setFocusable(!shouldInterceptClicks(conversationMessage.getMessageRecord()) && batchSelected.isEmpty());
mediaThumbnailStub.get().setClickable(!shouldInterceptClicks(conversationMessage.getMessageRecord()) && batchSelected.isEmpty());
mediaThumbnailStub.get().setLongClickable(batchSelected.isEmpty());
}
if (audioViewStub.resolved()) {
audioViewStub.get().setFocusable(!shouldInterceptClicks(messageRecord) && batchSelected.isEmpty());
audioViewStub.get().setFocusable(!shouldInterceptClicks(conversationMessage.getMessageRecord()) && batchSelected.isEmpty());
audioViewStub.get().setClickable(batchSelected.isEmpty());
audioViewStub.get().setEnabled(batchSelected.isEmpty());
}
if (documentViewStub.resolved()) {
documentViewStub.get().setFocusable(!shouldInterceptClicks(messageRecord) && batchSelected.isEmpty());
documentViewStub.get().setFocusable(!shouldInterceptClicks(conversationMessage.getMessageRecord()) && batchSelected.isEmpty());
documentViewStub.get().setClickable(batchSelected.isEmpty());
}
}
@@ -968,7 +970,7 @@ public class ConversationItem extends LinearLayout implements BindableConversati
reactionsView.setOnClickListener(v -> {
if (eventListener == null) return;
eventListener.onReactionClicked(current.getId(), current.isMms());
eventListener.onReactionClicked(this, current.getId(), current.isMms());
});
}

View File

@@ -114,8 +114,8 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
private void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder) {
if (cannotSwipeViewHolder(viewHolder)) return;
ConversationItem item = ((ConversationItem) viewHolder.itemView);
MessageRecord messageRecord = item.getMessageRecord();
ConversationItem item = ((ConversationItem) viewHolder.itemView);
ConversationMessage messageRecord = item.getConversationMessage();
onSwipeListener.onSwipe(messageRecord);
}
@@ -169,7 +169,7 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
if (!(viewHolder.itemView instanceof ConversationItem)) return true;
ConversationItem item = ((ConversationItem) viewHolder.itemView);
return !swipeAvailabilityProvider.isSwipeAvailable(item.getMessageRecord()) ||
return !swipeAvailabilityProvider.isSwipeAvailable(item.getConversationMessage()) ||
item.disallowSwipe(latestDownX, latestDownY);
}
@@ -192,10 +192,10 @@ class ConversationItemSwipeCallback extends ItemTouchHelper.SimpleCallback {
}
interface SwipeAvailabilityProvider {
boolean isSwipeAvailable(MessageRecord messageRecord);
boolean isSwipeAvailable(ConversationMessage conversationMessage);
}
interface OnSwipeListener {
void onSwipe(MessageRecord messageRecord);
void onSwipe(ConversationMessage conversationMessage);
}
}

View File

@@ -0,0 +1,44 @@
package org.thoughtcrime.securesms.conversation;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.util.Conversions;
import java.security.MessageDigest;
/**
* A view level model used to pass arbitrary message related information needed
* for various presentations.
*/
public class ConversationMessage {
private final MessageRecord messageRecord;
public ConversationMessage(@NonNull MessageRecord messageRecord) {
this.messageRecord = messageRecord;
}
public @NonNull MessageRecord getMessageRecord() {
return messageRecord;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
final ConversationMessage that = (ConversationMessage) o;
return messageRecord.equals(that.messageRecord);
}
@Override
public int hashCode() {
return messageRecord.hashCode();
}
public long getUniqueId(@NonNull MessageDigest digest) {
String unique = (messageRecord.isMms() ? "MMS::" : "SMS::") + messageRecord.getId();
byte[] bytes = digest.digest(unique.getBytes());
return Conversions.byteArrayToLong(bytes);
}
}

View File

@@ -5,7 +5,9 @@ import android.animation.AnimatorSet;
import android.app.Activity;
import android.content.Context;
import android.graphics.PointF;
import android.graphics.PorterDuff;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.util.AttributeSet;
import android.view.HapticFeedbackConstants;
@@ -19,7 +21,6 @@ import android.widget.RelativeLayout;
import androidx.annotation.IdRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.Toolbar;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
@@ -32,10 +33,11 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.components.MaskView;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.components.emoji.EmojiUtil;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -91,6 +93,7 @@ public final class ConversationReactionOverlay extends RelativeLayout {
private OnHideListener onHideListener;
private AnimatorSet revealAnimatorSet = new AnimatorSet();
private AnimatorSet revealMaskAnimatorSet = new AnimatorSet();
private AnimatorSet hideAnimatorSet = new AnimatorSet();
private AnimatorSet hideAllButMaskAnimatorSet = new AnimatorSet();
private AnimatorSet hideMaskAnimatorSet = new AnimatorSet();
@@ -185,16 +188,31 @@ public final class ConversationReactionOverlay extends RelativeLayout {
maskView.setTarget(maskTarget);
hideAnimatorSet.end();
toolbar.setVisibility(VISIBLE);
setVisibility(View.VISIBLE);
revealAnimatorSet.start();
if (Build.VERSION.SDK_INT >= 21) {
this.activity = activity;
originalStatusBarColor = activity.getWindow().getStatusBarColor();
activity.getWindow().setStatusBarColor(ContextCompat.getColor(activity, R.color.action_mode_status_bar));
activity.getWindow().setStatusBarColor(ThemeUtil.getThemedColor(getContext(), R.attr.reactions_overlay_toolbar_background_color));
if (!ThemeUtil.isDarkTheme(getContext()) && Build.VERSION.SDK_INT >= 23) {
activity.getWindow().getDecorView().setSystemUiVisibility(activity.getWindow().getDecorView().getSystemUiVisibility() | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
}
}
}
public void showMask(@NonNull View maskTarget, int maskPaddingTop, int maskPaddingBottom) {
maskView.setPadding(0, maskPaddingTop, 0, maskPaddingBottom);
maskView.setTarget(maskTarget);
hideAnimatorSet.end();
toolbar.setVisibility(GONE);
setVisibility(VISIBLE);
revealMaskAnimatorSet.start();
}
public void hide() {
maskView.setTarget(null);
hideInternal(hideAnimatorSet, onHideListener);
@@ -218,8 +236,9 @@ public final class ConversationReactionOverlay extends RelativeLayout {
revealAnimatorSet.end();
hideAnimatorSet.start();
if (Build.VERSION.SDK_INT >= 21 && activity != null) {
if (Build.VERSION.SDK_INT >= 23 && activity != null) {
activity.getWindow().setStatusBarColor(originalStatusBarColor);
activity.getWindow().getDecorView().setSystemUiVisibility(activity.getWindow().getDecorView().getSystemUiVisibility() & ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
activity = null;
}
@@ -358,7 +377,7 @@ public final class ConversationReactionOverlay extends RelativeLayout {
view.setTranslationY(0);
boolean isAtCustomIndex = i == customEmojiIndex;
boolean isNotAtCustomIndexAndOldEmojiMatches = !isAtCustomIndex && ReactionEmoji.values()[i].emoji.equals(oldEmoji);
boolean isNotAtCustomIndexAndOldEmojiMatches = !isAtCustomIndex && oldEmoji != null && ReactionEmoji.values()[i].emoji.equals(EmojiUtil.getCanonicalRepresentation(oldEmoji));
boolean isAtCustomIndexAndOldEmojiExists = isAtCustomIndex && oldEmoji != null;
if (!foundSelected &&
@@ -379,13 +398,13 @@ public final class ConversationReactionOverlay extends RelativeLayout {
view.setImageEmoji(oldEmoji);
view.setTag(oldEmoji);
} else {
view.setImageEmoji(ReactionEmoji.values()[i].emoji);
view.setImageEmoji(SignalStore.emojiValues().getPreferredVariation(ReactionEmoji.values()[i].emoji));
}
} else if (isAtCustomIndex) {
view.setImageDrawable(AppCompatResources.getDrawable(getContext(), R.drawable.ic_any_emoji_32));
view.setImageDrawable(ThemeUtil.getThemedDrawable(getContext(), R.attr.reactions_overlay_custom_emoji_icon));
view.setTag(null);
} else {
view.setImageEmoji(ReactionEmoji.values()[i].emoji);
view.setImageEmoji(SignalStore.emojiValues().getPreferredVariation(ReactionEmoji.values()[i].emoji));
}
}
}
@@ -447,7 +466,7 @@ public final class ConversationReactionOverlay extends RelativeLayout {
if (selected == customEmojiIndex) {
onReactionSelectedListener.onCustomReactionSelected(messageRecord, emojiViews[selected].getTag() != null);
} else {
onReactionSelectedListener.onReactionSelected(messageRecord, ReactionEmoji.values()[selected].emoji);
onReactionSelectedListener.onReactionSelected(messageRecord, SignalStore.emojiValues().getPreferredVariation(ReactionEmoji.values()[selected].emoji));
}
} else {
hide();
@@ -534,6 +553,9 @@ public final class ConversationReactionOverlay extends RelativeLayout {
revealAnimatorSet.setInterpolator(INTERPOLATOR);
revealAnimatorSet.playTogether(reveals);
revealMaskAnimatorSet.setInterpolator(INTERPOLATOR);
revealMaskAnimatorSet.playTogether(overlayRevealAnim);
List<Animator> hides = Stream.of(emojiViews)
.mapIndexed((idx, v) -> {
Animator anim = AnimatorInflaterCompat.loadAnimator(getContext(), R.animator.reactions_scrubber_hide);

View File

@@ -4,6 +4,7 @@ import android.content.Context;
import android.graphics.ColorFilter;
import android.graphics.PorterDuff;
import android.graphics.PorterDuffColorFilter;
import android.text.SpannableString;
import android.util.AttributeSet;
import android.view.View;
import android.widget.ImageView;
@@ -13,43 +14,55 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.Transformations;
import org.thoughtcrime.securesms.BindableConversationItem;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.VerifyIdentityActivity;
import org.thoughtcrime.securesms.database.IdentityDatabase.IdentityRecord;
import org.thoughtcrime.securesms.database.model.LiveUpdateMessage;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.UpdateDescription;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ExecutionException;
public class ConversationUpdateItem extends LinearLayout
implements RecipientForeverObserver, BindableConversationItem
public final class ConversationUpdateItem extends LinearLayout
implements RecipientForeverObserver,
BindableConversationItem,
Observer<SpannableString>
{
private static final String TAG = ConversationUpdateItem.class.getSimpleName();
private Set<MessageRecord> batchSelected;
private Set<ConversationMessage> batchSelected;
private ImageView icon;
private TextView title;
private TextView body;
private TextView date;
private LiveRecipient sender;
private MessageRecord messageRecord;
private Locale locale;
private ImageView icon;
private TextView title;
private TextView body;
private TextView date;
private LiveRecipient sender;
private ConversationMessage conversationMessage;
private MessageRecord messageRecord;
private Locale locale;
private LiveData<SpannableString> displayBody;
private final Debouncer bodyClearDebouncer = new Debouncer(150);
public ConversationUpdateItem(Context context) {
super(context);
@@ -72,19 +85,19 @@ public class ConversationUpdateItem extends LinearLayout
}
@Override
public void bind(@NonNull MessageRecord messageRecord,
public void bind(@NonNull ConversationMessage conversationMessage,
@NonNull Optional<MessageRecord> previousMessageRecord,
@NonNull Optional<MessageRecord> nextMessageRecord,
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@NonNull Set<MessageRecord> batchSelected,
@NonNull Recipient conversationRecipient,
@Nullable String searchQuery,
boolean pulseUpdate)
@NonNull GlideRequests glideRequests,
@NonNull Locale locale,
@NonNull Set<ConversationMessage> batchSelected,
@NonNull Recipient conversationRecipient,
@Nullable String searchQuery,
boolean pulseUpdate)
{
this.batchSelected = batchSelected;
bind(messageRecord, locale);
bind(conversationMessage, locale);
}
@Override
@@ -99,46 +112,73 @@ public class ConversationUpdateItem extends LinearLayout
}
@Override
public MessageRecord getMessageRecord() {
return messageRecord;
public ConversationMessage getConversationMessage() {
return conversationMessage;
}
private void bind(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
private void bind(@NonNull ConversationMessage conversationMessage, @NonNull Locale locale) {
if (this.sender != null) {
this.sender.removeForeverObserver(this);
}
if (this.messageRecord != null && messageRecord.isGroupAction()) {
GroupUtil.getDescription(getContext(), messageRecord.getBody(), messageRecord.isGroupV2()).removeObserver(this);
}
observeDisplayBody(null);
setBodyText(null);
this.messageRecord = messageRecord;
this.sender = messageRecord.getIndividualRecipient().live();
this.locale = locale;
this.conversationMessage = conversationMessage;
this.messageRecord = conversationMessage.getMessageRecord();
this.sender = messageRecord.getIndividualRecipient().live();
this.locale = locale;
this.sender.observeForever(this);
if (this.messageRecord != null && messageRecord.isGroupAction()) {
GroupUtil.getDescription(getContext(), messageRecord.getBody(), messageRecord.isGroupV2()).addObserver(this);
}
UpdateDescription updateDescription = Objects.requireNonNull(messageRecord.getUpdateDisplayBody(getContext()));
LiveData<String> liveUpdateMessage = LiveUpdateMessage.fromMessageDescription(updateDescription);
LiveData<SpannableString> spannableStringMessage = Transformations.map(liveUpdateMessage, SpannableString::new);
present(messageRecord);
present(conversationMessage);
observeDisplayBody(spannableStringMessage);
}
private void present(MessageRecord messageRecord) {
if (messageRecord.isGroupAction()) setGroupRecord(messageRecord);
private void observeDisplayBody(@Nullable LiveData<SpannableString> displayBody) {
if (this.displayBody != displayBody) {
if (this.displayBody != null) {
this.displayBody.removeObserver(this);
}
this.displayBody = displayBody;
if (this.displayBody != null) {
this.displayBody.observeForever(this);
}
}
}
private void setBodyText(@Nullable CharSequence text) {
if (text == null) {
bodyClearDebouncer.publish(() -> body.setText(null));
} else {
bodyClearDebouncer.clear();
body.setText(text);
body.setVisibility(VISIBLE);
}
}
private void present(ConversationMessage conversationMessage) {
MessageRecord messageRecord = conversationMessage.getMessageRecord();
if (messageRecord.isGroupAction()) setGroupRecord();
else if (messageRecord.isCallLog()) setCallRecord(messageRecord);
else if (messageRecord.isJoined()) setJoinedRecord(messageRecord);
else if (messageRecord.isJoined()) setJoinedRecord();
else if (messageRecord.isExpirationTimerUpdate()) setTimerRecord(messageRecord);
else if (messageRecord.isEndSession()) setEndSessionRecord(messageRecord);
else if (messageRecord.isIdentityUpdate()) setIdentityRecord(messageRecord);
else if (messageRecord.isEndSession()) setEndSessionRecord();
else if (messageRecord.isIdentityUpdate()) setIdentityRecord();
else if (messageRecord.isIdentityVerified() ||
messageRecord.isIdentityDefault()) setIdentityVerifyUpdate(messageRecord);
else if (messageRecord.isProfileChange()) setProfileNameChangeRecord(messageRecord);
else if (messageRecord.isProfileChange()) setProfileNameChangeRecord();
else throw new AssertionError("Neither group nor log nor joined.");
if (batchSelected.contains(messageRecord)) setSelected(true);
else setSelected(false);
if (batchSelected.contains(conversationMessage)) setSelected(true);
else setSelected(false);
}
private void setCallRecord(MessageRecord messageRecord) {
@@ -146,11 +186,9 @@ public class ConversationUpdateItem extends LinearLayout
else if (messageRecord.isOutgoingCall()) icon.setImageResource(R.drawable.ic_call_made_grey600_24dp);
else icon.setImageResource(R.drawable.ic_call_missed_grey600_24dp);
body.setText(messageRecord.getDisplayBody(getContext()));
date.setText(DateUtils.getExtendedRelativeTimeSpanString(getContext(), locale, messageRecord.getDateReceived()));
title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(View.VISIBLE);
}
@@ -163,10 +201,8 @@ public class ConversationUpdateItem extends LinearLayout
icon.setColorFilter(getIconTintFilter());
title.setText(ExpirationUtil.getExpirationDisplayValue(getContext(), (int)(messageRecord.getExpiresIn() / 1000)));
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(VISIBLE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE);
}
@@ -174,72 +210,56 @@ public class ConversationUpdateItem extends LinearLayout
return new PorterDuffColorFilter(ThemeUtil.getThemedColor(getContext(), R.attr.icon_tint), PorterDuff.Mode.SRC_IN);
}
private void setIdentityRecord(final MessageRecord messageRecord) {
private void setIdentityRecord() {
icon.setImageDrawable(ThemeUtil.getThemedDrawable(getContext(), R.attr.safety_number_icon));
icon.setColorFilter(getIconTintFilter());
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE);
}
private void setIdentityVerifyUpdate(final MessageRecord messageRecord) {
if (messageRecord.isIdentityVerified()) icon.setImageResource(R.drawable.ic_check_white_24dp);
else icon.setImageResource(R.drawable.ic_info_outline_white_24dp);
else icon.setImageResource(R.drawable.ic_info_outline_white_24);
icon.setColorFilter(getIconTintFilter());
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE);
}
private void setProfileNameChangeRecord(MessageRecord messageRecord) {
private void setProfileNameChangeRecord() {
icon.setImageDrawable(ContextCompat.getDrawable(getContext(), R.drawable.ic_profile_outline_20));
icon.setColorFilter(getIconTintFilter());
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE);
}
private void setGroupRecord(MessageRecord messageRecord) {
private void setGroupRecord() {
icon.setImageDrawable(ThemeUtil.getThemedDrawable(getContext(), R.attr.menu_group_icon));
icon.clearColorFilter();
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE);
}
private void setJoinedRecord(MessageRecord messageRecord) {
private void setJoinedRecord() {
icon.setImageResource(R.drawable.ic_favorite_grey600_24dp);
icon.clearColorFilter();
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE);
}
private void setEndSessionRecord(MessageRecord messageRecord) {
private void setEndSessionRecord() {
icon.setImageResource(R.drawable.ic_refresh_white_24dp);
icon.setColorFilter(getIconTintFilter());
body.setText(messageRecord.getDisplayBody(getContext()));
title.setVisibility(GONE);
body.setVisibility(VISIBLE);
date.setVisibility(GONE);
}
@Override
public void onRecipientChanged(@NonNull Recipient recipient) {
present(messageRecord);
present(conversationMessage);
}
@Override
@@ -252,9 +272,13 @@ public class ConversationUpdateItem extends LinearLayout
if (sender != null) {
sender.removeForeverObserver(this);
}
if (this.messageRecord != null && messageRecord.isGroupAction()) {
GroupUtil.getDescription(getContext(), messageRecord.getBody(), messageRecord.isGroupV2()).removeObserver(this);
}
observeDisplayBody(null);
}
@Override
public void onChanged(SpannableString update) {
setBodyText(update);
}
private class InternalClickListener implements View.OnClickListener {

View File

@@ -28,14 +28,14 @@ class ConversationViewModel extends ViewModel {
private static final String TAG = Log.tag(ConversationViewModel.class);
private final Application context;
private final MediaRepository mediaRepository;
private final ConversationRepository conversationRepository;
private final MutableLiveData<List<Media>> recentMedia;
private final MutableLiveData<Long> threadId;
private final LiveData<PagedList<MessageRecord>> messages;
private final LiveData<ConversationData> conversationMetadata;
private final Invalidator invalidator;
private final Application context;
private final MediaRepository mediaRepository;
private final ConversationRepository conversationRepository;
private final MutableLiveData<List<Media>> recentMedia;
private final MutableLiveData<Long> threadId;
private final LiveData<PagedList<ConversationMessage>> messages;
private final LiveData<ConversationData> conversationMetadata;
private final Invalidator invalidator;
private int jumpToPosition;
@@ -55,12 +55,12 @@ class ConversationViewModel extends ViewModel {
return conversationData;
});
LiveData<Pair<Long, PagedList<MessageRecord>>> messagesForThreadId = Transformations.switchMap(metadata, data -> {
DataSource.Factory<Integer, MessageRecord> factory = new ConversationDataSource.Factory(context, data.getThreadId(), invalidator);
PagedList.Config config = new PagedList.Config.Builder()
.setPageSize(25)
.setInitialLoadSizeHint(25)
.build();
LiveData<Pair<Long, PagedList<ConversationMessage>>> messagesForThreadId = Transformations.switchMap(metadata, data -> {
DataSource.Factory<Integer, ConversationMessage> factory = new ConversationDataSource.Factory(context, data.getThreadId(), invalidator);
PagedList.Config config = new PagedList.Config.Builder()
.setPageSize(25)
.setInitialLoadSizeHint(25)
.build();
final int startPosition;
if (data.shouldJumpToMessage()) {
@@ -109,7 +109,7 @@ class ConversationViewModel extends ViewModel {
return conversationMetadata;
}
@NonNull LiveData<PagedList<MessageRecord>> getMessages() {
@NonNull LiveData<PagedList<ConversationMessage>> getMessages() {
return messages;
}

View File

@@ -80,7 +80,7 @@ final class SafetyNumberChangeRepository {
try {
switch (messageType) {
case MmsSmsDatabase.SMS_TRANSPORT:
return DatabaseFactory.getSmsDatabase(context).getMessage(messageId);
return DatabaseFactory.getSmsDatabase(context).getMessageRecord(messageId);
case MmsSmsDatabase.MMS_TRANSPORT:
return DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId);
default:

View File

@@ -0,0 +1,47 @@
package org.thoughtcrime.securesms.conversation.ui.mentions;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MappingAdapter;
import org.thoughtcrime.securesms.util.MappingViewHolder;
public class MentionViewHolder extends MappingViewHolder<MentionViewState> {
private final AvatarImageView avatar;
private final TextView name;
@Nullable private final MentionEventsListener mentionEventsListener;
public MentionViewHolder(@NonNull View itemView, @Nullable MentionEventsListener mentionEventsListener) {
super(itemView);
this.mentionEventsListener = mentionEventsListener;
avatar = findViewById(R.id.mention_recipient_avatar);
name = findViewById(R.id.mention_recipient_name);
}
@Override
public void bind(@NonNull MentionViewState model) {
avatar.setRecipient(model.getRecipient());
name.setText(model.getName(context));
itemView.setOnClickListener(v -> {
if (mentionEventsListener != null) {
mentionEventsListener.onMentionClicked(model.getRecipient());
}
});
}
public interface MentionEventsListener {
void onMentionClicked(@NonNull Recipient recipient);
}
public static MappingAdapter.Factory<MentionViewState> createFactory(@Nullable MentionEventsListener mentionEventsListener) {
return new MappingAdapter.LayoutFactory<>(view -> new MentionViewHolder(view, mentionEventsListener), R.layout.mentions_recipient_list_item);
}
}

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.conversation.ui.mentions;
import android.content.Context;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MappingModel;
import java.util.Objects;
public final class MentionViewState implements MappingModel<MentionViewState> {
private final Recipient recipient;
public MentionViewState(@NonNull Recipient recipient) {
this.recipient = recipient;
}
@NonNull String getName(@NonNull Context context) {
return recipient.getDisplayName(context);
}
@NonNull Recipient getRecipient() {
return recipient;
}
@Override
public boolean areItemsTheSame(@NonNull MentionViewState newItem) {
return recipient.getId().equals(newItem.recipient.getId());
}
@Override
public boolean areContentsTheSame(@NonNull MentionViewState newItem) {
Context context = ApplicationDependencies.getApplication();
return recipient.getDisplayName(context).equals(newItem.recipient.getDisplayName(context)) &&
Objects.equals(recipient.getProfileAvatar(), newItem.recipient.getProfileAvatar());
}
}

View File

@@ -0,0 +1,12 @@
package org.thoughtcrime.securesms.conversation.ui.mentions;
import androidx.annotation.Nullable;
import org.thoughtcrime.securesms.conversation.ui.mentions.MentionViewHolder.MentionEventsListener;
import org.thoughtcrime.securesms.util.MappingAdapter;
public class MentionsPickerAdapter extends MappingAdapter {
public MentionsPickerAdapter(@Nullable MentionEventsListener mentionEventsListener) {
registerFactory(MentionViewState.class, MentionViewHolder.createFactory(mentionEventsListener));
}
}

View File

@@ -0,0 +1,88 @@
package org.thoughtcrime.securesms.conversation.ui.mentions;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
import org.thoughtcrime.securesms.LoggingFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.List;
public class MentionsPickerFragment extends LoggingFragment {
private MentionsPickerAdapter adapter;
private RecyclerView list;
private BottomSheetBehavior<View> behavior;
private MentionsPickerViewModel viewModel;
@Override
public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.mentions_picker_fragment, container, false);
list = view.findViewById(R.id.mentions_picker_list);
behavior = BottomSheetBehavior.from(view.findViewById(R.id.mentions_picker_bottom_sheet));
return view;
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
initializeList();
viewModel = ViewModelProviders.of(requireActivity()).get(MentionsPickerViewModel.class);
viewModel.getMentionList().observe(getViewLifecycleOwner(), this::updateList);
}
private void initializeList() {
adapter = new MentionsPickerAdapter(this::handleMentionClicked);
RecyclerView.LayoutManager layoutManager = new LinearLayoutManager(requireContext()) {
@Override
public void onLayoutCompleted(RecyclerView.State state) {
super.onLayoutCompleted(state);
updateBottomSheetBehavior(adapter.getItemCount());
}
};
list.setLayoutManager(layoutManager);
list.setAdapter(adapter);
list.setItemAnimator(null);
}
private void handleMentionClicked(@NonNull Recipient recipient) {
viewModel.onSelectionChange(recipient);
}
private void updateList(@NonNull List<MappingModel<?>> mappingModels) {
adapter.submitList(mappingModels);
if (mappingModels.isEmpty()) {
updateBottomSheetBehavior(0);
}
}
private void updateBottomSheetBehavior(int count) {
if (count > 0) {
if (behavior.getPeekHeight() == 0) {
behavior.setPeekHeight(ViewUtil.dpToPx(240), true);
behavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
}
} else {
behavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
behavior.setPeekHeight(0);
}
}
}

View File

@@ -0,0 +1,86 @@
package org.thoughtcrime.securesms.conversation.ui.mentions;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.LiveGroup;
import org.thoughtcrime.securesms.groups.ui.GroupMemberEntry.FullMember;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MappingModel;
import org.thoughtcrime.securesms.util.SingleLiveEvent;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import java.util.Collections;
import java.util.List;
public class MentionsPickerViewModel extends ViewModel {
private final SingleLiveEvent<Recipient> selectedRecipient;
private final LiveData<List<MappingModel<?>>> mentionList;
private final MutableLiveData<LiveGroup> group;
private final MutableLiveData<CharSequence> liveQuery;
MentionsPickerViewModel() {
group = new MutableLiveData<>();
liveQuery = new MutableLiveData<>();
selectedRecipient = new SingleLiveEvent<>();
// TODO [cody] [mentions] simple query support implement for building UI/UX, to be replaced with better search before launch
LiveData<List<FullMember>> members = Transformations.distinctUntilChanged(Transformations.switchMap(group, LiveGroup::getFullMembers));
mentionList = LiveDataUtil.combineLatest(Transformations.distinctUntilChanged(liveQuery), members, this::filterMembers);
}
@NonNull LiveData<List<MappingModel<?>>> getMentionList() {
return mentionList;
}
void onSelectionChange(@NonNull Recipient recipient) {
selectedRecipient.setValue(recipient);
}
public @NonNull LiveData<Recipient> getSelectedRecipient() {
return selectedRecipient;
}
public void onQueryChange(@NonNull CharSequence query) {
liveQuery.setValue(query);
}
public void onRecipientChange(@NonNull Recipient recipient) {
GroupId groupId = recipient.getGroupId().orNull();
if (groupId != null) {
LiveGroup liveGroup = new LiveGroup(groupId);
group.setValue(liveGroup);
}
}
private @NonNull List<MappingModel<?>> filterMembers(@NonNull CharSequence query, @NonNull List<FullMember> members) {
if (TextUtils.isEmpty(query)) {
return Collections.emptyList();
}
return Stream.of(members)
.filter(m -> m.getMember().getDisplayName(ApplicationDependencies.getApplication()).toLowerCase().replaceAll("\\s", "").startsWith(query.toString()))
.<MappingModel<?>>map(m -> new MentionViewState(m.getMember()))
.toList();
}
public static final class Factory implements ViewModelProvider.Factory {
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions
return modelClass.cast(new MentionsPickerViewModel());
}
}
}

View File

@@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.ThrottledDebouncer;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.util.paging.Invalidator;
import org.thoughtcrime.securesms.util.paging.SizeFixResult;
@@ -84,11 +85,11 @@ abstract class ConversationListDataSource extends PositionalDataSource<Conversat
if (!isInvalid()) {
SizeFixResult<Conversation> result = SizeFixResult.ensureMultipleOfPageSize(conversations, params.requestedStartPosition, params.pageSize, totalCount);
callback.onResult(result.getItems(), params.requestedStartPosition, result.getTotal());
Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms | start: " + params.requestedStartPosition + ", requestedSize: " + params.requestedLoadSize + ", actualSize: " + result.getItems().size() + ", totalCount: " + result.getTotal() + ", class: " + getClass().getSimpleName());
} else {
Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms | start: " + params.requestedStartPosition + ", requestedSize: " + params.requestedLoadSize + ", totalCount: " + totalCount + ", class: " + getClass().getSimpleName() + " -- invalidated");
}
Log.d(TAG, "[Initial Load] " + (System.currentTimeMillis() - start) + " ms | start: " + params.requestedStartPosition + ", size: " + params.requestedLoadSize + ", totalCount: " + totalCount + ", class: " + getClass().getSimpleName() + (isInvalid() ? " -- invalidated" : ""));
}
@Override

View File

@@ -500,9 +500,9 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private void initializeViewModel() {
viewModel = ViewModelProviders.of(this, new ConversationListViewModel.Factory(isArchived())).get(ConversationListViewModel.class);
viewModel.getSearchResult().observe(this, this::onSearchResultChanged);
viewModel.getMegaphone().observe(this, this::onMegaphoneChanged);
viewModel.getConversationList().observe(this, this::onSubmitList);
viewModel.getSearchResult().observe(getViewLifecycleOwner(), this::onSearchResultChanged);
viewModel.getMegaphone().observe(getViewLifecycleOwner(), this::onMegaphoneChanged);
viewModel.getConversationList().observe(getViewLifecycleOwner(), this::onSubmitList);
ProcessLifecycleOwner.get().getLifecycle().addObserver(new DefaultLifecycleObserver() {
@Override
@@ -751,6 +751,7 @@ public class ConversationListFragment extends MainFragment implements ActionMode
private void onSubmitList(@NonNull ConversationListViewModel.ConversationList conversationList) {
if (conversationList.isEmpty()) {
Log.i(TAG, "Received an empty data set.");
list.setVisibility(View.INVISIBLE);
emptyState.setVisibility(View.VISIBLE);
emptyImage.setImageResource(EMPTY_IMAGES[(int) (Math.random() * EMPTY_IMAGES.length)]);

View File

@@ -21,7 +21,6 @@ import android.content.res.ColorStateList;
import android.graphics.Typeface;
import android.graphics.drawable.RippleDrawable;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.style.StyleSpan;
@@ -32,6 +31,9 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.Transformations;
import org.thoughtcrime.securesms.BindableConversationListItem;
import org.thoughtcrime.securesms.R;
@@ -42,42 +44,49 @@ import org.thoughtcrime.securesms.components.DeliveryStatusView;
import org.thoughtcrime.securesms.components.FromTextView;
import org.thoughtcrime.securesms.components.ThumbnailView;
import org.thoughtcrime.securesms.components.TypingIndicatorView;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.SmsDatabase;
import org.thoughtcrime.securesms.database.ThreadDatabase;
import org.thoughtcrime.securesms.database.model.LiveUpdateMessage;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ThreadRecord;
import org.thoughtcrime.securesms.database.model.UpdateDescription;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import org.thoughtcrime.securesms.conversationlist.model.MessageResult;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.SearchUtil;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import java.util.Collections;
import java.util.Locale;
import java.util.Set;
public class ConversationListItem extends RelativeLayout
implements RecipientForeverObserver,
BindableConversationListItem, Unbindable
import static org.thoughtcrime.securesms.database.model.LiveUpdateMessage.recipientToStringAsync;
public final class ConversationListItem extends RelativeLayout
implements RecipientForeverObserver,
BindableConversationListItem,
Unbindable,
Observer<SpannableString>
{
@SuppressWarnings("unused")
private final static String TAG = ConversationListItem.class.getSimpleName();
private final static String TAG = Log.tag(ConversationListItem.class);
private final static Typeface BOLD_TYPEFACE = Typeface.create("sans-serif-medium", Typeface.NORMAL);
private final static Typeface LIGHT_TYPEFACE = Typeface.create("sans-serif", Typeface.NORMAL);
private static final int MAX_SNIPPET_LENGTH = 500;
private Set<Long> selectedThreads;
private Set<Long> typingThreads;
private LiveRecipient recipient;
private LiveRecipient groupAddedBy;
private long threadId;
private GlideRequests glideRequests;
private View subjectContainer;
@@ -97,13 +106,9 @@ public class ConversationListItem extends RelativeLayout
private AvatarImageView contactPhotoImage;
private ThumbnailView thumbnailView;
private int distributionType;
private final Debouncer subjectViewClearDebouncer = new Debouncer(150);
private final RecipientForeverObserver groupAddedByObserver = adder -> {
if (isAttachedToWindow() && subjectView != null && thread != null) {
subjectView.setText(getThreadDisplayBody(getContext(), thread));
}
};
private LiveData<SpannableString> displayBody;
public ConversationListItem(Context context) {
this(context, null);
@@ -153,16 +158,16 @@ public class ConversationListItem extends RelativeLayout
@Nullable String highlightSubstring)
{
if (this.recipient != null) this.recipient.removeForeverObserver(this);
if (this.groupAddedBy != null) this.groupAddedBy.removeForeverObserver(groupAddedByObserver);
observeDisplayBody(null);
setSubjectViewText(null);
this.selectedThreads = selectedThreads;
this.recipient = thread.getRecipient().live();
this.threadId = thread.getThreadId();
this.glideRequests = glideRequests;
this.unreadCount = thread.getUnreadCount();
this.distributionType = thread.getDistributionType();
this.lastSeen = thread.getLastSeen();
this.thread = thread;
this.selectedThreads = selectedThreads;
this.recipient = thread.getRecipient().live();
this.threadId = thread.getThreadId();
this.glideRequests = glideRequests;
this.unreadCount = thread.getUnreadCount();
this.lastSeen = thread.getLastSeen();
this.thread = thread;
this.recipient.observeForever(this);
if (highlightSubstring != null) {
@@ -173,14 +178,10 @@ public class ConversationListItem extends RelativeLayout
this.fromView.setText(recipient.get(), thread.isRead());
}
this.typingThreads = typingThreads;
updateTypingIndicator(typingThreads);
this.subjectView.setText(getTrimmedSnippet(getThreadDisplayBody(getContext(), thread)));
if (thread.getGroupAddedBy() != null) {
groupAddedBy = Recipient.live(thread.getGroupAddedBy());
groupAddedBy.observeForever(groupAddedByObserver);
}
observeDisplayBody(getThreadDisplayBody(getContext(), thread));
this.subjectView.setTypeface(thread.isRead() ? LIGHT_TYPEFACE : BOLD_TYPEFACE);
this.subjectView.setTextColor(thread.isRead() ? ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_subject_color)
@@ -214,7 +215,8 @@ public class ConversationListItem extends RelativeLayout
@Nullable String highlightSubstring)
{
if (this.recipient != null) this.recipient.removeForeverObserver(this);
if (this.groupAddedBy != null) this.groupAddedBy.removeForeverObserver(groupAddedByObserver);
observeDisplayBody(null);
setSubjectViewText(null);
this.selectedThreads = Collections.emptySet();
this.recipient = contact.live();
@@ -243,7 +245,8 @@ public class ConversationListItem extends RelativeLayout
@Nullable String highlightSubstring)
{
if (this.recipient != null) this.recipient.removeForeverObserver(this);
if (this.groupAddedBy != null) this.groupAddedBy.removeForeverObserver(groupAddedByObserver);
observeDisplayBody(null);
setSubjectViewText(null);
this.selectedThreads = Collections.emptySet();
this.recipient = messageResult.conversationRecipient.live();
@@ -275,10 +278,7 @@ public class ConversationListItem extends RelativeLayout
contactPhotoImage.setAvatar(glideRequests, null, !batchMode);
}
if (this.groupAddedBy != null) {
this.groupAddedBy.removeForeverObserver(groupAddedByObserver);
this.groupAddedBy = null;
}
observeDisplayBody(null);
}
@Override
@@ -318,17 +318,30 @@ public class ConversationListItem extends RelativeLayout
return unreadCount;
}
public int getDistributionType() {
return distributionType;
}
public long getLastSeen() {
return lastSeen;
}
private static @NonNull CharSequence getTrimmedSnippet(@NonNull CharSequence snippet) {
return snippet.length() <= MAX_SNIPPET_LENGTH ? snippet
: snippet.subSequence(0, MAX_SNIPPET_LENGTH);
private void observeDisplayBody(@Nullable LiveData<SpannableString> displayBody) {
if (this.displayBody != null) {
this.displayBody.removeObserver(this);
}
this.displayBody = displayBody;
if (this.displayBody != null) {
this.displayBody.observeForever(this);
}
}
private void setSubjectViewText(@Nullable CharSequence text) {
if (text == null) {
subjectViewClearDebouncer.publish(() -> subjectView.setText(null));
} else {
subjectViewClearDebouncer.clear();
subjectView.setText(text);
subjectView.setVisibility(VISIBLE);
}
}
private void setThumbnailSnippet(ThreadRecord thread) {
@@ -372,7 +385,7 @@ public class ConversationListItem extends RelativeLayout
}
private void setRippleColor(Recipient recipient) {
if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) {
if (VERSION.SDK_INT >= 21) {
((RippleDrawable)(getBackground()).mutate())
.setColor(ColorStateList.valueOf(recipient.getColor().toConversationColor(getContext())));
}
@@ -395,16 +408,20 @@ public class ConversationListItem extends RelativeLayout
setRippleColor(recipient);
}
private static SpannableString getThreadDisplayBody(@NonNull Context context, @NonNull ThreadRecord thread) {
private static @NonNull LiveData<SpannableString> getThreadDisplayBody(@NonNull Context context, @NonNull ThreadRecord thread) {
if (thread.getGroupAddedBy() != null) {
return emphasisAdded(context.getString(thread.isGv2Invite() ? R.string.ThreadRecord_s_invited_you_to_the_group
: R.string.ThreadRecord_s_added_you_to_the_group,
Recipient.live(thread.getGroupAddedBy()).get().getDisplayName(context)));
return emphasisAdded(recipientToStringAsync(thread.getGroupAddedBy(),
r -> context.getString(thread.isGv2Invite() ? R.string.ThreadRecord_s_invited_you_to_the_group
: R.string.ThreadRecord_s_added_you_to_the_group,
r.getDisplayName(context))));
} else if (!thread.isMessageRequestAccepted()) {
return emphasisAdded(context.getString(R.string.ThreadRecord_message_request));
} else if (SmsDatabase.Types.isGroupUpdate(thread.getType())) {
return emphasisAdded(context.getString(R.string.ThreadRecord_group_updated));
if (thread.getRecipient().isPushV2Group()) {
return emphasisAdded(MessageRecord.getGv2ChangeDescription(context, thread.getBody()));
} else {
return emphasisAdded(context.getString(R.string.ThreadRecord_group_updated));
}
} else if (SmsDatabase.Types.isGroupQuit(thread.getType())) {
return emphasisAdded(context.getString(R.string.ThreadRecord_left_the_group));
} else if (SmsDatabase.Types.isKeyExchangeType(thread.getType())) {
@@ -419,15 +436,15 @@ public class ConversationListItem extends RelativeLayout
return emphasisAdded(context.getString(R.string.MessageRecord_message_encrypted_with_a_legacy_protocol_version_that_is_no_longer_supported));
} else if (MmsSmsColumns.Types.isDraftMessageType(thread.getType())) {
String draftText = context.getString(R.string.ThreadRecord_draft);
return emphasisAdded(draftText + " " + thread.getBody(), 0, draftText.length());
return emphasisAdded(draftText + " " + thread.getBody());
} else if (SmsDatabase.Types.isOutgoingCall(thread.getType())) {
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_called));
return emphasisAdded(context.getString(R.string.ThreadRecord_called));
} else if (SmsDatabase.Types.isIncomingCall(thread.getType())) {
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_called_you));
return emphasisAdded(context.getString(R.string.ThreadRecord_called_you));
} else if (SmsDatabase.Types.isMissedCall(thread.getType())) {
return emphasisAdded(context.getString(org.thoughtcrime.securesms.R.string.ThreadRecord_missed_call));
return emphasisAdded(context.getString(R.string.ThreadRecord_missed_call));
} else if (SmsDatabase.Types.isJoinedType(thread.getType())) {
return emphasisAdded(context.getString(R.string.ThreadRecord_s_is_on_signal, thread.getRecipient().getDisplayName(context)));
return emphasisAdded(recipientToStringAsync(thread.getRecipient().getId(), r -> context.getString(R.string.ThreadRecord_s_is_on_signal, r.getDisplayName(context))));
} else if (SmsDatabase.Types.isExpirationTimerUpdate(thread.getType())) {
int seconds = (int)(thread.getExpiresIn() / 1000);
if (seconds <= 0) {
@@ -439,7 +456,7 @@ public class ConversationListItem extends RelativeLayout
if (thread.getRecipient().isGroup()) {
return emphasisAdded(context.getString(R.string.ThreadRecord_safety_number_changed));
} else {
return emphasisAdded(context.getString(R.string.ThreadRecord_your_safety_number_with_s_has_changed, thread.getRecipient().getDisplayName(context)));
return emphasisAdded(recipientToStringAsync(thread.getRecipient().getId(), r -> context.getString(R.string.ThreadRecord_your_safety_number_with_s_has_changed, r.getDisplayName(context))));
}
} else if (SmsDatabase.Types.isIdentityVerified(thread.getType())) {
return emphasisAdded(context.getString(R.string.ThreadRecord_you_marked_verified));
@@ -450,26 +467,44 @@ public class ConversationListItem extends RelativeLayout
} else {
ThreadDatabase.Extra extra = thread.getExtra();
if (extra != null && extra.isViewOnce()) {
return new SpannableString(emphasisAdded(getViewOnceDescription(context, thread.getContentType())));
return emphasisAdded(getViewOnceDescription(context, thread.getContentType()));
} else if (extra != null && extra.isRemoteDelete()) {
return new SpannableString(emphasisAdded(context.getString(thread.isOutgoing() ? R.string.ThreadRecord_you_deleted_this_message : R.string.ThreadRecord_this_message_was_deleted)));
return emphasisAdded(context.getString(thread.isOutgoing() ? R.string.ThreadRecord_you_deleted_this_message : R.string.ThreadRecord_this_message_was_deleted));
} else {
return new SpannableString(Util.emptyIfNull(thread.getBody()));
return LiveDataUtil.just(new SpannableString(removeNewlines(thread.getBody())));
}
}
}
private static @NonNull SpannableString emphasisAdded(String sequence) {
return emphasisAdded(sequence, 0, sequence.length());
private static @NonNull String removeNewlines(@Nullable String text) {
if (text == null) {
return "";
}
if (text.indexOf('\n') >= 0) {
return text.replaceAll("\n", " ");
} else {
return text;
}
}
private static @NonNull SpannableString emphasisAdded(String sequence, int start, int end) {
SpannableString spannable = new SpannableString(sequence);
spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC),
start,
end,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return spannable;
private static @NonNull LiveData<SpannableString> emphasisAdded(@NonNull String string) {
return emphasisAdded(UpdateDescription.staticDescription(string));
}
private static @NonNull LiveData<SpannableString> emphasisAdded(@NonNull UpdateDescription description) {
return emphasisAdded(LiveUpdateMessage.fromMessageDescription(description));
}
private static @NonNull LiveData<SpannableString> emphasisAdded(@NonNull LiveData<String> description) {
return Transformations.map(description, sequence -> {
SpannableString spannable = new SpannableString(sequence);
spannable.setSpan(new StyleSpan(Typeface.ITALIC),
0,
sequence.length(),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
return spannable;
});
}
private static String getViewOnceDescription(@NonNull Context context, @Nullable String contentType) {
@@ -482,6 +517,15 @@ public class ConversationListItem extends RelativeLayout
}
}
@Override
public void onChanged(SpannableString spannableString) {
setSubjectViewText(spannableString);
if (typingThreads != null) {
updateTypingIndicator(typingThreads);
}
}
private static class ThumbnailPositioner implements Runnable {
private final View thumbnailView;

View File

@@ -8,6 +8,7 @@ import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import androidx.paging.DataSource;
@@ -19,6 +20,7 @@ import org.thoughtcrime.securesms.conversationlist.model.SearchResult;
import org.thoughtcrime.securesms.database.DatabaseContentProviders;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.megaphone.Megaphone;
import org.thoughtcrime.securesms.megaphone.MegaphoneRepository;
import org.thoughtcrime.securesms.megaphone.Megaphones;
@@ -31,10 +33,11 @@ import org.thoughtcrime.securesms.util.paging.Invalidator;
class ConversationListViewModel extends ViewModel {
private static final String TAG = Log.tag(ConversationListViewModel.class);
private final Application application;
private final MutableLiveData<Megaphone> megaphone;
private final MutableLiveData<SearchResult> searchResult;
private final MutableLiveData<Integer> archivedCount;
private final LiveData<ConversationList> conversationList;
private final SearchRepository searchRepository;
private final MegaphoneRepository megaphoneRepository;
@@ -48,7 +51,6 @@ class ConversationListViewModel extends ViewModel {
this.application = application;
this.megaphone = new MutableLiveData<>();
this.searchResult = new MutableLiveData<>();
this.archivedCount = new MutableLiveData<>();
this.searchRepository = searchRepository;
this.megaphoneRepository = ApplicationDependencies.getMegaphoneRepository();
this.debouncer = new Debouncer(300);
@@ -59,10 +61,6 @@ class ConversationListViewModel extends ViewModel {
if (!TextUtils.isEmpty(getLastQuery())) {
searchRepository.query(getLastQuery(), searchResult::postValue);
}
if (!isArchived) {
updateArchivedCount();
}
}
};
@@ -77,15 +75,27 @@ class ConversationListViewModel extends ViewModel {
.setInitialLoadKey(0)
.build();
if (isArchived) {
this.archivedCount.setValue(0);
} else {
updateArchivedCount();
}
application.getContentResolver().registerContentObserver(DatabaseContentProviders.ConversationList.CONTENT_URI, true, observer);
this.conversationList = LiveDataUtil.combineLatest(conversationList, this.archivedCount, ConversationList::new);
this.conversationList = Transformations.switchMap(conversationList, conversation -> {
if (conversation.getDataSource().isInvalid()) {
Log.w(TAG, "Received an invalid conversation list. Ignoring.");
return new MutableLiveData<>();
}
MutableLiveData<ConversationList> updated = new MutableLiveData<>();
if (isArchived) {
updated.postValue(new ConversationList(conversation, 0));
} else {
SignalExecutors.BOUNDED.execute(() -> {
int archiveCount = DatabaseFactory.getThreadDatabase(application).getArchivedConversationListCount();
updated.postValue(new ConversationList(conversation, archiveCount));
});
}
return updated;
});
}
@NonNull LiveData<SearchResult> getSearchResult() {
@@ -140,12 +150,6 @@ class ConversationListViewModel extends ViewModel {
application.getContentResolver().unregisterContentObserver(observer);
}
private void updateArchivedCount() {
SignalExecutors.BOUNDED.execute(() -> {
archivedCount.postValue(DatabaseFactory.getThreadDatabase(application).getArchivedConversationListCount());
});
}
public static class Factory extends ViewModelProvider.NewInstanceFactory {
private final boolean isArchived;

View File

@@ -286,8 +286,9 @@ public final class GroupDatabase extends Database {
List<Recipient> recipients = new ArrayList<>(currentMembers.size());
for (RecipientId member : currentMembers) {
if (memberSet.includeSelf || !Recipient.resolved(member).isLocalNumber()) {
recipients.add(Recipient.resolved(member));
Recipient resolved = Recipient.resolved(member);
if (memberSet.includeSelf || !resolved.isLocalNumber()) {
recipients.add(resolved);
}
}
@@ -817,9 +818,7 @@ public final class GroupDatabase extends Database {
}
public List<Recipient> getMemberRecipients(@NonNull MemberSet memberSet) {
return Stream.of(getMemberRecipientIds(memberSet))
.map(Recipient::resolved)
.toList();
return Recipient.resolvedList(getMemberRecipientIds(memberSet));
}
public List<RecipientId> getMemberRecipientIds(@NonNull MemberSet memberSet) {

View File

@@ -16,6 +16,8 @@ import org.thoughtcrime.securesms.database.documents.Document;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatchList;
import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
import org.thoughtcrime.securesms.database.model.databaseprotos.ReactionList;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.insights.InsightsConstants;
@@ -55,6 +57,8 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn
public abstract void markAsSending(long messageId);
public abstract void markAsRemoteDelete(long messageId);
public abstract MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException;
final int getInsecureMessagesSentForThread(long threadId) {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
String[] projection = new String[]{"COUNT(*)"};

View File

@@ -445,6 +445,7 @@ public class MmsDatabase extends MessagingDatabase {
return rawQuery(RAW_ID_WHERE, new String[] {messageId + ""});
}
@Override
public MessageRecord getMessageRecord(long messageId) throws NoSuchMessageException {
try (Cursor cursor = rawQuery(RAW_ID_WHERE, new String[] {messageId + ""})) {
MessageRecord record = new Reader(cursor).getNext();

View File

@@ -56,7 +56,6 @@ import org.whispersystems.signalservice.api.storage.SignalContactRecord;
import org.whispersystems.signalservice.api.storage.SignalGroupV1Record;
import org.whispersystems.signalservice.api.storage.SignalGroupV2Record;
import org.whispersystems.signalservice.api.storage.StorageId;
import org.whispersystems.signalservice.api.util.OptionalUtil;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.Closeable;
@@ -1392,13 +1391,13 @@ public class RecipientDatabase extends Database {
* If from authoritative source, this will overwrite local, otherwise it will only write to the
* database if missing.
*/
public Collection<RecipientId> persistProfileKeySet(@NonNull ProfileKeySet profileKeySet) {
public Set<RecipientId> persistProfileKeySet(@NonNull ProfileKeySet profileKeySet) {
Map<UUID, ProfileKey> profileKeys = profileKeySet.getProfileKeys();
Map<UUID, ProfileKey> authoritativeProfileKeys = profileKeySet.getAuthoritativeProfileKeys();
int totalKeys = profileKeys.size() + authoritativeProfileKeys.size();
if (totalKeys == 0) {
return Collections.emptyList();
return Collections.emptySet();
}
Log.i(TAG, String.format(Locale.US, "Persisting %d Profile keys, %d of which are authoritative", totalKeys, authoritativeProfileKeys.size()));
@@ -1832,7 +1831,7 @@ public class RecipientDatabase extends Database {
}
public @Nullable Cursor querySignalContacts(@NonNull String query, boolean includeSelf) {
query = buildGlobPattern(query);
query = buildCaseInsensitiveGlobPattern(query);
String selection = BLOCKED + " = ? AND " +
REGISTERED + " = ? AND " +
@@ -1870,7 +1869,7 @@ public class RecipientDatabase extends Database {
}
public @Nullable Cursor queryNonSignalContacts(@NonNull String query) {
query = buildGlobPattern(query);
query = buildCaseInsensitiveGlobPattern(query);
String selection = BLOCKED + " = ? AND " +
REGISTERED + " != ? AND " +
@@ -1889,8 +1888,7 @@ public class RecipientDatabase extends Database {
}
public @Nullable Cursor queryAllContacts(@NonNull String query) {
query = buildGlobPattern(query);
query = "*" + query + "*";
query = buildCaseInsensitiveGlobPattern(query);
String selection = BLOCKED + " = ? AND " +
"(" +
@@ -1904,7 +1902,14 @@ public class RecipientDatabase extends Database {
return databaseHelper.getReadableDatabase().query(TABLE_NAME, SEARCH_PROJECTION, selection, args, null, null, null);
}
private static String buildGlobPattern(@NonNull String query) {
/**
* Builds a case-insensitive GLOB pattern for fuzzy text queries. Works with all unicode
* characters.
*
* Ex:
* cat -> [cC][aA][tT]
*/
private static String buildCaseInsensitiveGlobPattern(@NonNull String query) {
if (TextUtils.isEmpty(query)) {
return "*";
}

View File

@@ -64,7 +64,7 @@ class RemappedRecords {
*/
void addThread(@NonNull Context context, long oldId, long newId) {
ensureInTransaction(context);
ensureRecipientMapIsPopulated(context);
ensureThreadMapIsPopulated(context);
threadMap.put(oldId, newId);
DatabaseFactory.getRemappedRecordsDatabase(context).addThreadMapping(oldId, newId);
}

View File

@@ -673,7 +673,7 @@ public class SmsDatabase extends MessagingDatabase {
db.insert(TABLE_NAME, null, values);
DatabaseFactory.getThreadDatabase(context).update(threadId, true);
DatabaseFactory.getThreadDatabase(context).update(threadId, false);
notifyConversationListeners(threadId);
ApplicationDependencies.getJobManager().add(new TrimThreadJob(threadId));
});
@@ -854,7 +854,8 @@ public class SmsDatabase extends MessagingDatabase {
return db.query(TABLE_NAME, MESSAGE_PROJECTION, where, null, null, null, null);
}
public SmsMessageRecord getMessage(long messageId) throws NoSuchMessageException {
@Override
public SmsMessageRecord getMessageRecord(long messageId) throws NoSuchMessageException {
SQLiteDatabase db = databaseHelper.getReadableDatabase();
Cursor cursor = db.query(TABLE_NAME, MESSAGE_PROJECTION, ID_WHERE, new String[]{messageId + ""}, null, null, null);
Reader reader = new Reader(cursor);

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.database.model;
import android.content.Context;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import com.google.protobuf.ByteString;
@@ -17,10 +18,13 @@ import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemov
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.groups.GV2AccessLevelUtil;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.StringUtil;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.UUID;
@@ -37,8 +41,7 @@ final class GroupsV2UpdateMessageProducer {
*/
GroupsV2UpdateMessageProducer(@NonNull Context context,
@NonNull DescribeMemberStrategy descriptionStrategy,
@NonNull UUID selfUuid)
{
@NonNull UUID selfUuid) {
this.context = context;
this.descriptionStrategy = descriptionStrategy;
this.selfUuid = selfUuid;
@@ -50,10 +53,10 @@ final class GroupsV2UpdateMessageProducer {
* <p>
* Invitation and groups you create are the most common cases where no change is available.
*/
String describeNewGroup(@NonNull DecryptedGroup group) {
UpdateDescription describeNewGroup(@NonNull DecryptedGroup group) {
Optional<DecryptedPendingMember> selfPending = DecryptedGroupUtil.findPendingByUuid(group.getPendingMembersList(), selfUuid);
if (selfPending.isPresent()) {
return context.getString(R.string.MessageRecord_s_invited_you_to_the_group, describe(selfPending.get().getAddedByUuid()));
return updateDescription(selfPending.get().getAddedByUuid(), inviteBy -> context.getString(R.string.MessageRecord_s_invited_you_to_the_group, inviteBy));
}
if (group.getRevision() == 0) {
@@ -61,26 +64,26 @@ final class GroupsV2UpdateMessageProducer {
if (foundingMember.isPresent()) {
ByteString foundingMemberUuid = foundingMember.get().getUuid();
if (selfUuidBytes.equals(foundingMemberUuid)) {
return context.getString(R.string.MessageRecord_you_created_the_group);
return updateDescription(context.getString(R.string.MessageRecord_you_created_the_group));
} else {
return context.getString(R.string.MessageRecord_s_added_you, describe(foundingMemberUuid));
return updateDescription(foundingMemberUuid, creator -> context.getString(R.string.MessageRecord_s_added_you, creator));
}
}
}
if (DecryptedGroupUtil.findMemberByUuid(group.getMembersList(), selfUuid).isPresent()) {
return context.getString(R.string.MessageRecord_you_joined_the_group);
return updateDescription(context.getString(R.string.MessageRecord_you_joined_the_group));
} else {
return context.getString(R.string.MessageRecord_group_updated);
return updateDescription(context.getString(R.string.MessageRecord_group_updated));
}
}
List<String> describeChange(@NonNull DecryptedGroupChange change) {
List<String> updates = new LinkedList<>();
List<UpdateDescription> describeChanges(@NonNull DecryptedGroupChange change) {
List<UpdateDescription> updates = new LinkedList<>();
if (change.getEditor().isEmpty() || UuidUtil.UNKNOWN_UUID.equals(UuidUtil.fromByteString(change.getEditor()))) {
describeUnknownEditorMemberAdditions(change, updates);
describeUnknownEditorMemberRemovals(change, updates);
describeUnknownEditorModifyMemberRoles(change, updates);
describeUnknownEditorInvitations(change, updates);
describeUnknownEditorRevokedInvitations(change, updates);
@@ -91,13 +94,15 @@ final class GroupsV2UpdateMessageProducer {
describeUnknownEditorNewAttributeAccess(change, updates);
describeUnknownEditorNewMembershipAccess(change, updates);
describeUnknownEditorMemberRemovals(change, updates);
if (updates.isEmpty()) {
describeUnknownEditorUnknownChange(updates);
}
} else {
describeMemberAdditions(change, updates);
describeMemberRemovals(change, updates);
describeModifyMemberRoles(change, updates);
describeInvitations(change, updates);
describeRevokedInvitations(change, updates);
@@ -108,6 +113,8 @@ final class GroupsV2UpdateMessageProducer {
describeNewAttributeAccess(change, updates);
describeNewMembershipAccess(change, updates);
describeMemberRemovals(change, updates);
if (updates.isEmpty()) {
describeUnknownChange(change, updates);
}
@@ -119,21 +126,21 @@ final class GroupsV2UpdateMessageProducer {
/**
* Handles case of future protocol versions where we don't know what has changed.
*/
private void describeUnknownChange(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeUnknownChange(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_updated_group));
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_updated_group)));
} else {
updates.add(context.getString(R.string.MessageRecord_s_updated_group, describe(change.getEditor())));
updates.add(updateDescription(change.getEditor(), (editor) -> context.getString(R.string.MessageRecord_s_updated_group, editor)));
}
}
private void describeUnknownEditorUnknownChange(@NonNull List<String> updates) {
updates.add(context.getString(R.string.MessageRecord_the_group_was_updated));
private void describeUnknownEditorUnknownChange(@NonNull List<UpdateDescription> updates) {
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_was_updated)));
}
private void describeMemberAdditions(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeMemberAdditions(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
for (DecryptedMember member : change.getNewMembersList()) {
@@ -141,37 +148,37 @@ final class GroupsV2UpdateMessageProducer {
if (editorIsYou) {
if (newMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_joined_the_group));
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_joined_the_group)));
} else {
updates.add(context.getString(R.string.MessageRecord_you_added_s, describe(member.getUuid())));
updates.add(updateDescription(member.getUuid(), added -> context.getString(R.string.MessageRecord_you_added_s, added)));
}
} else {
if (newMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_s_added_you, describe(change.getEditor())));
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_added_you, editor)));
} else {
if (member.getUuid().equals(change.getEditor())) {
updates.add(context.getString(R.string.MessageRecord_s_joined_the_group, describe(member.getUuid())));
updates.add(updateDescription(member.getUuid(), newMember -> context.getString(R.string.MessageRecord_s_joined_the_group, newMember)));
} else {
updates.add(context.getString(R.string.MessageRecord_s_added_s, describe(change.getEditor()), describe(member.getUuid())));
updates.add(updateDescription(change.getEditor(), member.getUuid(), (editor, newMember) -> context.getString(R.string.MessageRecord_s_added_s, editor, newMember)));
}
}
}
}
}
private void describeUnknownEditorMemberAdditions(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeUnknownEditorMemberAdditions(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
for (DecryptedMember member : change.getNewMembersList()) {
boolean newMemberIsYou = member.getUuid().equals(selfUuidBytes);
if (newMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_joined_the_group));
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_joined_the_group)));
} else {
updates.add(context.getString(R.string.MessageRecord_s_joined_the_group, describe(member.getUuid())));
updates.add(updateDescription(member.getUuid(), newMember -> context.getString(R.string.MessageRecord_s_joined_the_group, newMember)));
}
}
}
private void describeMemberRemovals(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeMemberRemovals(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
for (ByteString member : change.getDeleteMembersList()) {
@@ -179,98 +186,98 @@ final class GroupsV2UpdateMessageProducer {
if (editorIsYou) {
if (removedMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_left_the_group));
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_left_the_group)));
} else {
updates.add(context.getString(R.string.MessageRecord_you_removed_s, describe(member)));
updates.add(updateDescription(member, removedMember -> context.getString(R.string.MessageRecord_you_removed_s, removedMember)));
}
} else {
if (removedMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_s_removed_you_from_the_group, describe(change.getEditor())));
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_removed_you_from_the_group, editor)));
} else {
if (member.equals(change.getEditor())) {
updates.add(context.getString(R.string.MessageRecord_s_left_the_group, describe(member)));
updates.add(updateDescription(member, leavingMember -> context.getString(R.string.MessageRecord_s_left_the_group, leavingMember)));
} else {
updates.add(context.getString(R.string.MessageRecord_s_removed_s, describe(change.getEditor()), describe(member)));
updates.add(updateDescription(change.getEditor(), member, (editor, removedMember) -> context.getString(R.string.MessageRecord_s_removed_s, editor, removedMember)));
}
}
}
}
}
private void describeUnknownEditorMemberRemovals(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeUnknownEditorMemberRemovals(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
for (ByteString member : change.getDeleteMembersList()) {
boolean removedMemberIsYou = member.equals(selfUuidBytes);
if (removedMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_are_no_longer_in_the_group));
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_are_no_longer_in_the_group)));
} else {
updates.add(context.getString(R.string.MessageRecord_s_is_no_longer_in_the_group, describe(member)));
updates.add(updateDescription(member, oldMember -> context.getString(R.string.MessageRecord_s_is_no_longer_in_the_group, oldMember)));
}
}
}
private void describeModifyMemberRoles(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeModifyMemberRoles(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
for (DecryptedModifyMemberRole roleChange : change.getModifyMemberRolesList()) {
boolean changedMemberIsYou = roleChange.getUuid().equals(selfUuidBytes);
if (roleChange.getRole() == Member.Role.ADMINISTRATOR) {
if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_made_s_an_admin, describe(roleChange.getUuid())));
updates.add(updateDescription(roleChange.getUuid(), newAdmin -> context.getString(R.string.MessageRecord_you_made_s_an_admin, newAdmin)));
} else {
if (changedMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_s_made_you_an_admin, describe(change.getEditor())));
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_made_you_an_admin, editor)));
} else {
updates.add(context.getString(R.string.MessageRecord_s_made_s_an_admin, describe(change.getEditor()), describe(roleChange.getUuid())));
updates.add(updateDescription(change.getEditor(), roleChange.getUuid(), (editor, newAdmin) -> context.getString(R.string.MessageRecord_s_made_s_an_admin, editor, newAdmin)));
}
}
} else {
if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_revoked_admin_privileges_from_s, describe(roleChange.getUuid())));
updates.add(updateDescription(roleChange.getUuid(), oldAdmin -> context.getString(R.string.MessageRecord_you_revoked_admin_privileges_from_s, oldAdmin)));
} else {
if (changedMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_s_revoked_your_admin_privileges, describe(change.getEditor())));
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_revoked_your_admin_privileges, editor)));
} else {
updates.add(context.getString(R.string.MessageRecord_s_revoked_admin_privileges_from_s, describe(change.getEditor()), describe(roleChange.getUuid())));
updates.add(updateDescription(change.getEditor(), roleChange.getUuid(), (editor, oldAdmin) -> context.getString(R.string.MessageRecord_s_revoked_admin_privileges_from_s, editor, oldAdmin)));
}
}
}
}
}
private void describeUnknownEditorModifyMemberRoles(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeUnknownEditorModifyMemberRoles(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
for (DecryptedModifyMemberRole roleChange : change.getModifyMemberRolesList()) {
boolean changedMemberIsYou = roleChange.getUuid().equals(selfUuidBytes);
if (roleChange.getRole() == Member.Role.ADMINISTRATOR) {
if (changedMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_are_now_an_admin));
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_are_now_an_admin)));
} else {
updates.add(context.getString(R.string.MessageRecord_s_is_now_an_admin, describe(roleChange.getUuid())));
updates.add(updateDescription(roleChange.getUuid(), newAdmin -> context.getString(R.string.MessageRecord_s_is_now_an_admin, newAdmin)));
}
} else {
if (changedMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_are_no_longer_an_admin));
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_are_no_longer_an_admin)));
} else {
updates.add(context.getString(R.string.MessageRecord_s_is_no_longer_an_admin, describe(roleChange.getUuid())));
updates.add(updateDescription(roleChange.getUuid(), oldAdmin -> context.getString(R.string.MessageRecord_s_is_no_longer_an_admin, oldAdmin)));
}
}
}
}
private void describeInvitations(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
int notYouInviteCount = 0;
private void describeInvitations(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
int notYouInviteCount = 0;
for (DecryptedPendingMember invitee : change.getNewPendingMembersList()) {
boolean newMemberIsYou = invitee.getUuid().equals(selfUuidBytes);
if (newMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_s_invited_you_to_the_group, describe(change.getEditor())));
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_invited_you_to_the_group, editor)));
} else {
if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_invited_s_to_the_group, describe(invitee.getUuid())));
updates.add(updateDescription(invitee.getUuid(), newInvitee -> context.getString(R.string.MessageRecord_you_invited_s_to_the_group, newInvitee)));
} else {
notYouInviteCount++;
}
@@ -278,39 +285,40 @@ final class GroupsV2UpdateMessageProducer {
}
if (notYouInviteCount > 0) {
updates.add(context.getResources().getQuantityString(R.plurals.MessageRecord_s_invited_members, notYouInviteCount, describe(change.getEditor()), notYouInviteCount));
final int notYouInviteCountFinalCopy = notYouInviteCount;
updates.add(updateDescription(change.getEditor(), editor -> context.getResources().getQuantityString(R.plurals.MessageRecord_s_invited_members, notYouInviteCountFinalCopy, editor, notYouInviteCountFinalCopy)));
}
}
private void describeUnknownEditorInvitations(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeUnknownEditorInvitations(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
int notYouInviteCount = 0;
for (DecryptedPendingMember invitee : change.getNewPendingMembersList()) {
boolean newMemberIsYou = invitee.getUuid().equals(selfUuidBytes);
if (newMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_were_invited_to_the_group));
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_were_invited_to_the_group)));
} else {
notYouInviteCount++;
}
}
if (notYouInviteCount > 0) {
updates.add(context.getResources().getQuantityString(R.plurals.MessageRecord_d_people_were_invited_to_the_group, notYouInviteCount, notYouInviteCount));
updates.add(updateDescription(context.getResources().getQuantityString(R.plurals.MessageRecord_d_people_were_invited_to_the_group, notYouInviteCount, notYouInviteCount)));
}
}
private void describeRevokedInvitations(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
int notDeclineCount = 0;
private void describeRevokedInvitations(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
int notDeclineCount = 0;
for (DecryptedPendingMemberRemoval invitee : change.getDeletePendingMembersList()) {
boolean decline = invitee.getUuid().equals(change.getEditor());
if (decline) {
if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_declined_the_invitation_to_the_group));
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_declined_the_invitation_to_the_group)));
} else {
updates.add(context.getString(R.string.MessageRecord_someone_declined_an_invitation_to_the_group));
updates.add(updateDescription(context.getString(R.string.MessageRecord_someone_declined_an_invitation_to_the_group)));
}
} else {
notDeclineCount++;
@@ -319,176 +327,201 @@ final class GroupsV2UpdateMessageProducer {
if (notDeclineCount > 0) {
if (editorIsYou) {
updates.add(context.getResources().getQuantityString(R.plurals.MessageRecord_you_revoked_invites, notDeclineCount, notDeclineCount));
updates.add(updateDescription(context.getResources().getQuantityString(R.plurals.MessageRecord_you_revoked_invites, notDeclineCount, notDeclineCount)));
} else {
updates.add(context.getResources().getQuantityString(R.plurals.MessageRecord_s_revoked_invites, notDeclineCount, describe(change.getEditor()), notDeclineCount));
final int notDeclineCountFinalCopy = notDeclineCount;
updates.add(updateDescription(change.getEditor(), editor -> context.getResources().getQuantityString(R.plurals.MessageRecord_s_revoked_invites, notDeclineCountFinalCopy, editor, notDeclineCountFinalCopy)));
}
}
}
private void describeUnknownEditorRevokedInvitations(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeUnknownEditorRevokedInvitations(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
int notDeclineCount = 0;
for (DecryptedPendingMemberRemoval invitee : change.getDeletePendingMembersList()) {
boolean inviteeWasYou = invitee.getUuid().equals(selfUuidBytes);
if (inviteeWasYou) {
updates.add(context.getString(R.string.MessageRecord_your_invitation_to_the_group_was_revoked));
updates.add(updateDescription(context.getString(R.string.MessageRecord_your_invitation_to_the_group_was_revoked)));
} else {
notDeclineCount++;
}
}
if (notDeclineCount > 0) {
updates.add(context.getResources().getQuantityString(R.plurals.MessageRecord_d_invitations_were_revoked, notDeclineCount, notDeclineCount));
updates.add(updateDescription(context.getResources().getQuantityString(R.plurals.MessageRecord_d_invitations_were_revoked, notDeclineCount, notDeclineCount)));
}
}
private void describePromotePending(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describePromotePending(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
for (DecryptedMember newMember : change.getPromotePendingMembersList()) {
ByteString uuid = newMember.getUuid();
boolean newMemberIsYou = uuid.equals(selfUuidBytes);
ByteString uuid = newMember.getUuid();
boolean newMemberIsYou = uuid.equals(selfUuidBytes);
if (editorIsYou) {
if (newMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_accepted_invite));
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_accepted_invite)));
} else {
updates.add(context.getString(R.string.MessageRecord_you_added_invited_member_s, describe(uuid)));
updates.add(updateDescription(uuid, newPromotedMember -> context.getString(R.string.MessageRecord_you_added_invited_member_s, newPromotedMember)));
}
} else {
if (newMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_s_added_you, describe(change.getEditor())));
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_added_you, editor)));
} else {
if (uuid.equals(change.getEditor())) {
updates.add(context.getString(R.string.MessageRecord_s_accepted_invite, describe(uuid)));
updates.add(updateDescription(uuid, newAcceptedMember -> context.getString(R.string.MessageRecord_s_accepted_invite, newAcceptedMember)));
} else {
updates.add(context.getString(R.string.MessageRecord_s_added_invited_member_s, describe(change.getEditor()), describe(uuid)));
updates.add(updateDescription(change.getEditor(), uuid, (editor, newAcceptedMember) -> context.getString(R.string.MessageRecord_s_added_invited_member_s, editor, newAcceptedMember)));
}
}
}
}
}
private void describeUnknownEditorPromotePending(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeUnknownEditorPromotePending(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
for (DecryptedMember newMember : change.getPromotePendingMembersList()) {
ByteString uuid = newMember.getUuid();
boolean newMemberIsYou = uuid.equals(selfUuidBytes);
ByteString uuid = newMember.getUuid();
boolean newMemberIsYou = uuid.equals(selfUuidBytes);
if (newMemberIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_joined_the_group));
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_joined_the_group)));
} else {
updates.add(context.getString(R.string.MessageRecord_s_joined_the_group, describe(uuid)));
updates.add(updateDescription(uuid, newMemberName -> context.getString(R.string.MessageRecord_s_joined_the_group, newMemberName)));
}
}
}
private void describeNewTitle(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeNewTitle(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (change.hasNewTitle()) {
String newTitle = StringUtil.isolateBidi(change.getNewTitle().getValue());
if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_changed_the_group_name_to_s, change.getNewTitle().getValue()));
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_changed_the_group_name_to_s, newTitle)));
} else {
updates.add(context.getString(R.string.MessageRecord_s_changed_the_group_name_to_s, describe(change.getEditor()), change.getNewTitle().getValue()));
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_changed_the_group_name_to_s, editor, newTitle)));
}
}
}
private void describeUnknownEditorNewTitle(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeUnknownEditorNewTitle(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
if (change.hasNewTitle()) {
updates.add(context.getString(R.string.MessageRecord_the_group_name_has_changed_to_s, change.getNewTitle().getValue()));
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_name_has_changed_to_s, StringUtil.isolateBidi(change.getNewTitle().getValue()))));
}
}
private void describeNewAvatar(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeNewAvatar(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (change.hasNewAvatar()) {
if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_changed_the_group_avatar));
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_changed_the_group_avatar)));
} else {
updates.add(context.getString(R.string.MessageRecord_s_changed_the_group_avatar, describe(change.getEditor())));
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_changed_the_group_avatar, editor)));
}
}
}
private void describeUnknownEditorNewAvatar(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeUnknownEditorNewAvatar(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
if (change.hasNewAvatar()) {
updates.add(context.getString(R.string.MessageRecord_the_group_group_avatar_has_been_changed));
updates.add(updateDescription(context.getString(R.string.MessageRecord_the_group_group_avatar_has_been_changed)));
}
}
private void describeNewTimer(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeNewTimer(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (change.hasNewTimer()) {
String time = ExpirationUtil.getExpirationDisplayValue(context, change.getNewTimer().getDuration());
if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time));
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time)));
} else {
updates.add(context.getString(R.string.MessageRecord_s_set_disappearing_message_time_to_s, describe(change.getEditor()), time));
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_set_disappearing_message_time_to_s, editor, time)));
}
}
}
private void describeUnknownEditorNewTimer(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeUnknownEditorNewTimer(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
if (change.hasNewTimer()) {
String time = ExpirationUtil.getExpirationDisplayValue(context, change.getNewTimer().getDuration());
updates.add(context.getString(R.string.MessageRecord_disappearing_message_time_set_to_s, time));
updates.add(updateDescription(context.getString(R.string.MessageRecord_disappearing_message_time_set_to_s, time)));
}
}
private void describeNewAttributeAccess(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeNewAttributeAccess(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (change.getNewAttributeAccess() != AccessControl.AccessRequired.UNKNOWN) {
String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewAttributeAccess());
if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_changed_who_can_edit_group_info_to_s, accessLevel));
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_changed_who_can_edit_group_info_to_s, accessLevel)));
} else {
updates.add(context.getString(R.string.MessageRecord_s_changed_who_can_edit_group_info_to_s, describe(change.getEditor()), accessLevel));
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_changed_who_can_edit_group_info_to_s, editor, accessLevel)));
}
}
}
private void describeUnknownEditorNewAttributeAccess(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeUnknownEditorNewAttributeAccess(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
if (change.getNewAttributeAccess() != AccessControl.AccessRequired.UNKNOWN) {
String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewAttributeAccess());
updates.add(context.getString(R.string.MessageRecord_who_can_edit_group_info_has_been_changed_to_s, accessLevel));
updates.add(updateDescription(context.getString(R.string.MessageRecord_who_can_edit_group_info_has_been_changed_to_s, accessLevel)));
}
}
private void describeNewMembershipAccess(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeNewMembershipAccess(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
boolean editorIsYou = change.getEditor().equals(selfUuidBytes);
if (change.getNewMemberAccess() != AccessControl.AccessRequired.UNKNOWN) {
String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewMemberAccess());
if (editorIsYou) {
updates.add(context.getString(R.string.MessageRecord_you_changed_who_can_edit_group_membership_to_s, accessLevel));
updates.add(updateDescription(context.getString(R.string.MessageRecord_you_changed_who_can_edit_group_membership_to_s, accessLevel)));
} else {
updates.add(context.getString(R.string.MessageRecord_s_changed_who_can_edit_group_membership_to_s, describe(change.getEditor()), accessLevel));
updates.add(updateDescription(change.getEditor(), editor -> context.getString(R.string.MessageRecord_s_changed_who_can_edit_group_membership_to_s, editor, accessLevel)));
}
}
}
private void describeUnknownEditorNewMembershipAccess(@NonNull DecryptedGroupChange change, @NonNull List<String> updates) {
private void describeUnknownEditorNewMembershipAccess(@NonNull DecryptedGroupChange change, @NonNull List<UpdateDescription> updates) {
if (change.getNewMemberAccess() != AccessControl.AccessRequired.UNKNOWN) {
String accessLevel = GV2AccessLevelUtil.toString(context, change.getNewMemberAccess());
updates.add(context.getString(R.string.MessageRecord_who_can_edit_group_membership_has_been_changed_to_s, accessLevel));
updates.add(updateDescription(context.getString(R.string.MessageRecord_who_can_edit_group_membership_has_been_changed_to_s, accessLevel)));
}
}
private @NonNull String describe(@NonNull ByteString uuid) {
return descriptionStrategy.describe(UuidUtil.fromByteString(uuid));
}
interface DescribeMemberStrategy {
/**
* Map a UUID to a string that describes the group member.
*/
@NonNull String describe(@NonNull UUID uuid);
@NonNull
@WorkerThread
String describe(@NonNull UUID uuid);
}
private interface StringFactory1Arg {
String create(String arg1);
}
private interface StringFactory2Args {
String create(String arg1, String arg2);
}
private static UpdateDescription updateDescription(@NonNull String string) {
return UpdateDescription.staticDescription(string);
}
private UpdateDescription updateDescription(@NonNull ByteString uuid1Bytes, @NonNull StringFactory1Arg stringFactory) {
UUID uuid1 = UuidUtil.fromByteStringOrUnknown(uuid1Bytes);
return UpdateDescription.mentioning(Collections.singletonList(uuid1), () -> stringFactory.create(descriptionStrategy.describe(uuid1)));
}
private UpdateDescription updateDescription(@NonNull ByteString uuid1Bytes, @NonNull ByteString uuid2Bytes, @NonNull StringFactory2Args stringFactory) {
UUID uuid1 = UuidUtil.fromByteStringOrUnknown(uuid1Bytes);
UUID uuid2 = UuidUtil.fromByteStringOrUnknown(uuid2Bytes);
return UpdateDescription.mentioning(Arrays.asList(uuid1, uuid2), () -> stringFactory.create(descriptionStrategy.describe(uuid1), descriptionStrategy.describe(uuid2)));
}
}

View File

@@ -0,0 +1,47 @@
package org.thoughtcrime.securesms.database.model;
import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.whispersystems.libsignal.util.guava.Function;
import java.util.List;
public final class LiveUpdateMessage {
/**
* Creates a live data that observes the recipients mentioned in the {@link UpdateDescription} and
* recreates the string asynchronously when they change.
*/
@AnyThread
public static LiveData<String> fromMessageDescription(@NonNull UpdateDescription updateDescription) {
if (updateDescription.isStringStatic()) {
return LiveDataUtil.just(updateDescription.getStaticString());
}
List<LiveData<Recipient>> allMentionedRecipients = Stream.of(updateDescription.getMentioned())
.map(uuid -> Recipient.resolved(RecipientId.from(uuid, null)).live().getLiveData())
.toList();
LiveData<?> mentionedRecipientChangeStream = allMentionedRecipients.isEmpty() ? LiveDataUtil.just(new Object())
: LiveDataUtil.merge(allMentionedRecipients);
return LiveDataUtil.mapAsync(mentionedRecipientChangeStream, event -> updateDescription.getString());
}
/**
* Observes a single recipient and recreates the string asynchronously when they change.
*/
public static LiveData<String> recipientToStringAsync(@NonNull RecipientId recipientId,
@NonNull Function<Recipient, String> createStringInBackground)
{
return LiveDataUtil.mapAsync(Recipient.live(recipientId).getLiveData(), createStringInBackground);
}
}

View File

@@ -23,8 +23,8 @@ import android.text.style.RelativeSizeSpan;
import android.text.style.StyleSpan;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.database.MmsSmsColumns;
import org.thoughtcrime.securesms.database.SmsDatabase;
@@ -39,9 +39,12 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.Base64;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.StringUtil;
import org.whispersystems.libsignal.util.guava.Function;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
@@ -109,78 +112,87 @@ public abstract class MessageRecord extends DisplayRecord {
@Override
public SpannableString getDisplayBody(@NonNull Context context) {
if (isGroupUpdate() && isGroupV2()) {
return new SpannableString(getGv2Description(context));
} else if (isGroupUpdate() && isOutgoing()) {
return new SpannableString(context.getString(R.string.MessageRecord_you_updated_group));
} else if (isGroupUpdate()) {
return new SpannableString(GroupUtil.getDescription(context, getBody(), false).toString(getIndividualRecipient()));
} else if (isGroupQuit() && isOutgoing()) {
return new SpannableString(context.getString(R.string.MessageRecord_left_group));
} else if (isGroupQuit()) {
return new SpannableString(context.getString(R.string.ConversationItem_group_action_left, getIndividualRecipient().getDisplayName(context)));
} else if (isIncomingCall()) {
return new SpannableString(context.getString(R.string.MessageRecord_s_called_you, getIndividualRecipient().getDisplayName(context)));
} else if (isOutgoingCall()) {
return new SpannableString(context.getString(R.string.MessageRecord_you_called));
} else if (isMissedCall()) {
return new SpannableString(context.getString(R.string.MessageRecord_missed_call));
} else if (isJoined()) {
return new SpannableString(context.getString(R.string.MessageRecord_s_joined_signal, getIndividualRecipient().getDisplayName(context)));
} else if (isExpirationTimerUpdate()) {
int seconds = (int)(getExpiresIn() / 1000);
if (seconds <= 0) {
return isOutgoing() ? new SpannableString(context.getString(R.string.MessageRecord_you_disabled_disappearing_messages))
: new SpannableString(context.getString(R.string.MessageRecord_s_disabled_disappearing_messages, getIndividualRecipient().getDisplayName(context)));
}
String time = ExpirationUtil.getExpirationDisplayValue(context, seconds);
return isOutgoing() ? new SpannableString(context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time))
: new SpannableString(context.getString(R.string.MessageRecord_s_set_disappearing_message_time_to_s, getIndividualRecipient().getDisplayName(context), time));
} else if (isIdentityUpdate()) {
return new SpannableString(context.getString(R.string.MessageRecord_your_safety_number_with_s_has_changed, getIndividualRecipient().getDisplayName(context)));
} else if (isIdentityVerified()) {
if (isOutgoing()) return new SpannableString(context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_verified, getIndividualRecipient().getDisplayName(context)));
else return new SpannableString(context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_verified_from_another_device, getIndividualRecipient().getDisplayName(context)));
} else if (isIdentityDefault()) {
if (isOutgoing()) return new SpannableString(context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_unverified, getIndividualRecipient().getDisplayName(context)));
else return new SpannableString(context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_unverified_from_another_device, getIndividualRecipient().getDisplayName(context)));
} else if (isProfileChange()) {
return new SpannableString(getProfileChangeDescription(context));
UpdateDescription updateDisplayBody = getUpdateDisplayBody(context);
if (updateDisplayBody != null) {
return new SpannableString(updateDisplayBody.getString());
}
return new SpannableString(getBody());
}
private @NonNull String getGv2Description(@NonNull Context context) {
if (!isGroupUpdate() || !isGroupV2()) {
throw new AssertionError();
public @Nullable UpdateDescription getUpdateDisplayBody(@NonNull Context context) {
if (isGroupUpdate() && isGroupV2()) {
return getGv2ChangeDescription(context, getBody());
} else if (isGroupUpdate() && isOutgoing()) {
return staticUpdateDescription(context.getString(R.string.MessageRecord_you_updated_group));
} else if (isGroupUpdate()) {
return fromRecipient(getIndividualRecipient(), r -> GroupUtil.getNonV2GroupDescription(context, getBody()).toString(r));
} else if (isGroupQuit() && isOutgoing()) {
return staticUpdateDescription(context.getString(R.string.MessageRecord_left_group));
} else if (isGroupQuit()) {
return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.ConversationItem_group_action_left, r.getDisplayName(context)));
} else if (isIncomingCall()) {
return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_s_called_you, r.getDisplayName(context)));
} else if (isOutgoingCall()) {
return staticUpdateDescription(context.getString(R.string.MessageRecord_you_called));
} else if (isMissedCall()) {
return staticUpdateDescription(context.getString(R.string.MessageRecord_missed_call));
} else if (isJoined()) {
return staticUpdateDescription(context.getString(R.string.MessageRecord_s_joined_signal, getIndividualRecipient().getDisplayName(context)));
} else if (isExpirationTimerUpdate()) {
int seconds = (int)(getExpiresIn() / 1000);
if (seconds <= 0) {
return isOutgoing() ? staticUpdateDescription(context.getString(R.string.MessageRecord_you_disabled_disappearing_messages))
: fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_s_disabled_disappearing_messages, r.getDisplayName(context)));
}
String time = ExpirationUtil.getExpirationDisplayValue(context, seconds);
return isOutgoing() ? staticUpdateDescription(context.getString(R.string.MessageRecord_you_set_disappearing_message_time_to_s, time))
: fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_s_set_disappearing_message_time_to_s, r.getDisplayName(context), time));
} else if (isIdentityUpdate()) {
return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_your_safety_number_with_s_has_changed, r.getDisplayName(context)));
} else if (isIdentityVerified()) {
if (isOutgoing()) return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_verified, r.getDisplayName(context)));
else return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_verified_from_another_device, r.getDisplayName(context)));
} else if (isIdentityDefault()) {
if (isOutgoing()) return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_unverified, r.getDisplayName(context)));
else return fromRecipient(getIndividualRecipient(), r -> context.getString(R.string.MessageRecord_you_marked_your_safety_number_with_s_unverified_from_another_device, r.getDisplayName(context)));
} else if (isProfileChange()) {
return staticUpdateDescription(getProfileChangeDescription(context));
} else if (isEndSession()) {
if (isOutgoing()) return staticUpdateDescription(context.getString(R.string.SmsMessageRecord_secure_session_reset));
else return fromRecipient(getIndividualRecipient(), r-> context.getString(R.string.SmsMessageRecord_secure_session_reset_s, r.getDisplayName(context)));
}
return null;
}
public static @NonNull UpdateDescription getGv2ChangeDescription(@NonNull Context context, @NonNull String body) {
try {
ShortStringDescriptionStrategy descriptionStrategy = new ShortStringDescriptionStrategy(context);
byte[] decoded = Base64.decode(getBody());
byte[] decoded = Base64.decode(body);
DecryptedGroupV2Context decryptedGroupV2Context = DecryptedGroupV2Context.parseFrom(decoded);
GroupsV2UpdateMessageProducer updateMessageProducer = new GroupsV2UpdateMessageProducer(context, descriptionStrategy, Recipient.self().getUuid().get());
if (decryptedGroupV2Context.hasChange() && decryptedGroupV2Context.getGroupState().getRevision() > 0) {
DecryptedGroupChange change = decryptedGroupV2Context.getChange();
List<String> strings = updateMessageProducer.describeChange(change);
StringBuilder result = new StringBuilder();
for (int i = 0; i < strings.size(); i++) {
if (i > 0) result.append('\n');
result.append(strings.get(i));
}
return result.toString();
return UpdateDescription.concatWithNewLines(updateMessageProducer.describeChanges(decryptedGroupV2Context.getChange()));
} else {
return updateMessageProducer.describeNewGroup(decryptedGroupV2Context.getGroupState());
}
} catch (IOException e) {
Log.w(TAG, "GV2 Message update detail could not be read", e);
return context.getString(R.string.MessageRecord_group_updated);
return staticUpdateDescription(context.getString(R.string.MessageRecord_group_updated));
}
}
private static @NonNull UpdateDescription fromRecipient(@NonNull Recipient recipient, @NonNull Function<Recipient, String> stringFunction) {
return UpdateDescription.mentioning(Collections.singletonList(recipient.getUuid().or(UuidUtil.UNKNOWN_UUID)), () -> stringFunction.apply(recipient.resolve()));
}
private static @NonNull UpdateDescription staticUpdateDescription(@NonNull String string) {
return UpdateDescription.staticDescription(string);
}
private @NonNull String getProfileChangeDescription(@NonNull Context context) {
try {
byte[] decoded = Base64.decode(getBody());
@@ -188,8 +200,8 @@ public abstract class MessageRecord extends DisplayRecord {
if (profileChangeDetails.hasProfileNameChange()) {
String displayName = getIndividualRecipient().getDisplayName(context);
String newName = ProfileName.fromSerialized(profileChangeDetails.getProfileNameChange().getNew()).toString();
String previousName = ProfileName.fromSerialized(profileChangeDetails.getProfileNameChange().getPrevious()).toString();
String newName = StringUtil.isolateBidi(ProfileName.fromSerialized(profileChangeDetails.getProfileNameChange().getNew()).toString());
String previousName = StringUtil.isolateBidi(ProfileName.fromSerialized(profileChangeDetails.getProfileNameChange().getPrevious()).toString());
if (getIndividualRecipient().isSystemContact()) {
return context.getString(R.string.MessageRecord_changed_their_profile_name_from_to, displayName, previousName, newName);
@@ -316,7 +328,7 @@ public abstract class MessageRecord extends DisplayRecord {
return isFailed() && ((getRecipient().isPushGroup() && hasNetworkFailures()) || !isIdentityMismatchFailure());
}
protected SpannableString emphasisAdded(String sequence) {
protected static SpannableString emphasisAdded(String sequence) {
SpannableString spannable = new SpannableString(sequence);
spannable.setSpan(new RelativeSizeSpan(0.9f), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
spannable.setSpan(new StyleSpan(android.graphics.Typeface.ITALIC), 0, sequence.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);

View File

@@ -0,0 +1,151 @@
package org.thoughtcrime.securesms.database.model;
import androidx.annotation.AnyThread;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.api.util.UuidUtil;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
* Contains a list of people mentioned in an update message and a function to create the update message.
*/
public final class UpdateDescription {
public interface StringFactory {
@WorkerThread
String create();
}
private final Collection<UUID> mentioned;
private final StringFactory stringFactory;
private final String staticString;
private UpdateDescription(@NonNull Collection<UUID> mentioned,
@Nullable StringFactory stringFactory,
@Nullable String staticString)
{
if (staticString == null && stringFactory == null) {
throw new AssertionError();
}
this.mentioned = mentioned;
this.stringFactory = stringFactory;
this.staticString = staticString;
}
/**
* Create an update description which has a string value created by a supplied factory method that
* will be run on a background thread.
*
* @param mentioned UUIDs of recipients that are mentioned in the string.
* @param stringFactory The background method for generating the string.
*/
public static UpdateDescription mentioning(@NonNull Collection<UUID> mentioned,
@NonNull StringFactory stringFactory)
{
return new UpdateDescription(UuidUtil.filterKnown(mentioned),
stringFactory,
null);
}
/**
* Create an update description that's string value is fixed.
*/
public static UpdateDescription staticDescription(@NonNull String staticString) {
return new UpdateDescription(Collections.emptyList(), null, staticString);
}
public boolean isStringStatic() {
return staticString != null;
}
@AnyThread
public @NonNull String getStaticString() {
if (staticString == null) {
throw new UnsupportedOperationException();
}
return staticString;
}
@WorkerThread
public @NonNull String getString() {
if (staticString != null) {
return staticString;
}
Util.assertNotMainThread();
//noinspection ConstantConditions
return stringFactory.create();
}
@AnyThread
public Collection<UUID> getMentioned() {
return mentioned;
}
public static UpdateDescription concatWithNewLines(@NonNull List<UpdateDescription> updateDescriptions) {
if (updateDescriptions.size() == 0) {
throw new AssertionError();
}
if (updateDescriptions.size() == 1) {
return updateDescriptions.get(0);
}
if (allAreStatic(updateDescriptions)) {
return UpdateDescription.staticDescription(concatStaticLines(updateDescriptions));
}
Set<UUID> allMentioned = new HashSet<>();
for (UpdateDescription updateDescription : updateDescriptions) {
allMentioned.addAll(updateDescription.getMentioned());
}
return UpdateDescription.mentioning(allMentioned, () -> concatLines(updateDescriptions));
}
private static boolean allAreStatic(@NonNull Collection<UpdateDescription> updateDescriptions) {
for (UpdateDescription description : updateDescriptions) {
if (!description.isStringStatic()) {
return false;
}
}
return true;
}
@WorkerThread
private static String concatLines(@NonNull List<UpdateDescription> updateDescriptions) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < updateDescriptions.size(); i++) {
if (i > 0) result.append('\n');
result.append(updateDescriptions.get(i).getString());
}
return result.toString();
}
@AnyThread
private static String concatStaticLines(@NonNull List<UpdateDescription> updateDescriptions) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < updateDescriptions.size(); i++) {
if (i > 0) result.append('\n');
result.append(updateDescriptions.get(i).getStaticString());
}
return result.toString();
}
}

View File

@@ -18,6 +18,6 @@ public class GiphyStickerLoader extends GiphyLoader {
@Override
protected String getSearchUrl() {
return "https://api.giphy.com/v1/stickers/search?q=cat&api_key=3o6ZsYH6U6Eri53TXy&offset=%d&limit=" + PAGE_SIZE + "&q=%s";
return "https://api.giphy.com/v1/stickers/search?api_key=3o6ZsYH6U6Eri53TXy&offset=%d&limit=" + PAGE_SIZE + "&q=%s";
}
}

View File

@@ -36,6 +36,7 @@ import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupC
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
@@ -122,12 +123,15 @@ final class GroupManagerV1 {
RecipientId groupRecipientId = DatabaseFactory.getRecipientDatabase(context).getOrInsertFromGroupId(groupId);
Recipient groupRecipient = Recipient.resolved(groupRecipientId);
List<GroupContext.Member> uuidMembers = new LinkedList<>();
List<String> e164Members = new LinkedList<>();
List<GroupContext.Member> uuidMembers = new ArrayList<>(members.size());
List<String> e164Members = new ArrayList<>(members.size());
for (RecipientId member : members) {
Recipient recipient = Recipient.resolved(member);
uuidMembers.add(GroupV1MessageProcessor.createMember(RecipientUtil.toSignalServiceAddress(context, recipient)));
if (recipient.hasE164()) {
e164Members.add(recipient.requireE164());
uuidMembers.add(GroupV1MessageProcessor.createMember(recipient.requireE164()));
}
}
GroupContext.Builder groupContextBuilder = GroupContext.newBuilder()
@@ -135,7 +139,9 @@ final class GroupManagerV1 {
.setType(GroupContext.Type.UPDATE)
.addAllMembersE164(e164Members)
.addAllMembers(uuidMembers);
if (groupName != null) groupContextBuilder.setName(groupName);
GroupContext groupContext = groupContextBuilder.build();
if (avatar != null) {

View File

@@ -65,15 +65,14 @@ final class GroupManagerV2 {
private static final String TAG = Log.tag(GroupManagerV2.class);
private final Context context;
private final GroupDatabase groupDatabase;
private final GroupsV2Api groupsV2Api;
private final GroupsV2Operations groupsV2Operations;
private final GroupsV2Authorization authorization;
private final GroupsV2StateProcessor groupsV2StateProcessor;
private final UUID selfUuid;
private final GroupCandidateHelper groupCandidateHelper;
private final GroupsV2CapabilityChecker capabilityChecker;
private final Context context;
private final GroupDatabase groupDatabase;
private final GroupsV2Api groupsV2Api;
private final GroupsV2Operations groupsV2Operations;
private final GroupsV2Authorization authorization;
private final GroupsV2StateProcessor groupsV2StateProcessor;
private final UUID selfUuid;
private final GroupCandidateHelper groupCandidateHelper;
GroupManagerV2(@NonNull Context context) {
this.context = context;
@@ -84,7 +83,6 @@ final class GroupManagerV2 {
this.groupsV2StateProcessor = ApplicationDependencies.getGroupsV2StateProcessor();
this.selfUuid = Recipient.self().getUuid().get();
this.groupCandidateHelper = new GroupCandidateHelper(context);
this.capabilityChecker = new GroupsV2CapabilityChecker();
}
@WorkerThread
@@ -116,7 +114,7 @@ final class GroupManagerV2 {
@Nullable byte[] avatar)
throws GroupChangeFailedException, IOException, MembershipNotSuitableForV2Exception
{
if (!capabilityChecker.allAndSelfSupportGroupsV2AndUuid(members)) {
if (!GroupsV2CapabilityChecker.allAndSelfSupportGroupsV2AndUuid(members)) {
throw new MembershipNotSuitableForV2Exception("At least one potential new member does not support GV2 or UUID capabilities");
}
@@ -196,7 +194,7 @@ final class GroupManagerV2 {
@NonNull GroupManager.GroupActionResult addMembers(@NonNull Collection<RecipientId> newMembers)
throws GroupChangeFailedException, GroupInsufficientRightsException, IOException, GroupNotAMemberException, MembershipNotSuitableForV2Exception
{
if (!capabilityChecker.allSupportGroupsV2AndUuid(newMembers)) {
if (!GroupsV2CapabilityChecker.allSupportGroupsV2AndUuid(newMembers)) {
throw new MembershipNotSuitableForV2Exception("At least one potential new member does not support GV2 or UUID capabilities");
}

View File

@@ -101,7 +101,7 @@ public final class GroupV1MessageProcessor {
if (group.getMembers().isPresent()) {
for (SignalServiceAddress member : group.getMembers().get()) {
members.add(Recipient.externalPush(context, member).getId());
members.add(Recipient.externalGV1Member(context, member).getId());
}
}
@@ -131,8 +131,10 @@ public final class GroupV1MessageProcessor {
Set<RecipientId> recordMembers = new HashSet<>(groupRecord.getMembers());
Set<RecipientId> messageMembers = new HashSet<>();
for (SignalServiceAddress messageMember : group.getMembers().get()) {
messageMembers.add(Recipient.externalPush(context, messageMember).getId());
if (group.getMembers().isPresent()) {
for (SignalServiceAddress messageMember : group.getMembers().get()) {
messageMembers.add(Recipient.externalGV1Member(context, messageMember).getId());
}
}
Set<RecipientId> addedMembers = new HashSet<>(messageMembers);
@@ -150,18 +152,19 @@ public final class GroupV1MessageProcessor {
database.updateMembers(id, new LinkedList<>(unionMembers));
builder.clearMembers();
builder.clearMembersE164();
for (RecipientId addedMember : addedMembers) {
Recipient recipient = Recipient.resolved(addedMember);
if (recipient.getE164().isPresent()) {
builder.addMembersE164(recipient.getE164().get());
builder.addMembersE164(recipient.requireE164());
builder.addMembers(createMember(recipient.requireE164()));
}
builder.addMembers(createMember(RecipientUtil.toSignalServiceAddress(context, recipient)));
}
} else {
builder.clearMembers();
builder.clearMembersE164();
}
if (missingMembers.size() > 0) {
@@ -287,6 +290,8 @@ public final class GroupV1MessageProcessor {
.map(a -> a.getNumber().get())
.toList());
builder.addAllMembers(Stream.of(group.getMembers().get())
.filter(address -> address.getNumber().isPresent())
.map(address -> address.getNumber().get())
.map(GroupV1MessageProcessor::createMember)
.toList());
}
@@ -294,17 +299,9 @@ public final class GroupV1MessageProcessor {
return builder;
}
public static GroupContext.Member createMember(SignalServiceAddress address) {
public static GroupContext.Member createMember(@NonNull String e164) {
GroupContext.Member.Builder member = GroupContext.Member.newBuilder();
if (address.getUuid().isPresent()) {
member.setUuid(address.getUuid().get().toString());
}
if (address.getNumber().isPresent()) {
member.setE164(address.getNumber().get());
}
member.setE164(e164);
return member.build();
}
}

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.groups;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
@@ -24,17 +25,17 @@ public final class GroupsV2CapabilityChecker {
private static final String TAG = Log.tag(GroupsV2CapabilityChecker.class);
public GroupsV2CapabilityChecker() {}
private GroupsV2CapabilityChecker() {}
/**
* @param resolved A collection of resolved recipients.
*/
@WorkerThread
public void refreshCapabilitiesIfNecessary(@NonNull Collection<Recipient> resolved) throws IOException {
List<RecipientId> needsRefresh = Stream.of(resolved)
.filter(r -> r.getGroupsV2Capability() != Recipient.Capability.SUPPORTED)
.map(Recipient::getId)
.toList();
public static void refreshCapabilitiesIfNecessary(@NonNull Collection<Recipient> resolved) throws IOException {
Set<RecipientId> needsRefresh = Stream.of(resolved)
.filter(r -> r.getGroupsV2Capability() != Recipient.Capability.SUPPORTED)
.map(Recipient::getId)
.collect(Collectors.toSet());
if (needsRefresh.size() > 0) {
Log.d(TAG, "[refreshCapabilitiesIfNecessary] Need to refresh " + needsRefresh.size() + " recipients.");
@@ -51,7 +52,7 @@ public final class GroupsV2CapabilityChecker {
}
@WorkerThread
boolean allAndSelfSupportGroupsV2AndUuid(@NonNull Collection<RecipientId> recipientIds)
static boolean allAndSelfSupportGroupsV2AndUuid(@NonNull Collection<RecipientId> recipientIds)
throws IOException
{
HashSet<RecipientId> recipientIdsSet = new HashSet<>(recipientIds);
@@ -62,7 +63,7 @@ public final class GroupsV2CapabilityChecker {
}
@WorkerThread
boolean allSupportGroupsV2AndUuid(@NonNull Collection<RecipientId> recipientIds)
static boolean allSupportGroupsV2AndUuid(@NonNull Collection<RecipientId> recipientIds)
throws IOException
{
Set<RecipientId> recipientIdsSet = new HashSet<>(recipientIds);

View File

@@ -9,6 +9,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.DefaultValueLiveData;
import java.util.Collection;
import java.util.Objects;
public abstract class GroupMemberEntry {
@@ -152,7 +153,7 @@ public abstract class GroupMemberEntry {
PendingMember other = (PendingMember) obj;
return other.invitee.equals(invitee) &&
other.inviteeCipherText.equals(inviteeCipherText) &&
Objects.equals(other.inviteeCipherText, inviteeCipherText) &&
other.cancellable == cancellable;
}
@@ -160,7 +161,7 @@ public abstract class GroupMemberEntry {
public int hashCode() {
int hash = invitee.hashCode();
hash *= 31;
hash += inviteeCipherText.hashCode();
hash += Objects.hashCode(inviteeCipherText);
hash *= 31;
return hash + (cancellable ? 1 : 0);
}

View File

@@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.groups.ui.addmembers;
import android.content.Context;
import android.os.Bundle;
import android.view.View;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
@@ -58,14 +59,19 @@ public class AddMembersActivity extends PushContactSelectionActivity {
}
@Override
public void onContactSelected(Optional<RecipientId> recipientId, String number) {
public boolean onContactSelected(Optional<RecipientId> recipientId, String number) {
if (getGroupId().isV1() && recipientId.isPresent() && !Recipient.resolved(recipientId.get()).hasE164()) {
Toast.makeText(this, R.string.AddMembersActivity__this_person_cant_be_added_to_legacy_groups, Toast.LENGTH_SHORT).show();
return false;
}
if (contactsFragment.hasQueryFilter()) {
getToolbar().clear();
}
if (contactsFragment.getSelectedContactsCount() >= 1) {
enableDone();
}
enableDone();
return true;
}
@Override

View File

@@ -39,13 +39,18 @@ public final class AddToGroupViewModel extends ViewModel {
events.postValue(new Event.CloseEvent());
} else if (groupRecipientIds.size() == 1) {
SignalExecutors.BOUNDED.execute(() -> {
Recipient recipient = Recipient.resolved(recipientId);
Recipient groupRecipient = Recipient.resolved(groupRecipientIds.get(0));
String recipientName = Recipient.resolved(recipientId).getDisplayName(context);
String recipientName = recipient.getDisplayName(context);
String groupName = groupRecipient.getDisplayName(context);
events.postValue(new Event.AddToSingleGroupConfirmationEvent(context.getResources().getString(R.string.AddToGroupActivity_add_member),
context.getResources().getString(R.string.AddToGroupActivity_add_s_to_s, recipientName, groupName),
groupRecipient, recipientName, groupName));
if (groupRecipient.getGroupId().get().isV1() && !recipient.hasE164()) {
events.postValue(new Event.LegacyGroupDenialEvent());
} else {
events.postValue(new Event.AddToSingleGroupConfirmationEvent(context.getResources().getString(R.string.AddToGroupActivity_add_member),
context.getResources().getString(R.string.AddToGroupActivity_add_s_to_s, recipientName, groupName),
groupRecipient, recipientName, groupName));
}
});
} else {
throw new AssertionError("Does not support multi-select");
@@ -107,6 +112,9 @@ public final class AddToGroupViewModel extends ViewModel {
return message;
}
}
static class LegacyGroupDenialEvent extends Event {
}
}
public static class Factory implements ViewModelProvider.Factory {

View File

@@ -91,6 +91,8 @@ public final class AddToGroupsActivity extends ContactSelectionActivity {
.setPositiveButton(android.R.string.ok, (dialog, which) -> viewModel.onAddToGroupsConfirmed(addEvent))
.setNegativeButton(android.R.string.cancel, null)
.show();
} else if (event instanceof Event.LegacyGroupDenialEvent) {
Toast.makeText(this, R.string.AddToGroupActivity_this_person_cant_be_added_to_legacy_groups, Toast.LENGTH_SHORT).show();
} else {
throw new AssertionError();
}
@@ -112,20 +114,23 @@ public final class AddToGroupsActivity extends ContactSelectionActivity {
}
@Override
public void onContactSelected(Optional<RecipientId> recipientId, String number) {
public boolean onContactSelected(Optional<RecipientId> recipientId, String number) {
if (contactsFragment.isMulti()) {
if (contactsFragment.hasQueryFilter()) {
getToolbar().clear();
}
if (contactsFragment.getSelectedContactsCount() >= MINIMUM_GROUP_SELECT_SIZE) {
enableNext();
}
throw new UnsupportedOperationException("Not yet built to handle multi-select.");
// if (contactsFragment.hasQueryFilter()) {
// getToolbar().clear();
// }
//
// if (contactsFragment.getSelectedContactsCount() >= MINIMUM_GROUP_SELECT_SIZE) {
// enableNext();
// }
} else {
if (recipientId.isPresent()) {
viewModel.onContinueWithSelection(Collections.singletonList(recipientId.get()));
}
}
return true;
}
@Override

View File

@@ -8,6 +8,7 @@ import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import com.annimon.stream.Stream;
@@ -30,6 +31,8 @@ import org.thoughtcrime.securesms.util.views.SimpleProgressDialog;
import org.whispersystems.libsignal.util.guava.Optional;
import java.io.IOException;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
public class CreateGroupActivity extends ContactSelectionActivity {
@@ -90,14 +93,14 @@ public class CreateGroupActivity extends ContactSelectionActivity {
}
@Override
public void onContactSelected(Optional<RecipientId> recipientId, String number) {
public boolean onContactSelected(Optional<RecipientId> recipientId, String number) {
if (contactsFragment.hasQueryFilter()) {
getToolbar().clear();
}
if (contactsFragment.getSelectedContactsCount() >= MINIMUM_GROUP_SIZE) {
enableNext();
}
enableNext();
return true;
}
@Override
@@ -150,9 +153,9 @@ public class CreateGroupActivity extends ContactSelectionActivity {
stopwatch.split("registered");
if (FeatureFlags.groupsV2()) {
if (FeatureFlags.groupsV2create()) {
try {
new GroupsV2CapabilityChecker().refreshCapabilitiesIfNecessary(resolved);
GroupsV2CapabilityChecker.refreshCapabilitiesIfNecessary(resolved);
} catch (IOException e) {
Log.w(TAG, "Failed to refresh all recipient capabilities.", e);
}
@@ -160,13 +163,31 @@ public class CreateGroupActivity extends ContactSelectionActivity {
stopwatch.split("capabilities");
resolved = Recipient.resolvedList(ids);
if (Stream.of(resolved).anyMatch(r -> r.getGroupsV2Capability() != Recipient.Capability.SUPPORTED) &&
Stream.of(resolved).anyMatch(r -> !r.hasE164()))
{
Log.w(TAG, "Invalid GV1 group...");
ids = Collections.emptyList();
}
stopwatch.split("gv1-check");
return ids;
}, ids -> {
dismissibleDialog.dismiss();
stopwatch.stop(TAG);
startActivityForResult(AddGroupDetailsActivity.newIntent(this, ids), REQUEST_CODE_ADD_DETAILS);
if (ids.isEmpty()) {
new AlertDialog.Builder(this)
.setMessage(R.string.CreateGroupActivity_some_contacts_cannot_be_in_legacy_groups)
.setPositiveButton(android.R.string.ok, (d, w) -> d.dismiss())
.show();
} else {
startActivityForResult(AddGroupDetailsActivity.newIntent(this, ids), REQUEST_CODE_ADD_DETAILS);
}
});
}
}

View File

@@ -25,7 +25,9 @@ import org.thoughtcrime.securesms.groups.GroupNotAMemberException;
import org.thoughtcrime.securesms.groups.GroupProtoUtil;
import org.thoughtcrime.securesms.groups.GroupsV2Authorization;
import org.thoughtcrime.securesms.groups.v2.ProfileKeySet;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.jobmanager.JobTracker;
import org.thoughtcrime.securesms.jobs.AvatarGroupsV2DownloadJob;
import org.thoughtcrime.securesms.jobs.RequestGroupV2InfoJob;
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob;
@@ -51,6 +53,7 @@ import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
/**
@@ -297,11 +300,14 @@ public final class GroupsV2StateProcessor {
}
}
Collection<RecipientId> updated = recipientDatabase.persistProfileKeySet(profileKeys);
Set<RecipientId> updated = recipientDatabase.persistProfileKeySet(profileKeys);
if (!updated.isEmpty()) {
Log.i(TAG, String.format(Locale.US, "Learned %d new profile keys, scheduling profile retrievals", updated.size()));
RetrieveProfileJob.enqueue(updated);
Log.i(TAG, String.format(Locale.US, "Learned %d new profile keys, fetching profiles", updated.size()));
for (Job job : RetrieveProfileJob.forRecipients(updated)) {
jobManager.runSynchronously(job, 5000);
}
}
}

View File

@@ -36,7 +36,7 @@ public final class GroupV2UpdateSelfProfileKeyJob extends BaseJob {
private final GroupId.V2 groupId;
GroupV2UpdateSelfProfileKeyJob(@NonNull GroupId.V2 groupId) {
public GroupV2UpdateSelfProfileKeyJob(@NonNull GroupId.V2 groupId) {
this(new Parameters.Builder()
.addConstraint(NetworkConstraint.KEY)
.setLifespan(TimeUnit.DAYS.toMillis(1))

View File

@@ -65,7 +65,7 @@ import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
public class PushGroupSendJob extends PushSendJob {
public final class PushGroupSendJob extends PushSendJob {
public static final String KEY = "PushGroupSendJob";
@@ -74,8 +74,8 @@ public class PushGroupSendJob extends PushSendJob {
private static final String KEY_MESSAGE_ID = "message_id";
private static final String KEY_FILTER_RECIPIENT = "filter_recipient";
private long messageId;
private RecipientId filterRecipient;
private final long messageId;
private final RecipientId filterRecipient;
public PushGroupSendJob(long messageId, @NonNull RecipientId destination, @Nullable RecipientId filterRecipient, boolean hasMedia) {
this(new Job.Parameters.Builder()
@@ -237,7 +237,10 @@ public class PushGroupSendJob extends PushSendJob {
database.markAsSentFailed(messageId);
notifyMediaMessageDeliveryFailed(context, messageId);
List<RecipientId> mismatchRecipientIds = Stream.of(identityMismatches).map(mismatch -> mismatch.getRecipientId(context)).toList();
Set<RecipientId> mismatchRecipientIds = Stream.of(identityMismatches)
.map(mismatch -> mismatch.getRecipientId(context))
.collect(Collectors.toSet());
RetrieveProfileJob.enqueue(mismatchRecipientIds);
}
} catch (UntrustedIdentityException | UndeliverableMessageException e) {
@@ -320,8 +323,8 @@ public class PushGroupSendJob extends PushSendJob {
GroupContext groupContext = properties.getGroupContext();
SignalServiceAttachment avatar = attachmentPointers.isEmpty() ? null : attachmentPointers.get(0);
SignalServiceGroup.Type type = properties.isQuit() ? SignalServiceGroup.Type.QUIT : SignalServiceGroup.Type.UPDATE;
List<SignalServiceAddress> members = Stream.of(groupContext.getMembersList())
.map(m -> new SignalServiceAddress(UuidUtil.parseOrNull(m.getUuid()), m.getE164()))
List<SignalServiceAddress> members = Stream.of(groupContext.getMembersE164List())
.map(e164 -> new SignalServiceAddress(null, e164))
.toList();
SignalServiceGroup group = new SignalServiceGroup(type, groupId.getDecodedId(), groupContext.getName(), members, avatar);
SignalServiceDataMessage groupDataMessage = SignalServiceDataMessage.newBuilder()

View File

@@ -508,7 +508,8 @@ public final class PushProcessMessageJob extends BaseJob {
.putExtra(WebRtcCallService.EXTRA_CALL_ID, message.getId())
.putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, remotePeer)
.putExtra(WebRtcCallService.EXTRA_REMOTE_DEVICE, content.getSenderDevice())
.putExtra(WebRtcCallService.EXTRA_OFFER_DESCRIPTION, message.getDescription())
.putExtra(WebRtcCallService.EXTRA_OFFER_OPAQUE, message.getOpaque())
.putExtra(WebRtcCallService.EXTRA_OFFER_SDP, message.getSdp())
.putExtra(WebRtcCallService.EXTRA_SERVER_RECEIVED_TIMESTAMP, content.getServerReceivedTimestamp())
.putExtra(WebRtcCallService.EXTRA_SERVER_DELIVERED_TIMESTAMP, content.getServerDeliveredTimestamp())
.putExtra(WebRtcCallService.EXTRA_OFFER_TYPE, message.getType().getCode())
@@ -527,11 +528,12 @@ public final class PushProcessMessageJob extends BaseJob {
RemotePeer remotePeer = new RemotePeer(Recipient.externalHighTrustPush(context, content.getSender()).getId());
intent.setAction(WebRtcCallService.ACTION_RECEIVE_ANSWER)
.putExtra(WebRtcCallService.EXTRA_CALL_ID, message.getId())
.putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, remotePeer)
.putExtra(WebRtcCallService.EXTRA_REMOTE_DEVICE, content.getSenderDevice())
.putExtra(WebRtcCallService.EXTRA_ANSWER_DESCRIPTION, message.getDescription())
.putExtra(WebRtcCallService.EXTRA_MULTI_RING, content.getCallMessage().get().isMultiRing());
.putExtra(WebRtcCallService.EXTRA_CALL_ID, message.getId())
.putExtra(WebRtcCallService.EXTRA_REMOTE_PEER, remotePeer)
.putExtra(WebRtcCallService.EXTRA_REMOTE_DEVICE, content.getSenderDevice())
.putExtra(WebRtcCallService.EXTRA_ANSWER_OPAQUE, message.getOpaque())
.putExtra(WebRtcCallService.EXTRA_ANSWER_SDP, message.getSdp())
.putExtra(WebRtcCallService.EXTRA_MULTI_RING, content.getCallMessage().get().isMultiRing());
context.startService(intent);
}
@@ -1755,6 +1757,12 @@ public final class PushProcessMessageJob extends BaseJob {
} else if (conversation.isGroup()) {
GroupDatabase groupDatabase = DatabaseFactory.getGroupDatabase(context);
Optional<GroupId> groupId = GroupUtil.idFromGroupContext(message.getGroupContext());
boolean isGv2Message = message.isGroupV2Message();
if (isGv2Message && !FeatureFlags.groupsV2() && groupDatabase.isUnknownGroup(groupId.get())) {
Log.i(TAG, "Ignoring GV2 message for a new group by feature flag.");
return true;
}
if (groupId.isPresent() && groupDatabase.isUnknownGroup(groupId.get())) {
return false;
@@ -1763,17 +1771,11 @@ public final class PushProcessMessageJob extends BaseJob {
boolean isTextMessage = message.getBody().isPresent();
boolean isMediaMessage = message.getAttachments().isPresent() || message.getQuote().isPresent() || message.getSharedContacts().isPresent();
boolean isExpireMessage = message.isExpirationUpdate();
boolean isGv2Message = message.isGroupV2Message();
boolean isGv2Update = message.isGroupV2Update();
boolean isContentMessage = !message.isGroupV1Update() && !isGv2Update && !isExpireMessage && (isTextMessage || isMediaMessage);
boolean isGroupActive = groupId.isPresent() && groupDatabase.isActive(groupId.get());
boolean isLeaveMessage = message.getGroupContext().isPresent() && message.getGroupContext().get().getGroupV1Type() == SignalServiceGroup.Type.QUIT;
if (isGv2Message && !FeatureFlags.groupsV2()) {
Log.i(TAG, "Ignoring GV2 message by feature flag.");
return true;
}
return (isContentMessage && !isGroupActive) || (sender.isBlocked() && !isLeaveMessage && !isGv2Update);
} else {
return sender.isBlocked();

View File

@@ -14,7 +14,6 @@ import org.thoughtcrime.securesms.database.model.SmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobmanager.Data;
import org.thoughtcrime.securesms.jobmanager.Job;
import org.thoughtcrime.securesms.jobmanager.JobManager;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
@@ -72,7 +71,7 @@ public class PushTextSendJob extends PushSendJob {
public void onPushSend() throws NoSuchMessageException, RetryLaterException {
ExpiringMessageManager expirationManager = ApplicationContext.getInstance(context).getExpiringMessageManager();
SmsDatabase database = DatabaseFactory.getSmsDatabase(context);
SmsMessageRecord record = database.getMessage(messageId);
SmsMessageRecord record = database.getMessageRecord(messageId);
if (!record.isPending() && !record.isFailed()) {
warn(TAG, "Message " + messageId + " was already sent. Ignoring.");

View File

@@ -67,7 +67,7 @@ public class ReactionSendJob extends BaseJob {
throws NoSuchMessageException
{
MessageRecord message = isMms ? DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId)
: DatabaseFactory.getSmsDatabase(context).getMessage(messageId);
: DatabaseFactory.getSmsDatabase(context).getMessageRecord(messageId);
Recipient conversationRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(message.getThreadId());
@@ -140,7 +140,7 @@ public class ReactionSendJob extends BaseJob {
message = DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId);
} else {
db = DatabaseFactory.getSmsDatabase(context);
message = DatabaseFactory.getSmsDatabase(context).getMessage(messageId);
message = DatabaseFactory.getSmsDatabase(context).getMessageRecord(messageId);
}
Recipient targetAuthor = message.isOutgoing() ? Recipient.self() : message.getIndividualRecipient();

View File

@@ -57,7 +57,7 @@ public class RemoteDeleteSendJob extends BaseJob {
throws NoSuchMessageException
{
MessageRecord message = isMms ? DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId)
: DatabaseFactory.getSmsDatabase(context).getMessage(messageId);
: DatabaseFactory.getSmsDatabase(context).getMessageRecord(messageId);
Recipient conversationRecipient = DatabaseFactory.getThreadDatabase(context).getRecipientForThreadId(message.getThreadId());
@@ -119,7 +119,7 @@ public class RemoteDeleteSendJob extends BaseJob {
message = DatabaseFactory.getMmsDatabase(context).getMessageRecord(messageId);
} else {
db = DatabaseFactory.getSmsDatabase(context);
message = DatabaseFactory.getSmsDatabase(context).getMessage(messageId);
message = DatabaseFactory.getSmsDatabase(context).getMessageRecord(messageId);
}
long targetSentTimestamp = message.getDateSent();

View File

@@ -71,11 +71,6 @@ final class RequestGroupV2InfoWorkerJob extends BaseJob {
@Override
public void onRun() throws IOException, GroupNotAMemberException, GroupChangeBusyException {
if (!FeatureFlags.groupsV2()) {
Log.w(TAG, "Group update skipped due to feature flag " + groupId);
return;
}
Log.i(TAG, "Updating group to revision " + toRevision);
Optional<GroupDatabase.GroupRecord> group = DatabaseFactory.getGroupDatabase(context).getGroup(groupId);

View File

@@ -1,6 +1,5 @@
package org.thoughtcrime.securesms.jobs;
import android.app.Application;
import android.content.Context;
import android.text.TextUtils;
@@ -9,6 +8,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import com.annimon.stream.Collectors;
import com.annimon.stream.Stream;
import org.signal.zkgroup.profiles.ProfileKey;
@@ -50,10 +50,9 @@ import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
import org.whispersystems.signalservice.internal.util.concurrent.ListenableFuture;
import java.io.IOException;
import java.util.Collection;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
@@ -73,22 +72,20 @@ public class RetrieveProfileJob extends BaseJob {
private static final String KEY_RECIPIENTS = "recipients";
private final List<RecipientId> recipientIds;
private final Set<RecipientId> recipientIds;
/**
* Identical to {@link #enqueue(Collection)})}, but run on a background thread for convenience.
* Identical to {@link #enqueue(Set)})}, but run on a background thread for convenience.
*/
public static void enqueueAsync(@NonNull RecipientId recipientId) {
SignalExecutors.BOUNDED.execute(() -> {
ApplicationDependencies.getJobManager().add(forRecipient(recipientId));
});
SignalExecutors.BOUNDED.execute(() -> ApplicationDependencies.getJobManager().add(forRecipient(recipientId)));
}
/**
* Submits the necessary job to refresh the profile of the requested recipient. Works for any
* RecipientId, including individuals, groups, or yourself.
*
* Identical to {@link #enqueue(Collection)})}
* Identical to {@link #enqueue(Set)})}
*/
@WorkerThread
public static void enqueue(@NonNull RecipientId recipientId) {
@@ -100,7 +97,7 @@ public class RetrieveProfileJob extends BaseJob {
* RecipientIds, including individuals, groups, or yourself.
*/
@WorkerThread
public static void enqueue(@NonNull Collection<RecipientId> recipientIds) {
public static void enqueue(@NonNull Set<RecipientId> recipientIds) {
JobManager jobManager = ApplicationDependencies.getJobManager();
for (Job job : forRecipients(recipientIds)) {
@@ -121,26 +118,28 @@ public class RetrieveProfileJob extends BaseJob {
Context context = ApplicationDependencies.getApplication();
List<Recipient> recipients = DatabaseFactory.getGroupDatabase(context).getGroupMembers(recipient.requireGroupId(), GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF);
return new RetrieveProfileJob(Stream.of(recipients).map(Recipient::getId).toList());
return new RetrieveProfileJob(Stream.of(recipients).map(Recipient::getId).collect(Collectors.toSet()));
} else {
return new RetrieveProfileJob(Collections.singletonList(recipientId));
return new RetrieveProfileJob(Collections.singleton(recipientId));
}
}
/**
* Works for any RecipientId, whether it's an individual, group, or yourself.
*
* @return A list of length 2 or less. Two iff you are in the recipients.
*/
@WorkerThread
public static @NonNull List<Job> forRecipients(@NonNull Collection<RecipientId> recipientIds) {
Context context = ApplicationDependencies.getApplication();
List<RecipientId> combined = new LinkedList<>();
List<Job> jobs = new LinkedList<>();
public static @NonNull List<Job> forRecipients(@NonNull Set<RecipientId> recipientIds) {
Context context = ApplicationDependencies.getApplication();
Set<RecipientId> combined = new HashSet<>(recipientIds.size());
boolean includeSelf = false;
for (RecipientId recipientId : recipientIds) {
Recipient recipient = Recipient.resolved(recipientId);
if (recipient.isLocalNumber()) {
jobs.add(new RefreshOwnProfileJob());
includeSelf = true;
} else if (recipient.isGroup()) {
List<Recipient> recipients = DatabaseFactory.getGroupDatabase(context).getGroupMembers(recipient.requireGroupId(), GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF);
combined.addAll(Stream.of(recipients).map(Recipient::getId).toList());
@@ -149,7 +148,15 @@ public class RetrieveProfileJob extends BaseJob {
}
}
jobs.add(new RetrieveProfileJob(combined));
List<Job> jobs = new ArrayList<>(2);
if (includeSelf) {
jobs.add(new RefreshOwnProfileJob());
}
if (combined.size() > 0) {
jobs.add(new RetrieveProfileJob(combined));
}
return jobs;
}
@@ -158,7 +165,7 @@ public class RetrieveProfileJob extends BaseJob {
* Will fetch some profiles to ensure we're decently up-to-date if we haven't done so within a
* certain time period.
*/
public static void enqueueRoutineFetchIfNeccessary(Application application) {
public static void enqueueRoutineFetchIfNecessary(Application application) {
long timeSinceRefresh = System.currentTimeMillis() - SignalStore.misc().getLastProfileRefreshTime();
if (timeSinceRefresh < TimeUnit.HOURS.toMillis(12)) {
Log.i(TAG, "Too soon to refresh. Did the last refresh " + timeSinceRefresh + " ms ago.");
@@ -175,7 +182,7 @@ public class RetrieveProfileJob extends BaseJob {
if (ids.size() > 0) {
Log.i(TAG, "Optimistically refreshing " + ids.size() + " eligible recipient(s).");
enqueue(ids);
enqueue(new HashSet<>(ids));
} else {
Log.i(TAG, "No recipients to refresh.");
}
@@ -184,7 +191,7 @@ public class RetrieveProfileJob extends BaseJob {
});
}
private RetrieveProfileJob(@NonNull List<RecipientId> recipientIds) {
private RetrieveProfileJob(@NonNull Set<RecipientId> recipientIds) {
this(new Job.Parameters.Builder()
.addConstraint(NetworkConstraint.KEY)
.setMaxAttempts(3)
@@ -192,7 +199,7 @@ public class RetrieveProfileJob extends BaseJob {
recipientIds);
}
private RetrieveProfileJob(@NonNull Job.Parameters parameters, @NonNull List<RecipientId> recipientIds) {
private RetrieveProfileJob(@NonNull Job.Parameters parameters, @NonNull Set<RecipientId> recipientIds) {
super(parameters);
this.recipientIds = recipientIds;
}
@@ -279,7 +286,7 @@ public class RetrieveProfileJob extends BaseJob {
@Override
public void onFailure() {}
private void process(Recipient recipient, ProfileAndCredential profileAndCredential) throws IOException {
private void process(Recipient recipient, ProfileAndCredential profileAndCredential) {
SignalServiceProfile profile = profileAndCredential.getProfile();
ProfileKey recipientProfileKey = ProfileKeyUtil.profileKeyOrNull(recipient.getProfileKey());
@@ -410,7 +417,7 @@ public class RetrieveProfileJob extends BaseJob {
}
}
private void setProfileAvatar(Recipient recipient, String profileAvatar) {
private static void setProfileAvatar(Recipient recipient, String profileAvatar) {
if (recipient.getProfileKey() == null) return;
if (!Util.equals(profileAvatar, recipient.getProfileAvatar())) {
@@ -434,8 +441,8 @@ public class RetrieveProfileJob extends BaseJob {
@Override
public @NonNull RetrieveProfileJob create(@NonNull Parameters parameters, @NonNull Data data) {
String[] ids = data.getStringArray(KEY_RECIPIENTS);
List<RecipientId> recipientIds = Stream.of(ids).map(RecipientId::from).toList();
String[] ids = data.getStringArray(KEY_RECIPIENTS);
Set<RecipientId> recipientIds = Stream.of(ids).map(RecipientId::from).collect(Collectors.toSet());
return new RetrieveProfileJob(parameters, recipientIds);
}

View File

@@ -78,7 +78,7 @@ public class SmsSendJob extends SendJob {
}
SmsDatabase database = DatabaseFactory.getSmsDatabase(context);
SmsMessageRecord record = database.getMessage(messageId);
SmsMessageRecord record = database.getMessageRecord(messageId);
if (!record.isPending() && !record.isFailed()) {
warn(TAG, "Message " + messageId + " was already sent. Ignoring.");

View File

@@ -92,7 +92,7 @@ public class SmsSentJob extends BaseJob {
private void handleSentResult(long messageId, int result) {
try {
SmsDatabase database = DatabaseFactory.getSmsDatabase(context);
SmsMessageRecord record = database.getMessage(messageId);
SmsMessageRecord record = database.getMessageRecord(messageId);
switch (result) {
case Activity.RESULT_OK:

View File

@@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.keyvalue;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.components.emoji.EmojiUtil;
public class EmojiValues extends SignalStoreValues {
private static final String PREFIX = "emojiPref__";
EmojiValues(@NonNull KeyValueStore store) {
super(store);
}
@Override
void onFirstEverAppLaunch() {
}
public void setPreferredVariation(@NonNull String emoji) {
String canonical = EmojiUtil.getCanonicalRepresentation(emoji);
if (canonical.equals(emoji)) {
getStore().beginWrite().remove(PREFIX + canonical).apply();
} else {
putString(PREFIX + canonical, emoji);
}
}
public @NonNull String getPreferredVariation(@NonNull String emoji) {
String canonical = EmojiUtil.getCanonicalRepresentation(emoji);
return getString(PREFIX + canonical, emoji);
}
}

View File

@@ -24,6 +24,7 @@ public final class SignalStore {
private final TooltipValues tooltipValues;
private final MiscellaneousValues misc;
private final InternalValues internalValues;
private final EmojiValues emojiValues;
private SignalStore() {
this.store = ApplicationDependencies.getKeyValueStore();
@@ -36,6 +37,7 @@ public final class SignalStore {
this.tooltipValues = new TooltipValues(store);
this.misc = new MiscellaneousValues(store);
this.internalValues = new InternalValues(store);
this.emojiValues = new EmojiValues(store);
}
public static void onFirstEverAppLaunch() {
@@ -86,6 +88,10 @@ public final class SignalStore {
return INSTANCE.internalValues;
}
public static @NonNull EmojiValues emojiValues() {
return INSTANCE.emojiValues;
}
public static @NonNull GroupsV2AuthorizationSignalStoreCache groupsV2AuthorizationCache() {
return new GroupsV2AuthorizationSignalStoreCache(getStore());
}

View File

@@ -12,6 +12,7 @@ import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.ConversationItem;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.sms.MessageSender;
@@ -80,7 +81,7 @@ final class MessageHeaderViewHolder extends RecyclerView.ViewHolder {
else if (messageRecord.isOutgoing()) conversationItem = (ConversationItem) sentStub.inflate();
else conversationItem = (ConversationItem) receivedStub.inflate();
}
conversationItem.bind(messageRecord, Optional.absent(), Optional.absent(), glideRequests, Locale.getDefault(), new HashSet<>(), messageRecord.getRecipient(), null, false);
conversationItem.bind(new ConversationMessage(messageRecord), Optional.absent(), Optional.absent(), glideRequests, Locale.getDefault(), new HashSet<>(), messageRecord.getRecipient(), null, false);
}
private void bindErrorState(MessageRecord messageRecord) {

View File

@@ -65,7 +65,7 @@ public class MessageRequestsBottomView extends ConstraintLayout {
if (recipient.isGroup()) {
question.setText(R.string.MessageRequestBottomView_unblock_this_group_and_share_your_name_and_photo_with_its_members);
} else {
String name = recipient.getProfileName().isEmpty() ? recipient.getDisplayName(getContext()) : recipient.getProfileName().getGivenName();
String name = recipient.getShortDisplayName(getContext());
question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_wont_receive_any_messages_until_you_unblock_them, HtmlUtil.bold(name)), 0));
}
setActiveInactiveGroups(blockedButtons, normalButtons);
@@ -77,7 +77,7 @@ public class MessageRequestsBottomView extends ConstraintLayout {
question.setText(R.string.MessageRequestBottomView_do_you_want_to_join_this_group_they_wont_know_youve_seen_their_messages_until_you_accept);
}
} else {
String name = recipient.getProfileName().isEmpty() ? recipient.getDisplayName(getContext()) : recipient.getProfileName().getGivenName();
String name = recipient.getShortDisplayName(getContext());
question.setText(HtmlCompat.fromHtml(getContext().getString(R.string.MessageRequestBottomView_do_you_want_to_let_s_message_you_they_wont_know_youve_seen_their_messages_until_you_accept, HtmlUtil.bold(name)), 0));
}
setActiveInactiveGroups(normalButtons, blockedButtons);

View File

@@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.mms;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import org.signal.storageservice.protos.groups.local.DecryptedMember;
import org.signal.zkgroup.InvalidInputException;
import org.signal.zkgroup.groups.GroupMasterKey;
@@ -11,6 +13,7 @@ import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.Base64;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.UuidUtil;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContext;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2;
@@ -123,20 +126,15 @@ public final class MessageGroupContext {
@Override
public @NonNull List<RecipientId> getMembersListExcludingSelf() {
List<GroupContext.Member> membersList = groupContext.getMembersList();
if (membersList.isEmpty()) {
return Collections.emptyList();
} else {
LinkedList<RecipientId> members = new LinkedList<>();
RecipientId selfId = Recipient.self().getId();
for (GroupContext.Member member : membersList) {
RecipientId recipient = RecipientId.from(UuidUtil.parseOrNull(member.getUuid()), member.getE164());
if (!Recipient.self().getId().equals(recipient)) {
members.add(recipient);
}
}
return members;
}
return Stream.of(groupContext.getMembersList())
.map(GroupContext.Member::getE164)
.withoutNulls()
.map(e164 -> new SignalServiceAddress(null, e164))
.map(RecipientId::from)
.filterNot(selfId::equals)
.toList();
}
}

View File

@@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientExporter;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
public class UnknownSenderView extends FrameLayout {
@@ -50,10 +51,14 @@ public class UnknownSenderView extends FrameLayout {
protected Void doInBackground(Void... params) {
DatabaseFactory.getRecipientDatabase(context).setBlocked(recipient.getId(), true);
if (threadId != -1) DatabaseFactory.getThreadDatabase(context).setHasSent(threadId, true);
listener.onActionTaken();
return null;
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
@Override
protected void onPostExecute(Void aVoid) {
listener.onActionTaken();
}
}.executeOnExecutor(SignalExecutors.BOUNDED);
})
.setNegativeButton(android.R.string.cancel, null)
.show();
@@ -78,10 +83,14 @@ public class UnknownSenderView extends FrameLayout {
protected Void doInBackground(Void... params) {
DatabaseFactory.getRecipientDatabase(context).setProfileSharing(recipient.getId(), true);
if (threadId != -1) DatabaseFactory.getThreadDatabase(context).setHasSent(threadId, true);
listener.onActionTaken();
return null;
}
}.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR);
@Override
protected void onPostExecute(Void aVoid) {
listener.onActionTaken();
}
}.executeOnExecutor(SignalExecutors.BOUNDED);
})
.setNegativeButton(android.R.string.cancel, null)
.show();

View File

@@ -13,7 +13,7 @@ import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.util.StringUtil;
import org.thoughtcrime.securesms.util.livedata.LiveDataPair;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.whispersystems.libsignal.util.guava.Optional;
import java.util.Objects;
@@ -22,19 +22,20 @@ class EditProfileViewModel extends ViewModel {
private final MutableLiveData<String> givenName = new MutableLiveData<>();
private final MutableLiveData<String> familyName = new MutableLiveData<>();
private final LiveData<ProfileName> internalProfileName = Transformations.map(new LiveDataPair<>(givenName, familyName),
pair -> ProfileName.fromParts(pair.first(), pair.second()));
private final LiveData<String> trimmedGivenName = Transformations.map(givenName, StringUtil::trimToVisualBounds);
private final LiveData<String> trimmedFamilyName = Transformations.map(familyName, StringUtil::trimToVisualBounds);
private final LiveData<ProfileName> internalProfileName = LiveDataUtil.combineLatest(trimmedGivenName, trimmedFamilyName, ProfileName::fromParts);
private final MutableLiveData<byte[]> internalAvatar = new MutableLiveData<>();
private final MutableLiveData<byte[]> originalAvatar = new MutableLiveData<>();
private final MutableLiveData<Optional<String>> internalUsername = new MutableLiveData<>();
private final MutableLiveData<String> originalDisplayName = new MutableLiveData<>();
private final LiveData<Boolean> isFormValid = Transformations.map(givenName, name -> !StringUtil.isVisuallyEmpty(name));
private final LiveData<Boolean> isFormValid = Transformations.map(trimmedGivenName, s -> s.length() > 0);
private final EditProfileRepository repository;
private final GroupId groupId;
private EditProfileViewModel(@NonNull EditProfileRepository repository, boolean hasInstanceState, @Nullable GroupId groupId) {
this.repository = repository;
this.groupId = groupId;
this.repository = repository;
this.groupId = groupId;
repository.getCurrentUsername(internalUsername::postValue);
@@ -141,9 +142,8 @@ class EditProfileViewModel extends ViewModel {
this.groupId = groupId;
}
@NonNull
@Override
public <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection unchecked
return (T) new EditProfileViewModel(repository, hasInstanceState, groupId);
}

View File

@@ -2,15 +2,25 @@ package org.thoughtcrime.securesms.reactions;
import androidx.annotation.NonNull;
final class EmojiCount {
private final String baseEmoji;
private final String displayEmoji;
private final int count;
import java.util.List;
EmojiCount(@NonNull String baseEmoji, @NonNull String emoji, int count) {
final class EmojiCount {
static EmojiCount all(@NonNull List<ReactionDetails> reactions) {
return new EmojiCount("", "", reactions);
}
private final String baseEmoji;
private final String displayEmoji;
private final List<ReactionDetails> reactions;
EmojiCount(@NonNull String baseEmoji,
@NonNull String emoji,
@NonNull List<ReactionDetails> reactions)
{
this.baseEmoji = baseEmoji;
this.displayEmoji = emoji;
this.count = count;
this.reactions = reactions;
}
public @NonNull String getBaseEmoji() {
@@ -22,6 +32,10 @@ final class EmojiCount {
}
public int getCount() {
return count;
return reactions.size();
}
public @NonNull List<ReactionDetails> getReactions() {
return reactions;
}
}

View File

@@ -0,0 +1,35 @@
package org.thoughtcrime.securesms.reactions;
import androidx.annotation.NonNull;
import org.thoughtcrime.securesms.recipients.Recipient;
public class ReactionDetails {
private final Recipient sender;
private final String baseEmoji;
private final String displayEmoji;
private final long timestamp;
ReactionDetails(@NonNull Recipient sender, @NonNull String baseEmoji, @NonNull String displayEmoji, long timestamp) {
this.sender = sender;
this.baseEmoji = baseEmoji;
this.displayEmoji = displayEmoji;
this.timestamp = timestamp;
}
public @NonNull Recipient getSender() {
return sender;
}
public @NonNull String getBaseEmoji() {
return baseEmoji;
}
public @NonNull String getDisplayEmoji() {
return displayEmoji;
}
public long getTimestamp() {
return timestamp;
}
}

View File

@@ -1,130 +0,0 @@
package org.thoughtcrime.securesms.reactions;
import android.graphics.drawable.Drawable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.RecyclerView;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.util.ThemeUtil;
import java.util.Collections;
import java.util.List;
final class ReactionEmojiCountAdapter extends RecyclerView.Adapter<ReactionEmojiCountAdapter.ViewHolder> {
private List<EmojiCount> emojiCountList = Collections.emptyList();
private int totalCount = 0;
private int selectedPosition = -1;
private final OnEmojiCountSelectedListener onEmojiCountSelectedListener;
ReactionEmojiCountAdapter(@NonNull OnEmojiCountSelectedListener onEmojiCountSelectedListener) {
this.onEmojiCountSelectedListener = onEmojiCountSelectedListener;
}
void updateData(@NonNull List<EmojiCount> newEmojiCount) {
if (selectedPosition != -1 && selectedPosition != 0) {
int emojiPosition = selectedPosition - 1;
EmojiCount oldSelection = emojiCountList.get(emojiPosition);
int newPosition = -1;
for (int i = 0; i < newEmojiCount.size(); i++) {
if (newEmojiCount.get(i).getBaseEmoji().equals(oldSelection.getBaseEmoji())) {
newPosition = i;
break;
}
}
if (newPosition == -1 && !newEmojiCount.isEmpty()) {
selectedPosition = 0;
onEmojiCountSelectedListener.onSelected(null);
} else {
selectedPosition = newPosition + 1;
}
} else if (!newEmojiCount.isEmpty()) {
selectedPosition = 0;
onEmojiCountSelectedListener.onSelected(null);
}
this.emojiCountList = newEmojiCount;
this.totalCount = Stream.of(emojiCountList).reduce(0, (sum, e) -> sum + e.getCount());
notifyDataSetChanged();
}
@Override
public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.reactions_bottom_sheet_dialog_fragment_emoji_item, parent, false), position -> {
if (position != -1 && position != selectedPosition) {
onEmojiCountSelectedListener.onSelected(position == 0 ? null : emojiCountList.get(position - 1).getBaseEmoji());
int oldPosition = selectedPosition;
selectedPosition = position;
notifyItemChanged(oldPosition);
notifyItemChanged(position);
}
});
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
if (position == 0) {
holder.bind(null, totalCount, selectedPosition == position);
} else {
EmojiCount item = emojiCountList.get(position - 1);
holder.bind(item.getDisplayEmoji(), item.getCount(), selectedPosition == position);
}
}
@Override
public int getItemCount() {
return 1 + emojiCountList.size();
}
static final class ViewHolder extends RecyclerView.ViewHolder {
private final Drawable selectedBackground;
private final EmojiTextView emojiView;
private final TextView countView;
ViewHolder(@NonNull View itemView, @NonNull OnViewHolderClickListener onClickListener) {
super(itemView);
emojiView = itemView.findViewById(R.id.reactions_bottom_view_emoji_item_emoji);
countView = itemView.findViewById(R.id.reactions_bottom_view_emoji_item_text );
selectedBackground = ThemeUtil.getThemedDrawable(itemView.getContext(), R.attr.reactions_bottom_dialog_fragment_emoji_selected);
itemView.setOnClickListener(v -> onClickListener.onClick(getAdapterPosition()));
}
void bind(@Nullable String emoji, int count, boolean selected) {
if (emoji != null) {
emojiView.setVisibility(View.VISIBLE);
emojiView.setText(emoji);
countView.setText(String.valueOf(count));
} else {
emojiView.setVisibility(View.GONE);
countView.setText(itemView.getContext().getString(R.string.ReactionsBottomSheetDialogFragment_all, count));
}
itemView.setBackground(selected ? selectedBackground : null);
}
}
interface OnViewHolderClickListener {
void onClick(int position);
}
interface OnEmojiCountSelectedListener {
void onSelected(@Nullable String emoji);
}
}

View File

@@ -11,7 +11,6 @@ import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.AvatarImageView;
import org.thoughtcrime.securesms.mms.GlideApp;
import org.thoughtcrime.securesms.reactions.ReactionsLoader.Reaction;
import org.thoughtcrime.securesms.util.AvatarUtil;
import java.util.Collections;
@@ -19,9 +18,9 @@ import java.util.List;
final class ReactionRecipientsAdapter extends RecyclerView.Adapter<ReactionRecipientsAdapter.ViewHolder> {
private List<Reaction> data = Collections.emptyList();
private List<ReactionDetails> data = Collections.emptyList();
public void updateData(List<Reaction> newData) {
public void updateData(List<ReactionDetails> newData) {
data = newData;
notifyDataSetChanged();
}
@@ -44,7 +43,7 @@ final class ReactionRecipientsAdapter extends RecyclerView.Adapter<ReactionRecip
return data.size();
}
final class ViewHolder extends RecyclerView.ViewHolder {
static final class ViewHolder extends RecyclerView.ViewHolder {
private final AvatarImageView avatar;
private final TextView recipient;
@@ -58,7 +57,7 @@ final class ReactionRecipientsAdapter extends RecyclerView.Adapter<ReactionRecip
emoji = itemView.findViewById(R.id.reactions_bottom_view_recipient_emoji);
}
void bind(@NonNull Reaction reaction) {
void bind(@NonNull ReactionDetails reaction) {
this.emoji.setText(reaction.getDisplayEmoji());
if (reaction.getSender().isLocalNumber()) {

View File

@@ -0,0 +1,91 @@
package org.thoughtcrime.securesms.reactions;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.util.adapter.AlwaysChangedDiffUtil;
import java.util.List;
/**
* ReactionViewPagerAdapter provides pages to a ViewPager2 which contains the reactions on a given message.
*/
class ReactionViewPagerAdapter extends ListAdapter<EmojiCount, ReactionViewPagerAdapter.ViewHolder> {
private int selectedPosition = 0;
protected ReactionViewPagerAdapter() {
super(new AlwaysChangedDiffUtil<>());
}
@NonNull EmojiCount getEmojiCount(int position) {
return getItem(position);
}
void enableNestedScrollingForPosition(int position) {
selectedPosition = position;
notifyItemRangeChanged(0, getItemCount(), new Object());
}
@Override
public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.reactions_bottom_sheet_dialog_fragment_recycler, parent, false));
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position, @NonNull List<Object> payloads) {
if (payloads.isEmpty()) {
onBindViewHolder(holder, position);
} else {
holder.setSelected(selectedPosition);
}
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.onBind(getItem(position));
holder.setSelected(selectedPosition);
}
@Override
public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) {
recyclerView.setNestedScrollingEnabled(false);
ViewGroup.LayoutParams params = recyclerView.getLayoutParams();
params.height = (int) (recyclerView.getResources().getDisplayMetrics().heightPixels * 0.80);
recyclerView.setLayoutParams(params);
recyclerView.setHasFixedSize(true);
}
static class ViewHolder extends RecyclerView.ViewHolder {
private final RecyclerView recycler;
private final ReactionRecipientsAdapter adapter = new ReactionRecipientsAdapter();
public ViewHolder(@NonNull View itemView) {
super(itemView);
recycler = (RecyclerView) itemView;
ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT);
recycler.setLayoutParams(params);
recycler.setAdapter(adapter);
}
public void onBind(@NonNull EmojiCount emojiCount) {
adapter.updateData(emojiCount.getReactions());
}
public void setSelected(int position) {
recycler.setNestedScrollingEnabled(getAdapterPosition() == position);
}
}
}

View File

@@ -1,21 +1,32 @@
package org.thoughtcrime.securesms.reactions;
import android.content.Context;
import android.content.DialogInterface;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
import androidx.fragment.app.DialogFragment;
import androidx.lifecycle.ViewModelProviders;
import androidx.loader.app.LoaderManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import com.google.android.material.tabs.TabLayout;
import com.google.android.material.tabs.TabLayoutMediator;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiImageView;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import java.util.Objects;
public final class ReactionsBottomSheetDialogFragment extends BottomSheetDialogFragment {
@@ -23,12 +34,11 @@ public final class ReactionsBottomSheetDialogFragment extends BottomSheetDialogF
private static final String ARGS_IS_MMS = "reactions.args.is.mms";
private long messageId;
private RecyclerView recipientRecyclerView;
private RecyclerView emojiRecyclerView;
private ViewPager2 recipientPagerView;
private ReactionsLoader reactionsLoader;
private ReactionRecipientsAdapter recipientsAdapter;
private ReactionEmojiCountAdapter emojiCountAdapter;
private ReactionViewPagerAdapter recipientsAdapter;
private ReactionsViewModel viewModel;
private Callback callback;
public static DialogFragment create(long messageId, boolean isMms) {
Bundle args = new Bundle();
@@ -42,13 +52,20 @@ public final class ReactionsBottomSheetDialogFragment extends BottomSheetDialogF
return fragment;
}
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
callback = (Callback) context;
}
@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
if (ThemeUtil.isDarkTheme(requireContext())) {
setStyle(DialogFragment.STYLE_NORMAL, R.style.Theme_Signal_BottomSheetDialog_Fixed);
setStyle(DialogFragment.STYLE_NORMAL, R.style.Theme_Signal_BottomSheetDialog_Fixed_ReactWithAny);
} else {
setStyle(DialogFragment.STYLE_NORMAL, R.style.Theme_Signal_Light_BottomSheetDialog_Fixed);
setStyle(DialogFragment.STYLE_NORMAL, R.style.Theme_Signal_Light_BottomSheetDialog_Fixed_ReactWithAny);
}
super.onCreate(savedInstanceState);
@@ -63,16 +80,56 @@ public final class ReactionsBottomSheetDialogFragment extends BottomSheetDialogF
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
recipientRecyclerView = view.findViewById(R.id.reactions_bottom_view_recipient_recycler);
emojiRecyclerView = view.findViewById(R.id.reactions_bottom_view_emoji_recycler);
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
emojiRecyclerView.setNestedScrollingEnabled(false);
messageId = getArguments().getLong(ARGS_MESSAGE_ID);
if (savedInstanceState == null) {
FrameLayout container = requireDialog().findViewById(R.id.container);
LayoutInflater layoutInflater = LayoutInflater.from(requireContext());
View statusBarShader = layoutInflater.inflate(R.layout.react_with_any_emoji_status_fade, container, false);
TabLayout emojiTabs = (TabLayout) layoutInflater.inflate(R.layout.reactions_bottom_sheet_dialog_fragment_tabs, container, false);
ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewUtil.getStatusBarHeight(container));
statusBarShader.setLayoutParams(params);
container.addView(statusBarShader, 0);
container.addView(emojiTabs);
ViewCompat.setOnApplyWindowInsetsListener(container, (v, insets) -> insets.consumeSystemWindowInsets());
new TabLayoutMediator(emojiTabs, recipientPagerView, (tab, position) -> {
tab.setCustomView(R.layout.reactions_bottom_sheet_dialog_fragment_emoji_item);
View customView = Objects.requireNonNull(tab.getCustomView());
EmojiImageView emoji = customView.findViewById(R.id.reactions_bottom_view_emoji_item_emoji);
TextView text = customView.findViewById(R.id.reactions_bottom_view_emoji_item_text);
EmojiCount emojiCount = recipientsAdapter.getEmojiCount(position);
if (position != 0) {
emoji.setVisibility(View.VISIBLE);
emoji.setImageEmoji(emojiCount.getDisplayEmoji());
text.setText(String.valueOf(emojiCount.getCount()));
} else {
emoji.setVisibility(View.GONE);
text.setText(requireContext().getString(R.string.ReactionsBottomSheetDialogFragment_all, emojiCount.getCount()));
}
}).attach();
}
setUpViewModel();
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
recipientPagerView = view.findViewById(R.id.reactions_bottom_view_recipient_pager);
messageId = requireArguments().getLong(ARGS_MESSAGE_ID);
setUpRecipientsRecyclerView();
setUpEmojiRecyclerView();
setUpViewModel();
reactionsLoader = new ReactionsLoader(requireContext(),
requireArguments().getLong(ARGS_MESSAGE_ID),
requireArguments().getBoolean(ARGS_IS_MMS));
LoaderManager.getInstance(requireActivity()).initLoader((int) messageId, null, reactionsLoader);
}
@@ -83,34 +140,48 @@ public final class ReactionsBottomSheetDialogFragment extends BottomSheetDialogF
super.onDestroyView();
}
private void setUpRecipientsRecyclerView() {
recipientsAdapter = new ReactionRecipientsAdapter();
recipientRecyclerView.setAdapter(recipientsAdapter);
@Override
public void onDismiss(@NonNull DialogInterface dialog) {
super.onDismiss(dialog);
callback.onReactionsDialogDismissed();
}
private void setUpEmojiRecyclerView() {
emojiCountAdapter = new ReactionEmojiCountAdapter((emoji -> viewModel.setFilterEmoji(emoji)));
emojiRecyclerView.setAdapter(emojiCountAdapter);
private void setUpRecipientsRecyclerView() {
recipientsAdapter = new ReactionViewPagerAdapter();
recipientPagerView.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
@Override
public void onPageSelected(int position) {
recipientPagerView.post(() -> {
recipientsAdapter.enableNestedScrollingForPosition(position);
});
}
@Override
public void onPageScrollStateChanged(int state) {
if (state == ViewPager2.SCROLL_STATE_IDLE) {
recipientPagerView.requestLayout();
}
}
});
recipientPagerView.setAdapter(recipientsAdapter);
}
private void setUpViewModel() {
reactionsLoader = new ReactionsLoader(requireContext(),
getArguments().getLong(ARGS_MESSAGE_ID),
getArguments().getBoolean(ARGS_IS_MMS));
ReactionsViewModel.Factory factory = new ReactionsViewModel.Factory(reactionsLoader);
viewModel = ViewModelProviders.of(this, factory).get(ReactionsViewModel.class);
viewModel.getRecipients().observe(getViewLifecycleOwner(), reactions -> {
if (reactions.size() == 0) dismiss();
recipientsAdapter.updateData(reactions);
});
viewModel.getEmojiCounts().observe(getViewLifecycleOwner(), emojiCounts -> {
if (emojiCounts.size() == 0) dismiss();
if (emojiCounts.size() <= 1) dismiss();
emojiCountAdapter.updateData(emojiCounts);
recipientsAdapter.submitList(emojiCounts);
});
}
public interface Callback {
void onReactionsDialogDismissed();
}
}

View File

@@ -29,7 +29,7 @@ public class ReactionsLoader implements ReactionsViewModel.Repository, LoaderMan
private final boolean isMms;
private final Context appContext;
private MutableLiveData<List<Reaction>> internalLiveData = new MutableLiveData<>();
private MutableLiveData<List<ReactionDetails>> internalLiveData = new MutableLiveData<>();
public ReactionsLoader(@NonNull Context context, long messageId, boolean isMms)
{
@@ -47,6 +47,8 @@ public class ReactionsLoader implements ReactionsViewModel.Repository, LoaderMan
@Override
public void onLoadFinished(@NonNull Loader<Cursor> loader, Cursor data) {
SignalExecutors.BOUNDED.execute(() -> {
data.moveToPosition(-1);
MessageRecord record = isMms ? DatabaseFactory.getMmsDatabase(appContext).readerFor(data).getNext()
: DatabaseFactory.getSmsDatabase(appContext).readerFor(data).getNext();
@@ -54,10 +56,10 @@ public class ReactionsLoader implements ReactionsViewModel.Repository, LoaderMan
internalLiveData.postValue(Collections.emptyList());
} else {
internalLiveData.postValue(Stream.of(record.getReactions())
.map(reactionRecord -> new Reaction(Recipient.resolved(reactionRecord.getAuthor()),
EmojiUtil.getCanonicalRepresentation(reactionRecord.getEmoji()),
reactionRecord.getEmoji(),
reactionRecord.getDateReceived()))
.map(reactionRecord -> new ReactionDetails(Recipient.resolved(reactionRecord.getAuthor()),
EmojiUtil.getCanonicalRepresentation(reactionRecord.getEmoji()),
reactionRecord.getEmoji(),
reactionRecord.getDateReceived()))
.toList());
}
});
@@ -69,7 +71,7 @@ public class ReactionsLoader implements ReactionsViewModel.Repository, LoaderMan
}
@Override
public LiveData<List<Reaction>> getReactions() {
public LiveData<List<ReactionDetails>> getReactions() {
return internalLiveData;
}
@@ -103,33 +105,4 @@ public class ReactionsLoader implements ReactionsViewModel.Repository, LoaderMan
}
}
static class Reaction {
private final Recipient sender;
private final String baseEmoji;
private final String displayEmoji;
private final long timestamp;
private Reaction(@NonNull Recipient sender, @NonNull String baseEmoji, @NonNull String displayEmoji, long timestamp) {
this.sender = sender;
this.baseEmoji = baseEmoji;
this.displayEmoji = displayEmoji;
this.timestamp = timestamp;
}
public @NonNull Recipient getSender() {
return sender;
}
public @NonNull String getBaseEmoji() {
return baseEmoji;
}
public @NonNull String getDisplayEmoji() {
return displayEmoji;
}
public long getTimestamp() {
return timestamp;
}
}
}

View File

@@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.reactions;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
@@ -12,39 +11,32 @@ import com.annimon.stream.Stream;
import java.util.List;
import java.util.Map;
import static org.thoughtcrime.securesms.reactions.ReactionsLoader.*;
public class ReactionsViewModel extends ViewModel {
private final Repository repository;
private final MutableLiveData<String> filterEmoji = new MutableLiveData<>();
public ReactionsViewModel(@NonNull Repository repository) {
this.repository = repository;
}
public @NonNull LiveData<List<Reaction>> getRecipients() {
return Transformations.switchMap(filterEmoji,
emoji -> Transformations.map(repository.getReactions(),
reactions -> Stream.of(reactions)
.filter(reaction -> emoji == null || reaction.getBaseEmoji().equals(emoji))
.toList()));
}
public @NonNull LiveData<List<EmojiCount>> getEmojiCounts() {
return Transformations.map(repository.getReactions(),
reactionList -> Stream.of(reactionList)
.groupBy(Reaction::getBaseEmoji)
.sorted(this::compareReactions)
.map(entry -> new EmojiCount(entry.getKey(), getCountDisplayEmoji(entry.getValue()), entry.getValue().size()))
.toList());
reactionList -> {
List<EmojiCount> emojiCounts = Stream.of(reactionList)
.groupBy(ReactionDetails::getBaseEmoji)
.sorted(this::compareReactions)
.map(entry -> new EmojiCount(entry.getKey(),
getCountDisplayEmoji(entry.getValue()),
entry.getValue()))
.toList();
emojiCounts.add(0, EmojiCount.all(reactionList));
return emojiCounts;
});
}
public void setFilterEmoji(String filterEmoji) {
this.filterEmoji.setValue(filterEmoji);
}
private int compareReactions(@NonNull Map.Entry<String, List<Reaction>> lhs, @NonNull Map.Entry<String, List<Reaction>> rhs) {
private int compareReactions(@NonNull Map.Entry<String, List<ReactionDetails>> lhs, @NonNull Map.Entry<String, List<ReactionDetails>> rhs) {
int lengthComparison = -Integer.compare(lhs.getValue().size(), rhs.getValue().size());
if (lengthComparison != 0) return lengthComparison;
@@ -54,15 +46,15 @@ public class ReactionsViewModel extends ViewModel {
return -Long.compare(latestTimestampLhs, latestTimestampRhs);
}
private long getLatestTimestamp(List<Reaction> reactions) {
private long getLatestTimestamp(List<ReactionDetails> reactions) {
return Stream.of(reactions)
.max((a, b) -> Long.compare(a.getTimestamp(), b.getTimestamp()))
.map(Reaction::getTimestamp)
.map(ReactionDetails::getTimestamp)
.orElse(-1L);
}
private @NonNull String getCountDisplayEmoji(@NonNull List<Reaction> reactions) {
for (Reaction reaction : reactions) {
private @NonNull String getCountDisplayEmoji(@NonNull List<ReactionDetails> reactions) {
for (ReactionDetails reaction : reactions) {
if (reaction.getSender().isLocalNumber()) {
return reaction.getDisplayEmoji();
}
@@ -72,7 +64,7 @@ public class ReactionsViewModel extends ViewModel {
}
interface Repository {
LiveData<List<Reaction>> getReactions();
LiveData<List<ReactionDetails>> getReactions();
}
static final class Factory implements ViewModelProvider.Factory {

View File

@@ -1,53 +1,82 @@
package org.thoughtcrime.securesms.reactions.any;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.core.widget.NestedScrollView;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider;
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel;
import org.thoughtcrime.securesms.components.emoji.EmojiPageView;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter;
import org.thoughtcrime.securesms.util.adapter.AlwaysChangedDiffUtil;
import java.util.Collections;
import java.util.List;
final class ReactWithAnyEmojiAdapter extends RecyclerView.Adapter<ReactWithAnyEmojiAdapter.ViewHolder> {
final class ReactWithAnyEmojiAdapter extends ListAdapter<ReactWithAnyEmojiPage, ReactWithAnyEmojiAdapter.ReactWithAnyEmojiPageViewHolder> {
private static final int VIEW_TYPE_SINGLE = 0;
private static final int VIEW_TYPE_DUAL = 1;
private final List<EmojiPageModel> models;
private final EmojiKeyboardProvider.EmojiEventListener emojiEventListener;
private final EmojiPageViewGridAdapter.VariationSelectorListener variationSelectorListener;
private final Callbacks callbacks;
ReactWithAnyEmojiAdapter(@NonNull List<EmojiPageModel> models,
@NonNull EmojiKeyboardProvider.EmojiEventListener emojiEventListener,
ReactWithAnyEmojiAdapter(@NonNull EmojiKeyboardProvider.EmojiEventListener emojiEventListener,
@NonNull EmojiPageViewGridAdapter.VariationSelectorListener variationSelectorListener,
@NonNull Callbacks callbacks)
{
this.models = models;
super(new AlwaysChangedDiffUtil<>());
this.emojiEventListener = emojiEventListener;
this.variationSelectorListener = variationSelectorListener;
this.callbacks = callbacks;
}
@Override
public @NonNull ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return new ViewHolder(new EmojiPageView(parent.getContext(), emojiEventListener, variationSelectorListener, true));
public ReactWithAnyEmojiPage getItem(int position) {
return super.getItem(position);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
holder.bind(models.get(position));
public @NonNull ReactWithAnyEmojiPageViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
switch (viewType) {
case VIEW_TYPE_SINGLE:
return new SinglePageBlockViewHolder(createEmojiPageView(parent.getContext()));
case VIEW_TYPE_DUAL:
EmojiPageView block1 = createEmojiPageView(parent.getContext());
EmojiPageView block2 = createEmojiPageView(parent.getContext());
NestedScrollView scrollView = (NestedScrollView) LayoutInflater.from(parent.getContext()).inflate(R.layout.react_with_any_emoji_dual_block_item, parent, false);
LinearLayout container = scrollView.findViewById(R.id.react_with_any_emoji_dual_block_item_container);
block1.setRecyclerNestedScrollingEnabled(false);
block2.setRecyclerNestedScrollingEnabled(false);
container.addView(block1, 0);
container.addView(block2);
return new DualPageBlockViewHolder(scrollView, block1, block2);
default:
throw new IllegalArgumentException("Unknown viewType: " + viewType);
}
}
@Override
public int getItemCount() {
return models.size();
public void onBindViewHolder(@NonNull ReactWithAnyEmojiPageViewHolder holder, int position) {
holder.bind(getItem(position));
}
@Override
public void onViewAttachedToWindow(@NonNull ViewHolder holder) {
callbacks.onViewHolderAttached(holder.getAdapterPosition(), holder.emojiPageView);
public void onViewAttachedToWindow(@NonNull ReactWithAnyEmojiPageViewHolder holder) {
callbacks.onViewHolderAttached(holder.getAdapterPosition(), holder);
}
@Override
@@ -59,14 +88,32 @@ final class ReactWithAnyEmojiAdapter extends RecyclerView.Adapter<ReactWithAnyEm
recyclerView.setHasFixedSize(true);
}
static final class ViewHolder extends RecyclerView.ViewHolder {
@Override
public int getItemViewType(int position) {
return getItem(position).getPageBlocks().size() > 1 ? VIEW_TYPE_DUAL : VIEW_TYPE_SINGLE;
}
private EmojiPageView createEmojiPageView(@NonNull Context context) {
return new EmojiPageView(context, emojiEventListener, variationSelectorListener, true);
}
static abstract class ReactWithAnyEmojiPageViewHolder extends RecyclerView.ViewHolder implements ScrollableChild {
public ReactWithAnyEmojiPageViewHolder(@NonNull View itemView) {
super(itemView);
}
abstract void bind(@NonNull ReactWithAnyEmojiPage reactWithAnyEmojiPage);
}
static final class SinglePageBlockViewHolder extends ReactWithAnyEmojiPageViewHolder {
private final EmojiPageView emojiPageView;
ViewHolder(@NonNull EmojiPageView itemView) {
public SinglePageBlockViewHolder(@NonNull View itemView) {
super(itemView);
emojiPageView = itemView;
emojiPageView = (EmojiPageView) itemView;
ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT);
@@ -74,12 +121,52 @@ final class ReactWithAnyEmojiAdapter extends RecyclerView.Adapter<ReactWithAnyEm
emojiPageView.setLayoutParams(params);
}
void bind(@NonNull EmojiPageModel model) {
emojiPageView.setModel(model);
@Override
void bind(@NonNull ReactWithAnyEmojiPage reactWithAnyEmojiPage) {
emojiPageView.setModel(reactWithAnyEmojiPage.getPageBlocks().get(0).getPageModel());
}
@Override
public void setNestedScrollingEnabled(boolean isEnabled) {
emojiPageView.setRecyclerNestedScrollingEnabled(isEnabled);
}
}
static final class DualPageBlockViewHolder extends ReactWithAnyEmojiPageViewHolder {
private final EmojiPageView block1;
private final EmojiPageView block2;
private final TextView block2Label;
public DualPageBlockViewHolder(@NonNull View itemView,
@NonNull EmojiPageView block1,
@NonNull EmojiPageView block2)
{
super(itemView);
this.block1 = block1;
this.block2 = block2;
this.block2Label = itemView.findViewById(R.id.react_with_any_emoji_dual_block_item_block_2_label);
}
@Override
void bind(@NonNull ReactWithAnyEmojiPage reactWithAnyEmojiPage) {
block1.setModel(reactWithAnyEmojiPage.getPageBlocks().get(0).getPageModel());
block2.setModel(reactWithAnyEmojiPage.getPageBlocks().get(1).getPageModel());
block2Label.setText(reactWithAnyEmojiPage.getPageBlocks().get(1).getLabel());
}
@Override
public void setNestedScrollingEnabled(boolean isEnabled) {
((NestedScrollView) itemView).setNestedScrollingEnabled(isEnabled);
}
}
interface Callbacks {
void onViewHolderAttached(int adapterPosition, EmojiPageView pageView);
void onViewHolderAttached(int adapterPosition, ScrollableChild pageView);
}
interface ScrollableChild {
void setNestedScrollingEnabled(boolean isEnabled);
}
}

View File

@@ -18,6 +18,7 @@ import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
import androidx.fragment.app.DialogFragment;
import androidx.lifecycle.ViewModelProviders;
import androidx.loader.app.LoaderManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2;
@@ -35,6 +36,7 @@ import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider;
import org.thoughtcrime.securesms.components.emoji.EmojiPageView;
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.reactions.ReactionsLoader;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -48,13 +50,14 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends BottomShee
private static final String ARG_MESSAGE_ID = "arg_message_id";
private static final String ARG_IS_MMS = "arg_is_mms";
private ReactWithAnyEmojiViewModel viewModel;
private TextSwitcher categoryLabel;
private ViewPager2 categoryPager;
private ReactWithAnyEmojiAdapter adapter;
private OnPageChanged onPageChanged;
private SparseArray<EmojiPageView> pageArray = new SparseArray<>();
private Callback callback;
private ReactWithAnyEmojiViewModel viewModel;
private TextSwitcher categoryLabel;
private ViewPager2 categoryPager;
private ReactWithAnyEmojiAdapter adapter;
private OnPageChanged onPageChanged;
private SparseArray<ReactWithAnyEmojiAdapter.ScrollableChild> pageArray = new SparseArray<>();
private Callback callback;
private ReactionsLoader reactionsLoader;
public static DialogFragment createForMessageRecord(@NonNull MessageRecord messageRecord) {
DialogFragment fragment = new ReactWithAnyEmojiBottomSheetDialogFragment();
@@ -122,12 +125,18 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends BottomShee
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
reactionsLoader = new ReactionsLoader(requireContext(),
requireArguments().getLong(ARG_MESSAGE_ID),
requireArguments().getBoolean(ARG_IS_MMS));
LoaderManager.getInstance(requireActivity()).initLoader((int) requireArguments().getLong(ARG_MESSAGE_ID), null, reactionsLoader);
initializeViewModel();
categoryLabel = view.findViewById(R.id.category_label);
categoryPager = view.findViewById(R.id.category_pager);
categoryLabel = view.findViewById(R.id.category_label);
categoryPager = view.findViewById(R.id.category_pager);
adapter = new ReactWithAnyEmojiAdapter(viewModel.getEmojiPageModels(), this, this, (position, pageView) -> {
adapter = new ReactWithAnyEmojiAdapter(this, this, (position, pageView) -> {
pageArray.put(position, pageView);
if (categoryPager.getCurrentItem() == position) {
@@ -140,10 +149,15 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends BottomShee
categoryPager.setAdapter(adapter);
categoryPager.registerOnPageChangeCallback(onPageChanged);
int startPateIndex = viewModel.getStartIndex();
viewModel.getEmojiPageModels().observe(getViewLifecycleOwner(), pages -> {
int pageToSet = adapter.getItemCount() == 0 ? (pages.get(0).hasEmoji() ? 0 : 1) : -1;
categoryPager.setCurrentItem(startPateIndex, false);
presentCategoryLabel(viewModel.getCategoryIconAttr(startPateIndex));
adapter.submitList(pages);
if (pageToSet >= 0) {
categoryPager.setCurrentItem(pageToSet);
}
});
}
@Override
@@ -166,7 +180,7 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends BottomShee
new TabLayoutMediator(categoryTabs, categoryPager, (tab, position) -> {
tab.setCustomView(react_with_any_emoji_tab)
.setIcon(ThemeUtil.getThemedDrawable(requireContext(), viewModel.getCategoryIconAttr(position)));
.setIcon(ThemeUtil.getThemedDrawable(requireContext(), adapter.getItem(position).getIconAttr()));
}).attach();
}
}
@@ -174,6 +188,7 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends BottomShee
@Override
public void onDestroyView() {
super.onDestroyView();
LoaderManager.getInstance(requireActivity()).destroyLoader((int) requireArguments().getLong(ARG_MESSAGE_ID));
categoryPager.unregisterOnPageChangeCallback(onPageChanged);
}
@@ -188,7 +203,7 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends BottomShee
private void initializeViewModel() {
Bundle args = requireArguments();
ReactWithAnyEmojiRepository repository = new ReactWithAnyEmojiRepository(requireContext());
ReactWithAnyEmojiViewModel.Factory factory = new ReactWithAnyEmojiViewModel.Factory(repository, args.getLong(ARG_MESSAGE_ID), args.getBoolean(ARG_IS_MMS));
ReactWithAnyEmojiViewModel.Factory factory = new ReactWithAnyEmojiViewModel.Factory(reactionsLoader, repository, args.getLong(ARG_MESSAGE_ID), args.getBoolean(ARG_IS_MMS));
viewModel = ViewModelProviders.of(this, factory).get(ReactWithAnyEmojiViewModel.class);
}
@@ -210,53 +225,16 @@ public final class ReactWithAnyEmojiBottomSheetDialogFragment extends BottomShee
private void updateFocusedRecycler(int position) {
for (int i = 0; i < pageArray.size(); i++) {
pageArray.valueAt(i).setRecyclerNestedScrollingEnabled(false);
pageArray.valueAt(i).setNestedScrollingEnabled(false);
}
EmojiPageView toFocus = pageArray.get(position);
ReactWithAnyEmojiAdapter.ScrollableChild toFocus = pageArray.get(position);
if (toFocus != null) {
toFocus.setRecyclerNestedScrollingEnabled(true);
toFocus.setNestedScrollingEnabled(true);
categoryPager.requestLayout();
}
presentCategoryLabel(viewModel.getCategoryIconAttr(position));
}
private void presentCategoryLabel(@AttrRes int iconAttr) {
switch (iconAttr) {
case R.attr.emoji_category_recent:
categoryLabel.setText(getString(R.string.ReactWithAnyEmojiBottomSheetDialogFragment__recently_used));
break;
case R.attr.emoji_category_people:
categoryLabel.setText(getString(R.string.ReactWithAnyEmojiBottomSheetDialogFragment__smileys_and_people));
break;
case R.attr.emoji_category_nature:
categoryLabel.setText(getString(R.string.ReactWithAnyEmojiBottomSheetDialogFragment__nature));
break;
case R.attr.emoji_category_foods:
categoryLabel.setText(getString(R.string.ReactWithAnyEmojiBottomSheetDialogFragment__food));
break;
case R.attr.emoji_category_activity:
categoryLabel.setText(getString(R.string.ReactWithAnyEmojiBottomSheetDialogFragment__activities));
break;
case R.attr.emoji_category_places:
categoryLabel.setText(getString(R.string.ReactWithAnyEmojiBottomSheetDialogFragment__places));
break;
case R.attr.emoji_category_objects:
categoryLabel.setText(getString(R.string.ReactWithAnyEmojiBottomSheetDialogFragment__objects));
break;
case R.attr.emoji_category_symbols:
categoryLabel.setText(getString(R.string.ReactWithAnyEmojiBottomSheetDialogFragment__symbols));
break;
case R.attr.emoji_category_flags:
categoryLabel.setText(getString(R.string.ReactWithAnyEmojiBottomSheetDialogFragment__flags));
break;
case R.attr.emoji_category_emoticons:
categoryLabel.setText(getString(R.string.ReactWithAnyEmojiBottomSheetDialogFragment__emoticons));
break;
default:
throw new AssertionError();
}
categoryLabel.setText(getString(adapter.getItem(position).getLabel()));
}
private class OnPageChanged extends ViewPager2.OnPageChangeCallback {

View File

@@ -0,0 +1,44 @@
package org.thoughtcrime.securesms.reactions.any;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import org.whispersystems.libsignal.util.guava.Preconditions;
import java.util.List;
/**
* Represents a swipeable page in the ReactWithAnyEmoji dialog fragment, encapsulating any
* {@link ReactWithAnyEmojiPageBlock}s contained on that page. It is assumed that there is at least
* one page present.
*
* This class also exposes several properties based off of that list, in order to allow the ReactWithAny
* bottom sheet to properly lay out its tabs and assign labels as the user moves between pages.
*/
class ReactWithAnyEmojiPage {
private final List<ReactWithAnyEmojiPageBlock> pageBlocks;
ReactWithAnyEmojiPage(@NonNull List<ReactWithAnyEmojiPageBlock> pageBlocks) {
Preconditions.checkArgument(!pageBlocks.isEmpty());
this.pageBlocks = pageBlocks;
}
public @StringRes int getLabel() {
return pageBlocks.get(0).getLabel();
}
public boolean hasEmoji() {
return !pageBlocks.get(0).getPageModel().getEmoji().isEmpty();
}
public List<ReactWithAnyEmojiPageBlock> getPageBlocks() {
return pageBlocks;
}
public @AttrRes int getIconAttr() {
return pageBlocks.get(0).getPageModel().getIconAttr();
}
}

View File

@@ -0,0 +1,29 @@
package org.thoughtcrime.securesms.reactions.any;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel;
/**
* Wraps a single "class" of Emojis, be it a predefined category, recents, etc. and provides
* a label for that "class".
*/
class ReactWithAnyEmojiPageBlock {
private final int label;
private final EmojiPageModel pageModel;
ReactWithAnyEmojiPageBlock(@StringRes int label, @NonNull EmojiPageModel pageModel) {
this.label = label;
this.pageModel = pageModel;
}
public @StringRes int getLabel() {
return label;
}
public EmojiPageModel getPageModel() {
return pageModel;
}
}

View File

@@ -2,44 +2,117 @@ package org.thoughtcrime.securesms.reactions.any;
import android.content.Context;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel;
import org.thoughtcrime.securesms.components.emoji.EmojiUtil;
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.MessagingDatabase;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.reactions.ReactionDetails;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.sms.MessageSender;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
final class ReactWithAnyEmojiRepository {
private static final String TAG = Log.tag(ReactWithAnyEmojiRepository.class);
private static final String RECENT_STORAGE_KEY = "reactions_recent_emoji";
private final Context context;
private final RecentEmojiPageModel recentEmojiPageModel;
private final List<EmojiPageModel> emojiPageModels;
private final List<ReactWithAnyEmojiPage> emojiPages;
ReactWithAnyEmojiRepository(@NonNull Context context) {
this.context = context;
this.recentEmojiPageModel = new RecentEmojiPageModel(context, RECENT_STORAGE_KEY);
this.emojiPageModels = new LinkedList<>();
this.emojiPages = new LinkedList<>();
emojiPageModels.add(recentEmojiPageModel);
emojiPageModels.addAll(EmojiUtil.getDisplayPages());
emojiPageModels.remove(emojiPageModels.size() - 1);
emojiPages.addAll(Stream.of(EmojiUtil.getDisplayPages())
.map(page -> new ReactWithAnyEmojiPage(Collections.singletonList(new ReactWithAnyEmojiPageBlock(getCategoryLabel(page.getIconAttr()), page))))
.toList());
emojiPages.remove(emojiPages.size() - 1);
}
List<EmojiPageModel> getEmojiPageModels() {
return emojiPageModels;
List<ReactWithAnyEmojiPage> getEmojiPageModels(@NonNull List<ReactionDetails> thisMessagesReactions) {
List<ReactWithAnyEmojiPage> pages = new LinkedList<>();
List<String> thisMessage = Stream.of(thisMessagesReactions)
.map(ReactionDetails::getDisplayEmoji)
.distinct()
.toList();
if (thisMessage.isEmpty()) {
pages.add(new ReactWithAnyEmojiPage(Collections.singletonList(new ReactWithAnyEmojiPageBlock(R.string.ReactWithAnyEmojiBottomSheetDialogFragment__recently_used, recentEmojiPageModel))));
} else {
pages.add(new ReactWithAnyEmojiPage(Arrays.asList(new ReactWithAnyEmojiPageBlock(R.string.ReactWithAnyEmojiBottomSheetDialogFragment__this_message, new ThisMessageEmojiPageModel(thisMessage)),
new ReactWithAnyEmojiPageBlock(R.string.ReactWithAnyEmojiBottomSheetDialogFragment__recently_used, recentEmojiPageModel))));
}
pages.addAll(emojiPages);
return pages;
}
void addEmojiToMessage(@NonNull String emoji, long messageId, boolean isMms) {
recentEmojiPageModel.onCodePointSelected(emoji);
SignalExecutors.BOUNDED.execute(() -> {
try {
MessagingDatabase db = isMms ? DatabaseFactory.getMmsDatabase(context) : DatabaseFactory.getSmsDatabase(context);
MessageRecord messageRecord = db.getMessageRecord(messageId);
ReactionRecord oldRecord = Stream.of(messageRecord.getReactions())
.filter(record -> record.getAuthor().equals(Recipient.self().getId()))
.findFirst()
.orElse(null);
SignalExecutors.BOUNDED.execute(() -> MessageSender.sendNewReaction(context, messageId, isMms, emoji));
if (oldRecord != null && oldRecord.getEmoji().equals(emoji)) {
MessageSender.sendReactionRemoval(context, messageRecord.getId(), messageRecord.isMms(), oldRecord);
} else {
MessageSender.sendNewReaction(context, messageRecord.getId(), messageRecord.isMms(), emoji);
Util.runOnMain(() -> recentEmojiPageModel.onCodePointSelected(emoji));
}
} catch (NoSuchMessageException e) {
Log.w(TAG, "Message not found! Ignoring.");
}
});
}
private @StringRes int getCategoryLabel(@AttrRes int iconAttr) {
switch (iconAttr) {
case R.attr.emoji_category_people:
return R.string.ReactWithAnyEmojiBottomSheetDialogFragment__smileys_and_people;
case R.attr.emoji_category_nature:
return R.string.ReactWithAnyEmojiBottomSheetDialogFragment__nature;
case R.attr.emoji_category_foods:
return R.string.ReactWithAnyEmojiBottomSheetDialogFragment__food;
case R.attr.emoji_category_activity:
return R.string.ReactWithAnyEmojiBottomSheetDialogFragment__activities;
case R.attr.emoji_category_places:
return R.string.ReactWithAnyEmojiBottomSheetDialogFragment__places;
case R.attr.emoji_category_objects:
return R.string.ReactWithAnyEmojiBottomSheetDialogFragment__objects;
case R.attr.emoji_category_symbols:
return R.string.ReactWithAnyEmojiBottomSheetDialogFragment__symbols;
case R.attr.emoji_category_flags:
return R.string.ReactWithAnyEmojiBottomSheetDialogFragment__flags;
case R.attr.emoji_category_emoticons:
return R.string.ReactWithAnyEmojiBottomSheetDialogFragment__emoticons;
default:
throw new AssertionError();
}
}
}

View File

@@ -2,57 +2,65 @@ package org.thoughtcrime.securesms.reactions.any;
import androidx.annotation.AttrRes;
import androidx.annotation.NonNull;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Transformations;
import androidx.lifecycle.ViewModel;
import androidx.lifecycle.ViewModelProvider;
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.reactions.ReactionsLoader;
import java.util.Collections;
import java.util.List;
public final class ReactWithAnyEmojiViewModel extends ViewModel {
private final ReactionsLoader reactionsLoader;
private final ReactWithAnyEmojiRepository repository;
private final long messageId;
private final boolean isMms;
private ReactWithAnyEmojiViewModel(@NonNull ReactWithAnyEmojiRepository repository, long messageId, boolean isMms) {
this.repository = repository;
this.messageId = messageId;
this.isMms = isMms;
private final LiveData<List<ReactWithAnyEmojiPage>> pages;
private ReactWithAnyEmojiViewModel(@NonNull ReactionsLoader reactionsLoader,
@NonNull ReactWithAnyEmojiRepository repository,
long messageId,
boolean isMms) {
this.reactionsLoader = reactionsLoader;
this.repository = repository;
this.messageId = messageId;
this.isMms = isMms;
this.pages = Transformations.map(reactionsLoader.getReactions(), repository::getEmojiPageModels);
}
List<EmojiPageModel> getEmojiPageModels() {
return repository.getEmojiPageModels();
}
int getStartIndex() {
return repository.getEmojiPageModels().get(0).getEmoji().size() == 0 ? 1 : 0;
LiveData<List<ReactWithAnyEmojiPage>> getEmojiPageModels() {
return pages;
}
void onEmojiSelected(@NonNull String emoji) {
SignalStore.emojiValues().setPreferredVariation(emoji);
repository.addEmojiToMessage(emoji, messageId, isMms);
}
@AttrRes int getCategoryIconAttr(int position) {
return repository.getEmojiPageModels().get(position).getIconAttr();
}
static class Factory implements ViewModelProvider.Factory {
private final ReactionsLoader reactionsLoader;
private final ReactWithAnyEmojiRepository repository;
private final long messageId;
private final boolean isMms;
Factory(@NonNull ReactWithAnyEmojiRepository repository, long messageId, boolean isMms) {
this.repository = repository;
this.messageId = messageId;
this.isMms = isMms;
Factory(@NonNull ReactionsLoader reactionsLoader, @NonNull ReactWithAnyEmojiRepository repository, long messageId, boolean isMms) {
this.reactionsLoader = reactionsLoader;
this.repository = repository;
this.messageId = messageId;
this.isMms = isMms;
}
@Override
public @NonNull <T extends ViewModel> T create(@NonNull Class<T> modelClass) {
//noinspection ConstantConditions
return modelClass.cast(new ReactWithAnyEmojiViewModel(repository, messageId, isMms));
return modelClass.cast(new ReactWithAnyEmojiViewModel(reactionsLoader, repository, messageId, isMms));
}
}

View File

@@ -0,0 +1,54 @@
package org.thoughtcrime.securesms.reactions.any;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.emoji.Emoji;
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel;
import java.util.List;
/**
* Contains the Emojis that have been used in reactions for a given message.
*/
class ThisMessageEmojiPageModel implements EmojiPageModel {
private final List<String> emoji;
ThisMessageEmojiPageModel(@NonNull List<String> emoji) {
this.emoji = emoji;
}
@Override
public int getIconAttr() {
return R.attr.emoji_category_recent;
}
@Override
public @NonNull List<String> getEmoji() {
return emoji;
}
@Override
public @NonNull List<Emoji> getDisplayEmoji() {
return Stream.of(getEmoji()).map(Emoji::new).toList();
}
@Override
public boolean hasSpriteMap() {
return false;
}
@Override
public @Nullable String getSprite() {
return null;
}
@Override
public boolean isDynamic() {
return true;
}
}

View File

@@ -26,19 +26,18 @@ import org.thoughtcrime.securesms.contacts.avatars.TransparentContactPhoto;
import org.thoughtcrime.securesms.database.DatabaseFactory;
import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus;
import org.thoughtcrime.securesms.database.RecipientDatabase;
import org.thoughtcrime.securesms.database.RecipientDatabase.RecipientIdResult;
import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState;
import org.thoughtcrime.securesms.database.RecipientDatabase.UnidentifiedAccessMode;
import org.thoughtcrime.securesms.database.RecipientDatabase.VibrateState;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.jobs.DirectoryRefreshJob;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.notifications.NotificationChannels;
import org.thoughtcrime.securesms.phonenumbers.NumberUtil;
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberFormatter;
import org.thoughtcrime.securesms.profiles.ProfileName;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.StringUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.concurrent.SignalExecutors;
import org.whispersystems.libsignal.util.guava.Optional;
@@ -157,6 +156,20 @@ public class Recipient {
return externalPush(context, signalServiceAddress.getUuid().orNull(), signalServiceAddress.getNumber().orNull(), false);
}
/**
* Returns a fully-populated {@link Recipient} based off of a {@link SignalServiceAddress},
* creating one in the database if necessary. We special-case GV1 members because we want to
* prioritize E164 addresses and not use the UUIDs if possible.
*/
@WorkerThread
public static @NonNull Recipient externalGV1Member(@NonNull Context context, @NonNull SignalServiceAddress address) {
if (address.getNumber().isPresent()) {
return externalPush(context, null, address.getNumber().get(), false);
} else {
return externalPush(context, address.getUuid().orNull(), null, false);
}
}
/**
* Returns a fully-populated {@link Recipient} based off of a {@link SignalServiceAddress},
* creating one in the database if necessary. This should only used for high-trust sources,
@@ -386,18 +399,22 @@ public class Recipient {
}
public @NonNull String getDisplayName(@NonNull Context context) {
return Util.getFirstNonEmpty(getName(context),
getProfileName().toString(),
getDisplayUsername(),
e164,
email,
context.getString(R.string.Recipient_unknown));
String name = Util.getFirstNonEmpty(getName(context),
getProfileName().toString(),
getDisplayUsername(),
e164,
email,
context.getString(R.string.Recipient_unknown));
return StringUtil.isolateBidi(name);
}
public @NonNull String getShortDisplayName(@NonNull Context context) {
return Util.getFirstNonEmpty(getName(context),
getProfileName().getGivenName(),
getDisplayName(context));
String name = Util.getFirstNonEmpty(getName(context),
getProfileName().getGivenName(),
getDisplayName(context));
return StringUtil.isolateBidi(name);
}
public @NonNull MaterialColor getColor() {
@@ -784,7 +801,7 @@ public class Recipient {
return ApplicationDependencies.getRecipientCache().getLive(id);
}
private @Nullable String getDisplayUsername() {
public @Nullable String getDisplayUsername() {
if (!TextUtils.isEmpty(username)) {
return "@" + username;
} else {

View File

@@ -4,9 +4,11 @@ import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.NonNull;
import org.signal.ringrtc.CallId;
import com.google.protobuf.ByteString;
import org.signal.ringrtc.CallId;
import org.signal.ringrtc.IceCandidate;
import org.webrtc.IceCandidate;
import org.whispersystems.signalservice.api.messages.calls.IceUpdateMessage;
/**
@@ -24,14 +26,11 @@ public class IceCandidateParcel implements Parcelable {
}
public IceCandidateParcel(@NonNull IceUpdateMessage iceUpdateMessage) {
this.iceCandidate = new IceCandidate(iceUpdateMessage.getSdpMid(),
iceUpdateMessage.getSdpMLineIndex(),
iceUpdateMessage.getSdp());
this.iceCandidate = new IceCandidate(iceUpdateMessage.getOpaque(), iceUpdateMessage.getSdp());
}
private IceCandidateParcel(@NonNull Parcel in) {
this.iceCandidate = new IceCandidate(in.readString(),
in.readInt(),
this.iceCandidate = new IceCandidate(in.createByteArray(),
in.readString());
}
@@ -41,9 +40,8 @@ public class IceCandidateParcel implements Parcelable {
public @NonNull IceUpdateMessage getIceUpdateMessage(@NonNull CallId callId) {
return new IceUpdateMessage(callId.longValue(),
iceCandidate.sdpMid,
iceCandidate.sdpMLineIndex,
iceCandidate.sdp);
iceCandidate.getOpaque(),
iceCandidate.getSdp());
}
@Override
@@ -53,9 +51,8 @@ public class IceCandidateParcel implements Parcelable {
@Override
public void writeToParcel(@NonNull Parcel dest, int flags) {
dest.writeString(iceCandidate.sdpMid);
dest.writeInt(iceCandidate.sdpMLineIndex);
dest.writeString(iceCandidate.sdp);
dest.writeByteArray(iceCandidate.getOpaque());
dest.writeString(iceCandidate.getSdp());
}
public static final Creator<IceCandidateParcel> CREATOR = new Creator<IceCandidateParcel>() {

View File

@@ -26,7 +26,7 @@ import java.util.regex.Pattern;
public class VerificationCodeParser {
private static final Pattern CHALLENGE_PATTERN = Pattern.compile(".*Your (Signal|TextSecure) verification code:? ([0-9]{3,4})-([0-9]{3,4}).*", Pattern.DOTALL);
private static final Pattern CHALLENGE_PATTERN = Pattern.compile("(.*\\D|^)([0-9]{3,4})-([0-9]{3,4}).*", Pattern.DOTALL);
public static Optional<String> parse(Context context, String messageBody) {
if (messageBody == null) {
@@ -39,6 +39,7 @@ public class VerificationCodeParser {
return Optional.absent();
}
return Optional.of(challengeMatcher.group(2) + challengeMatcher.group(3));
return Optional.of(challengeMatcher.group(challengeMatcher.groupCount() - 1) +
challengeMatcher.group(challengeMatcher.groupCount()));
}
}

View File

@@ -17,11 +17,14 @@ import android.util.Pair;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.protobuf.ByteString;
import org.greenrobot.eventbus.EventBus;
import org.signal.ringrtc.CallException;
import org.signal.ringrtc.CallId;
import org.signal.ringrtc.CallManager;
import org.signal.ringrtc.CallManager.CallEvent;
import org.signal.ringrtc.IceCandidate;
import org.signal.ringrtc.Remote;
import org.thoughtcrime.securesms.ApplicationContext;
import org.thoughtcrime.securesms.WebRtcCallActivity;
@@ -56,7 +59,6 @@ import org.thoughtcrime.securesms.webrtc.audio.OutgoingRinger;
import org.thoughtcrime.securesms.webrtc.audio.SignalAudioManager;
import org.thoughtcrime.securesms.webrtc.locks.LockManager;
import org.webrtc.EglBase;
import org.webrtc.IceCandidate;
import org.webrtc.PeerConnection;
import org.whispersystems.libsignal.IdentityKey;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
@@ -105,13 +107,15 @@ public class WebRtcCallService extends Service implements CallManager.Observer,
public static final String EXTRA_BLUETOOTH = "audio_bluetooth";
public static final String EXTRA_REMOTE_PEER = "remote_peer";
public static final String EXTRA_REMOTE_DEVICE = "remote_device";
public static final String EXTRA_OFFER_DESCRIPTION = "offer_description";
public static final String EXTRA_OFFER_OPAQUE = "offer_opaque";
public static final String EXTRA_OFFER_SDP = "offer_sdp";
public static final String EXTRA_OFFER_TYPE = "offer_type";
public static final String EXTRA_MULTI_RING = "multi_ring";
public static final String EXTRA_HANGUP_TYPE = "hangup_type";
public static final String EXTRA_HANGUP_IS_LEGACY = "hangup_is_legacy";
public static final String EXTRA_HANGUP_DEVICE_ID = "hangup_device_id";
public static final String EXTRA_ANSWER_DESCRIPTION = "answer_description";
public static final String EXTRA_ANSWER_OPAQUE = "answer_opaque";
public static final String EXTRA_ANSWER_SDP = "answer_sdp";
public static final String EXTRA_ICE_CANDIDATES = "ice_candidates";
public static final String EXTRA_ENABLE = "enable_value";
public static final String EXTRA_BROADCAST = "broadcast";
@@ -389,7 +393,8 @@ public class WebRtcCallService extends Service implements CallManager.Observer,
CallId callId = getCallId(intent);
RemotePeer remotePeer = getRemotePeer(intent);
Integer remoteDevice = intent.getIntExtra(EXTRA_REMOTE_DEVICE, -1);
String offer = intent.getStringExtra(EXTRA_OFFER_DESCRIPTION);
byte[] opaque = intent.getByteArrayExtra(EXTRA_OFFER_OPAQUE);
String sdp = intent.getStringExtra(EXTRA_OFFER_SDP);
long serverReceivedTimestamp = intent.getLongExtra(EXTRA_SERVER_RECEIVED_TIMESTAMP, -1);
long serverDeliveredTimestamp = intent.getLongExtra(EXTRA_SERVER_DELIVERED_TIMESTAMP, -1);
OfferMessage.Type offerType = OfferMessage.Type.fromCode(intent.getStringExtra(EXTRA_OFFER_TYPE));
@@ -422,7 +427,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer,
Log.i(TAG, "handleReceivedOffer(): messageAgeSec: " + messageAgeSec + ", serverReceivedTimestamp: " + serverReceivedTimestamp + ", serverDeliveredTimestamp: " + serverDeliveredTimestamp);
try {
callManager.receivedOffer(callId, remotePeer, remoteDevice, offer, messageAgeSec, callType, 1, isMultiRing, true);
callManager.receivedOffer(callId, remotePeer, remoteDevice, opaque, sdp, messageAgeSec, callType, 1, isMultiRing, true);
} catch (CallException e) {
callFailure("Unable to process received offer: ", e);
}
@@ -620,9 +625,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer,
camera,
iceServers,
isAlwaysTurn,
deviceList,
enableVideoOnCreate,
true);
enableVideoOnCreate);
} catch (CallException e) {
callFailure("Unable to proceed with call: ", e);
}
@@ -664,9 +667,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer,
camera,
iceServers,
hideIp,
deviceList,
false,
true);
false);
} catch (CallException e) {
callFailure("Unable to proceed with call: ", e);
}
@@ -704,12 +705,13 @@ public class WebRtcCallService extends Service implements CallManager.Observer,
CallId callId = getCallId(intent);
Integer remoteDevice = intent.getIntExtra(EXTRA_REMOTE_DEVICE, -1);
boolean broadcast = intent.getBooleanExtra(EXTRA_BROADCAST, false);
String offer = intent.getStringExtra(EXTRA_OFFER_DESCRIPTION);
byte[] opaque = intent.getByteArrayExtra(EXTRA_OFFER_OPAQUE);
String sdp = intent.getStringExtra(EXTRA_OFFER_SDP);
OfferMessage.Type offerType = OfferMessage.Type.fromCode(intent.getStringExtra(EXTRA_OFFER_TYPE));
Log.i(TAG, "handleSendOffer: id: " + callId.format(remoteDevice));
OfferMessage offerMessage = new OfferMessage(callId.longValue(), offer, offerType);
OfferMessage offerMessage = new OfferMessage(callId.longValue(), sdp, offerType, opaque);
Integer destinationDeviceId = broadcast ? null : remoteDevice;
SignalServiceCallMessage callMessage = SignalServiceCallMessage.forOffer(offerMessage, true, destinationDeviceId);
@@ -721,11 +723,12 @@ public class WebRtcCallService extends Service implements CallManager.Observer,
CallId callId = getCallId(intent);
Integer remoteDevice = intent.getIntExtra(EXTRA_REMOTE_DEVICE, -1);
boolean broadcast = intent.getBooleanExtra(EXTRA_BROADCAST, false);
String answer = intent.getStringExtra(EXTRA_ANSWER_DESCRIPTION);
byte[] opaque = intent.getByteArrayExtra(EXTRA_ANSWER_OPAQUE);
String sdp = intent.getStringExtra(EXTRA_ANSWER_SDP);
Log.i(TAG, "handleSendAnswer: id: " + callId.format(remoteDevice));
AnswerMessage answerMessage = new AnswerMessage(callId.longValue(), answer);
AnswerMessage answerMessage = new AnswerMessage(callId.longValue(), sdp, opaque);
Integer destinationDeviceId = broadcast ? null : remoteDevice;
SignalServiceCallMessage callMessage = SignalServiceCallMessage.forAnswer(answerMessage, true, destinationDeviceId);
@@ -788,13 +791,14 @@ public class WebRtcCallService extends Service implements CallManager.Observer,
private void handleReceivedAnswer(Intent intent) {
CallId callId = getCallId(intent);
Integer remoteDevice = intent.getIntExtra(EXTRA_REMOTE_DEVICE, -1);
String description = intent.getStringExtra(EXTRA_ANSWER_DESCRIPTION);
byte[] opaque = intent.getByteArrayExtra(EXTRA_ANSWER_OPAQUE);
String sdp = intent.getStringExtra(EXTRA_ANSWER_SDP);
boolean isMultiRing = intent.getBooleanExtra(EXTRA_MULTI_RING, false);
Log.i(TAG, "handleReceivedAnswer(): id: " + callId.format(remoteDevice));
try {
callManager.receivedAnswer(callId, remoteDevice, description , isMultiRing);
callManager.receivedAnswer(callId, remoteDevice, opaque, sdp, isMultiRing);
} catch (CallException e) {
callFailure("receivedAnswer() failed: ", e);
}
@@ -1802,7 +1806,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer,
}
@Override
public void onSendOffer(CallId callId, Remote remote, Integer remoteDevice, Boolean broadcast, String offer, CallManager.CallMediaType callMediaType) {
public void onSendOffer(CallId callId, Remote remote, Integer remoteDevice, Boolean broadcast, byte[] opaque, String sdp, CallManager.CallMediaType callMediaType) {
Log.i(TAG, "onSendOffer: id: " + callId.format(remoteDevice) + " type: " + callMediaType.name());
if (remote instanceof RemotePeer) {
@@ -1811,12 +1815,13 @@ public class WebRtcCallService extends Service implements CallManager.Observer,
Intent intent = new Intent(this, WebRtcCallService.class);
intent.setAction(ACTION_SEND_OFFER)
.putExtra(EXTRA_CALL_ID, callId.longValue())
.putExtra(EXTRA_REMOTE_PEER, remotePeer)
.putExtra(EXTRA_REMOTE_DEVICE, remoteDevice)
.putExtra(EXTRA_BROADCAST, broadcast)
.putExtra(EXTRA_OFFER_DESCRIPTION, offer)
.putExtra(EXTRA_OFFER_TYPE, offerType);
.putExtra(EXTRA_CALL_ID, callId.longValue())
.putExtra(EXTRA_REMOTE_PEER, remotePeer)
.putExtra(EXTRA_REMOTE_DEVICE, remoteDevice)
.putExtra(EXTRA_BROADCAST, broadcast)
.putExtra(EXTRA_OFFER_OPAQUE, opaque)
.putExtra(EXTRA_OFFER_SDP, sdp)
.putExtra(EXTRA_OFFER_TYPE, offerType);
startService(intent);
} else {
@@ -1825,7 +1830,7 @@ public class WebRtcCallService extends Service implements CallManager.Observer,
}
@Override
public void onSendAnswer(CallId callId, Remote remote, Integer remoteDevice, Boolean broadcast, String answer) {
public void onSendAnswer(CallId callId, Remote remote, Integer remoteDevice, Boolean broadcast, byte[] opaque, String sdp) {
Log.i(TAG, "onSendAnswer: id: " + callId.format(remoteDevice));
if (remote instanceof RemotePeer) {
@@ -1833,11 +1838,12 @@ public class WebRtcCallService extends Service implements CallManager.Observer,
Intent intent = new Intent(this, WebRtcCallService.class);
intent.setAction(ACTION_SEND_ANSWER)
.putExtra(EXTRA_CALL_ID, callId.longValue())
.putExtra(EXTRA_REMOTE_PEER, remotePeer)
.putExtra(EXTRA_REMOTE_DEVICE, remoteDevice)
.putExtra(EXTRA_BROADCAST, broadcast)
.putExtra(EXTRA_ANSWER_DESCRIPTION, answer);
.putExtra(EXTRA_CALL_ID, callId.longValue())
.putExtra(EXTRA_REMOTE_PEER, remotePeer)
.putExtra(EXTRA_REMOTE_DEVICE, remoteDevice)
.putExtra(EXTRA_BROADCAST, broadcast)
.putExtra(EXTRA_ANSWER_OPAQUE, opaque)
.putExtra(EXTRA_ANSWER_SDP, sdp);
startService(intent);
} else {

View File

@@ -148,8 +148,9 @@ public class ShareActivity extends PassphraseRequiredActivity
else super.onBackPressed();
}
@Override
public void onContactSelected(Optional<RecipientId> recipientId, String number) {
public boolean onContactSelected(Optional<RecipientId> recipientId, String number) {
SimpleTask.run(this.getLifecycle(), () -> {
Recipient recipient;
if (recipientId.isPresent()) {
@@ -162,6 +163,8 @@ public class ShareActivity extends PassphraseRequiredActivity
long existingThread = DatabaseFactory.getThreadDatabase(this).getThreadIdIfExistsFor(recipient);
return new Pair<>(existingThread, recipient);
}, result -> onDestinationChosen(result.first(), result.second().getId()));
return true;
}
@Override

View File

@@ -502,7 +502,7 @@ public class MessageSender {
ExpiringMessageManager expirationManager = ApplicationContext.getInstance(context).getExpiringMessageManager();
SmsDatabase smsDatabase = DatabaseFactory.getSmsDatabase(context);
MmsSmsDatabase mmsSmsDatabase = DatabaseFactory.getMmsSmsDatabase(context);
SmsMessageRecord message = smsDatabase.getMessage(messageId);
SmsMessageRecord message = smsDatabase.getMessageRecord(messageId);
SyncMessageId syncId = new SyncMessageId(Recipient.self().getId(), message.getDateSent());
smsDatabase.markAsSent(messageId, true);

View File

@@ -4,7 +4,9 @@ import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.drawable.Drawable;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.core.graphics.drawable.DrawableCompat;
public final class DrawableUtil {
@@ -19,4 +21,13 @@ public final class DrawableUtil {
return bitmap;
}
/**
* Returns a new {@link Drawable} that safely wraps and tints the provided drawable.
*/
public static @NonNull Drawable tint(@NonNull Drawable drawable, @ColorInt int tint) {
Drawable tinted = DrawableCompat.wrap(drawable).mutate();
DrawableCompat.setTint(tinted, tint);
return tinted;
}
}

View File

@@ -46,7 +46,7 @@ public final class FeatureFlags {
private static final String TAG = Log.tag(FeatureFlags.class);
private static final long FETCH_INTERVAL = TimeUnit.HOURS.toMillis(0);
private static final long FETCH_INTERVAL = TimeUnit.HOURS.toMillis(2);
private static final String USERNAMES = "android.usernames";
private static final String ATTACHMENTS_V3 = "android.attachmentsV3.2";
@@ -58,6 +58,7 @@ public final class FeatureFlags {
private static final String CDS = "android.cds";
private static final String RECIPIENT_TRUST = "android.recipientTrust";
private static final String INTERNAL_USER = "android.internalUser";
private static final String MENTIONS = "android.mentions";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@@ -71,7 +72,8 @@ public final class FeatureFlags {
GROUPS_V2_CREATE,
GROUPS_V2_CAPACITY,
RECIPIENT_TRUST,
INTERNAL_USER
INTERNAL_USER,
MENTIONS
);
/**
@@ -221,6 +223,11 @@ public final class FeatureFlags {
return getBoolean(RECIPIENT_TRUST, false);
}
/** Whether or not we allow mentions send support in groups. */
public static boolean mentions() {
return getBoolean(MENTIONS, false);
}
/** Only for rendering debug info. */
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);

View File

@@ -14,7 +14,6 @@ import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.logging.Log;
import org.thoughtcrime.securesms.mms.MessageGroupContext;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientForeverObserver;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.libsignal.util.guava.Optional;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
@@ -67,13 +66,13 @@ public final class GroupUtil {
return Optional.absent();
}
public static @NonNull GroupDescription getDescription(@NonNull Context context, @Nullable String encodedGroup, boolean isV2) {
public static @NonNull GroupDescription getNonV2GroupDescription(@NonNull Context context, @Nullable String encodedGroup) {
if (encodedGroup == null) {
return new GroupDescription(context, null);
}
try {
MessageGroupContext groupContext = new MessageGroupContext(encodedGroup, isV2);
MessageGroupContext groupContext = new MessageGroupContext(encodedGroup, false);
return new GroupDescription(context, groupContext);
} catch (IOException e) {
Log.w(TAG, e);
@@ -117,7 +116,8 @@ public final class GroupUtil {
}
}
public String toString(Recipient sender) {
@WorkerThread
public String toString(@NonNull Recipient sender) {
StringBuilder description = new StringBuilder();
description.append(context.getString(R.string.MessageRecord_s_updated_group, sender.getDisplayName(context)));
@@ -125,7 +125,7 @@ public final class GroupUtil {
return description.toString();
}
String title = groupContext.getName();
String title = StringUtil.isolateBidi(groupContext.getName());
if (members != null && members.size() > 0) {
description.append("\n");
@@ -133,7 +133,7 @@ public final class GroupUtil {
members.size(), toString(members)));
}
if (title != null && !title.trim().isEmpty()) {
if (!title.trim().isEmpty()) {
if (members != null) description.append(" ");
else description.append("\n");
description.append(context.getString(R.string.GroupUtil_group_name_is_now, title));
@@ -142,22 +142,6 @@ public final class GroupUtil {
return description.toString();
}
public void addObserver(RecipientForeverObserver listener) {
if (this.members != null) {
for (RecipientId member : this.members) {
Recipient.live(member).observeForever(listener);
}
}
}
public void removeObserver(RecipientForeverObserver listener) {
if (this.members != null) {
for (RecipientId member : this.members) {
Recipient.live(member).removeForeverObserver(listener);
}
}
}
private String toString(List<RecipientId> recipients) {
StringBuilder result = new StringBuilder();

View File

@@ -0,0 +1,61 @@
package org.thoughtcrime.securesms.util;
import android.text.Layout;
import androidx.annotation.NonNull;
/**
* Utility functions for dealing with {@link Layout}.
*
* Ported and modified from https://github.com/googlearchive/android-text/tree/master/RoundedBackground-Kotlin
*/
public class LayoutUtil {
private static final float DEFAULT_LINE_SPACING_EXTRA = 0f;
private static final float DEFAULT_LINE_SPACING_MULTIPLIER = 1f;
public static int getLineHeight(@NonNull Layout layout, int line) {
return layout.getLineTop(line + 1) - layout.getLineTop(line);
}
public static int getLineTopWithoutPadding(@NonNull Layout layout, int line) {
int lineTop = layout.getLineTop(line);
if (line == 0) {
lineTop -= layout.getTopPadding();
}
return lineTop;
}
public static int getLineBottomWithoutPadding(@NonNull Layout layout, int line) {
int lineBottom = getLineBottomWithoutSpacing(layout, line);
if (line == layout.getLineCount() - 1) {
lineBottom -= layout.getBottomPadding();
}
return lineBottom;
}
public static int getLineBottomWithoutSpacing(@NonNull Layout layout, int line) {
int lineBottom = layout.getLineBottom(line);
boolean isLastLine = line == layout.getLineCount() - 1;
float lineSpacingExtra = layout.getSpacingAdd();
float lineSpacingMultiplier = layout.getSpacingMultiplier();
boolean hasLineSpacing = lineSpacingExtra != DEFAULT_LINE_SPACING_EXTRA || lineSpacingMultiplier != DEFAULT_LINE_SPACING_MULTIPLIER;
int lineBottomWithoutSpacing;
if (!hasLineSpacing || isLastLine) {
lineBottomWithoutSpacing = lineBottom;
} else {
float extra;
if (Float.compare(lineSpacingMultiplier, DEFAULT_LINE_SPACING_MULTIPLIER) != 0) {
int lineHeight = getLineHeight(layout, line);
extra = lineHeight - (lineHeight - lineSpacingExtra) / lineSpacingMultiplier;
} else {
extra = lineSpacingExtra;
}
lineBottomWithoutSpacing = (int) (lineBottom - extra);
}
return lineBottomWithoutSpacing;
}
}

View File

@@ -0,0 +1,133 @@
package org.thoughtcrime.securesms.util;
import android.annotation.SuppressLint;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import org.whispersystems.libsignal.util.guava.Function;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* A reusable and composable {@link androidx.recyclerview.widget.RecyclerView.Adapter} built on-top of {@link ListAdapter} to
* provide async item diffing support.
* <p></p>
* The adapter makes use of mapping a model class to view holder factory at runtime via one of the {@link #registerFactory(Class, Factory)}
* methods. The factory creates a view holder specifically designed to handle the paired model type. This allows the view holder concretely
* deal with the model type it cares about. Due to the enforcement of matching generics during factory registration we can safely ignore or
* override compiler typing recommendations when binding and diffing.
* <p></p>
* General pattern for implementation:
* <ol>
* <li>Create {@link MappingModel}s for the items in the list. These encapsulate data massaging methods for views to use and the diff logic.</li>
* <li>Create {@link MappingViewHolder}s for each item type in the list and their corresponding {@link Factory}.</li>
* <li>Create an instance or subclass of {@link MappingAdapter} and register the mapping of model type to view holder factory for that model type.</li>
* </ol>
* Event listeners, click or otherwise, are handled at the view holder level and should be passed into the appropriate view holder factories. This
* pattern mimics how we pass data into view models via factories.
* <p></p>
* NOTE: There can only be on factory registered per model type. Registering two for the same type will result in the last one being used. However, the
* same factory can be registered multiple times for multiple model types (if the model type class hierarchy supports it).
*/
public class MappingAdapter extends ListAdapter<MappingModel<?>, MappingViewHolder<?>> {
private final Map<Integer, Factory<?>> factories;
private final Map<Class<?>, Integer> itemTypes;
private int typeCount;
public MappingAdapter() {
super(new MappingDiffCallback());
factories = new HashMap<>();
itemTypes = new HashMap<>();
typeCount = 0;
}
@Override
public void onViewAttachedToWindow(@NonNull MappingViewHolder<?> holder) {
super.onViewAttachedToWindow(holder);
holder.onAttachedToWindow();
}
@Override
public void onViewDetachedFromWindow(@NonNull MappingViewHolder<?> holder) {
super.onViewDetachedFromWindow(holder);
holder.onDetachedFromWindow();
}
public <T extends MappingModel<T>> void registerFactory(Class<T> clazz, Factory<T> factory) {
int type = typeCount++;
factories.put(type, factory);
itemTypes.put(clazz, type);
}
@Override
public int getItemViewType(int position) {
Integer type = itemTypes.get(getItem(position).getClass());
if (type != null) {
return type;
}
throw new AssertionError("No view holder factory for type: " + getItem(position).getClass());
}
@Override
public @NonNull MappingViewHolder<?> onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return Objects.requireNonNull(factories.get(viewType)).createViewHolder(parent);
}
@Override
public void onBindViewHolder(@NonNull MappingViewHolder holder, int position) {
//noinspection unchecked
holder.bind(getItem(position));
}
private static class MappingDiffCallback extends DiffUtil.ItemCallback<MappingModel<?>> {
@Override
public boolean areItemsTheSame(@NonNull MappingModel oldItem, @NonNull MappingModel newItem) {
if (oldItem.getClass() == newItem.getClass()) {
//noinspection unchecked
return oldItem.areItemsTheSame(newItem);
}
return false;
}
@SuppressLint("DiffUtilEquals")
@Override
public boolean areContentsTheSame(@NonNull MappingModel oldItem, @NonNull MappingModel newItem) {
if (oldItem.getClass() == newItem.getClass()) {
//noinspection unchecked
return oldItem.areContentsTheSame(newItem);
}
return false;
}
}
public interface Factory<T extends MappingModel<T>> {
@NonNull MappingViewHolder<T> createViewHolder(ViewGroup parent);
}
public static class LayoutFactory<T extends MappingModel<T>> implements Factory<T> {
private Function<View, MappingViewHolder<T>> creator;
private final int layout;
public LayoutFactory(Function<View, MappingViewHolder<T>> creator, @LayoutRes int layout) {
this.creator = creator;
this.layout = layout;
}
@Override
public @NonNull MappingViewHolder<T> createViewHolder(ViewGroup parent) {
return creator.apply(LayoutInflater.from(parent.getContext()).inflate(layout, parent, false));
}
}
}

View File

@@ -0,0 +1,8 @@
package org.thoughtcrime.securesms.util;
import androidx.annotation.NonNull;
public interface MappingModel<T> {
boolean areItemsTheSame(@NonNull T newItem);
boolean areContentsTheSame(@NonNull T newItem);
}

Some files were not shown because too many files have changed in this diff Show More