EmojiSearch.java

  1package com.cheogram.android;
  2
  3import android.app.Activity;
  4import android.content.Context;
  5import android.graphics.drawable.Drawable;
  6import android.text.Spannable;
  7import android.text.SpannableStringBuilder;
  8import android.view.LayoutInflater;
  9import android.view.View;
 10import android.view.ViewGroup;
 11
 12import androidx.databinding.DataBindingUtil;
 13import androidx.recyclerview.widget.DiffUtil;
 14import androidx.recyclerview.widget.ListAdapter;
 15import androidx.recyclerview.widget.RecyclerView;
 16
 17import com.google.android.material.chip.Chip;
 18import com.google.android.material.color.MaterialColors;
 19import com.google.common.collect.Lists;
 20import com.google.common.io.CharStreams;
 21
 22import io.ipfs.cid.Cid;
 23
 24import java.io.IOException;
 25import java.io.InputStreamReader;
 26import java.lang.Comparable;
 27import java.util.ArrayList;
 28import java.util.Arrays;
 29import java.util.Collections;
 30import java.util.List;
 31import java.util.Locale;
 32import java.util.PriorityQueue;
 33import java.util.Set;
 34import java.util.TreeSet;
 35import java.util.concurrent.Semaphore;
 36import java.util.function.Consumer;
 37
 38import me.xdrop.fuzzywuzzy.FuzzySearch;
 39import me.xdrop.fuzzywuzzy.model.BoundExtractedResult;
 40
 41import org.json.JSONArray;
 42import org.json.JSONException;
 43import org.json.JSONObject;
 44
 45import eu.siacs.conversations.R;
 46import eu.siacs.conversations.databinding.EmojiSearchRowBinding;
 47import eu.siacs.conversations.entities.DownloadableFile;
 48import eu.siacs.conversations.utils.ReplacingSerialSingleThreadExecutor;
 49
 50public class EmojiSearch {
 51	protected final Set<Emoji> emoji = new TreeSet<>();
 52
 53	public EmojiSearch(Context context) {
 54		try {
 55			final JSONArray data = new JSONArray(CharStreams.toString(new InputStreamReader(context.getResources().openRawResource(R.raw.emoji), "UTF-8")));
 56			for (int i = 0; i < data.length(); i++) {
 57				emoji.add(new Emoji(data.getJSONObject(i)));
 58			}
 59		} catch (final JSONException | IOException e) {
 60			throw new IllegalStateException("emoji.json invalid: " + e);
 61		}
 62	}
 63
 64	public synchronized void addEmoji(final Emoji one) {
 65		emoji.add(one);
 66	}
 67
 68	public synchronized List<Emoji> find(final String q) {
 69		final ResultPQ pq = new ResultPQ();
 70		for (Emoji e : emoji) {
 71			if (e.emoticonMatch(q)) {
 72				pq.addTopK(e, 999999, 10);
 73			}
 74			int shortcodeScore = e.shortcodes.isEmpty() ? 0 : Collections.max(Lists.transform(e.shortcodes, (shortcode) -> FuzzySearch.ratio(q, shortcode)));
 75			int tagScore = e.tags.isEmpty() ? 0 : Collections.max(Lists.transform(e.tags, (tag) -> FuzzySearch.ratio(q, tag))) - 2;
 76			pq.addTopK(e, Math.max(shortcodeScore, tagScore), 10);
 77		}
 78
 79		for (BoundExtractedResult<Emoji> r : new ArrayList<>(pq)) {
 80			for (Emoji e : emoji) {
 81				if (e.shortcodeMatch(r.getReferent().uniquePart())) {
 82					// hack see https://stackoverflow.com/questions/76880072/imagespan-with-emojicompat
 83					e.shortcodes.clear();
 84					e.shortcodes.addAll(r.getReferent().shortcodes);
 85
 86					pq.addTopK(e, r.getScore() - 1, 10);
 87				}
 88			}
 89		}
 90
 91		List<Emoji> result = new ArrayList<>();
 92		for (int i = 0; i < 10; i++) {
 93			BoundExtractedResult<Emoji> e = pq.poll();
 94			if (e != null) result.add(e.getReferent());
 95		}
 96		Collections.reverse(result);
 97		return result;
 98	}
 99
100	public EmojiSearchAdapter makeAdapter(Consumer<Emoji> callback) {
101		return new EmojiSearchAdapter(callback);
102	}
103
104	public static class ResultPQ extends PriorityQueue<BoundExtractedResult<Emoji>> {
105		public void addTopK(Emoji e, int score, int k) {
106			BoundExtractedResult r = new BoundExtractedResult(e, null, score, 0);
107			if (size() < k) {
108				add(r);
109			} else if (r.compareTo(peek()) > 0) {
110				poll();
111				add(r);
112			}
113		}
114	}
115
116	public static class Emoji implements Comparable<Emoji> {
117		protected final String unicode;
118		protected final int order;
119		protected final List<String> tags = new ArrayList<>();
120		protected final List<String> emoticon = new ArrayList<>();
121		protected final List<String> shortcodes = new ArrayList<>();
122
123		public Emoji(final String unicode, final int order) {
124			this.unicode = unicode;
125			this.order = order;
126		}
127
128		public Emoji(JSONObject o) throws JSONException {
129			unicode = o.getString("unicode");
130			order = o.getInt("order");
131			final JSONArray rawTags = o.getJSONArray("tags");
132			for (int i = 0; i < rawTags.length(); i++) {
133				tags.add(rawTags.getString(i));
134			}
135			final JSONArray rawEmoticon = o.getJSONArray("emoticon");
136			for (int i = 0; i < rawEmoticon.length(); i++) {
137				emoticon.add(rawEmoticon.getString(i));
138			}
139			final JSONArray rawShortcodes = o.getJSONArray("shortcodes");
140			for (int i = 0; i < rawShortcodes.length(); i++) {
141				shortcodes.add(rawShortcodes.getString(i));
142			}
143		}
144
145		public boolean emoticonMatch(final String q) {
146			for (final String emote : emoticon) {
147				if (emote.equals(q) || emote.equals(":" + q)) return true;
148			}
149
150			return false;
151		}
152
153		public boolean shortcodeMatch(final String q) {
154			for (final String shortcode : shortcodes) {
155				if (shortcode.equals(q)) return true;
156			}
157
158			return false;
159		}
160
161		public SpannableStringBuilder toInsert() {
162			return new SpannableStringBuilder(unicode);
163		}
164
165		public void setupChip(Chip chip, int count) {
166			if (count < 2) {
167				chip.setText(unicode);
168			} else {
169				chip.setText(String.format(Locale.ENGLISH, "%s %d", unicode, count));
170			}
171		}
172
173		@Override
174		public String toString() {
175			return unicode;
176		}
177
178		public String uniquePart() {
179			return unicode;
180		}
181
182		@Override
183		public int compareTo(Emoji o) {
184			if (equals(o)) return 0;
185			if (order == o.order) return uniquePart().compareTo(o.uniquePart());
186			return order - o.order;
187		}
188
189		@Override
190		public boolean equals(Object o) {
191			if (!(o instanceof Emoji)) return false;
192
193			return uniquePart().equals(((Emoji) o).uniquePart());
194		}
195
196		@Override
197		public int hashCode() {
198			return uniquePart().hashCode();
199		}
200	}
201
202	public static class CustomEmoji extends Emoji {
203		public final String source;
204		protected final Drawable icon;
205
206		public CustomEmoji(final String shortcode, final String source, final Drawable icon, final String tag) {
207			super(null, 10);
208			shortcodes.add(shortcode);
209			if (tag != null) tags.add(tag);
210			this.source = source;
211			this.icon = icon;
212		}
213
214		public SpannableStringBuilder toInsert() {
215			SpannableStringBuilder builder = new SpannableStringBuilder(toString());
216			builder.setSpan(new InlineImageSpan(icon == null ? new android.graphics.drawable.ColorDrawable(0) : icon, source), 0, builder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
217			return builder;
218		}
219
220		public void setupChip(Chip chip, int count) {
221			if (icon == null) {
222				chip.setChipIconResource(R.drawable.ic_photo_24dp);
223				chip.setChipIconTint(
224						MaterialColors.getColorStateListOrNull(
225								chip.getContext(),
226								com.google.android.material.R.attr.colorOnSurface));
227			} else {
228				SpannableStringBuilder builder = new SpannableStringBuilder("😇"); // needs to be same size as an emoji
229				if (icon != null) builder.setSpan(new InlineImageSpan(icon, source), 0, builder.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
230				chip.setText(builder); // We cannot use icon because it is a hardware bitmap
231			}
232			if (count > 1) {
233				chip.append(String.format(Locale.ENGLISH, " %d", count));
234			}
235		}
236
237		@Override
238		public String uniquePart() {
239			return source;
240		}
241
242		@Override
243		public String toString() {
244			return ":" + shortcodes.get(0) + ":";
245		}
246	}
247
248	public class EmojiSearchAdapter extends ListAdapter<Emoji, EmojiSearchAdapter.ViewHolder> {
249		ReplacingSerialSingleThreadExecutor executor = new ReplacingSerialSingleThreadExecutor("EmojiSearchAdapter");
250
251		static final DiffUtil.ItemCallback<Emoji> DIFF = new DiffUtil.ItemCallback<Emoji>() {
252			@Override
253			public boolean areItemsTheSame(Emoji a, Emoji b) {
254				return a.equals(b);
255			}
256
257			@Override
258			public boolean areContentsTheSame(Emoji a, Emoji b) {
259				return a.equals(b);
260			}
261		};
262		final Consumer<Emoji> callback;
263		protected Semaphore doingUpdate = new Semaphore(1);
264
265		public EmojiSearchAdapter(final Consumer<Emoji> callback) {
266			super(DIFF);
267			this.callback = callback;
268		}
269
270		@Override
271		public ViewHolder onCreateViewHolder(ViewGroup viewGroup, int position) {
272			return new ViewHolder(DataBindingUtil.inflate(LayoutInflater.from(viewGroup.getContext()), R.layout.emoji_search_row, viewGroup, false));
273		}
274
275		@Override
276		public void onBindViewHolder(ViewHolder viewHolder, int position) {
277			final var binding = viewHolder.binding;
278			final var item = getItem(position);
279			if (item instanceof CustomEmoji) {
280				binding.nonunicode.setText(item.toInsert());
281				binding.nonunicode.setVisibility(View.VISIBLE);
282				binding.unicode.setVisibility(View.GONE);
283			} else {
284				binding.unicode.setText(item.toInsert());
285				binding.unicode.setVisibility(View.VISIBLE);
286				binding.nonunicode.setVisibility(View.GONE);
287			}
288			binding.shortcode.setText(item.shortcodes.get(0));
289			binding.getRoot().setOnClickListener(v -> {
290				callback.accept(item);
291			});
292		}
293
294		public void search(final Activity activity, final RecyclerView view, final String q) {
295			executor.execute(() -> {
296				final List<Emoji> results = find(q);
297				try {
298					// Acquire outside so to not block UI thread
299					doingUpdate.acquire();
300					activity.runOnUiThread(() -> {
301						submitList(results, () -> {
302							activity.runOnUiThread(() -> doingUpdate.release());
303						});
304					});
305				} catch (final InterruptedException e) { }
306			});
307		}
308
309		public static class ViewHolder extends RecyclerView.ViewHolder {
310			public final EmojiSearchRowBinding binding;
311
312			private ViewHolder(EmojiSearchRowBinding binding) {
313				super(binding.getRoot());
314				this.binding = binding;
315			}
316		}
317	}
318}