do not merge message bubbles

Daniel Gultsch created

Change summary

src/main/java/eu/siacs/conversations/entities/Conversation.java          | 249 
src/main/java/eu/siacs/conversations/entities/Message.java               | 290 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java |  22 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java        | 128 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java      |  61 
src/main/java/eu/siacs/conversations/ui/util/ShareUtil.java              | 129 
src/main/java/eu/siacs/conversations/utils/MessageUtils.java             |  26 
src/main/java/eu/siacs/conversations/utils/StylingHelper.java            | 378 
8 files changed, 647 insertions(+), 636 deletions(-)

Detailed changes

src/main/java/eu/siacs/conversations/entities/Conversation.java 🔗

@@ -1,27 +1,16 @@
 package eu.siacs.conversations.entities;
 
+import static eu.siacs.conversations.entities.Bookmark.printableValue;
+
 import android.content.ContentValues;
 import android.database.Cursor;
 import android.text.TextUtils;
-
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
-
+import com.google.common.base.Strings;
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
-
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
-
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-import java.util.ListIterator;
-import java.util.concurrent.atomic.AtomicBoolean;
-
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.crypto.OmemoSetting;
 import eu.siacs.conversations.crypto.PgpDecryptionService;
@@ -34,11 +23,17 @@ import eu.siacs.conversations.utils.UIHelper;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.chatstate.ChatState;
 import eu.siacs.conversations.xmpp.mam.MamReference;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
 
-import static eu.siacs.conversations.entities.Bookmark.printableValue;
-
-
-public class Conversation extends AbstractEntity implements Blockable, Comparable<Conversation>, Conversational, AvatarService.Avatarable {
+public class Conversation extends AbstractEntity
+        implements Blockable, Comparable<Conversation>, Conversational, AvatarService.Avatarable {
     public static final String TABLENAME = "conversations";
 
     public static final int STATUS_AVAILABLE = 0;
@@ -56,7 +51,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
     public static final String ATTRIBUTE_MUTED_TILL = "muted_till";
     public static final String ATTRIBUTE_ALWAYS_NOTIFY = "always_notify";
     public static final String ATTRIBUTE_LAST_CLEAR_HISTORY = "last_clear_history";
-    public static final String ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS = "formerly_private_non_anonymous";
+    public static final String ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS =
+            "formerly_private_non_anonymous";
     public static final String ATTRIBUTE_PINNED_ON_TOP = "pinned_on_top";
     static final String ATTRIBUTE_MUC_PASSWORD = "muc_password";
     static final String ATTRIBUTE_MEMBERS_ONLY = "members_only";
@@ -78,7 +74,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
     private int status;
     private final long created;
     private int mode;
-    private JSONObject attributes;
+    private final JSONObject attributes;
     private Jid nextCounterpart;
     private transient MucOptions mucOptions = null;
     private boolean messagesLeftOnServer = true;
@@ -87,17 +83,31 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
     private String mFirstMamReference = null;
     private String displayState = null;
 
-    public Conversation(final String name, final Account account, final Jid contactJid,
-                        final int mode) {
-        this(java.util.UUID.randomUUID().toString(), name, null, account
-                        .getUuid(), contactJid, System.currentTimeMillis(),
-                STATUS_AVAILABLE, mode, "");
+    public Conversation(
+            final String name, final Account account, final Jid contactJid, final int mode) {
+        this(
+                java.util.UUID.randomUUID().toString(),
+                name,
+                null,
+                account.getUuid(),
+                contactJid,
+                System.currentTimeMillis(),
+                STATUS_AVAILABLE,
+                mode,
+                "");
         this.account = account;
     }
 
-    public Conversation(final String uuid, final String name, final String contactUuid,
-                        final String accountUuid, final Jid contactJid, final long created, final int status,
-                        final int mode, final String attributes) {
+    public Conversation(
+            final String uuid,
+            final String name,
+            final String contactUuid,
+            final String accountUuid,
+            final Jid contactJid,
+            final long created,
+            final int status,
+            final int mode,
+            final String attributes) {
         this.uuid = uuid;
         this.name = name;
         this.contactUuid = contactUuid;
@@ -106,26 +116,37 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         this.created = created;
         this.status = status;
         this.mode = mode;
-        try {
-            this.attributes = new JSONObject(attributes == null ? "" : attributes);
-        } catch (JSONException e) {
-            this.attributes = new JSONObject();
+        this.attributes = parseAttributes(attributes);
+    }
+
+    private static JSONObject parseAttributes(final String attributes) {
+        if (Strings.isNullOrEmpty(attributes)) {
+            return new JSONObject();
+        } else {
+            try {
+                return new JSONObject(attributes);
+            } catch (final JSONException e) {
+                return new JSONObject();
+            }
         }
     }
 
-    public static Conversation fromCursor(Cursor cursor) {
-        return new Conversation(cursor.getString(cursor.getColumnIndex(UUID)),
-                cursor.getString(cursor.getColumnIndex(NAME)),
-                cursor.getString(cursor.getColumnIndex(CONTACT)),
-                cursor.getString(cursor.getColumnIndex(ACCOUNT)),
-                JidHelper.parseOrFallbackToInvalid(cursor.getString(cursor.getColumnIndex(CONTACTJID))),
-                cursor.getLong(cursor.getColumnIndex(CREATED)),
-                cursor.getInt(cursor.getColumnIndex(STATUS)),
-                cursor.getInt(cursor.getColumnIndex(MODE)),
-                cursor.getString(cursor.getColumnIndex(ATTRIBUTES)));
+    public static Conversation fromCursor(final Cursor cursor) {
+        return new Conversation(
+                cursor.getString(cursor.getColumnIndexOrThrow(UUID)),
+                cursor.getString(cursor.getColumnIndexOrThrow(NAME)),
+                cursor.getString(cursor.getColumnIndexOrThrow(CONTACT)),
+                cursor.getString(cursor.getColumnIndexOrThrow(ACCOUNT)),
+                JidHelper.parseOrFallbackToInvalid(
+                        cursor.getString(cursor.getColumnIndexOrThrow(CONTACTJID))),
+                cursor.getLong(cursor.getColumnIndexOrThrow(CREATED)),
+                cursor.getInt(cursor.getColumnIndexOrThrow(STATUS)),
+                cursor.getInt(cursor.getColumnIndexOrThrow(MODE)),
+                cursor.getString(cursor.getColumnIndexOrThrow(ATTRIBUTES)));
     }
 
-    public static Message getLatestMarkableMessage(final List<Message> messages, boolean isPrivateAndNonAnonymousMuc) {
+    public static Message getLatestMarkableMessage(
+            final List<Message> messages, boolean isPrivateAndNonAnonymousMuc) {
         for (int i = messages.size() - 1; i >= 0; --i) {
             final Message message = messages.get(i);
             if (message.getStatus() <= Message.STATUS_RECEIVED
@@ -146,10 +167,13 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         }
         final String contact = conversation.getJid().getDomain().toEscapedString();
         final String account = conversation.getAccount().getServer();
-        if (Config.OMEMO_EXCEPTIONS.matchesContactDomain(contact) || Config.OMEMO_EXCEPTIONS.ACCOUNT_DOMAINS.contains(account)) {
+        if (Config.OMEMO_EXCEPTIONS.matchesContactDomain(contact)
+                || Config.OMEMO_EXCEPTIONS.ACCOUNT_DOMAINS.contains(account)) {
             return false;
         }
-        return conversation.isSingleOrPrivateAndNonAnonymous() || conversation.getBooleanAttribute(ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS, false);
+        return conversation.isSingleOrPrivateAndNonAnonymous()
+                || conversation.getBooleanAttribute(
+                        ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS, false);
     }
 
     public boolean hasMessagesLeftOnServer() {
@@ -193,7 +217,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
     public int countFailedDeliveries() {
         int count = 0;
         synchronized (this.messages) {
-            for(final Message message : this.messages) {
+            for (final Message message : this.messages) {
                 if (message.getStatus() == Message.STATUS_SEND_FAILED) {
                     ++count;
                 }
@@ -216,12 +240,12 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         return null;
     }
 
-
     public Message findUnsentMessageWithUuid(String uuid) {
         synchronized (this.messages) {
             for (final Message message : this.messages) {
                 final int s = message.getStatus();
-                if ((s == Message.STATUS_UNSEND || s == Message.STATUS_WAITING) && message.getUuid().equals(uuid)) {
+                if ((s == Message.STATUS_UNSEND || s == Message.STATUS_WAITING)
+                        && message.getUuid().equals(uuid)) {
                     return message;
                 }
             }
@@ -262,10 +286,16 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         synchronized (this.messages) {
             for (final Message message : this.messages) {
                 final Transferable transferable = message.getTransferable();
-                final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(message);
+                final boolean unInitiatedButKnownSize =
+                        MessageUtils.unInitiatedButKnownSize(message);
                 if (message.getUuid().equals(uuid)
                         && message.getEncryption() != Message.ENCRYPTION_PGP
-                        && (message.isFileOrImage() || message.treatAsDownloadable() || unInitiatedButKnownSize || (transferable != null && transferable.getStatus() != Transferable.STATUS_UPLOADING))) {
+                        && (message.isFileOrImage()
+                                || message.treatAsDownloadable()
+                                || unInitiatedButKnownSize
+                                || (transferable != null
+                                        && transferable.getStatus()
+                                                != Transferable.STATUS_UPLOADING))) {
                     return message;
                 }
             }
@@ -292,7 +322,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                 if (uuids.contains(message.getUuid())) {
                     message.setDeleted(true);
                     deleted = true;
-                    if (message.getEncryption() == Message.ENCRYPTION_PGP && pgpDecryptionService != null) {
+                    if (message.getEncryption() == Message.ENCRYPTION_PGP
+                            && pgpDecryptionService != null) {
                         pgpDecryptionService.discard(message);
                     }
                 }
@@ -310,7 +341,9 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                     if (file.uuid.toString().equals(message.getUuid())) {
                         message.setDeleted(file.deleted);
                         changed = true;
-                        if (file.deleted && message.getEncryption() == Message.ENCRYPTION_PGP && pgpDecryptionService != null) {
+                        if (file.deleted
+                                && message.getEncryption() == Message.ENCRYPTION_PGP
+                                && pgpDecryptionService != null) {
                             pgpDecryptionService.discard(message);
                         }
                     }
@@ -338,7 +371,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
     }
 
     public boolean setOutgoingChatState(ChatState state) {
-        if (mode == MODE_SINGLE && !getContact().isSelf() || (isPrivateAndNonAnonymous() && getNextCounterpart() == null)) {
+        if (mode == MODE_SINGLE && !getContact().isSelf()
+                || (isPrivateAndNonAnonymous() && getNextCounterpart() == null)) {
             if (this.mOutgoingChatState != state) {
                 this.mOutgoingChatState = state;
                 return true;
@@ -371,7 +405,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         final ArrayList<Message> results = new ArrayList<>();
         synchronized (this.messages) {
             for (Message message : this.messages) {
-                if ((message.getType() == Message.TYPE_TEXT || message.hasFileOnRemoteHost()) && message.getStatus() == Message.STATUS_UNSEND) {
+                if ((message.getType() == Message.TYPE_TEXT || message.hasFileOnRemoteHost())
+                        && message.getStatus() == Message.STATUS_UNSEND) {
                     results.add(message);
                 }
             }
@@ -386,7 +421,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
             for (Message message : this.messages) {
                 if (id.equals(message.getUuid())
                         || (message.getStatus() >= Message.STATUS_SEND
-                        && id.equals(message.getRemoteMsgId()))) {
+                                && id.equals(message.getRemoteMsgId()))) {
                     return message;
                 }
             }
@@ -405,7 +440,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         return null;
     }
 
-    public Message findMessageWithRemoteIdAndCounterpart(String id, Jid counterpart, boolean received, boolean carbon) {
+    public Message findMessageWithRemoteIdAndCounterpart(
+            String id, Jid counterpart, boolean received, boolean carbon) {
         synchronized (this.messages) {
             for (int i = this.messages.size() - 1; i >= 0; --i) {
                 final Message message = messages.get(i);
@@ -413,9 +449,12 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                 if (mcp == null) {
                     continue;
                 }
-                if (mcp.equals(counterpart) && ((message.getStatus() == Message.STATUS_RECEIVED) == received)
+                if (mcp.equals(counterpart)
+                        && ((message.getStatus() == Message.STATUS_RECEIVED) == received)
                         && (carbon == message.isCarbon() || received)) {
-                    if (id.equals(message.getRemoteMsgId()) && !message.isFileOrImage() && !message.treatAsDownloadable()) {
+                    if (id.equals(message.getRemoteMsgId())
+                            && !message.isFileOrImage()
+                            && !message.treatAsDownloadable()) {
                         return message;
                     } else {
                         return null;
@@ -452,7 +491,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
     public Message findReceivedWithRemoteId(final String id) {
         synchronized (this.messages) {
             for (final Message message : this.messages) {
-                if (message.getStatus() == Message.STATUS_RECEIVED && id.equals(message.getRemoteMsgId())) {
+                if (message.getStatus() == Message.STATUS_RECEIVED
+                        && id.equals(message.getRemoteMsgId())) {
                     return message;
                 }
             }
@@ -487,11 +527,6 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
             messages.clear();
             messages.addAll(this.messages);
         }
-        for (Iterator<Message> iterator = messages.iterator(); iterator.hasNext(); ) {
-            if (iterator.next().wasMergedIntoPrevious()) {
-                iterator.remove();
-            }
-        }
     }
 
     @Override
@@ -548,7 +583,9 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
     }
 
     public boolean setCorrectingMessage(Message correctingMessage) {
-        setAttribute(ATTRIBUTE_CORRECTING_MESSAGE, correctingMessage == null ? null : correctingMessage.getUuid());
+        setAttribute(
+                ATTRIBUTE_CORRECTING_MESSAGE,
+                correctingMessage == null ? null : correctingMessage.getUuid());
         return correctingMessage == null && draftMessage != null;
     }
 
@@ -564,7 +601,9 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
     @Override
     public int compareTo(@NonNull Conversation another) {
         return ComparisonChain.start()
-                .compareFalseFirst(another.getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false), getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false))
+                .compareFalseFirst(
+                        another.getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false),
+                        getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false))
                 .compare(another.getSortableTime(), getSortableTime())
                 .result();
     }
@@ -589,7 +628,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
 
     public boolean isRead() {
         synchronized (this.messages) {
-            for(final Message message : Lists.reverse(this.messages)) {
+            for (final Message message : Lists.reverse(this.messages)) {
                 if (message.isRead() && message.getType() == Message.TYPE_RTP_SESSION) {
                     continue;
                 }
@@ -628,8 +667,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         }
     }
 
-    public @NonNull
-    CharSequence getName() {
+    public @NonNull CharSequence getName() {
         if (getMode() == MODE_MULTI) {
             final String roomName = getMucOptions().getName();
             final String subject = getMucOptions().getSubject();
@@ -649,7 +687,9 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                     return contactJid.getLocal() != null ? contactJid.getLocal() : contactJid;
                 }
             }
-        } else if ((QuickConversationsService.isConversations() || !Config.QUICKSY_DOMAIN.equals(contactJid.getDomain())) && isWithStranger()) {
+        } else if ((QuickConversationsService.isConversations()
+                        || !Config.QUICKSY_DOMAIN.equals(contactJid.getDomain()))
+                && isWithStranger()) {
             return contactJid;
         } else {
             return this.getContact().getDisplayName();
@@ -713,9 +753,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         this.mode = mode;
     }
 
-    /**
-     * short for is Private and Non-anonymous
-     */
+    /** short for is Private and Non-anonymous */
     public boolean isSingleOrPrivateAndNonAnonymous() {
         return mode == MODE_SINGLE || isPrivateAndNonAnonymous();
     }
@@ -752,7 +790,9 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
             return Message.ENCRYPTION_NONE;
         }
         if (OmemoSetting.isAlways()) {
-            return suitableForOmemoByDefault(this) ? Message.ENCRYPTION_AXOLOTL : Message.ENCRYPTION_NONE;
+            return suitableForOmemoByDefault(this)
+                    ? Message.ENCRYPTION_AXOLOTL
+                    : Message.ENCRYPTION_NONE;
         }
         final int defaultEncryption;
         if (suitableForOmemoByDefault(this)) {
@@ -777,8 +817,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         return nextMessage == null ? "" : nextMessage;
     }
 
-    public @Nullable
-    Draft getDraft() {
+    public @Nullable Draft getDraft() {
         long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0);
         if (timestamp > getLatestMessage().getTimeSent()) {
             String message = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
@@ -794,7 +833,9 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         boolean changed = !getNextMessage().equals(message);
         this.setAttribute(ATTRIBUTE_NEXT_MESSAGE, message);
         if (changed) {
-            this.setAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, message == null ? 0 : System.currentTimeMillis());
+            this.setAttribute(
+                    ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP,
+                    message == null ? 0 : System.currentTimeMillis());
         }
         return changed;
     }
@@ -822,7 +863,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         synchronized (this.messages) {
             for (int i = this.messages.size() - 1; i >= 0; --i) {
                 Message message = this.messages.get(i);
-                if (message.getStatus() == Message.STATUS_UNSEND || message.getStatus() == Message.STATUS_SEND) {
+                if (message.getStatus() == Message.STATUS_UNSEND
+                        || message.getStatus() == Message.STATUS_SEND) {
                     String otherBody;
                     if (message.hasFileOnRemoteHost()) {
                         otherBody = message.getFileParams().url;
@@ -842,7 +884,9 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         synchronized (this.messages) {
             for (int i = this.messages.size() - 1; i >= 0; --i) {
                 final Message message = this.messages.get(i);
-                if ((message.getStatus() == s) && (message.getType() == Message.TYPE_RTP_SESSION) && sessionId.equals(message.getRemoteMsgId())) {
+                if ((message.getStatus() == s)
+                        && (message.getType() == Message.TYPE_RTP_SESSION)
+                        && sessionId.equals(message.getRemoteMsgId())) {
                     return message;
                 }
             }
@@ -856,7 +900,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
         }
         synchronized (this.messages) {
             for (Message message : this.messages) {
-                if (serverMsgId.equals(message.getServerMsgId()) || remoteMsgId.equals(message.getRemoteMsgId())) {
+                if (serverMsgId.equals(message.getServerMsgId())
+                        || remoteMsgId.equals(message.getRemoteMsgId())) {
                     return true;
                 }
             }
@@ -871,10 +916,14 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
             for (int i = this.messages.size() - 1; i >= 0; --i) {
                 final Message message = this.messages.get(i);
                 if (message.isPrivateMessage()) {
-                    continue; //it's unsafe to use private messages as anchor. They could be coming from user archive
+                    continue; // it's unsafe to use private messages as anchor. They could be coming
+                    // from user archive
                 }
-                if (message.getStatus() == Message.STATUS_RECEIVED || message.isCarbon() || message.getServerMsgId() != null) {
-                    lastReceived = new MamReference(message.getTimeSent(), message.getServerMsgId());
+                if (message.getStatus() == Message.STATUS_RECEIVED
+                        || message.isCarbon()
+                        || message.getServerMsgId() != null) {
+                    lastReceived =
+                            new MamReference(message.getTimeSent(), message.getServerMsgId());
                     break;
                 }
             }
@@ -891,7 +940,10 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
     }
 
     public boolean alwaysNotify() {
-        return mode == MODE_SINGLE || getBooleanAttribute(ATTRIBUTE_ALWAYS_NOTIFY, Config.ALWAYS_NOTIFY_BY_DEFAULT || isPrivateAndNonAnonymous());
+        return mode == MODE_SINGLE
+                || getBooleanAttribute(
+                        ATTRIBUTE_ALWAYS_NOTIFY,
+                        Config.ALWAYS_NOTIFY_BY_DEFAULT || isPrivateAndNonAnonymous());
     }
 
     public boolean setAttribute(String key, boolean value) {
@@ -957,11 +1009,11 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
                     try {
                         list.add(Jid.of(array.getString(i)));
                     } catch (IllegalArgumentException e) {
-                        //ignored
+                        // ignored
                     }
                 }
             } catch (JSONException e) {
-                //ignored
+                // ignored
             }
         }
         return list;
@@ -1023,7 +1075,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
 
     public void expireOldMessages(long timestamp) {
         synchronized (this.messages) {
-            for (ListIterator<Message> iterator = this.messages.listIterator(); iterator.hasNext(); ) {
+            for (ListIterator<Message> iterator = this.messages.listIterator();
+                    iterator.hasNext(); ) {
                 if (iterator.next().getTimeSent() < timestamp) {
                     iterator.remove();
                 }
@@ -1034,15 +1087,17 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
 
     public void sort() {
         synchronized (this.messages) {
-            Collections.sort(this.messages, (left, right) -> {
-                if (left.getTimeSent() < right.getTimeSent()) {
-                    return -1;
-                } else if (left.getTimeSent() > right.getTimeSent()) {
-                    return 1;
-                } else {
-                    return 0;
-                }
-            });
+            Collections.sort(
+                    this.messages,
+                    (left, right) -> {
+                        if (left.getTimeSent() < right.getTimeSent()) {
+                            return -1;
+                        } else if (left.getTimeSent() > right.getTimeSent()) {
+                            return 1;
+                        } else {
+                            return 0;
+                        }
+                    });
             untieMessages();
         }
     }
@@ -1056,7 +1111,7 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl
     public int unreadCount() {
         synchronized (this.messages) {
             int count = 0;
-            for(final Message message : Lists.reverse(this.messages)) {
+            for (final Message message : Lists.reverse(this.messages)) {
                 if (message.isRead()) {
                     if (message.getType() == Message.TYPE_RTP_SESSION) {
                         continue;

src/main/java/eu/siacs/conversations/entities/Message.java 🔗

@@ -3,26 +3,11 @@ package eu.siacs.conversations.entities;
 import android.content.ContentValues;
 import android.database.Cursor;
 import android.graphics.Color;
-import android.text.SpannableStringBuilder;
 import android.util.Log;
-
 import com.google.common.base.Strings;
 import com.google.common.collect.Collections2;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.primitives.Longs;
-
-import org.json.JSONException;
-
-import java.lang.ref.WeakReference;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Iterator;
-import java.util.List;
-import java.util.Set;
-import java.util.concurrent.CopyOnWriteArraySet;
-
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.crypto.axolotl.AxolotlService;
 import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
@@ -36,6 +21,15 @@ import eu.siacs.conversations.utils.MessageUtils;
 import eu.siacs.conversations.utils.MimeUtils;
 import eu.siacs.conversations.utils.UIHelper;
 import eu.siacs.conversations.xmpp.Jid;
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArraySet;
+import org.json.JSONException;
 
 public class Message extends AbstractEntity implements AvatarService.Avatarable {
 
@@ -94,7 +88,6 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
 
     public static final String ERROR_MESSAGE_CANCELLED = "eu.siacs.conversations.cancelled";
 
-
     public boolean markable = false;
     protected String conversationUuid;
     protected Jid counterpart;
@@ -140,7 +133,9 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
     }
 
     public Message(Conversational conversation, String body, int encryption, int status) {
-        this(conversation, java.util.UUID.randomUUID().toString(),
+        this(
+                conversation,
+                java.util.UUID.randomUUID().toString(),
                 conversation.getUuid(),
                 conversation.getJid() == null ? null : conversation.getJid().asBareJid(),
                 null,
@@ -167,7 +162,9 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
     }
 
     public Message(Conversation conversation, int status, int type, final String remoteMsgId) {
-        this(conversation, java.util.UUID.randomUUID().toString(),
+        this(
+                conversation,
+                java.util.UUID.randomUUID().toString(),
                 conversation.getUuid(),
                 conversation.getJid() == null ? null : conversation.getJid().asBareJid(),
                 null,
@@ -193,13 +190,32 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
                 Collections.emptyList());
     }
 
-    protected Message(final Conversational conversation, final String uuid, final String conversationUUid, final Jid counterpart,
-                      final Jid trueCounterpart, final String body, final long timeSent,
-                      final int encryption, final int status, final int type, final boolean carbon,
-                      final String remoteMsgId, final String relativeFilePath,
-                      final String serverMsgId, final String fingerprint, final boolean read,
-                      final String edited, final boolean oob, final String errorMessage, final Set<ReadByMarker> readByMarkers,
-                      final boolean markable, final boolean deleted, final String bodyLanguage, final String occupantId, final Collection<Reaction> reactions) {
+    protected Message(
+            final Conversational conversation,
+            final String uuid,
+            final String conversationUUid,
+            final Jid counterpart,
+            final Jid trueCounterpart,
+            final String body,
+            final long timeSent,
+            final int encryption,
+            final int status,
+            final int type,
+            final boolean carbon,
+            final String remoteMsgId,
+            final String relativeFilePath,
+            final String serverMsgId,
+            final String fingerprint,
+            final boolean read,
+            final String edited,
+            final boolean oob,
+            final String errorMessage,
+            final Set<ReadByMarker> readByMarkers,
+            final boolean markable,
+            final boolean deleted,
+            final String bodyLanguage,
+            final String occupantId,
+            final Collection<Reaction> reactions) {
         this.conversation = conversation;
         this.uuid = uuid;
         this.conversationUuid = conversationUUid;
@@ -228,7 +244,8 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
     }
 
     public static Message fromCursor(final Cursor cursor, final Conversation conversation) {
-        return new Message(conversation,
+        return new Message(
+                conversation,
                 cursor.getString(cursor.getColumnIndexOrThrow(UUID)),
                 cursor.getString(cursor.getColumnIndexOrThrow(CONVERSATION)),
                 fromString(cursor.getString(cursor.getColumnIndexOrThrow(COUNTERPART))),
@@ -247,14 +264,13 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
                 cursor.getString(cursor.getColumnIndexOrThrow(EDITED)),
                 cursor.getInt(cursor.getColumnIndexOrThrow(OOB)) > 0,
                 cursor.getString(cursor.getColumnIndexOrThrow(ERROR_MESSAGE)),
-                ReadByMarker.fromJsonString(cursor.getString(cursor.getColumnIndexOrThrow(READ_BY_MARKERS))),
+                ReadByMarker.fromJsonString(
+                        cursor.getString(cursor.getColumnIndexOrThrow(READ_BY_MARKERS))),
                 cursor.getInt(cursor.getColumnIndexOrThrow(MARKABLE)) > 0,
                 cursor.getInt(cursor.getColumnIndexOrThrow(DELETED)) > 0,
                 cursor.getString(cursor.getColumnIndexOrThrow(BODY_LANGUAGE)),
                 cursor.getString(cursor.getColumnIndexOrThrow(OCCUPANT_ID)),
-                Reaction.fromString(cursor.getString(cursor.getColumnIndexOrThrow(REACTIONS)))
-
-        );
+                Reaction.fromString(cursor.getString(cursor.getColumnIndexOrThrow(REACTIONS))));
     }
 
     private static Jid fromString(String value) {
@@ -298,7 +314,11 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         } else {
             values.put(TRUE_COUNTERPART, trueCounterpart.toString());
         }
-        values.put(BODY, body.length() > Config.MAX_STORAGE_MESSAGE_CHARS ? body.substring(0, Config.MAX_STORAGE_MESSAGE_CHARS) : body);
+        values.put(
+                BODY,
+                body.length() > Config.MAX_STORAGE_MESSAGE_CHARS
+                        ? body.substring(0, Config.MAX_STORAGE_MESSAGE_CHARS)
+                        : body);
         values.put(TIME_SENT, timeSent);
         values.put(ENCRYPTION, encryption);
         values.put(STATUS, status);
@@ -348,7 +368,9 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
             if (this.trueCounterpart == null) {
                 return null;
             } else {
-                return this.conversation.getAccount().getRoster()
+                return this.conversation
+                        .getAccount()
+                        .getRoster()
                         .getContactFromContactList(this.trueCounterpart);
             }
         }
@@ -375,7 +397,8 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
 
     public boolean sameMucUser(Message otherMessage) {
         final MucOptions.User thisUser = this.user == null ? null : this.user.get();
-        final MucOptions.User otherUser = otherMessage.user == null ? null : otherMessage.user.get();
+        final MucOptions.User otherUser =
+                otherMessage.user == null ? null : otherMessage.user.get();
         return thisUser != null && thisUser == otherUser;
     }
 
@@ -384,8 +407,9 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
     }
 
     public boolean setErrorMessage(String message) {
-        boolean changed = (message != null && !message.equals(errorMessage))
-                || (message == null && errorMessage != null);
+        boolean changed =
+                (message != null && !message.equals(errorMessage))
+                        || (message == null && errorMessage != null);
         this.errorMessage = message;
         return changed;
     }
@@ -533,7 +557,8 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
                 Iterator<ReadByMarker> iterator = this.readByMarkers.iterator();
                 while (iterator.hasNext()) {
                     ReadByMarker marker = iterator.next();
-                    if (marker.getRealJid() == null && readByMarker.getFullJid().equals(marker.getFullJid())) {
+                    if (marker.getRealJid() == null
+                            && readByMarker.getFullJid().equals(marker.getFullJid())) {
                         iterator.remove();
                     }
                 }
@@ -557,7 +582,8 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
 
     boolean similar(Message message) {
         if (!isPrivateMessage() && this.serverMsgId != null && message.getServerMsgId() != null) {
-            return this.serverMsgId.equals(message.getServerMsgId()) || Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId());
+            return this.serverMsgId.equals(message.getServerMsgId())
+                    || Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId());
         } else if (Edit.wasPreviouslyEditedServerMsgId(edits, message.getServerMsgId())) {
             return true;
         } else if (this.body == null || this.counterpart == null) {
@@ -573,32 +599,37 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
             }
             final boolean matchingCounterpart = this.counterpart.equals(message.getCounterpart());
             if (message.getRemoteMsgId() != null) {
-                final boolean hasUuid = CryptoHelper.UUID_PATTERN.matcher(message.getRemoteMsgId()).matches();
-                if (hasUuid && matchingCounterpart && Edit.wasPreviouslyEditedRemoteMsgId(edits, message.getRemoteMsgId())) {
+                final boolean hasUuid =
+                        CryptoHelper.UUID_PATTERN.matcher(message.getRemoteMsgId()).matches();
+                if (hasUuid
+                        && matchingCounterpart
+                        && Edit.wasPreviouslyEditedRemoteMsgId(edits, message.getRemoteMsgId())) {
                     return true;
                 }
-                return (message.getRemoteMsgId().equals(this.remoteMsgId) || message.getRemoteMsgId().equals(this.uuid))
+                return (message.getRemoteMsgId().equals(this.remoteMsgId)
+                                || message.getRemoteMsgId().equals(this.uuid))
                         && matchingCounterpart
-                        && (body.equals(otherBody) || (message.getEncryption() == Message.ENCRYPTION_PGP && hasUuid));
+                        && (body.equals(otherBody)
+                                || (message.getEncryption() == Message.ENCRYPTION_PGP && hasUuid));
             } else {
                 return this.remoteMsgId == null
                         && matchingCounterpart
                         && body.equals(otherBody)
-                        && Math.abs(this.getTimeSent() - message.getTimeSent()) < Config.MESSAGE_MERGE_WINDOW * 1000;
+                        && Math.abs(this.getTimeSent() - message.getTimeSent())
+                                < Config.MESSAGE_MERGE_WINDOW * 1000;
             }
         }
     }
 
     public Message next() {
-        if (this.conversation instanceof Conversation) {
-            final Conversation conversation = (Conversation) this.conversation;
-            synchronized (conversation.messages) {
+        if (this.conversation instanceof Conversation c) {
+            synchronized (c.messages) {
                 if (this.mNextMessage == null) {
-                    int index = conversation.messages.indexOf(this);
-                    if (index < 0 || index >= conversation.messages.size() - 1) {
+                    int index = c.messages.indexOf(this);
+                    if (index < 0 || index >= c.messages.size() - 1) {
                         this.mNextMessage = null;
                     } else {
-                        this.mNextMessage = conversation.messages.get(index + 1);
+                        this.mNextMessage = c.messages.get(index + 1);
                     }
                 }
                 return this.mNextMessage;
@@ -609,15 +640,14 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
     }
 
     public Message prev() {
-        if (this.conversation instanceof Conversation) {
-            final Conversation conversation = (Conversation) this.conversation;
-            synchronized (conversation.messages) {
+        if (this.conversation instanceof Conversation c) {
+            synchronized (c.messages) {
                 if (this.mPreviousMessage == null) {
-                    int index = conversation.messages.indexOf(this);
-                    if (index <= 0 || index > conversation.messages.size()) {
+                    int index = c.messages.indexOf(this);
+                    if (index <= 0 || index > c.messages.size()) {
                         this.mPreviousMessage = null;
                     } else {
-                        this.mPreviousMessage = conversation.messages.get(index - 1);
+                        this.mPreviousMessage = c.messages.get(index - 1);
                     }
                 }
             }
@@ -642,56 +672,6 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         return status != STATUS_RECEIVED && !isCarbon() && type != Message.TYPE_RTP_SESSION;
     }
 
-    public boolean mergeable(final Message message) {
-        return message != null &&
-                (message.getType() == Message.TYPE_TEXT &&
-                        this.getTransferable() == null &&
-                        message.getTransferable() == null &&
-                        message.getEncryption() != Message.ENCRYPTION_PGP &&
-                        message.getEncryption() != Message.ENCRYPTION_DECRYPTION_FAILED &&
-                        this.getType() == message.getType() &&
-                        this.isReactionsEmpty() &&
-                        message.isReactionsEmpty() &&
-                        isStatusMergeable(this.getStatus(), message.getStatus()) &&
-                        isEncryptionMergeable(this.getEncryption(),message.getEncryption()) &&
-                        this.getCounterpart() != null &&
-                        this.getCounterpart().equals(message.getCounterpart()) &&
-                        this.edited() == message.edited() &&
-                        (message.getTimeSent() - this.getTimeSent()) <= (Config.MESSAGE_MERGE_WINDOW * 1000) &&
-                        this.getBody().length() + message.getBody().length() <= Config.MAX_DISPLAY_MESSAGE_CHARS &&
-                        !message.isGeoUri() &&
-                        !this.isGeoUri() &&
-                        !message.isOOb() &&
-                        !this.isOOb() &&
-                        !message.treatAsDownloadable() &&
-                        !this.treatAsDownloadable() &&
-                        !message.hasMeCommand() &&
-                        !this.hasMeCommand() &&
-                        !this.bodyIsOnlyEmojis() &&
-                        !message.bodyIsOnlyEmojis() &&
-                        ((this.axolotlFingerprint == null && message.axolotlFingerprint == null) || this.axolotlFingerprint.equals(message.getFingerprint())) &&
-                        UIHelper.sameDay(message.getTimeSent(), this.getTimeSent()) &&
-                        this.getReadByMarkers().equals(message.getReadByMarkers()) &&
-                        !this.conversation.getJid().asBareJid().equals(Config.BUG_REPORTS)
-                );
-    }
-
-    private static boolean isStatusMergeable(int a, int b) {
-        return a == b || (
-                (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_UNSEND)
-                        || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_SEND)
-                        || (a == Message.STATUS_SEND_RECEIVED && b == Message.STATUS_WAITING)
-                        || (a == Message.STATUS_SEND && b == Message.STATUS_UNSEND)
-                        || (a == Message.STATUS_SEND && b == Message.STATUS_WAITING)
-        );
-    }
-
-    private static boolean isEncryptionMergeable(final int a, final int b) {
-        return a == b
-                && Arrays.asList(ENCRYPTION_NONE, ENCRYPTION_DECRYPTED, ENCRYPTION_AXOLOTL)
-                        .contains(a);
-    }
-
     public void setCounterparts(List<MucOptions.User> counterparts) {
         this.counterparts = counterparts;
     }
@@ -702,7 +682,9 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
 
     @Override
     public int getAvatarBackgroundColor() {
-        if (type == Message.TYPE_STATUS && getCounterparts() != null && getCounterparts().size() > 1) {
+        if (type == Message.TYPE_STATUS
+                && getCounterparts() != null
+                && getCounterparts().size() > 1) {
             return Color.TRANSPARENT;
         } else {
             return UIHelper.getColorForName(UIHelper.getMessageDisplayName(this));
@@ -730,10 +712,6 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         return this.reactions;
     }
 
-    public boolean isReactionsEmpty() {
-        return this.reactions.isEmpty();
-    }
-
     public Reaction.Aggregated getAggregatedReactions() {
         return Reaction.aggregated(this.reactions);
     }
@@ -742,75 +720,28 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         this.reactions = reactions;
     }
 
-    public static class MergeSeparator {
-    }
-
-    public SpannableStringBuilder getMergedBody() {
-        SpannableStringBuilder body = new SpannableStringBuilder(MessageUtils.filterLtrRtl(this.body).trim());
-        Message current = this;
-        while (current.mergeable(current.next())) {
-            current = current.next();
-            if (current == null) {
-                break;
-            }
-            body.append("\n\n");
-            body.setSpan(new MergeSeparator(), body.length() - 2, body.length(),
-                    SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE);
-            body.append(MessageUtils.filterLtrRtl(current.getBody()).trim());
-        }
-        return body;
-    }
-
     public boolean hasMeCommand() {
         return this.body.trim().startsWith(ME_COMMAND);
     }
 
-    public int getMergedStatus() {
-        int status = this.status;
-        Message current = this;
-        while (current.mergeable(current.next())) {
-            current = current.next();
-            if (current == null) {
-                break;
-            }
-            status = current.status;
-        }
-        return status;
-    }
-
-    public long getMergedTimeSent() {
-        long time = this.timeSent;
-        Message current = this;
-        while (current.mergeable(current.next())) {
-            current = current.next();
-            if (current == null) {
-                break;
-            }
-            time = current.timeSent;
-        }
-        return time;
-    }
-
-    public boolean wasMergedIntoPrevious() {
-        Message prev = this.prev();
-        return prev != null && prev.mergeable(this);
-    }
-
     public boolean trusted() {
         Contact contact = this.getContact();
-        return status > STATUS_RECEIVED || (contact != null && (contact.showInContactList() || contact.isSelf()));
+        return status > STATUS_RECEIVED
+                || (contact != null && (contact.showInContactList() || contact.isSelf()));
     }
 
     public boolean fixCounterpart() {
         final Presences presences = conversation.getContact().getPresences();
         if (counterpart != null && presences.has(Strings.nullToEmpty(counterpart.getResource()))) {
             return true;
-        } else if (presences.size() >= 1) {
-            counterpart = PresenceSelector.getNextCounterpart(getContact(), presences.toResourceArray()[0]);
-            return true;
-        } else {
+        } else if (presences.isEmpty()) {
             counterpart = null;
             return false;
+        } else {
+            counterpart =
+                    PresenceSelector.getNextCounterpart(
+                            getContact(), presences.toResourceArray()[0]);
+            return true;
         }
     }
 
@@ -930,7 +861,6 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         return type == TYPE_FILE || type == TYPE_IMAGE || type == TYPE_PRIVATE_FILE;
     }
 
-
     public boolean isTypeText() {
         return type == TYPE_TEXT || type == TYPE_PRIVATE;
     }
@@ -965,7 +895,10 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
 
     public boolean isTrusted() {
         final AxolotlService axolotlService = conversation.getAccount().getAxolotlService();
-        final FingerprintStatus s = axolotlService != null ? axolotlService.getFingerprintTrust(axolotlFingerprint) : null;
+        final FingerprintStatus s =
+                axolotlService != null
+                        ? axolotlService.getFingerprintTrust(axolotlFingerprint)
+                        : null;
         return s != null && s.isTrusted();
     }
 
@@ -980,17 +913,18 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
     }
 
     private int getNextEncryption() {
-        if (this.conversation instanceof Conversation) {
-            Conversation conversation = (Conversation) this.conversation;
+        if (this.conversation instanceof Conversation c) {
             for (Message iterator = this.next(); iterator != null; iterator = iterator.next()) {
                 if (iterator.isCarbon() || iterator.getStatus() == STATUS_RECEIVED) {
                     continue;
                 }
                 return iterator.getEncryption();
             }
-            return conversation.getNextEncryption();
+            return c.getNextEncryption();
         } else {
-            throw new AssertionError("This should never be called since isInValidSession should be disabled for stubs");
+            throw new AssertionError(
+                    "This should never be called since isInValidSession should be disabled for"
+                            + " stubs");
         }
     }
 
@@ -998,9 +932,10 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         int pastEncryption = getCleanedEncryption(this.getPreviousEncryption());
         int futureEncryption = getCleanedEncryption(this.getNextEncryption());
 
-        boolean inUnencryptedSession = pastEncryption == ENCRYPTION_NONE
-                || futureEncryption == ENCRYPTION_NONE
-                || pastEncryption != futureEncryption;
+        boolean inUnencryptedSession =
+                pastEncryption == ENCRYPTION_NONE
+                        || futureEncryption == ENCRYPTION_NONE
+                        || pastEncryption != futureEncryption;
 
         return inUnencryptedSession || getCleanedEncryption(this.getEncryption()) == pastEncryption;
     }
@@ -1009,7 +944,8 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         if (encryption == ENCRYPTION_DECRYPTED || encryption == ENCRYPTION_DECRYPTION_FAILED) {
             return ENCRYPTION_PGP;
         }
-        if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE || encryption == ENCRYPTION_AXOLOTL_FAILED) {
+        if (encryption == ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE
+                || encryption == ENCRYPTION_AXOLOTL_FAILED) {
             return ENCRYPTION_AXOLOTL;
         }
         return encryption;
@@ -1047,7 +983,11 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         return configurePrivateMessage(conversation, message, counterpart, false);
     }
 
-    private static boolean configurePrivateMessage(final Conversation conversation, final Message message, final Jid counterpart, final boolean isFile) {
+    private static boolean configurePrivateMessage(
+            final Conversation conversation,
+            final Message message,
+            final Jid counterpart,
+            final boolean isFile) {
         if (counterpart == null) {
             return false;
         }

src/main/java/eu/siacs/conversations/services/XmppConnectionService.java 🔗

@@ -5918,23 +5918,11 @@ public class XmppConnectionService extends Service {
     }
 
     public void resendFailedMessages(final Message message) {
-        final Collection<Message> messages = new ArrayList<>();
-        Message current = message;
-        while (current.getStatus() == Message.STATUS_SEND_FAILED) {
-            messages.add(current);
-            if (current.mergeable(current.next())) {
-                current = current.next();
-            } else {
-                break;
-            }
-        }
-        for (final Message msg : messages) {
-            msg.setTime(System.currentTimeMillis());
-            markMessage(msg, Message.STATUS_WAITING);
-            this.resendMessage(msg, false);
-        }
-        if (message.getConversation() instanceof Conversation) {
-            ((Conversation) message.getConversation()).sort();
+        message.setTime(System.currentTimeMillis());
+        markMessage(message, Message.STATUS_WAITING);
+        this.resendMessage(message, false);
+        if (message.getConversation() instanceof Conversation c) {
+            c.sort();
         }
         updateConversationUi();
     }

src/main/java/eu/siacs/conversations/ui/ConversationFragment.java 🔗

@@ -31,6 +31,7 @@ import android.os.SystemClock;
 import android.preference.PreferenceManager;
 import android.provider.MediaStore;
 import android.text.Editable;
+import android.text.SpannableStringBuilder;
 import android.text.TextUtils;
 import android.util.Log;
 import android.view.ContextMenu;
@@ -55,18 +56,15 @@ import android.widget.ListView;
 import android.widget.PopupMenu;
 import android.widget.TextView.OnEditorActionListener;
 import android.widget.Toast;
-
 import androidx.annotation.IdRes;
 import androidx.annotation.NonNull;
 import androidx.annotation.StringRes;
 import androidx.core.view.inputmethod.InputConnectionCompat;
 import androidx.core.view.inputmethod.InputContentInfoCompat;
 import androidx.databinding.DataBindingUtil;
-
 import com.google.android.material.dialog.MaterialAlertDialogBuilder;
 import com.google.common.base.Optional;
 import com.google.common.collect.ImmutableList;
-
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.crypto.axolotl.AxolotlService;
@@ -128,9 +126,6 @@ import eu.siacs.conversations.xmpp.jingle.JingleFileTransferConnection;
 import eu.siacs.conversations.xmpp.jingle.Media;
 import eu.siacs.conversations.xmpp.jingle.OngoingRtpSession;
 import eu.siacs.conversations.xmpp.jingle.RtpCapability;
-import eu.siacs.conversations.xmpp.jingle.RtpEndUserState;
-
-
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -329,7 +324,10 @@ public class ConversationFragment extends XmppFragment
                                                             } catch (IllegalStateException e) {
                                                                 Log.d(
                                                                         Config.LOGTAG,
-                                                                        "caught illegal state exception while updating status messages");
+                                                                        "caught illegal state"
+                                                                                + " exception while"
+                                                                                + " updating status"
+                                                                                + " messages");
                                                             }
                                                             messageListAdapter
                                                                     .notifyDataSetChanged();
@@ -692,14 +690,6 @@ public class ConversationFragment extends XmppFragment
         for (int i = 0; i < messages.size(); ++i) {
             if (uuid.equals(messages.get(i).getUuid())) {
                 return i;
-            } else {
-                Message next = messages.get(i);
-                while (next != null && next.wasMergedIntoPrevious()) {
-                    if (uuid.equals(next.getUuid())) {
-                        return i;
-                    }
-                    next = next.next();
-                }
             }
         }
         return -1;
@@ -1045,13 +1035,15 @@ public class ConversationFragment extends XmppFragment
                         } else if (attachment.getType() == Attachment.Type.IMAGE) {
                             Log.d(
                                     Config.LOGTAG,
-                                    "ConversationsActivity.commitAttachments() - attaching image to conversations. CHOOSE_IMAGE");
+                                    "ConversationsActivity.commitAttachments() - attaching image to"
+                                            + " conversations. CHOOSE_IMAGE");
                             attachImageToConversation(
                                     conversation, attachment.getUri(), attachment.getMime());
                         } else {
                             Log.d(
                                     Config.LOGTAG,
-                                    "ConversationsActivity.commitAttachments() - attaching file to conversations. CHOOSE_FILE/RECORD_VOICE/RECORD_VIDEO");
+                                    "ConversationsActivity.commitAttachments() - attaching file to"
+                                        + " conversations. CHOOSE_FILE/RECORD_VOICE/RECORD_VIDEO");
                             attachFileToConversation(
                                     conversation, attachment.getUri(), attachment.getMime());
                         }
@@ -1277,13 +1269,9 @@ public class ConversationFragment extends XmppFragment
         }
     }
 
-    private void populateContextMenu(ContextMenu menu) {
+    private void populateContextMenu(final ContextMenu menu) {
         final Message m = this.selectedMessage;
         final Transferable t = m.getTransferable();
-        Message relevantForCorrection = m;
-        while (relevantForCorrection.mergeable(relevantForCorrection.next())) {
-            relevantForCorrection = relevantForCorrection.next();
-        }
         if (m.getType() != Message.TYPE_STATUS && m.getType() != Message.TYPE_RTP_SESSION) {
 
             if (m.getEncryption() == Message.ENCRYPTION_AXOLOTL_NOT_FOR_THIS_DEVICE
@@ -1329,7 +1317,8 @@ public class ConversationFragment extends XmppFragment
                             && m.getErrorMessage() != null
                             && !Message.ERROR_MESSAGE_CANCELLED.equals(m.getErrorMessage());
             final Conversational conversational = m.getConversation();
-            if (m.getStatus() == Message.STATUS_RECEIVED && conversational instanceof Conversation c) {
+            if (m.getStatus() == Message.STATUS_RECEIVED
+                    && conversational instanceof Conversation c) {
                 final XmppConnection connection = c.getAccount().getXmppConnection();
                 if (c.isWithStranger()
                         && m.getServerMsgId() != null
@@ -1357,7 +1346,8 @@ public class ConversationFragment extends XmppFragment
                     && t == null) {
                 copyMessage.setVisible(true);
                 quoteMessage.setVisible(!showError && !MessageUtils.prepareQuote(m).isEmpty());
-                final String scheme = ShareUtil.getLinkScheme(m.getMergedBody());
+                final String scheme =
+                        ShareUtil.getLinkScheme(new SpannableStringBuilder(m.getBody()));
                 if ("xmpp".equals(scheme)) {
                     copyLink.setTitle(R.string.copy_jabber_id);
                     copyLink.setVisible(true);
@@ -1369,9 +1359,9 @@ public class ConversationFragment extends XmppFragment
                 retryDecryption.setVisible(true);
             }
             if (!showError
-                    && relevantForCorrection.getType() == Message.TYPE_TEXT
+                    && m.getType() == Message.TYPE_TEXT
                     && !m.isGeoUri()
-                    && relevantForCorrection.isLastCorrectableMessage()
+                    && m.isLastCorrectableMessage()
                     && m.getConversation() instanceof Conversation) {
                 correctMessage.setVisible(true);
             }
@@ -1672,7 +1662,11 @@ public class ConversationFragment extends XmppFragment
     }
 
     private void triggerRtpSession(final Account account, final Jid with, final String action) {
-        CallIntegrationConnectionService.placeCall(activity.xmppConnectionService, account,with,RtpSessionActivity.actionToMedia(action));
+        CallIntegrationConnectionService.placeCall(
+                activity.xmppConnectionService,
+                account,
+                with,
+                RtpSessionActivity.actionToMedia(action));
     }
 
     private void handleAttachmentSelection(MenuItem item) {
@@ -1946,7 +1940,8 @@ public class ConversationFragment extends XmppFragment
 
     @SuppressLint("InflateParams")
     protected void clearHistoryDialog(final Conversation conversation) {
-        final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireActivity());
+        final MaterialAlertDialogBuilder builder =
+                new MaterialAlertDialogBuilder(requireActivity());
         builder.setTitle(R.string.clear_conversation_history);
         final View dialogView =
                 requireActivity().getLayoutInflater().inflate(R.layout.dialog_clear_history, null);
@@ -1970,7 +1965,8 @@ public class ConversationFragment extends XmppFragment
     }
 
     protected void muteConversationDialog(final Conversation conversation) {
-        final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireActivity());
+        final MaterialAlertDialogBuilder builder =
+                new MaterialAlertDialogBuilder(requireActivity());
         builder.setTitle(R.string.disable_notifications);
         final int[] durations = getResources().getIntArray(R.array.mute_options_durations);
         final CharSequence[] labels = new CharSequence[durations.length];
@@ -2002,7 +1998,9 @@ public class ConversationFragment extends XmppFragment
     private boolean hasPermissions(int requestCode, List<String> permissions) {
         final List<String> missingPermissions = new ArrayList<>();
         for (String permission : permissions) {
-            if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU || Config.ONLY_INTERNAL_STORAGE) && permission.equals(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+            if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
+                            || Config.ONLY_INTERNAL_STORAGE)
+                    && permission.equals(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
                 continue;
             }
             if (activity.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
@@ -2012,9 +2010,7 @@ public class ConversationFragment extends XmppFragment
         if (missingPermissions.size() == 0) {
             return true;
         } else {
-            requestPermissions(
-                    missingPermissions.toArray(new String[0]),
-                    requestCode);
+            requestPermissions(missingPermissions.toArray(new String[0]), requestCode);
             return false;
         }
     }
@@ -2119,9 +2115,6 @@ public class ConversationFragment extends XmppFragment
                     }
                 }
                 if (message != null) {
-                    while (message.next() != null && message.next().wasMergedIntoPrevious()) {
-                        message = message.next();
-                    }
                     return message.getUuid();
                 }
             }
@@ -2140,12 +2133,15 @@ public class ConversationFragment extends XmppFragment
     }
 
     private void addReaction(final Message message) {
-        activity.addReaction(message, reactions -> {
-            if (activity.xmppConnectionService.sendReactions(message, reactions)) {
-                return;
-            }
-            Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG).show();
-        });
+        activity.addReaction(
+                message,
+                reactions -> {
+                    if (activity.xmppConnectionService.sendReactions(message, reactions)) {
+                        return;
+                    }
+                    Toast.makeText(activity, R.string.could_not_add_reaction, Toast.LENGTH_LONG)
+                            .show();
+                });
     }
 
     private void reportMessage(final Message message) {
@@ -2153,7 +2149,8 @@ public class ConversationFragment extends XmppFragment
     }
 
     private void showErrorMessage(final Message message) {
-        final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireActivity());
+        final MaterialAlertDialogBuilder builder =
+                new MaterialAlertDialogBuilder(requireActivity());
         builder.setTitle(R.string.error_message);
         final String errorMessage = message.getErrorMessage();
         final String[] errorMessageParts =
@@ -2180,7 +2177,8 @@ public class ConversationFragment extends XmppFragment
     }
 
     private void deleteFile(final Message message) {
-        final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireActivity());
+        final MaterialAlertDialogBuilder builder =
+                new MaterialAlertDialogBuilder(requireActivity());
         builder.setNegativeButton(R.string.cancel, null);
         builder.setTitle(R.string.delete_file_dialog);
         builder.setMessage(R.string.delete_file_dialog_msg);
@@ -2278,10 +2276,7 @@ public class ConversationFragment extends XmppFragment
         updateEditablity();
     }
 
-    private void correctMessage(Message message) {
-        while (message.mergeable(message.next())) {
-            message = message.next();
-        }
+    private void correctMessage(final Message message) {
         this.conversation.setCorrectingMessage(message);
         final Editable editable = binding.textinput.getText();
         this.conversation.setDraftMessage(editable.toString());
@@ -2395,7 +2390,8 @@ public class ConversationFragment extends XmppFragment
             final String uuid = pendingConversationsUuid.pop();
             Log.d(
                     Config.LOGTAG,
-                    "ConversationFragment.onStart() - activity was bound but no conversation loaded. uuid="
+                    "ConversationFragment.onStart() - activity was bound but no conversation"
+                            + " loaded. uuid="
                             + uuid);
             if (uuid != null) {
                 findAndReInitByUuidOrArchive(uuid);
@@ -2710,7 +2706,10 @@ public class ConversationFragment extends XmppFragment
                     R.string.enable,
                     this.mEnableAccountListener);
         } else if (account.getStatus() == Account.State.LOGGED_OUT) {
-            showSnackbar(R.string.this_account_is_logged_out,R.string.log_in,this.mEnableAccountListener);
+            showSnackbar(
+                    R.string.this_account_is_logged_out,
+                    R.string.log_in,
+                    this.mEnableAccountListener);
         } else if (conversation.isBlocked()) {
             showSnackbar(R.string.contact_blocked, R.string.unblock, this.mUnblockClickListener);
         } else if (contact != null
@@ -2772,7 +2771,8 @@ public class ConversationFragment extends XmppFragment
                     showSnackbar(R.string.conference_kicked, R.string.join, joinMuc);
                     break;
                 case TECHNICAL_PROBLEMS:
-                    showSnackbar(R.string.conference_technical_problems, R.string.try_again, joinMuc);
+                    showSnackbar(
+                            R.string.conference_technical_problems, R.string.try_again, joinMuc);
                     break;
                 case UNKNOWN:
                     showSnackbar(R.string.conference_unknown_error, R.string.try_again, joinMuc);
@@ -2942,12 +2942,14 @@ public class ConversationFragment extends XmppFragment
             status = Presence.Status.OFFLINE;
         }
         this.binding.textSendButton.setTag(action);
-        this.binding.textSendButton.setIconResource(SendButtonTool.getSendButtonImageResource(action));
-        this.binding.textSendButton.setIconTint(ColorStateList.valueOf(SendButtonTool.getSendButtonColor(this.binding.textSendButton, status)));
+        this.binding.textSendButton.setIconResource(
+                SendButtonTool.getSendButtonImageResource(action));
+        this.binding.textSendButton.setIconTint(
+                ColorStateList.valueOf(
+                        SendButtonTool.getSendButtonColor(this.binding.textSendButton, status)));
         // TODO send button color
         final Activity activity = getActivity();
-        if (activity != null) {
-        }
+        if (activity != null) {}
     }
 
     protected void updateStatusMessages() {
@@ -3010,7 +3012,7 @@ public class ConversationFragment extends XmppFragment
                         if (!ReadByMarker.contains(marker, addedMarkers)) {
                             addedMarkers.add(
                                     marker); // may be put outside this condition. set should do
-                                             // dedup anyway
+                            // dedup anyway
                             MucOptions.User user = mucOptions.findUser(marker);
                             if (user != null && !users.contains(user)) {
                                 shownMarkers.add(user);
@@ -3269,8 +3271,10 @@ public class ConversationFragment extends XmppFragment
                         });
     }
 
-    public void showNoPGPKeyDialog(final boolean plural, final DialogInterface.OnClickListener listener) {
-        final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireActivity());
+    public void showNoPGPKeyDialog(
+            final boolean plural, final DialogInterface.OnClickListener listener) {
+        final MaterialAlertDialogBuilder builder =
+                new MaterialAlertDialogBuilder(requireActivity());
         if (plural) {
             builder.setTitle(getString(R.string.no_pgp_keys));
             builder.setMessage(getText(R.string.contacts_have_no_pgp_keys));
@@ -3439,7 +3443,13 @@ public class ConversationFragment extends XmppFragment
         try {
             getActivity()
                     .startIntentSenderForResult(
-                            pendingIntent.getIntentSender(), requestCode, null, 0, 0, 0, Compatibility.pgpStartIntentSenderOptions());
+                            pendingIntent.getIntentSender(),
+                            requestCode,
+                            null,
+                            0,
+                            0,
+                            0,
+                            Compatibility.pgpStartIntentSenderOptions());
         } catch (final SendIntentException ignored) {
         }
     }

src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java 🔗

@@ -15,18 +15,15 @@ import android.text.style.ForegroundColorSpan;
 import android.text.style.RelativeSizeSpan;
 import android.text.style.StyleSpan;
 import android.util.DisplayMetrics;
-import android.util.Log;
 import android.view.View;
 import android.view.ViewGroup;
 import android.view.WindowManager;
 import android.widget.ArrayAdapter;
-import android.widget.Button;
 import android.widget.ImageView;
 import android.widget.LinearLayout;
 import android.widget.RelativeLayout;
 import android.widget.TextView;
 import android.widget.Toast;
-
 import androidx.annotation.AttrRes;
 import androidx.annotation.ColorInt;
 import androidx.annotation.DrawableRes;
@@ -35,10 +32,6 @@ import androidx.annotation.Nullable;
 import androidx.core.app.ActivityCompat;
 import androidx.core.content.ContextCompat;
 import androidx.core.widget.ImageViewCompat;
-import androidx.databinding.DataBindingUtil;
-import androidx.emoji2.emojipicker.EmojiViewItem;
-import androidx.emoji2.emojipicker.RecentEmojiProvider;
-
 import com.google.android.material.button.MaterialButton;
 import com.google.android.material.chip.ChipGroup;
 import com.google.android.material.color.MaterialColors;
@@ -47,14 +40,10 @@ import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.collect.Collections2;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Lists;
-
 import eu.siacs.conversations.AppSettings;
 import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
-import eu.siacs.conversations.databinding.DialogAddReactionBinding;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Conversation;
 import eu.siacs.conversations.entities.Conversational;
@@ -89,14 +78,11 @@ import eu.siacs.conversations.utils.TimeFrameUtils;
 import eu.siacs.conversations.utils.UIHelper;
 import eu.siacs.conversations.xmpp.Jid;
 import eu.siacs.conversations.xmpp.mam.MamReference;
-import kotlin.coroutines.Continuation;
-
 import java.net.URI;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.List;
 import java.util.Locale;
-import java.util.function.Consumer;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -192,7 +178,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             final Message message,
             final int type,
             final BubbleColor bubbleColor) {
-        final int mergedStatus = message.getMergedStatus();
+        final int mergedStatus = message.getStatus();
         final boolean error;
         if (viewHolder.indicatorReceived != null) {
             viewHolder.indicatorReceived.setVisibility(View.GONE);
@@ -285,7 +271,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         }
 
         final String formattedTime =
-                UIHelper.readableTimeDifferenceFull(getContext(), message.getMergedTimeSent());
+                UIHelper.readableTimeDifferenceFull(getContext(), message.getTimeSent());
         final String bodyLanguage = message.getBodyLanguage();
         final ImmutableList.Builder<String> timeInfoBuilder = new ImmutableList.Builder<>();
         if (message.getStatus() <= Message.STATUS_RECEIVED) {
@@ -328,8 +314,8 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             case Message.STATUS_WAITING -> R.drawable.ic_more_horiz_24dp;
             case Message.STATUS_UNSEND -> transferable == null ? null : R.drawable.ic_upload_24dp;
             case Message.STATUS_SEND -> R.drawable.ic_done_24dp;
-            case Message.STATUS_SEND_RECEIVED, Message.STATUS_SEND_DISPLAYED -> R.drawable
-                    .ic_done_all_24dp;
+            case Message.STATUS_SEND_RECEIVED, Message.STATUS_SEND_DISPLAYED ->
+                    R.drawable.ic_done_all_24dp;
             case Message.STATUS_SEND_FAILED -> {
                 final String errorMessage = message.getErrorMessage();
                 if (Message.ERROR_MESSAGE_CANCELLED.equals(errorMessage)) {
@@ -486,21 +472,17 @@ public class MessageAdapter extends ArrayAdapter<Message> {
 
         if (message.getBody() != null) {
             final String nick = UIHelper.getMessageDisplayName(message);
-            SpannableStringBuilder body = message.getMergedBody();
-            boolean hasMeCommand = message.hasMeCommand();
-            if (hasMeCommand) {
-                body = body.replace(0, Message.ME_COMMAND.length(), nick + " ");
-            }
-            if (body.length() > Config.MAX_DISPLAY_MESSAGE_CHARS) {
-                body = new SpannableStringBuilder(body, 0, Config.MAX_DISPLAY_MESSAGE_CHARS);
-                body.append("\u2026");
+            final boolean hasMeCommand = message.hasMeCommand();
+            final var rawBody = message.getBody();
+            final SpannableStringBuilder body;
+            if (rawBody.length() > Config.MAX_DISPLAY_MESSAGE_CHARS) {
+                body = new SpannableStringBuilder(rawBody, 0, Config.MAX_DISPLAY_MESSAGE_CHARS);
+                body.append("…");
+            } else {
+                body = new SpannableStringBuilder(rawBody);
             }
-            Message.MergeSeparator[] mergeSeparators =
-                    body.getSpans(0, body.length(), Message.MergeSeparator.class);
-            for (Message.MergeSeparator mergeSeparator : mergeSeparators) {
-                int start = body.getSpanStart(mergeSeparator);
-                int end = body.getSpanEnd(mergeSeparator);
-                body.setSpan(new DividerSpan(true), start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+            if (hasMeCommand) {
+                body.replace(0, Message.ME_COMMAND.length(), String.format("%s ", nick));
             }
             boolean startsWithQuote = handleTextQuotes(viewHolder.messageBody, body, bubbleColor);
             if (!message.isPrivateMessage()) {
@@ -1211,12 +1193,15 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             final View view, final BubbleColor bubbleColor) {
         final @AttrRes int colorAttributeResId =
                 switch (bubbleColor) {
-                    case SURFACE -> Activities.isNightMode(view.getContext())
-                            ? com.google.android.material.R.attr.colorSurfaceContainerHigh
-                            : com.google.android.material.R.attr.colorSurfaceContainerLow;
-                    case SURFACE_HIGH -> Activities.isNightMode(view.getContext())
-                            ? com.google.android.material.R.attr.colorSurfaceContainerHighest
-                            : com.google.android.material.R.attr.colorSurfaceContainerHigh;
+                    case SURFACE ->
+                            Activities.isNightMode(view.getContext())
+                                    ? com.google.android.material.R.attr.colorSurfaceContainerHigh
+                                    : com.google.android.material.R.attr.colorSurfaceContainerLow;
+                    case SURFACE_HIGH ->
+                            Activities.isNightMode(view.getContext())
+                                    ? com.google.android.material.R.attr
+                                            .colorSurfaceContainerHighest
+                                    : com.google.android.material.R.attr.colorSurfaceContainerHigh;
                     case PRIMARY -> com.google.android.material.R.attr.colorPrimaryContainer;
                     case SECONDARY -> com.google.android.material.R.attr.colorSecondaryContainer;
                     case TERTIARY -> com.google.android.material.R.attr.colorTertiaryContainer;

src/main/java/eu/siacs/conversations/ui/util/ShareUtil.java 🔗

@@ -34,7 +34,6 @@ import android.content.Intent;
 import android.net.Uri;
 import android.text.SpannableStringBuilder;
 import android.widget.Toast;
-
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.entities.DownloadableFile;
 import eu.siacs.conversations.entities.Message;
@@ -46,66 +45,82 @@ import eu.siacs.conversations.xmpp.Jid;
 
 public class ShareUtil {
 
-	public static void share(XmppActivity activity, Message message) {
-		Intent shareIntent = new Intent();
-		shareIntent.setAction(Intent.ACTION_SEND);
-		if (message.isGeoUri()) {
-			shareIntent.putExtra(Intent.EXTRA_TEXT, message.getBody());
-			shareIntent.setType("text/plain");
-		} else if (!message.isFileOrImage()) {
-			shareIntent.putExtra(Intent.EXTRA_TEXT, message.getMergedBody().toString());
-			shareIntent.setType("text/plain");
-			shareIntent.putExtra(ConversationsActivity.EXTRA_AS_QUOTE, message.getStatus() == Message.STATUS_RECEIVED);
-		} else {
-			final DownloadableFile file = activity.xmppConnectionService.getFileBackend().getFile(message);
-			try {
-				shareIntent.putExtra(Intent.EXTRA_STREAM, FileBackend.getUriForFile(activity, file));
-			} catch (SecurityException e) {
-				Toast.makeText(activity, activity.getString(R.string.no_permission_to_access_x, file.getAbsolutePath()), Toast.LENGTH_SHORT).show();
-				return;
-			}
-			shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
-			String mime = message.getMimeType();
-			if (mime == null) {
-				mime = "*/*";
-			}
-			shareIntent.setType(mime);
-		}
-		try {
-			activity.startActivity(Intent.createChooser(shareIntent, activity.getText(R.string.share_with)));
-		} catch (ActivityNotFoundException e) {
-			//This should happen only on faulty androids because normally chooser is always available
-			Toast.makeText(activity, R.string.no_application_found_to_open_file, Toast.LENGTH_SHORT).show();
-		}
-	}
+    public static void share(XmppActivity activity, Message message) {
+        Intent shareIntent = new Intent();
+        shareIntent.setAction(Intent.ACTION_SEND);
+        if (message.isGeoUri()) {
+            shareIntent.putExtra(Intent.EXTRA_TEXT, message.getBody());
+            shareIntent.setType("text/plain");
+        } else if (!message.isFileOrImage()) {
+            shareIntent.putExtra(Intent.EXTRA_TEXT, message.getBody());
+            shareIntent.setType("text/plain");
+            shareIntent.putExtra(
+                    ConversationsActivity.EXTRA_AS_QUOTE,
+                    message.getStatus() == Message.STATUS_RECEIVED);
+        } else {
+            final DownloadableFile file =
+                    activity.xmppConnectionService.getFileBackend().getFile(message);
+            try {
+                shareIntent.putExtra(
+                        Intent.EXTRA_STREAM, FileBackend.getUriForFile(activity, file));
+            } catch (SecurityException e) {
+                Toast.makeText(
+                                activity,
+                                activity.getString(
+                                        R.string.no_permission_to_access_x, file.getAbsolutePath()),
+                                Toast.LENGTH_SHORT)
+                        .show();
+                return;
+            }
+            shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+            String mime = message.getMimeType();
+            if (mime == null) {
+                mime = "*/*";
+            }
+            shareIntent.setType(mime);
+        }
+        try {
+            activity.startActivity(
+                    Intent.createChooser(shareIntent, activity.getText(R.string.share_with)));
+        } catch (ActivityNotFoundException e) {
+            // This should happen only on faulty androids because normally chooser is always
+            // available
+            Toast.makeText(activity, R.string.no_application_found_to_open_file, Toast.LENGTH_SHORT)
+                    .show();
+        }
+    }
 
-	public static void copyToClipboard(XmppActivity activity, Message message) {
-		if (activity.copyTextToClipboard(message.getMergedBody().toString(), R.string.message)) {
-			Toast.makeText(activity, R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT).show();
-		}
-	}
+    public static void copyToClipboard(XmppActivity activity, Message message) {
+        if (activity.copyTextToClipboard(message.getBody(), R.string.message)) {
+            Toast.makeText(activity, R.string.message_copied_to_clipboard, Toast.LENGTH_SHORT)
+                    .show();
+        }
+    }
 
-	public static void copyUrlToClipboard(XmppActivity activity, Message message) {
-		final String url;
-		final int resId;
-		if (message.isGeoUri()) {
-			resId = R.string.location;
-			url = message.getBody();
-		} else if (message.hasFileOnRemoteHost()) {
-			resId = R.string.file_url;
-			url = message.getFileParams().url;
-		} else {
-			final Message.FileParams fileParams = message.getFileParams();
-			url = (fileParams != null && fileParams.url != null) ? fileParams.url : message.getBody().trim();
-			resId = R.string.file_url;
-		}
-		if (activity.copyTextToClipboard(url, resId)) {
-			Toast.makeText(activity, R.string.url_copied_to_clipboard, Toast.LENGTH_SHORT).show();
-		}
-	}
+    public static void copyUrlToClipboard(XmppActivity activity, Message message) {
+        final String url;
+        final int resId;
+        if (message.isGeoUri()) {
+            resId = R.string.location;
+            url = message.getBody();
+        } else if (message.hasFileOnRemoteHost()) {
+            resId = R.string.file_url;
+            url = message.getFileParams().url;
+        } else {
+            final Message.FileParams fileParams = message.getFileParams();
+            url =
+                    (fileParams != null && fileParams.url != null)
+                            ? fileParams.url
+                            : message.getBody().trim();
+            resId = R.string.file_url;
+        }
+        if (activity.copyTextToClipboard(url, resId)) {
+            Toast.makeText(activity, R.string.url_copied_to_clipboard, Toast.LENGTH_SHORT).show();
+        }
+    }
 
     public static void copyLinkToClipboard(final XmppActivity activity, final Message message) {
-        final SpannableStringBuilder body = message.getMergedBody();
+        final SpannableStringBuilder body = new SpannableStringBuilder(message.getBody());
         for (final String url : MyLinkify.extractLinks(body)) {
             final Uri uri = Uri.parse(url);
             if ("xmpp".equals(uri.getScheme())) {

src/main/java/eu/siacs/conversations/utils/MessageUtils.java 🔗

@@ -30,16 +30,14 @@
 package eu.siacs.conversations.utils;
 
 import com.google.common.base.Strings;
-
-import java.net.URI;
-import java.net.URISyntaxException;
-import java.util.regex.Pattern;
-
 import eu.siacs.conversations.entities.Conversational;
 import eu.siacs.conversations.entities.Message;
 import eu.siacs.conversations.http.AesGcmURL;
 import eu.siacs.conversations.http.URL;
 import eu.siacs.conversations.ui.util.QuoteHelper;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.regex.Pattern;
 
 public class MessageUtils {
 
@@ -47,7 +45,7 @@ public class MessageUtils {
 
     public static final String EMPTY_STRING = "";
 
-    public static String prepareQuote(Message message) {
+    public static String prepareQuote(final Message message) {
         final StringBuilder builder = new StringBuilder();
         final String body;
         if (message.hasMeCommand()) {
@@ -63,7 +61,7 @@ public class MessageUtils {
             }
             body = nick + " " + message.getBody().substring(Message.ME_COMMAND.length());
         } else {
-            body = message.getMergedBody().toString();
+            body = message.getBody();
         }
         for (String line : body.split("\n")) {
             if (!(line.length() <= 0) && QuoteHelper.isNestedTooDeeply(line)) {
@@ -100,8 +98,12 @@ public class MessageUtils {
         final String protocol = uri.getScheme();
         final boolean encrypted = ref != null && AesGcmURL.IV_KEY.matcher(ref).matches();
         final boolean followedByDataUri = lines.length == 2 && lines[1].startsWith("data:");
-        final boolean validAesGcm = AesGcmURL.PROTOCOL_NAME.equalsIgnoreCase(protocol) && encrypted && (lines.length == 1 || followedByDataUri);
-        final boolean validProtocol = "http".equalsIgnoreCase(protocol) || "https".equalsIgnoreCase(protocol);
+        final boolean validAesGcm =
+                AesGcmURL.PROTOCOL_NAME.equalsIgnoreCase(protocol)
+                        && encrypted
+                        && (lines.length == 1 || followedByDataUri);
+        final boolean validProtocol =
+                "http".equalsIgnoreCase(protocol) || "https".equalsIgnoreCase(protocol);
         final boolean validOob = validProtocol && (oob || encrypted) && lines.length == 1;
         return validAesGcm || validOob;
     }
@@ -111,6 +113,10 @@ public class MessageUtils {
     }
 
     public static boolean unInitiatedButKnownSize(Message message) {
-        return message.getType() == Message.TYPE_TEXT && message.getTransferable() == null && message.isOOb() && message.getFileParams().size != null && message.getFileParams().url != null;
+        return message.getType() == Message.TYPE_TEXT
+                && message.getTransferable() == null
+                && message.isOOb()
+                && message.getFileParams().size != null
+                && message.getFileParams().url != null;
     }
 }

src/main/java/eu/siacs/conversations/utils/StylingHelper.java 🔗

@@ -29,7 +29,6 @@
 
 package eu.siacs.conversations.utils;
 
-import android.content.Context;
 import android.graphics.Color;
 import android.graphics.Typeface;
 import android.text.Editable;
@@ -45,138 +44,149 @@ import android.text.style.StyleSpan;
 import android.text.style.TypefaceSpan;
 import android.widget.EditText;
 import android.widget.TextView;
-
 import androidx.annotation.ColorInt;
-import androidx.core.content.ContextCompat;
-
 import com.google.android.material.color.MaterialColors;
-
+import eu.siacs.conversations.ui.text.QuoteSpan;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 
-import eu.siacs.conversations.R;
-import eu.siacs.conversations.entities.Message;
-import eu.siacs.conversations.ui.text.QuoteSpan;
-
 public class StylingHelper {
 
-	private static final List<? extends Class<? extends ParcelableSpan>> SPAN_CLASSES = Arrays.asList(
-			StyleSpan.class,
-			StrikethroughSpan.class,
-			TypefaceSpan.class,
-			ForegroundColorSpan.class
-	);
-
-	public static void clear(final Editable editable) {
-		final int end = editable.length() - 1;
-		for (Class<? extends ParcelableSpan> clazz : SPAN_CLASSES) {
-			for (ParcelableSpan span : editable.getSpans(0, end, clazz)) {
-				editable.removeSpan(span);
-			}
-		}
-	}
-
-	public static void format(final Editable editable, int start, int end, @ColorInt int textColor) {
-		for (ImStyleParser.Style style : ImStyleParser.parse(editable, start, end)) {
-			final int keywordLength = style.getKeyword().length();
-			editable.setSpan(createSpanForStyle(style), style.getStart() + keywordLength, style.getEnd() - keywordLength + 1, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
-			makeKeywordOpaque(editable, style.getStart(), style.getStart() + keywordLength, textColor);
-			makeKeywordOpaque(editable, style.getEnd() - keywordLength + 1, style.getEnd() + 1, textColor);
-		}
-	}
-
-	public static void format(final Editable editable, @ColorInt int textColor) {
-		int end = 0;
-		Message.MergeSeparator[] spans = editable.getSpans(0, editable.length() - 1, Message.MergeSeparator.class);
-		for (Message.MergeSeparator span : spans) {
-			format(editable, end, editable.getSpanStart(span), textColor);
-			end = editable.getSpanEnd(span);
-		}
-		format(editable, end, editable.length() - 1, textColor);
-	}
-
-	public static void highlight(final TextView view, final Editable editable, final List<String> needles) {
-		for (final String needle : needles) {
-			if (!FtsUtils.isKeyword(needle)) {
-				highlight(view, editable, needle);
-			}
-		}
-	}
-
-	public static List<String> filterHighlightedWords(List<String> terms) {
-		List<String> words = new ArrayList<>();
-		for (String term : terms) {
-			if (!FtsUtils.isKeyword(term)) {
-				StringBuilder builder = new StringBuilder();
-				for (int codepoint, i = 0; i < term.length(); i += Character.charCount(codepoint)) {
-					codepoint = term.codePointAt(i);
-					if (Character.isLetterOrDigit(codepoint)) {
-						builder.append(Character.toChars(codepoint));
-					} else if (builder.length() > 0) {
-						words.add(builder.toString());
-						builder.delete(0, builder.length());
-					}
-				}
-				if (builder.length() > 0) {
-					words.add(builder.toString());
-				}
-			}
-		}
-		return words;
-	}
-
-	private static void highlight(final TextView view, final Editable editable, final String needle) {
-		final int length = needle.length();
-		String string = editable.toString();
-		int start = indexOfIgnoreCase(string, needle, 0);
-		while (start != -1) {
-			int end = start + length;
-			editable.setSpan(new BackgroundColorSpan(MaterialColors.getColor(view, com.google.android.material.R.attr.colorPrimaryFixedDim)), start, end, SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE);
-			editable.setSpan(new ForegroundColorSpan(MaterialColors.getColor(view, com.google.android.material.R.attr.colorOnPrimaryFixed)), start, end, SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE);
-			start = indexOfIgnoreCase(string, needle, start + length);
-		}
-
-	}
-
-	static CharSequence subSequence(CharSequence charSequence, int start, int end) {
-		if (start == 0 && charSequence.length() + 1 == end) {
-			return charSequence;
-		}
-		if (charSequence instanceof Spannable spannable) {
-			Spannable sub = (Spannable) spannable.subSequence(start, end);
-			for (Class<? extends ParcelableSpan> clazz : SPAN_CLASSES) {
-				ParcelableSpan[] spannables = spannable.getSpans(start, end, clazz);
-				for (ParcelableSpan parcelableSpan : spannables) {
-					int beginSpan = spannable.getSpanStart(parcelableSpan);
-					int endSpan = spannable.getSpanEnd(parcelableSpan);
-					if (beginSpan >= start && endSpan <= end) {
-						continue;
-					}
-					sub.setSpan(clone(parcelableSpan), Math.max(beginSpan - start, 0), Math.min(sub.length() - 1, endSpan), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
-				}
-			}
-			return sub;
-		} else {
-			return charSequence.subSequence(start, end);
-		}
-	}
-
-	private static ParcelableSpan clone(ParcelableSpan span) {
-		if (span instanceof ForegroundColorSpan) {
-			return new ForegroundColorSpan(((ForegroundColorSpan) span).getForegroundColor());
-		} else if (span instanceof TypefaceSpan) {
-			return new TypefaceSpan(((TypefaceSpan) span).getFamily());
-		} else if (span instanceof StyleSpan) {
-			return new StyleSpan(((StyleSpan) span).getStyle());
-		} else if (span instanceof StrikethroughSpan) {
-			return new StrikethroughSpan();
-		} else {
-			throw new AssertionError("Unknown Span");
-		}
-	}
-
-	private static ParcelableSpan createSpanForStyle(final ImStyleParser.Style style) {
+    private static final List<? extends Class<? extends ParcelableSpan>> SPAN_CLASSES =
+            Arrays.asList(
+                    StyleSpan.class,
+                    StrikethroughSpan.class,
+                    TypefaceSpan.class,
+                    ForegroundColorSpan.class);
+
+    public static void clear(final Editable editable) {
+        final int end = editable.length() - 1;
+        for (Class<? extends ParcelableSpan> clazz : SPAN_CLASSES) {
+            for (ParcelableSpan span : editable.getSpans(0, end, clazz)) {
+                editable.removeSpan(span);
+            }
+        }
+    }
+
+    public static void format(
+            final Editable editable, int start, int end, @ColorInt int textColor) {
+        for (ImStyleParser.Style style : ImStyleParser.parse(editable, start, end)) {
+            final int keywordLength = style.getKeyword().length();
+            editable.setSpan(
+                    createSpanForStyle(style),
+                    style.getStart() + keywordLength,
+                    style.getEnd() - keywordLength + 1,
+                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+            makeKeywordOpaque(
+                    editable, style.getStart(), style.getStart() + keywordLength, textColor);
+            makeKeywordOpaque(
+                    editable, style.getEnd() - keywordLength + 1, style.getEnd() + 1, textColor);
+        }
+    }
+
+    public static void format(final Editable editable, @ColorInt final int textColor) {
+        format(editable, 0, editable.length() - 1, textColor);
+    }
+
+    public static void highlight(
+            final TextView view, final Editable editable, final List<String> needles) {
+        for (final String needle : needles) {
+            if (!FtsUtils.isKeyword(needle)) {
+                highlight(view, editable, needle);
+            }
+        }
+    }
+
+    public static List<String> filterHighlightedWords(List<String> terms) {
+        List<String> words = new ArrayList<>();
+        for (String term : terms) {
+            if (!FtsUtils.isKeyword(term)) {
+                StringBuilder builder = new StringBuilder();
+                for (int codepoint, i = 0; i < term.length(); i += Character.charCount(codepoint)) {
+                    codepoint = term.codePointAt(i);
+                    if (Character.isLetterOrDigit(codepoint)) {
+                        builder.append(Character.toChars(codepoint));
+                    } else if (builder.length() > 0) {
+                        words.add(builder.toString());
+                        builder.delete(0, builder.length());
+                    }
+                }
+                if (builder.length() > 0) {
+                    words.add(builder.toString());
+                }
+            }
+        }
+        return words;
+    }
+
+    private static void highlight(
+            final TextView view, final Editable editable, final String needle) {
+        final int length = needle.length();
+        String string = editable.toString();
+        int start = indexOfIgnoreCase(string, needle, 0);
+        while (start != -1) {
+            int end = start + length;
+            editable.setSpan(
+                    new BackgroundColorSpan(
+                            MaterialColors.getColor(
+                                    view, com.google.android.material.R.attr.colorPrimaryFixedDim)),
+                    start,
+                    end,
+                    SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE);
+            editable.setSpan(
+                    new ForegroundColorSpan(
+                            MaterialColors.getColor(
+                                    view, com.google.android.material.R.attr.colorOnPrimaryFixed)),
+                    start,
+                    end,
+                    SpannableString.SPAN_EXCLUSIVE_EXCLUSIVE);
+            start = indexOfIgnoreCase(string, needle, start + length);
+        }
+    }
+
+    static CharSequence subSequence(CharSequence charSequence, int start, int end) {
+        if (start == 0 && charSequence.length() + 1 == end) {
+            return charSequence;
+        }
+        if (charSequence instanceof Spannable spannable) {
+            Spannable sub = (Spannable) spannable.subSequence(start, end);
+            for (Class<? extends ParcelableSpan> clazz : SPAN_CLASSES) {
+                ParcelableSpan[] spannables = spannable.getSpans(start, end, clazz);
+                for (ParcelableSpan parcelableSpan : spannables) {
+                    int beginSpan = spannable.getSpanStart(parcelableSpan);
+                    int endSpan = spannable.getSpanEnd(parcelableSpan);
+                    if (beginSpan >= start && endSpan <= end) {
+                        continue;
+                    }
+                    sub.setSpan(
+                            clone(parcelableSpan),
+                            Math.max(beginSpan - start, 0),
+                            Math.min(sub.length() - 1, endSpan),
+                            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+                }
+            }
+            return sub;
+        } else {
+            return charSequence.subSequence(start, end);
+        }
+    }
+
+    private static ParcelableSpan clone(ParcelableSpan span) {
+        if (span instanceof ForegroundColorSpan) {
+            return new ForegroundColorSpan(((ForegroundColorSpan) span).getForegroundColor());
+        } else if (span instanceof TypefaceSpan) {
+            return new TypefaceSpan(((TypefaceSpan) span).getFamily());
+        } else if (span instanceof StyleSpan) {
+            return new StyleSpan(((StyleSpan) span).getStyle());
+        } else if (span instanceof StrikethroughSpan) {
+            return new StrikethroughSpan();
+        } else {
+            throw new AssertionError("Unknown Span");
+        }
+    }
+
+    private static ParcelableSpan createSpanForStyle(final ImStyleParser.Style style) {
         return switch (style.getKeyword()) {
             case "*" -> new StyleSpan(Typeface.BOLD);
             case "_" -> new StyleSpan(Typeface.ITALIC);
@@ -184,62 +194,64 @@ public class StylingHelper {
             case "`", "```" -> new TypefaceSpan("monospace");
             default -> throw new AssertionError("Unknown Style");
         };
-	}
-
-	private static void makeKeywordOpaque(final Editable editable, int start, int end, @ColorInt int fallbackTextColor) {
-		QuoteSpan[] quoteSpans = editable.getSpans(start, end, QuoteSpan.class);
-		@ColorInt int textColor = quoteSpans.length > 0 ? quoteSpans[0].getColor() : fallbackTextColor;
-		@ColorInt int keywordColor = transformColor(textColor);
-		editable.setSpan(new ForegroundColorSpan(keywordColor), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
-	}
-
-	private static
-	@ColorInt
-	int transformColor(@ColorInt int c) {
-		return Color.argb(Math.round(Color.alpha(c) * 0.45f), Color.red(c), Color.green(c), Color.blue(c));
-	}
-
-	private static int indexOfIgnoreCase(final String haystack, final String needle, final int start) {
-		if (haystack == null || needle == null) {
-			return -1;
-		}
-		final int endLimit = haystack.length() - needle.length() + 1;
-		if (start > endLimit) {
-			return -1;
-		}
-		if (needle.length() == 0) {
-			return start;
-		}
-		for (int i = start; i < endLimit; i++) {
-			if (haystack.regionMatches(true, i, needle, 0, needle.length())) {
-				return i;
-			}
-		}
-		return -1;
-	}
-
-	public static class MessageEditorStyler implements TextWatcher {
-
-		private final EditText mEditText;
-
-		public MessageEditorStyler(EditText editText) {
-			this.mEditText = editText;
-		}
-
-		@Override
-		public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {
-
-		}
-
-		@Override
-		public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
-
-		}
-
-		@Override
-		public void afterTextChanged(Editable editable) {
-			clear(editable);
-			format(editable, mEditText.getCurrentTextColor());
-		}
-	}
+    }
+
+    private static void makeKeywordOpaque(
+            final Editable editable, int start, int end, @ColorInt int fallbackTextColor) {
+        QuoteSpan[] quoteSpans = editable.getSpans(start, end, QuoteSpan.class);
+        @ColorInt
+        int textColor = quoteSpans.length > 0 ? quoteSpans[0].getColor() : fallbackTextColor;
+        @ColorInt int keywordColor = transformColor(textColor);
+        editable.setSpan(
+                new ForegroundColorSpan(keywordColor),
+                start,
+                end,
+                Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+    }
+
+    private static @ColorInt int transformColor(@ColorInt int c) {
+        return Color.argb(
+                Math.round(Color.alpha(c) * 0.45f), Color.red(c), Color.green(c), Color.blue(c));
+    }
+
+    private static int indexOfIgnoreCase(
+            final String haystack, final String needle, final int start) {
+        if (haystack == null || needle == null) {
+            return -1;
+        }
+        final int endLimit = haystack.length() - needle.length() + 1;
+        if (start > endLimit) {
+            return -1;
+        }
+        if (needle.length() == 0) {
+            return start;
+        }
+        for (int i = start; i < endLimit; i++) {
+            if (haystack.regionMatches(true, i, needle, 0, needle.length())) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    public static class MessageEditorStyler implements TextWatcher {
+
+        private final EditText mEditText;
+
+        public MessageEditorStyler(EditText editText) {
+            this.mEditText = editText;
+        }
+
+        @Override
+        public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {}
+
+        @Override
+        public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {}
+
+        @Override
+        public void afterTextChanged(Editable editable) {
+            clear(editable);
+            format(editable, mEditText.getCurrentTextColor());
+        }
+    }
 }