Detailed changes
  
  
    
    @@ -120,6 +120,7 @@ dependencies {
     implementation 'org.tomlj:tomlj:1.1.0'
     implementation 'com.tbuonomo:dotsindicator:4.2'
     implementation 'com.github.Priyansh-Kedia:OpenGraphParser:2.5.5'
+    implementation 'me.xdrop:fuzzywuzzy:1.4.0'
     // INSERT
 }
 
  
  
  
    
    @@ -0,0 +1,153 @@
+package com.cheogram.android;
+import android.util.Log;
+
+import android.content.Context;
+import android.text.SpannableStringBuilder;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+
+import androidx.databinding.DataBindingUtil;
+
+import com.google.common.collect.Lists;
+import com.google.common.io.CharStreams;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.lang.Comparable;
+import java.util.Arrays;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+import me.xdrop.fuzzywuzzy.FuzzySearch;
+import me.xdrop.fuzzywuzzy.algorithms.WeightedRatio;
+import me.xdrop.fuzzywuzzy.model.BoundExtractedResult;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import eu.siacs.conversations.R;
+import eu.siacs.conversations.databinding.EmojiSearchRowBinding;
+
+public class EmojiSearch {
+	protected final Set<Emoji> emoji = new TreeSet<>();
+
+	public EmojiSearch(Context context) {
+		try {
+			final JSONArray data = new JSONArray(CharStreams.toString(new InputStreamReader(context.getResources().openRawResource(R.raw.emoji), "UTF-8")));
+			for (int i = 0; i < data.length(); i++) {
+				emoji.add(new Emoji(data.getJSONObject(i)));
+			}
+		} catch (final JSONException | IOException e) {
+			throw new IllegalStateException("emoji.json invalid: " + e);
+		}
+	}
+
+	public List<Emoji> find(final String q) {
+		final Set<Emoji> emoticon = new TreeSet<>();
+		for (Emoji e : emoji) {
+			if (e.emoticonMatch(q)) {
+				emoticon.add(e);
+			}
+		}
+
+		WeightedRatio wr = new WeightedRatio();
+		List<BoundExtractedResult<Emoji>> result = FuzzySearch.extractTop(
+			q,
+			emoji,
+			(e) -> e.fuzzyFind,
+			(query, s) -> {
+				int score = 0;
+				String[] kinds = s.split(">");
+				for (int i = 0; i < kinds.length; i++) {
+					int nscore = Collections.max(Lists.transform(Arrays.asList(kinds[i].split("~")), (x) -> wr.apply(query, x))) - (i * 2);
+					if (nscore > score) score = nscore;
+				}
+				return score;
+			},
+			10
+		);
+
+		List<Emoji> lst = new ArrayList<>(emoticon);
+		lst.addAll(Lists.transform(result, (r) -> r.getReferent()));
+		return lst;
+	}
+
+	public EmojiSearchAdapter makeAdapter(Context context) {
+		return new EmojiSearchAdapter(context);
+	}
+
+	public static class Emoji implements Comparable<Emoji> {
+		protected final String unicode;
+		protected final int order;
+		protected final List<String> tags = new ArrayList<>();
+		protected final List<String> emoticon = new ArrayList<>();
+		protected final List<String> shortcodes = new ArrayList<>();
+		protected final String fuzzyFind;
+
+		public Emoji(JSONObject o) throws JSONException {
+			unicode = o.getString("unicode");
+			order = o.getInt("order");
+			final JSONArray rawTags = o.getJSONArray("tags");
+			for (int i = 0; i < rawTags.length(); i++) {
+				tags.add(rawTags.getString(i));
+			}
+			final JSONArray rawEmoticon = o.getJSONArray("emoticon");
+			for (int i = 0; i < rawEmoticon.length(); i++) {
+				emoticon.add(rawEmoticon.getString(i));
+			}
+			final JSONArray rawShortcodes = o.getJSONArray("shortcodes");
+			for (int i = 0; i < rawShortcodes.length(); i++) {
+				shortcodes.add(rawShortcodes.getString(i));
+			}
+			fuzzyFind = String.join("~", shortcodes) + ">" + String.join("~", tags);
+		}
+
+		public boolean emoticonMatch(final String q) {
+			for (final String emote : emoticon) {
+				if (emote.equals(q) || emote.equals(":" + q)) return true;
+			}
+
+			return false;
+		}
+
+		public SpannableStringBuilder toInsert() {
+			return new SpannableStringBuilder(unicode);
+		}
+
+		public int compareTo(Emoji o) {
+			if (equals(o)) return 0;
+			if (order == o.order) return -1;
+			return order - o.order;
+		}
+
+		public boolean equals(Emoji o) {
+			return toInsert().equals(o.toInsert());
+		}
+	}
+
+	public class EmojiSearchAdapter extends ArrayAdapter<Emoji> {
+		public EmojiSearchAdapter(Context context) {
+			super(context, 0);
+		}
+
+		@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());
+			binding.shortcode.setText(getItem(position).shortcodes.get(0));
+			return binding.getRoot();
+		}
+
+		public void search(final String q) {
+			clear();
+			addAll(find(q));
+			notifyDataSetChanged();
+		}
+	}
+}
  
  
  
    
    @@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+  <ListView
+      android:id="@+id/emoji"
+      android:layout_width="fill_parent"
+      android:layout_height="fill_parent"
+      android:layout_alignParentStart="true"
+      android:layout_alignParentLeft="true"
+      android:layout_alignParentTop="true"
+      android:background="?attr/color_background_secondary"
+      android:divider="@android:color/transparent"
+      android:dividerHeight="0dp"></ListView>
+
+</layout>
  
  
  
    
    @@ -0,0 +1,35 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+  <LinearLayout
+      android:layout_width="fill_parent"
+      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">
+
+		<TextView
+				android:id="@+id/unicode"
+				android:layout_width="wrap_content"
+				android:layout_height="wrap_content"
+				android:gravity="center_vertical"
+				android:textAppearance="@style/TextAppearance.Conversations.Body1"
+				android:textColor="?attr/edit_text_color" />
+
+		<TextView
+				android:id="@+id/shortcode"
+				android:layout_width="wrap_content"
+				android:layout_height="wrap_content"
+				android:paddingLeft="8dp"
+				android:gravity="center_vertical"
+				android:textAppearance="@style/TextAppearance.Conversations.Body1"
+				android:textColor="?attr/edit_text_color" />
+
+	</LinearLayout>
+
+</layout>
  
  
  
    
    @@ -0,0 +1 @@
  
  
  
    
    @@ -56,6 +56,7 @@ import androidx.core.app.RemoteInput;
 import androidx.core.content.ContextCompat;
 import androidx.core.util.Consumer;
 
+import com.cheogram.android.EmojiSearch;
 import com.cheogram.android.WebxdcUpdate;
 
 import com.google.common.base.Objects;
@@ -515,6 +516,7 @@ public class XmppConnectionService extends Service {
     private LruCache<String, Drawable> mDrawableCache;
     private final BroadcastReceiver mInternalEventReceiver = new InternalEventReceiver();
     private final BroadcastReceiver mInternalScreenEventReceiver = new InternalEventReceiver();
+    private EmojiSearch emojiSearch = null;
 
     private static String generateFetchKey(Account account, final Avatar avatar) {
         return account.getJid().asBareJid() + "_" + avatar.owner + "_" + avatar.sha1sum;
@@ -718,6 +720,10 @@ public class XmppConnectionService extends Service {
         });
     }
 
+    public EmojiSearch emojiSearch() {
+        return emojiSearch;
+    }
+
     public Conversation find(Bookmark bookmark) {
         return find(bookmark.getAccount(), bookmark.getJid());
     }
@@ -1262,6 +1268,7 @@ public class XmppConnectionService extends Service {
     @Override
     public void onCreate() {
         LibIdnXmppStringprep.setup();
+        emojiSearch = new EmojiSearch(this);
         setTheme(ThemeHelper.find(this));
         ThemeHelper.applyCustomColors(this);
         if (Compatibility.runsTwentySix()) {
  
  
  
    
    @@ -25,6 +25,7 @@ import android.os.Build;
 import android.os.Bundle;
 import android.os.Environment;
 import android.os.Handler;
+import android.os.Looper;
 import android.os.storage.StorageManager;
 import android.os.SystemClock;
 import android.preference.PreferenceManager;
@@ -32,6 +33,7 @@ import android.provider.MediaStore;
 import android.text.Editable;
 import android.text.SpannableStringBuilder;
 import android.text.TextUtils;
+import android.text.TextWatcher;
 import android.util.Log;
 import android.view.ContextMenu;
 import android.view.ContextMenu.ContextMenuInfo;
@@ -46,6 +48,7 @@ import android.view.View.OnClickListener;
 import android.view.ViewGroup;
 import android.view.inputmethod.EditorInfo;
 import android.view.inputmethod.InputMethodManager;
+import android.view.WindowManager;
 import android.widget.AbsListView;
 import android.widget.AbsListView.OnScrollListener;
 import android.widget.AdapterView;
@@ -53,6 +56,7 @@ import android.widget.AdapterView.AdapterContextMenuInfo;
 import android.widget.CheckBox;
 import android.widget.ListView;
 import android.widget.PopupMenu;
+import android.widget.PopupWindow;
 import android.widget.TextView.OnEditorActionListener;
 import android.widget.Toast;
 
@@ -72,6 +76,7 @@ import androidx.viewpager.widget.PagerAdapter;
 import androidx.viewpager.widget.ViewPager;
 
 import com.cheogram.android.BobTransfer;
+import com.cheogram.android.EmojiSearch;
 import com.cheogram.android.WebxdcPage;
 
 import com.google.common.base.Optional;
@@ -101,6 +106,7 @@ import eu.siacs.conversations.Config;
 import eu.siacs.conversations.R;
 import eu.siacs.conversations.crypto.axolotl.AxolotlService;
 import eu.siacs.conversations.crypto.axolotl.FingerprintStatus;
+import eu.siacs.conversations.databinding.EmojiSearchBinding;
 import eu.siacs.conversations.databinding.FragmentConversationBinding;
 import eu.siacs.conversations.entities.Account;
 import eu.siacs.conversations.entities.Blockable;
@@ -223,6 +229,8 @@ public class ConversationFragment extends XmppFragment
     private boolean reInitRequiredOnStart = true;
     private int identiconWidth = -1;
     private File savingAsSticker = null;
+    private EmojiSearch emojiSearch = null;
+    private EmojiSearchBinding emojiSearchBinding = null;
     private final OnClickListener clickToMuc =
             new OnClickListener() {
 
@@ -1363,9 +1371,60 @@ public class ConversationFragment extends XmppFragment
             return true;
         });
 
+        emojiSearchBinding = DataBindingUtil.inflate(inflater, R.layout.emoji_search, null, false);
+        emojiSearchBinding.emoji.setOnItemClickListener((parent, view, position, id) -> {
+            EmojiSearch.EmojiSearchAdapter adapter = ((EmojiSearch.EmojiSearchAdapter) emojiSearchBinding.emoji.getAdapter());
+            Editable toInsert = adapter.getItem(position).toInsert();
+            toInsert.append(" ");
+            Editable s = binding.textinput.getText();
+            int lastColon = s.toString().lastIndexOf(':');
+            s.replace(lastColon, s.length(), toInsert, 0, toInsert.length());
+        });
+        setupEmojiSearch();
+        PopupWindow emojiPopup = new PopupWindow(emojiSearchBinding.getRoot(), WindowManager.LayoutParams.MATCH_PARENT, 400);
+        Handler emojiDebounce = new Handler(Looper.getMainLooper());
+        binding.textinput.addTextChangedListener(new TextWatcher() {
+            @Override
+            public void afterTextChanged(Editable s) {
+                emojiDebounce.removeCallbacksAndMessages(null);
+                emojiDebounce.postDelayed(() -> {
+                    int lastColon = s.toString().lastIndexOf(':');
+                    if (lastColon < 0) {
+                        emojiPopup.dismiss();
+                        return;
+                    }
+                    final String q = s.toString().substring(lastColon + 1);
+                    if (q.matches(".*[^\\w\\(\\)\\+'\\-].*")) {
+                        emojiPopup.dismiss();
+                    } else {
+                        EmojiSearch.EmojiSearchAdapter adapter = ((EmojiSearch.EmojiSearchAdapter) emojiSearchBinding.emoji.getAdapter());
+                        if (adapter != null) {
+                            adapter.search(q);
+                            emojiPopup.showAsDropDown(binding.textinput);
+                        }
+                    }
+                }, 300L);
+            }
+
+            @Override
+            public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
+
+            @Override
+            public void onTextChanged(CharSequence s, int start, int count, int after) { }
+        });
+
         return binding.getRoot();
     }
 
+    protected void setupEmojiSearch() {
+        if (emojiSearch == null && activity != null && activity.xmppConnectionService != null) {
+            emojiSearch = activity.xmppConnectionService.emojiSearch();
+        }
+        if (emojiSearch == null || emojiSearchBinding == null) return;
+
+        emojiSearchBinding.emoji.setAdapter(emojiSearch.makeAdapter(activity));
+    }
+
     protected void newThreadTutorialToast(String s) {
         final SharedPreferences p = PreferenceManager.getDefaultSharedPreferences(activity);
         final int tutorialCount = p.getInt("thread_tutorial", 0);
@@ -3939,6 +3998,7 @@ public class ConversationFragment extends XmppFragment
     @Override
     public void onBackendConnected() {
         Log.d(Config.LOGTAG, "ConversationFragment.onBackendConnected()");
+        setupEmojiSearch();
         String uuid = pendingConversationsUuid.pop();
         if (uuid != null) {
             if (!findAndReInitByUuidOrArchive(uuid)) {