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}