1 /*
2  * Copyright (C) 2015 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.inputmethod.keyboard.emoji;
18 
19 import android.content.SharedPreferences;
20 import android.content.res.Resources;
21 import android.content.res.TypedArray;
22 import android.graphics.Paint;
23 import android.graphics.Rect;
24 import android.os.Build;
25 import android.util.Log;
26 import android.util.Pair;
27 
28 import com.android.inputmethod.compat.BuildCompatUtils;
29 import com.android.inputmethod.keyboard.Key;
30 import com.android.inputmethod.keyboard.Keyboard;
31 import com.android.inputmethod.keyboard.KeyboardId;
32 import com.android.inputmethod.keyboard.KeyboardLayoutSet;
33 import com.android.inputmethod.latin.R;
34 import com.android.inputmethod.latin.settings.Settings;
35 
36 import java.util.ArrayList;
37 import java.util.Collections;
38 import java.util.Comparator;
39 import java.util.HashMap;
40 import java.util.List;
41 import java.util.concurrent.ConcurrentHashMap;
42 
43 final class EmojiCategory {
44     private final String TAG = EmojiCategory.class.getSimpleName();
45 
46     private static final int ID_UNSPECIFIED = -1;
47     public static final int ID_RECENTS = 0;
48     private static final int ID_PEOPLE = 1;
49     private static final int ID_OBJECTS = 2;
50     private static final int ID_NATURE = 3;
51     private static final int ID_PLACES = 4;
52     private static final int ID_SYMBOLS = 5;
53     private static final int ID_EMOTICONS = 6;
54     private static final int ID_FLAGS = 7;
55     private static final int ID_EIGHT_SMILEY_PEOPLE = 8;
56     private static final int ID_EIGHT_ANIMALS_NATURE = 9;
57     private static final int ID_EIGHT_FOOD_DRINK = 10;
58     private static final int ID_EIGHT_TRAVEL_PLACES = 11;
59     private static final int ID_EIGHT_ACTIVITY = 12;
60     private static final int ID_EIGHT_OBJECTS = 13;
61     private static final int ID_EIGHT_SYMBOLS = 14;
62     private static final int ID_EIGHT_FLAGS = 15;
63     private static final int ID_EIGHT_SMILEY_PEOPLE_BORING = 16;
64 
65     public final class CategoryProperties {
66         public final int mCategoryId;
67         public final int mPageCount;
CategoryProperties(final int categoryId, final int pageCount)68         public CategoryProperties(final int categoryId, final int pageCount) {
69             mCategoryId = categoryId;
70             mPageCount = pageCount;
71         }
72     }
73 
74     private static final String[] sCategoryName = {
75             "recents",
76             "people",
77             "objects",
78             "nature",
79             "places",
80             "symbols",
81             "emoticons",
82             "flags",
83             "smiley & people",
84             "animals & nature",
85             "food & drink",
86             "travel & places",
87             "activity",
88             "objects2",
89             "symbols2",
90             "flags2",
91             "smiley & people2" };
92 
93     private static final int[] sCategoryTabIconAttr = {
94             R.styleable.EmojiPalettesView_iconEmojiRecentsTab,
95             R.styleable.EmojiPalettesView_iconEmojiCategory1Tab,
96             R.styleable.EmojiPalettesView_iconEmojiCategory2Tab,
97             R.styleable.EmojiPalettesView_iconEmojiCategory3Tab,
98             R.styleable.EmojiPalettesView_iconEmojiCategory4Tab,
99             R.styleable.EmojiPalettesView_iconEmojiCategory5Tab,
100             R.styleable.EmojiPalettesView_iconEmojiCategory6Tab,
101             R.styleable.EmojiPalettesView_iconEmojiCategory7Tab,
102             R.styleable.EmojiPalettesView_iconEmojiCategory8Tab,
103             R.styleable.EmojiPalettesView_iconEmojiCategory9Tab,
104             R.styleable.EmojiPalettesView_iconEmojiCategory10Tab,
105             R.styleable.EmojiPalettesView_iconEmojiCategory11Tab,
106             R.styleable.EmojiPalettesView_iconEmojiCategory12Tab,
107             R.styleable.EmojiPalettesView_iconEmojiCategory13Tab,
108             R.styleable.EmojiPalettesView_iconEmojiCategory14Tab,
109             R.styleable.EmojiPalettesView_iconEmojiCategory15Tab,
110             R.styleable.EmojiPalettesView_iconEmojiCategory16Tab };
111 
112     private static final int[] sAccessibilityDescriptionResourceIdsForCategories = {
113             R.string.spoken_descrption_emoji_category_recents,
114             R.string.spoken_descrption_emoji_category_people,
115             R.string.spoken_descrption_emoji_category_objects,
116             R.string.spoken_descrption_emoji_category_nature,
117             R.string.spoken_descrption_emoji_category_places,
118             R.string.spoken_descrption_emoji_category_symbols,
119             R.string.spoken_descrption_emoji_category_emoticons,
120             R.string.spoken_descrption_emoji_category_flags,
121             R.string.spoken_descrption_emoji_category_eight_smiley_people,
122             R.string.spoken_descrption_emoji_category_eight_animals_nature,
123             R.string.spoken_descrption_emoji_category_eight_food_drink,
124             R.string.spoken_descrption_emoji_category_eight_travel_places,
125             R.string.spoken_descrption_emoji_category_eight_activity,
126             R.string.spoken_descrption_emoji_category_objects,
127             R.string.spoken_descrption_emoji_category_symbols,
128             R.string.spoken_descrption_emoji_category_flags,
129             R.string.spoken_descrption_emoji_category_eight_smiley_people };
130 
131     private static final int[] sCategoryElementId = {
132             KeyboardId.ELEMENT_EMOJI_RECENTS,
133             KeyboardId.ELEMENT_EMOJI_CATEGORY1,
134             KeyboardId.ELEMENT_EMOJI_CATEGORY2,
135             KeyboardId.ELEMENT_EMOJI_CATEGORY3,
136             KeyboardId.ELEMENT_EMOJI_CATEGORY4,
137             KeyboardId.ELEMENT_EMOJI_CATEGORY5,
138             KeyboardId.ELEMENT_EMOJI_CATEGORY6,
139             KeyboardId.ELEMENT_EMOJI_CATEGORY7,
140             KeyboardId.ELEMENT_EMOJI_CATEGORY8,
141             KeyboardId.ELEMENT_EMOJI_CATEGORY9,
142             KeyboardId.ELEMENT_EMOJI_CATEGORY10,
143             KeyboardId.ELEMENT_EMOJI_CATEGORY11,
144             KeyboardId.ELEMENT_EMOJI_CATEGORY12,
145             KeyboardId.ELEMENT_EMOJI_CATEGORY13,
146             KeyboardId.ELEMENT_EMOJI_CATEGORY14,
147             KeyboardId.ELEMENT_EMOJI_CATEGORY15,
148             KeyboardId.ELEMENT_EMOJI_CATEGORY16 };
149 
150     private final SharedPreferences mPrefs;
151     private final Resources mRes;
152     private final int mMaxPageKeyCount;
153     private final KeyboardLayoutSet mLayoutSet;
154     private final HashMap<String, Integer> mCategoryNameToIdMap = new HashMap<>();
155     private final int[] mCategoryTabIconId = new int[sCategoryName.length];
156     private final ArrayList<CategoryProperties> mShownCategories = new ArrayList<>();
157     private final ConcurrentHashMap<Long, DynamicGridKeyboard> mCategoryKeyboardMap =
158             new ConcurrentHashMap<>();
159 
160     private int mCurrentCategoryId = EmojiCategory.ID_UNSPECIFIED;
161     private int mCurrentCategoryPageId = 0;
162 
EmojiCategory(final SharedPreferences prefs, final Resources res, final KeyboardLayoutSet layoutSet, final TypedArray emojiPaletteViewAttr)163     public EmojiCategory(final SharedPreferences prefs, final Resources res,
164             final KeyboardLayoutSet layoutSet, final TypedArray emojiPaletteViewAttr) {
165         mPrefs = prefs;
166         mRes = res;
167         mMaxPageKeyCount = res.getInteger(R.integer.config_emoji_keyboard_max_page_key_count);
168         mLayoutSet = layoutSet;
169         for (int i = 0; i < sCategoryName.length; ++i) {
170             mCategoryNameToIdMap.put(sCategoryName[i], i);
171             mCategoryTabIconId[i] = emojiPaletteViewAttr.getResourceId(
172                     sCategoryTabIconAttr[i], 0);
173         }
174 
175         int defaultCategoryId = EmojiCategory.ID_SYMBOLS;
176         addShownCategoryId(EmojiCategory.ID_RECENTS);
177         if (BuildCompatUtils.EFFECTIVE_SDK_INT >= Build.VERSION_CODES.KITKAT) {
178             if (canShowUnicodeEightEmoji()) {
179                 defaultCategoryId = EmojiCategory.ID_EIGHT_SMILEY_PEOPLE;
180                 addShownCategoryId(EmojiCategory.ID_EIGHT_SMILEY_PEOPLE);
181                 addShownCategoryId(EmojiCategory.ID_EIGHT_ANIMALS_NATURE);
182                 addShownCategoryId(EmojiCategory.ID_EIGHT_FOOD_DRINK);
183                 addShownCategoryId(EmojiCategory.ID_EIGHT_TRAVEL_PLACES);
184                 addShownCategoryId(EmojiCategory.ID_EIGHT_ACTIVITY);
185                 addShownCategoryId(EmojiCategory.ID_EIGHT_OBJECTS);
186                 addShownCategoryId(EmojiCategory.ID_EIGHT_SYMBOLS);
187                 addShownCategoryId(EmojiCategory.ID_FLAGS); // Exclude combinations without glyphs.
188             } else {
189                 defaultCategoryId = EmojiCategory.ID_PEOPLE;
190                 addShownCategoryId(EmojiCategory.ID_PEOPLE);
191                 addShownCategoryId(EmojiCategory.ID_OBJECTS);
192                 addShownCategoryId(EmojiCategory.ID_NATURE);
193                 addShownCategoryId(EmojiCategory.ID_PLACES);
194                 addShownCategoryId(EmojiCategory.ID_SYMBOLS);
195                 if (canShowFlagEmoji()) {
196                     addShownCategoryId(EmojiCategory.ID_FLAGS);
197                 }
198             }
199         } else {
200             addShownCategoryId(EmojiCategory.ID_SYMBOLS);
201         }
202         addShownCategoryId(EmojiCategory.ID_EMOTICONS);
203 
204         DynamicGridKeyboard recentsKbd =
205                 getKeyboard(EmojiCategory.ID_RECENTS, 0 /* categoryPageId */);
206         recentsKbd.loadRecentKeys(mCategoryKeyboardMap.values());
207 
208         mCurrentCategoryId = Settings.readLastShownEmojiCategoryId(mPrefs, defaultCategoryId);
209         Log.i(TAG, "Last Emoji category id is " + mCurrentCategoryId);
210         if (!isShownCategoryId(mCurrentCategoryId)) {
211             Log.i(TAG, "Last emoji category " + mCurrentCategoryId +
212                     " is invalid, starting in " + defaultCategoryId);
213             mCurrentCategoryId = defaultCategoryId;
214         } else if (mCurrentCategoryId == EmojiCategory.ID_RECENTS &&
215                 recentsKbd.getSortedKeys().isEmpty()) {
216             Log.i(TAG, "No recent emojis found, starting in category " + defaultCategoryId);
217             mCurrentCategoryId = defaultCategoryId;
218         }
219     }
220 
addShownCategoryId(final int categoryId)221     private void addShownCategoryId(final int categoryId) {
222         // Load a keyboard of categoryId
223         getKeyboard(categoryId, 0 /* categoryPageId */);
224         final CategoryProperties properties =
225                 new CategoryProperties(categoryId, getCategoryPageCount(categoryId));
226         mShownCategories.add(properties);
227     }
228 
isShownCategoryId(final int categoryId)229     private boolean isShownCategoryId(final int categoryId) {
230         for (final CategoryProperties prop : mShownCategories) {
231             if (prop.mCategoryId == categoryId) {
232                 return true;
233             }
234         }
235         return false;
236     }
237 
getCategoryName(final int categoryId, final int categoryPageId)238     public static String getCategoryName(final int categoryId, final int categoryPageId) {
239         return sCategoryName[categoryId] + "-" + categoryPageId;
240     }
241 
getCategoryId(final String name)242     public int getCategoryId(final String name) {
243         final String[] strings = name.split("-");
244         return mCategoryNameToIdMap.get(strings[0]);
245     }
246 
getCategoryTabIcon(final int categoryId)247     public int getCategoryTabIcon(final int categoryId) {
248         return mCategoryTabIconId[categoryId];
249     }
250 
getAccessibilityDescription(final int categoryId)251     public String getAccessibilityDescription(final int categoryId) {
252         return mRes.getString(sAccessibilityDescriptionResourceIdsForCategories[categoryId]);
253     }
254 
getShownCategories()255     public ArrayList<CategoryProperties> getShownCategories() {
256         return mShownCategories;
257     }
258 
getCurrentCategoryId()259     public int getCurrentCategoryId() {
260         return mCurrentCategoryId;
261     }
262 
getCurrentCategoryPageSize()263     public int getCurrentCategoryPageSize() {
264         return getCategoryPageSize(mCurrentCategoryId);
265     }
266 
getCategoryPageSize(final int categoryId)267     public int getCategoryPageSize(final int categoryId) {
268         for (final CategoryProperties prop : mShownCategories) {
269             if (prop.mCategoryId == categoryId) {
270                 return prop.mPageCount;
271             }
272         }
273         Log.w(TAG, "Invalid category id: " + categoryId);
274         // Should not reach here.
275         return 0;
276     }
277 
setCurrentCategoryId(final int categoryId)278     public void setCurrentCategoryId(final int categoryId) {
279         mCurrentCategoryId = categoryId;
280         Settings.writeLastShownEmojiCategoryId(mPrefs, categoryId);
281     }
282 
setCurrentCategoryPageId(final int id)283     public void setCurrentCategoryPageId(final int id) {
284         mCurrentCategoryPageId = id;
285     }
286 
getCurrentCategoryPageId()287     public int getCurrentCategoryPageId() {
288         return mCurrentCategoryPageId;
289     }
290 
saveLastTypedCategoryPage()291     public void saveLastTypedCategoryPage() {
292         Settings.writeLastTypedEmojiCategoryPageId(
293                 mPrefs, mCurrentCategoryId, mCurrentCategoryPageId);
294     }
295 
isInRecentTab()296     public boolean isInRecentTab() {
297         return mCurrentCategoryId == EmojiCategory.ID_RECENTS;
298     }
299 
getTabIdFromCategoryId(final int categoryId)300     public int getTabIdFromCategoryId(final int categoryId) {
301         for (int i = 0; i < mShownCategories.size(); ++i) {
302             if (mShownCategories.get(i).mCategoryId == categoryId) {
303                 return i;
304             }
305         }
306         Log.w(TAG, "categoryId not found: " + categoryId);
307         return 0;
308     }
309 
310     // Returns the view pager's page position for the categoryId
getPageIdFromCategoryId(final int categoryId)311     public int getPageIdFromCategoryId(final int categoryId) {
312         final int lastSavedCategoryPageId =
313                 Settings.readLastTypedEmojiCategoryPageId(mPrefs, categoryId);
314         int sum = 0;
315         for (int i = 0; i < mShownCategories.size(); ++i) {
316             final CategoryProperties props = mShownCategories.get(i);
317             if (props.mCategoryId == categoryId) {
318                 return sum + lastSavedCategoryPageId;
319             }
320             sum += props.mPageCount;
321         }
322         Log.w(TAG, "categoryId not found: " + categoryId);
323         return 0;
324     }
325 
getRecentTabId()326     public int getRecentTabId() {
327         return getTabIdFromCategoryId(EmojiCategory.ID_RECENTS);
328     }
329 
getCategoryPageCount(final int categoryId)330     private int getCategoryPageCount(final int categoryId) {
331         final Keyboard keyboard = mLayoutSet.getKeyboard(sCategoryElementId[categoryId]);
332         return (keyboard.getSortedKeys().size() - 1) / mMaxPageKeyCount + 1;
333     }
334 
335     // Returns a pair of the category id and the category page id from the view pager's page
336     // position. The category page id is numbered in each category. And the view page position
337     // is the position of the current shown page in the view pager which contains all pages of
338     // all categories.
getCategoryIdAndPageIdFromPagePosition(final int position)339     public Pair<Integer, Integer> getCategoryIdAndPageIdFromPagePosition(final int position) {
340         int sum = 0;
341         for (final CategoryProperties properties : mShownCategories) {
342             final int temp = sum;
343             sum += properties.mPageCount;
344             if (sum > position) {
345                 return new Pair<>(properties.mCategoryId, position - temp);
346             }
347         }
348         return null;
349     }
350 
351     // Returns a keyboard from the view pager's page position.
getKeyboardFromPagePosition(final int position)352     public DynamicGridKeyboard getKeyboardFromPagePosition(final int position) {
353         final Pair<Integer, Integer> categoryAndId =
354                 getCategoryIdAndPageIdFromPagePosition(position);
355         if (categoryAndId != null) {
356             return getKeyboard(categoryAndId.first, categoryAndId.second);
357         }
358         return null;
359     }
360 
getCategoryKeyboardMapKey(final int categoryId, final int id)361     private static final Long getCategoryKeyboardMapKey(final int categoryId, final int id) {
362         return (((long) categoryId) << Integer.SIZE) | id;
363     }
364 
getKeyboard(final int categoryId, final int id)365     public DynamicGridKeyboard getKeyboard(final int categoryId, final int id) {
366         synchronized (mCategoryKeyboardMap) {
367             final Long categoryKeyboardMapKey = getCategoryKeyboardMapKey(categoryId, id);
368             if (mCategoryKeyboardMap.containsKey(categoryKeyboardMapKey)) {
369                 return mCategoryKeyboardMap.get(categoryKeyboardMapKey);
370             }
371 
372             if (categoryId == EmojiCategory.ID_RECENTS) {
373                 final DynamicGridKeyboard kbd = new DynamicGridKeyboard(mPrefs,
374                         mLayoutSet.getKeyboard(KeyboardId.ELEMENT_EMOJI_RECENTS),
375                         mMaxPageKeyCount, categoryId);
376                 mCategoryKeyboardMap.put(categoryKeyboardMapKey, kbd);
377                 return kbd;
378             }
379 
380             final Keyboard keyboard = mLayoutSet.getKeyboard(sCategoryElementId[categoryId]);
381             final Key[][] sortedKeys = sortKeysIntoPages(
382                     keyboard.getSortedKeys(), mMaxPageKeyCount);
383             for (int pageId = 0; pageId < sortedKeys.length; ++pageId) {
384                 final DynamicGridKeyboard tempKeyboard = new DynamicGridKeyboard(mPrefs,
385                         mLayoutSet.getKeyboard(KeyboardId.ELEMENT_EMOJI_RECENTS),
386                         mMaxPageKeyCount, categoryId);
387                 for (final Key emojiKey : sortedKeys[pageId]) {
388                     if (emojiKey == null) {
389                         break;
390                     }
391                     tempKeyboard.addKeyLast(emojiKey);
392                 }
393                 mCategoryKeyboardMap.put(
394                         getCategoryKeyboardMapKey(categoryId, pageId), tempKeyboard);
395             }
396             return mCategoryKeyboardMap.get(categoryKeyboardMapKey);
397         }
398     }
399 
getTotalPageCountOfAllCategories()400     public int getTotalPageCountOfAllCategories() {
401         int sum = 0;
402         for (CategoryProperties properties : mShownCategories) {
403             sum += properties.mPageCount;
404         }
405         return sum;
406     }
407 
408     private static Comparator<Key> EMOJI_KEY_COMPARATOR = new Comparator<Key>() {
409         @Override
410         public int compare(final Key lhs, final Key rhs) {
411             final Rect lHitBox = lhs.getHitBox();
412             final Rect rHitBox = rhs.getHitBox();
413             if (lHitBox.top < rHitBox.top) {
414                 return -1;
415             } else if (lHitBox.top > rHitBox.top) {
416                 return 1;
417             }
418             if (lHitBox.left < rHitBox.left) {
419                 return -1;
420             } else if (lHitBox.left > rHitBox.left) {
421                 return 1;
422             }
423             if (lhs.getCode() == rhs.getCode()) {
424                 return 0;
425             }
426             return lhs.getCode() < rhs.getCode() ? -1 : 1;
427         }
428     };
429 
sortKeysIntoPages(final List<Key> inKeys, final int maxPageCount)430     private static Key[][] sortKeysIntoPages(final List<Key> inKeys, final int maxPageCount) {
431         final ArrayList<Key> keys = new ArrayList<>(inKeys);
432         Collections.sort(keys, EMOJI_KEY_COMPARATOR);
433         final int pageCount = (keys.size() - 1) / maxPageCount + 1;
434         final Key[][] retval = new Key[pageCount][maxPageCount];
435         for (int i = 0; i < keys.size(); ++i) {
436             retval[i / maxPageCount][i % maxPageCount] = keys.get(i);
437         }
438         return retval;
439     }
440 
canShowFlagEmoji()441     private static boolean canShowFlagEmoji() {
442         Paint paint = new Paint();
443         String switzerland = "\uD83C\uDDE8\uD83C\uDDED"; //  U+1F1E8 U+1F1ED Flag for Switzerland
444         try {
445             return paint.hasGlyph(switzerland);
446         } catch (NoSuchMethodError e) {
447             // Compare display width of single-codepoint emoji to width of flag emoji to determine
448             // whether flag is rendered as single glyph or two adjacent regional indicator symbols.
449             float flagWidth = paint.measureText(switzerland);
450             float standardWidth = paint.measureText("\uD83D\uDC27"); //  U+1F427 Penguin
451             return flagWidth < standardWidth * 1.25;
452             // This assumes that a valid glyph for the flag emoji must be less than 1.25 times
453             // the width of the penguin.
454         }
455     }
456 
canShowUnicodeEightEmoji()457     private static boolean canShowUnicodeEightEmoji() {
458         Paint paint = new Paint();
459         String cheese = "\uD83E\uDDC0"; //  U+1F9C0 Cheese wedge
460         try {
461             return paint.hasGlyph(cheese);
462         } catch (NoSuchMethodError e) {
463             float cheeseWidth = paint.measureText(cheese);
464             float tofuWidth = paint.measureText("\uFFFE");
465             return cheeseWidth > tofuWidth;
466             // This assumes that a valid glyph for the cheese wedge must be greater than the width
467             // of the noncharacter.
468         }
469     }
470 }
471