Custom emoji from stickers

Stephen Paul Weber created

Change summary

src/cheogram/java/com/cheogram/android/EmojiSearch.java                  |  83 
src/cheogram/java/com/cheogram/android/InlineImageSpan.java              |  39 
src/cheogram/java/com/cheogram/android/SpannedToXHTML.java               | 129 
src/cheogram/res/layout/emoji_search_row.xml                             |  22 
src/main/java/eu/siacs/conversations/entities/Message.java               |  53 
src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java    |   2 
src/main/java/eu/siacs/conversations/services/XmppConnectionService.java |  50 
src/main/java/eu/siacs/conversations/ui/ConversationFragment.java        |  39 
src/main/java/eu/siacs/conversations/ui/ConversationsActivity.java       |   1 
src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java      |  64 
10 files changed, 431 insertions(+), 51 deletions(-)

Detailed changes

src/cheogram/java/com/cheogram/android/EmojiSearch.java 🔗

@@ -2,6 +2,8 @@ package com.cheogram.android;
 import android.util.Log;
 
 import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.text.Spannable;
 import android.text.SpannableStringBuilder;
 import android.view.LayoutInflater;
 import android.view.View;
@@ -48,7 +50,11 @@ public class EmojiSearch {
 		}
 	}
 
-	public List<Emoji> find(final String q) {
+	public synchronized void addEmoji(final Emoji one) {
+		emoji.add(one);
+	}
+
+	public synchronized List<Emoji> find(final String q) {
 		final Set<Emoji> emoticon = new TreeSet<>();
 		for (Emoji e : emoji) {
 			if (e.emoticonMatch(q)) {
@@ -75,6 +81,17 @@ public class EmojiSearch {
 
 		List<Emoji> lst = new ArrayList<>(emoticon);
 		lst.addAll(Lists.transform(result, (r) -> r.getReferent()));
+
+		List<Emoji> scanList = new ArrayList<>(lst);
+		int inserted = 0;
+		for (int i = 0; i < scanList.size(); i++) {
+			for (Emoji e : emoji) {
+				if (e.shortcodeMatch(scanList.get(i).uniquePart())) {
+					inserted ++;
+					lst.add(i + inserted, e);
+				}
+			}
+		}
 		return lst;
 	}
 
@@ -90,6 +107,12 @@ public class EmojiSearch {
 		protected final List<String> shortcodes = new ArrayList<>();
 		protected final String fuzzyFind;
 
+		public Emoji(final String unicode, final int order, final String fuzzyFind) {
+			this.unicode = unicode;
+			this.order = order;
+			this.fuzzyFind = fuzzyFind;
+		}
+
 		public Emoji(JSONObject o) throws JSONException {
 			unicode = o.getString("unicode");
 			order = o.getInt("order");
@@ -116,18 +139,60 @@ public class EmojiSearch {
 			return false;
 		}
 
+		public boolean shortcodeMatch(final String q) {
+			for (final String shortcode : shortcodes) {
+				if (shortcode.equals(q)) return true;
+			}
+
+			return false;
+		}
+
 		public SpannableStringBuilder toInsert() {
 			return new SpannableStringBuilder(unicode);
 		}
 
+		public String uniquePart() {
+			return unicode;
+		}
+
+		@Override
 		public int compareTo(Emoji o) {
 			if (equals(o)) return 0;
-			if (order == o.order) return -1;
+			if (order == o.order) return uniquePart().compareTo(o.uniquePart());
 			return order - o.order;
 		}
 
-		public boolean equals(Emoji o) {
-			return toInsert().equals(o.toInsert());
+		@Override
+		public boolean equals(Object o) {
+			if (!(o instanceof Emoji)) return false;
+
+			return uniquePart().equals(((Emoji) o).uniquePart());
+		}
+	}
+
+	public static class CustomEmoji extends Emoji {
+		protected final String source;
+		protected final Drawable icon;
+
+		public CustomEmoji(final String shortcode, final String source, final Drawable icon) {
+			super(null, 10, shortcode);
+			shortcodes.add(shortcode);
+			this.source = source;
+			this.icon = icon;
+			if (icon == null) {
+				throw new IllegalArgumentException("icon must not be null");
+			}
+		}
+
+		public SpannableStringBuilder toInsert() {
+			SpannableStringBuilder builder = new SpannableStringBuilder(":" + shortcodes.get(0) + ":");
+			builder.setSpan(new InlineImageSpan(icon, source), 0, builder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+			return builder;
+		}
+
+		@Override
+		public String uniquePart() {
+			return source;
 		}
 	}
 
@@ -139,7 +204,15 @@ public class EmojiSearch {
 		@Override
 		public View getView(int position, View view, ViewGroup parent) {
 			EmojiSearchRowBinding binding = DataBindingUtil.inflate(LayoutInflater.from(parent.getContext()), R.layout.emoji_search_row, parent, false);
-			binding.unicode.setText(getItem(position).toInsert());
+			if (getItem(position) instanceof CustomEmoji) {
+				binding.nonunicode.setText(getItem(position).toInsert());
+				binding.nonunicode.setVisibility(View.VISIBLE);
+				binding.unicode.setVisibility(View.GONE);
+			} else {
+				binding.unicode.setText(getItem(position).toInsert());
+				binding.unicode.setVisibility(View.VISIBLE);
+				binding.nonunicode.setVisibility(View.GONE);
+			}
 			binding.shortcode.setText(getItem(position).shortcodes.get(0));
 			return binding.getRoot();
 		}

src/cheogram/java/com/cheogram/android/InlineImageSpan.java 🔗

@@ -0,0 +1,39 @@
+package com.cheogram.android;
+
+import android.graphics.Paint;
+import android.graphics.drawable.AnimatedImageDrawable;
+import android.graphics.drawable.Drawable;
+import android.os.Build;
+import android.text.style.ImageSpan;
+
+public class InlineImageSpan extends ImageSpan {
+	private final Paint.FontMetricsInt mTmpFontMetrics = new Paint.FontMetricsInt();
+	private final float dHeight;
+	private final float dWidth;
+
+	public InlineImageSpan(Drawable d, final String source) {
+		super(d.getConstantState() == null ? d : d.getConstantState().newDrawable(), source);
+		dHeight = d.getIntrinsicHeight();
+		dWidth = d.getIntrinsicWidth();
+		if (Build.VERSION.SDK_INT >= 28 && d instanceof AnimatedImageDrawable) {
+			((AnimatedImageDrawable) getDrawable()).start();
+		}
+	}
+
+	@Override
+	public int getSize(final Paint paint, final CharSequence text, final int start, final int end, final Paint.FontMetricsInt fm) {
+		paint.getFontMetricsInt(mTmpFontMetrics);
+		final int fontHeight = Math.abs(mTmpFontMetrics.descent - mTmpFontMetrics.ascent);
+		float mRatio = fontHeight * 1.0f / dHeight;
+		int mHeight = (short) (dHeight * mRatio);
+		int mWidth = (short) (dWidth * mRatio);
+		getDrawable().setBounds(0, 0, mWidth, mHeight);
+		if (fm != null) {
+			fm.ascent = mTmpFontMetrics.ascent;
+			fm.descent = mTmpFontMetrics.descent;
+			fm.top = mTmpFontMetrics.top;
+			fm.bottom = mTmpFontMetrics.bottom;
+		}
+		return mWidth;
+	}
+}

src/cheogram/java/com/cheogram/android/SpannedToXHTML.java 🔗

@@ -0,0 +1,129 @@
+package com.cheogram.android;
+
+import android.app.Application;
+import android.graphics.Typeface;
+import android.text.Spanned;
+import android.text.style.AbsoluteSizeSpan;
+import android.text.style.AlignmentSpan;
+import android.text.style.BackgroundColorSpan;
+import android.text.style.BulletSpan;
+import android.text.style.CharacterStyle;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.ImageSpan;
+import android.text.style.ParagraphStyle;
+import android.text.style.QuoteSpan;
+import android.text.style.RelativeSizeSpan;
+import android.text.style.StrikethroughSpan;
+import android.text.style.StyleSpan;
+import android.text.style.SubscriptSpan;
+import android.text.style.SuperscriptSpan;
+import android.text.style.TypefaceSpan;
+import android.text.style.URLSpan;
+import android.text.style.UnderlineSpan;
+
+import io.ipfs.cid.Cid;
+
+import eu.siacs.conversations.xml.Element;
+import eu.siacs.conversations.xml.TextNode;
+
+public class SpannedToXHTML {
+	public static Element append(Element out, Spanned text) {
+		withinParagraph(out, text, 0, text.length());
+		return out;
+	}
+
+	private static void withinParagraph(Element outer, Spanned text, int start, int end) {
+		int next;
+		outer:
+		for (int i = start; i < end; i = next) {
+			Element out = outer;
+			next = text.nextSpanTransition(i, end, CharacterStyle.class);
+			CharacterStyle[] style = text.getSpans(i, next, CharacterStyle.class);
+			for (int j = 0; j < style.length; j++) {
+				if (style[j] instanceof StyleSpan) {
+					int s = ((StyleSpan) style[j]).getStyle();
+					if ((s & Typeface.BOLD) != 0) {
+						out = out.addChild("b");
+					}
+					if ((s & Typeface.ITALIC) != 0) {
+						out = out.addChild("i");
+					}
+				}
+				if (style[j] instanceof TypefaceSpan) {
+					String s = ((TypefaceSpan) style[j]).getFamily();
+					if ("monospace".equals(s)) {
+						out = out.addChild("tt");
+					}
+				}
+				if (style[j] instanceof SuperscriptSpan) {
+					out = out.addChild("sup");
+				}
+				if (style[j] instanceof SubscriptSpan) {
+					out = out.addChild("sub");
+				}
+				// TextEdit underlines text in current word, which ends up getting sent...
+				// SPAN_COMPOSING ?
+				/*if (style[j] instanceof UnderlineSpan) {
+					out = out.addChild("u");
+				}*/
+				if (style[j] instanceof StrikethroughSpan) {
+					out = out.addChild("span");
+					out.setAttribute("style", "text-decoration:line-through;");
+				}
+				if (style[j] instanceof URLSpan) {
+					out = out.addChild("a");
+					out.setAttribute("href", ((URLSpan) style[j]).getURL());
+				}
+				if (style[j] instanceof ImageSpan) {
+					String source = ((ImageSpan) style[j]).getSource();
+					if (source != null && source.length() > 0 && source.charAt(0) == 'z') {
+						try {
+							source = BobTransfer.uri(Cid.decode(source)).toString();
+						} catch (final Exception e) { }
+					}
+					out = out.addChild("img");
+					out.setAttribute("src", source);
+					out.setAttribute("alt", text.subSequence(i, next).toString());
+					continue outer;
+				}
+				if (style[j] instanceof AbsoluteSizeSpan) {
+					try {
+						AbsoluteSizeSpan s = ((AbsoluteSizeSpan) style[j]);
+						float sizeDip = s.getSize();
+						if (!s.getDip()) {
+							Class activityThreadClass = Class.forName("android.app.ActivityThread");
+							Application application = (Application) activityThreadClass.getMethod("currentApplication").invoke(null);
+							sizeDip /= application.getResources().getDisplayMetrics().density;
+						}
+						// px in CSS is the equivalance of dip in Android
+						out = out.addChild("span");
+						out.setAttribute("style", String.format("font-size:%.0fpx;", sizeDip));
+					} catch (final Exception e) { }
+				}
+				if (style[j] instanceof RelativeSizeSpan) {
+					float sizeEm = ((RelativeSizeSpan) style[j]).getSizeChange();
+					out = out.addChild("span");
+					out.setAttribute("style", String.format("font-size:%.0fem;", sizeEm));
+				}
+				if (style[j] instanceof ForegroundColorSpan) {
+					int color = ((ForegroundColorSpan) style[j]).getForegroundColor();
+					out = out.addChild("span");
+					out.setAttribute("style", String.format("color:#%06X;", 0xFFFFFF & color));
+				}
+				if (style[j] instanceof BackgroundColorSpan) {
+					int color = ((BackgroundColorSpan) style[j]).getBackgroundColor();
+					out = out.addChild("span");
+					out.setAttribute("style", String.format("background-color:#%06X;", 0xFFFFFF & color));
+				}
+			}
+			String content = text.subSequence(i, next).toString();
+			for (int c = 0; c < content.length(); c++) {
+				if (content.charAt(c) == '\n') {
+					out.addChild("br");
+				} else {
+					out.addChild(new TextNode("" + content.charAt(c)));
+				}
+			}
+		}
+	}
+}

src/cheogram/res/layout/emoji_search_row.xml 🔗

@@ -7,11 +7,21 @@
       android:layout_height="wrap_content"
       android:layout_centerVertical="true"
       android:orientation="horizontal"
-			android:paddingTop="8dp"
-			android:paddingBottom="8dp"
-			android:paddingLeft="@dimen/avatar_item_distance"
-			android:paddingRight="@dimen/avatar_item_distance"
-			android:background="@drawable/list_choice">
+		android:paddingTop="8dp"
+		android:paddingBottom="8dp"
+		android:paddingLeft="@dimen/avatar_item_distance"
+		android:paddingRight="@dimen/avatar_item_distance"
+		android:background="@drawable/list_choice">
+
+		<TextView
+				android:id="@+id/nonunicode"
+				android:layout_width="wrap_content"
+				android:layout_height="wrap_content"
+				android:gravity="center_vertical"
+				android:textAppearance="@style/TextAppearance.Conversations.Body1"
+				app:emojiCompatEnabled="false"
+				android:visibility="gone"
+				android:textColor="?attr/edit_text_color" />
 
 		<TextView
 				android:id="@+id/unicode"
@@ -25,8 +35,8 @@
 				android:id="@+id/shortcode"
 				android:layout_width="wrap_content"
 				android:layout_height="wrap_content"
-				android:paddingLeft="8dp"
 				android:gravity="center_vertical"
+				android:paddingLeft="8dp"
 				android:textAppearance="@style/TextAppearance.Conversations.Body1"
 				android:textColor="?attr/edit_text_color" />
 

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

@@ -1,4 +1,5 @@
 package eu.siacs.conversations.entities;
+import android.util.Log;
 
 import android.content.ContentValues;
 import android.database.Cursor;
@@ -17,6 +18,8 @@ import android.view.View;
 
 import com.cheogram.android.BobTransfer;
 import com.cheogram.android.GetThumbnailForCid;
+import com.cheogram.android.InlineImageSpan;
+import com.cheogram.android.SpannedToXHTML;
 
 import com.google.common.io.ByteSource;
 import com.google.common.base.Strings;
@@ -534,6 +537,26 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         this.payloads.removeAll(getFallbacks(includeFor));
     }
 
+    public synchronized Element getOrMakeHtml() {
+        Element html = getHtml();
+        if (html != null) return html;
+        html = new Element("html", "http://jabber.org/protocol/xhtml-im");
+        Element body = html.addChild("body", "http://www.w3.org/1999/xhtml");
+        SpannedToXHTML.append(body, new SpannableStringBuilder(getBody()));
+        addPayload(html);
+        return body;
+    }
+
+    public synchronized void setBody(Spanned span) {
+        setBody(span.toString());
+        final Element body = getOrMakeHtml();
+        body.clearChildren();
+        SpannedToXHTML.append(body, span);
+        if (body.getContent().equals(span.toString())) {
+            this.payloads.remove(getHtml(true));
+        }
+    }
+
     public synchronized void setBody(String body) {
         this.body = body;
         this.isGeoUri = null;
@@ -541,6 +564,15 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
         this.treatAsDownloadable = null;
     }
 
+    public synchronized void appendBody(Spanned append) {
+        final Element body = getOrMakeHtml();
+        SpannedToXHTML.append(body, append);
+        if (body.getContent().equals(this.body + append.toString())) {
+            this.payloads.remove(getHtml());
+        }
+        appendBody(append.toString());
+    }
+
     public synchronized void appendBody(String append) {
         this.body += append;
         this.isGeoUri = null;
@@ -979,6 +1011,8 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
                     public void onClick(View widget) { }
                 };
 
+                spannable.removeSpan(span);
+                spannable.setSpan(new InlineImageSpan(span.getDrawable(), span.getSource()), start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
                 spannable.setSpan(click_span, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
             }
 
@@ -1140,11 +1174,15 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
     }
 
     public Element getHtml() {
+        return getHtml(false);
+    }
+
+    public Element getHtml(boolean root) {
         if (this.payloads == null) return null;
 
         for (Element el : this.payloads) {
             if (el.getName().equals("html") && el.getNamespace().equals("http://jabber.org/protocol/xhtml-im")) {
-                return el.getChildren().get(0);
+                return root ? el : el.getChildren().get(0);
             }
         }
 
@@ -1187,6 +1225,19 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable
     public synchronized boolean bodyIsOnlyEmojis() {
         if (isEmojisOnly == null) {
             isEmojisOnly = Emoticons.isOnlyEmoji(getBody().replaceAll("\\s", ""));
+            if (isEmojisOnly) return true;
+
+            if (getHtml() != null) {
+                SpannableStringBuilder spannable = getSpannableBody(null, null);
+                ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
+                for (ImageSpan span : imageSpans) {
+                    final int start = spannable.getSpanStart(span);
+                    final int end = spannable.getSpanEnd(span);
+                    spannable.delete(start, end);
+                }
+                final String after = spannable.toString().replaceAll("\\s", "");
+                isEmojisOnly = after.length() == 0 || Emoticons.isOnlyEmoji(after);
+            }
         }
         return isEmojisOnly;
     }

src/main/java/eu/siacs/conversations/persistance/DatabaseBackend.java 🔗

@@ -802,6 +802,8 @@ public class DatabaseBackend extends SQLiteOpenHelper {
     }
 
     public DownloadableFile getFileForCid(Cid cid) {
+        if (cid == null) return null;
+
         SQLiteDatabase db = this.getReadableDatabase();
         Cursor cursor = db.query("cheogram.cids", new String[]{"path"}, "cid=?", new String[]{cid.toString()}, null, null, null);
         DownloadableFile f = null;

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

@@ -21,6 +21,7 @@ import android.content.SharedPreferences;
 import android.content.pm.PackageManager;
 import android.database.ContentObserver;
 import android.graphics.Bitmap;
+import android.graphics.drawable.AnimatedImageDrawable;
 import android.graphics.drawable.BitmapDrawable;
 import android.graphics.drawable.Drawable;
 import android.media.AudioManager;
@@ -40,6 +41,7 @@ import android.os.PowerManager.WakeLock;
 import android.os.SystemClock;
 import android.preference.PreferenceManager;
 import android.provider.ContactsContract;
+import android.provider.DocumentsContract;
 import android.security.KeyChain;
 import android.telephony.PhoneStateListener;
 import android.telephony.TelephonyManager;
@@ -62,6 +64,7 @@ import com.cheogram.android.WebxdcUpdate;
 import com.google.common.base.Objects;
 import com.google.common.base.Optional;
 import com.google.common.base.Strings;
+import com.google.common.io.Files;
 
 import com.kedia.ogparser.OpenGraphCallback;
 import com.kedia.ogparser.OpenGraphParser;
@@ -151,6 +154,7 @@ import eu.siacs.conversations.utils.CryptoHelper;
 import eu.siacs.conversations.utils.Emoticons;
 import eu.siacs.conversations.utils.EasyOnboardingInvite;
 import eu.siacs.conversations.utils.ExceptionHelper;
+import eu.siacs.conversations.utils.FileUtils;
 import eu.siacs.conversations.utils.MimeUtils;
 import eu.siacs.conversations.utils.PhoneHelper;
 import eu.siacs.conversations.utils.QuickLoader;
@@ -241,8 +245,10 @@ public class XmppConnectionService extends Service {
     };
     public DatabaseBackend databaseBackend;
     private final ReplacingSerialSingleThreadExecutor mContactMergerExecutor = new ReplacingSerialSingleThreadExecutor("ContactMerger");
+    private final ReplacingSerialSingleThreadExecutor mStickerScanExecutor = new ReplacingSerialSingleThreadExecutor("StickerScan");
     private long mLastActivity = 0;
     private long mLastMucPing = 0;
+    private long mLastStickerRescan = 0;
     private final FileBackend fileBackend = new FileBackend(this);
     private MemorizingTrustManager mMemorizingTrustManager;
     private final NotificationService mNotificationService = new NotificationService(this);
@@ -720,6 +726,49 @@ public class XmppConnectionService extends Service {
         });
     }
 
+    private File stickerDir() {
+        SharedPreferences p = PreferenceManager.getDefaultSharedPreferences(getBaseContext());
+        final String dir = p.getString("sticker_directory", "Stickers");
+        if (dir.startsWith("content://")) {
+            Uri uri = Uri.parse(dir);
+            uri = DocumentsContract.buildDocumentUriUsingTree(uri, DocumentsContract.getTreeDocumentId(uri));
+            return new File(FileUtils.getPath(getBaseContext(), uri));
+        } else {
+            return new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) + "/" + dir);
+        }
+    }
+
+    public void rescanStickers() {
+        long msToRescan = (mLastStickerRescan + 600000L) - SystemClock.elapsedRealtime();
+        if (msToRescan > 0) return;
+
+        mLastStickerRescan = SystemClock.elapsedRealtime();
+        mStickerScanExecutor.execute(() -> {
+            try {
+                for (File file : Files.fileTraverser().breadthFirst(stickerDir())) {
+                    try {
+                        if (file.isFile() && file.canRead()) {
+                            DownloadableFile df = new DownloadableFile(file.getAbsolutePath());
+                            Drawable icon = fileBackend.getThumbnail(df, getResources(), (int) (getResources().getDisplayMetrics().density * 288), false);
+                            if (Build.VERSION.SDK_INT >= 28 && icon instanceof AnimatedImageDrawable) {
+                                // Animated drawable not working in spans for me yet
+                                // https://stackoverflow.com/questions/76870075/using-animatedimagedrawable-inside-imagespan-renders-wrong-size
+                                continue;
+                            }
+                            final String filename = Files.getNameWithoutExtension(df.getName());
+                            Cid[] cids = fileBackend.calculateCids(new FileInputStream(df));
+                            emojiSearch.addEmoji(new EmojiSearch.CustomEmoji(filename, cids[0].toString(), icon));
+                        }
+                    } catch (final Exception e) {
+                        Log.w(Config.LOGTAG, "rescanStickers: " + e);
+                    }
+                }
+            } catch (final Exception e) {
+                Log.w(Config.LOGTAG, "rescanStickers: " + e);
+            }
+        });
+    }
+
     public EmojiSearch emojiSearch() {
         return emojiSearch;
     }
@@ -1374,6 +1423,7 @@ public class XmppConnectionService extends Service {
         mForceDuringOnCreate.set(false);
         toggleForegroundService();
         setupPhoneStateListener();
+        rescanStickers();
     }
 
 

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

@@ -34,6 +34,7 @@ import android.text.Editable;
 import android.text.SpannableStringBuilder;
 import android.text.TextUtils;
 import android.text.TextWatcher;
+import android.text.style.ImageSpan;
 import android.util.Log;
 import android.view.ContextMenu;
 import android.view.ContextMenu.ContextMenuInfo;
@@ -231,6 +232,7 @@ public class ConversationFragment extends XmppFragment
     private File savingAsSticker = null;
     private EmojiSearch emojiSearch = null;
     private EmojiSearchBinding emojiSearchBinding = null;
+    private PopupWindow emojiPopup = null;
     private final OnClickListener clickToMuc =
             new OnClickListener() {
 
@@ -896,8 +898,8 @@ public class ConversationFragment extends XmppFragment
             commitAttachments();
             return;
         }
-        final Editable text = this.binding.textinput.getText();
-        String body = text == null ? "" : text.toString();
+        Editable body = this.binding.textinput.getText();
+        if (body == null) body = new SpannableStringBuilder("");
         final Conversation conversation = this.conversation;
         if (body.length() == 0 || conversation == null) {
             return;
@@ -910,18 +912,40 @@ public class ConversationFragment extends XmppFragment
             boolean attention = false;
             if (Pattern.compile("\\A@here\\s.*").matcher(body).find()) {
                 attention = true;
-                body = body.replaceFirst("\\A@here\\s+", "");
+                body.delete(0, 6);
+                while (body.length() > 0 && Character.isWhitespace(body.charAt(0))) body.delete(0, 1);
             }
             if (conversation.getReplyTo() != null) {
-                if (Emoticons.isEmoji(body)) {
-                    message = conversation.getReplyTo().react(body);
+                if (Emoticons.isEmoji(body.toString())) {
+                    message = conversation.getReplyTo().react(body.toString());
                 } else {
                     message = conversation.getReplyTo().reply();
                     message.appendBody(body);
                 }
                 message.setEncryption(conversation.getNextEncryption());
             } else {
-                message = new Message(conversation, body, conversation.getNextEncryption());
+                message = new Message(conversation, body.toString(), conversation.getNextEncryption());
+                message.setBody(body);
+                if (message.bodyIsOnlyEmojis()) {
+                    SpannableStringBuilder spannable = message.getSpannableBody(null, null);
+                    ImageSpan[] imageSpans = spannable.getSpans(0, spannable.length(), ImageSpan.class);
+                    if (imageSpans.length == 1) {
+                        // Only one inline image, so it's a sticker
+                        String source = imageSpans[0].getSource();
+                        if (source != null && source.length() > 0 && source.substring(0, 4).equals("cid:")) {
+                            try {
+                                final Cid cid = BobTransfer.cid(Uri.parse(source));
+                                final String url = activity.xmppConnectionService.getUrlForCid(cid);
+                                final File f = activity.xmppConnectionService.getFileForCid(cid);
+                                if (url != null) {
+                                    message.setBody("");
+                                    message.setRelativeFilePath(f.getAbsolutePath());
+                                    activity.xmppConnectionService.getFileBackend().updateFileParams(message);
+                                }
+                            } catch (final Exception e) { }
+                        }
+                    }
+                }
             }
             message.setThread(conversation.getThread());
             if (attention) {
@@ -1381,7 +1405,7 @@ public class ConversationFragment extends XmppFragment
             s.replace(lastColon, s.length(), toInsert, 0, toInsert.length());
         });
         setupEmojiSearch();
-        PopupWindow emojiPopup = new PopupWindow(emojiSearchBinding.getRoot(), WindowManager.LayoutParams.MATCH_PARENT, 400);
+        emojiPopup = new PopupWindow(emojiSearchBinding.getRoot(), WindowManager.LayoutParams.MATCH_PARENT, 400);
         Handler emojiDebounce = new Handler(Looper.getMainLooper());
         binding.textinput.addTextChangedListener(new TextWatcher() {
             @Override
@@ -2829,6 +2853,7 @@ public class ConversationFragment extends XmppFragment
             this.activity.xmppConnectionService.getNotificationService().setOpenConversation(null);
         }
         this.reInitRequiredOnStart = true;
+        if (emojiPopup != null) emojiPopup.dismiss();
     }
 
     private void updateChatState(final Conversation conversation, final String msg) {

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

@@ -384,7 +384,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         viewHolder.messageBody.setTextIsSelectable(false);
     }
 
-    private void displayEmojiMessage(final ViewHolder viewHolder, final String body, final boolean darkBackground) {
+    private void displayEmojiMessage(final ViewHolder viewHolder, final SpannableStringBuilder body, final boolean darkBackground) {
         viewHolder.download_button.setVisibility(View.GONE);
         viewHolder.audioPlayer.setVisibility(View.GONE);
         viewHolder.image.setVisibility(View.GONE);
@@ -394,10 +394,10 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         } else {
             viewHolder.messageBody.setTextAppearance(getContext(), R.style.TextAppearance_Conversations_Body1_Emoji);
         }
-        Spannable span = new SpannableString(body);
-        float size = Emoticons.isEmoji(body) ? 3.0f : 2.0f;
-        span.setSpan(new RelativeSizeSpan(size), 0, body.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
-        viewHolder.messageBody.setText(span);
+        ImageSpan[] imageSpans = body.getSpans(0, body.length(), ImageSpan.class);
+        float size = imageSpans.length == 1 || Emoticons.isEmoji(body.toString()) ? 3.0f : 2.0f;
+        body.setSpan(new RelativeSizeSpan(size), 0, body.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
+        viewHolder.messageBody.setText(body);
     }
 
     private void applyQuoteSpan(SpannableStringBuilder body, int start, int end, boolean darkBackground) {
@@ -481,6 +481,31 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         return startsWithQuote;
     }
 
+    private SpannableStringBuilder getSpannableBody(final Message message) {
+        Drawable fallbackImg = ResourcesCompat.getDrawable(activity.getResources(), activity.getThemeResource(R.attr.ic_attach_photo, R.drawable.ic_attach_photo), null);
+        return message.getMergedBody((cid) -> {
+            try {
+                DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
+                if (f == null || !f.canRead()) {
+                    if (!message.trusted() && !message.getConversation().canInferPresence()) return null;
+
+                    try {
+                        new BobTransfer(BobTransfer.uri(cid), message.getConversation().getAccount(), message.getCounterpart(), activity.xmppConnectionService).start();
+                    } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
+                    return null;
+                }
+
+                Drawable d = activity.xmppConnectionService.getFileBackend().getThumbnail(f, activity.getResources(), (int) (metrics.density * 288), true);
+                if (d == null) {
+                    new ThumbnailTask().execute(f);
+                }
+                return d;
+            } catch (final IOException e) {
+                return fallbackImg;
+            }
+        }, fallbackImg);
+    }
+
     private void displayTextMessage(final ViewHolder viewHolder, final Message message, boolean darkBackground, int type) {
         viewHolder.download_button.setVisibility(View.GONE);
         viewHolder.image.setVisibility(View.GONE);
@@ -499,32 +524,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         if (message.getBody() != null && !message.getBody().equals("")) {
             viewHolder.messageBody.setVisibility(View.VISIBLE);
             final String nick = UIHelper.getMessageDisplayName(message);
-            Drawable fallbackImg = ResourcesCompat.getDrawable(activity.getResources(), activity.getThemeResource(R.attr.ic_attach_photo, R.drawable.ic_attach_photo), null);
-            fallbackImg.setBounds(FileBackend.rectForSize(fallbackImg.getIntrinsicWidth(), fallbackImg.getIntrinsicHeight(), (int) (metrics.density * 32)));
-            SpannableStringBuilder body = message.getMergedBody((cid) -> {
-                try {
-                    DownloadableFile f = activity.xmppConnectionService.getFileForCid(cid);
-                    if (f == null || !f.canRead()) {
-                        if (!message.trusted() && !message.getConversation().canInferPresence()) return null;
-
-                        try {
-                            new BobTransfer(BobTransfer.uri(cid), message.getConversation().getAccount(), message.getCounterpart(), activity.xmppConnectionService).start();
-                        } catch (final NoSuchAlgorithmException | URISyntaxException e) { }
-                        return null;
-                    }
-
-                    Drawable d = activity.xmppConnectionService.getFileBackend().getThumbnail(f, activity.getResources(), (int) (metrics.density * 288), true);
-                    if (d == null) {
-                        new ThumbnailTask().execute(f);
-                    } else {
-                        d = d.getConstantState().newDrawable();
-                        d.setBounds(FileBackend.rectForSize(d.getIntrinsicWidth(), d.getIntrinsicHeight(), (int) (metrics.density * 32)));
-                    }
-                    return d;
-                } catch (final IOException e) {
-                    return fallbackImg;
-                }
-            }, fallbackImg);
+            SpannableStringBuilder body = getSpannableBody(message);
             boolean hasMeCommand = message.hasMeCommand();
             if (hasMeCommand) {
                 body = body.replace(0, Message.ME_COMMAND.length(), nick + " ");
@@ -1090,7 +1090,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
                             darkBackground, type);
                 }
             } else if (message.bodyIsOnlyEmojis() && message.getType() != Message.TYPE_PRIVATE) {
-                displayEmojiMessage(viewHolder, message.getBody().trim(), darkBackground);
+                displayEmojiMessage(viewHolder, getSpannableBody(message), darkBackground);
             } else {
                 displayTextMessage(viewHolder, message, darkBackground, type);
             }