From 7af3eeb8277b13f22af53c5dc6c8a319b2620be7 Mon Sep 17 00:00:00 2001 From: Raj Yengisetty Date: Tue, 3 Mar 2015 18:40:07 -0800 Subject: App Drawer: internationalization Change-Id: Ib3976e0852aab4a2e78a90877c056ad3ecd3c448 --- Android.mk | 3 +- .../android/launcher3/AppDrawerListAdapter.java | 101 +++-- .../android/launcher3/locale/HanziToPinyin.java | 186 ++++++++ src/com/android/launcher3/locale/LocaleSet.java | 253 +++++++++++ .../android/launcher3/locale/LocaleSetManager.java | 82 ++++ src/com/android/launcher3/locale/LocaleUtils.java | 484 +++++++++++++++++++++ 6 files changed, 1073 insertions(+), 36 deletions(-) create mode 100644 src/com/android/launcher3/locale/HanziToPinyin.java create mode 100644 src/com/android/launcher3/locale/LocaleSet.java create mode 100644 src/com/android/launcher3/locale/LocaleSetManager.java create mode 100644 src/com/android/launcher3/locale/LocaleUtils.java diff --git a/Android.mk b/Android.mk index c7e2dfde1..b4f2e69e1 100644 --- a/Android.mk +++ b/Android.mk @@ -24,7 +24,8 @@ include $(CLEAR_VARS) LOCAL_MODULE_TAGS := optional LOCAL_STATIC_JAVA_LIBRARIES := android-support-v13 \ - android-support-v7-recyclerview + android-support-v7-recyclerview \ + guava LOCAL_SRC_FILES := $(call all-java-files-under, src) \ $(call all-java-files-under, WallpaperPicker/src) \ diff --git a/src/com/android/launcher3/AppDrawerListAdapter.java b/src/com/android/launcher3/AppDrawerListAdapter.java index ad6b35067..f0044212d 100644 --- a/src/com/android/launcher3/AppDrawerListAdapter.java +++ b/src/com/android/launcher3/AppDrawerListAdapter.java @@ -19,18 +19,21 @@ package com.android.launcher3; import android.content.ComponentName; import android.graphics.Rect; import android.graphics.drawable.Drawable; +import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.support.v7.widget.RecyclerView; import android.widget.LinearLayout; import android.widget.SectionIndexer; +import com.android.launcher3.locale.LocaleSetManager; +import com.android.launcher3.locale.LocaleUtils; +import java.text.Collator; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; -import java.util.ListIterator; /** * AppDrawerListAdapter - list adapter for the vertical app drawer @@ -38,7 +41,6 @@ import java.util.ListIterator; public class AppDrawerListAdapter extends RecyclerView.Adapter implements View.OnLongClickListener, DragSource, SectionIndexer { - private static final char NUMERIC_OR_SPECIAL_CHAR = '#'; private static final String NUMERIC_OR_SPECIAL_HEADER = "#"; private ArrayList mHeaderList; @@ -49,6 +51,7 @@ public class AppDrawerListAdapter extends RecyclerView.Adapter mSectionHeaders; private LinearLayout.LayoutParams mIconParams; private Rect mIconRect; + private LocaleSetManager mLocaleSetManager; public enum DrawerType { Drawer(0), @@ -88,6 +91,9 @@ public class AppDrawerListAdapter extends RecyclerView.Adapter(); mDeviceProfile = LauncherAppState.getInstance().getDynamicGrid().getDeviceProfile(); mLayoutInflater = LayoutInflater.from(launcher); + + mLocaleSetManager = new LocaleSetManager(mLauncher); + mLocaleSetManager.updateLocaleSet(mLocaleSetManager.getSystemLocaleSet()); initParams(); } @@ -106,50 +112,51 @@ public class AppDrawerListAdapter extends RecyclerView.Adapter info) { if (info == null || info.size() <= 0) { + Collections.sort(mHeaderList); return; } // Create a clone of AppInfo ArrayList to preserve data - ArrayList tempInfo = new ArrayList(info.size()); - for (AppInfo i : info) { - tempInfo.add(i); - } + ArrayList tempInfo = (ArrayList) info.clone(); - ListIterator it = tempInfo.listIterator(); ArrayList appInfos = new ArrayList(); - appInfos.clear(); // get next app - AppInfo app = it.next(); + AppInfo app = tempInfo.get(0); // get starting character - boolean isSpecial = false; - char startChar = app.title.toString().toUpperCase().charAt(0); - if (!Character.isLetter(startChar)) { - isSpecial = true; + LocaleUtils localeUtils = LocaleUtils.getInstance(); + int bucketIndex = localeUtils.getBucketIndex(app.title.toString()); + String startString + = localeUtils.getBucketLabel(bucketIndex); + if (TextUtils.isEmpty(startString)) { + startString = NUMERIC_OR_SPECIAL_HEADER; + bucketIndex = localeUtils.getBucketIndex(startString); } // now iterate through for (AppInfo info1 : tempInfo) { - char newChar = info1.title.toString().toUpperCase().charAt(0); + int newBucketIndex = localeUtils.getBucketIndex(info1.title.toString()); + + String newChar + = localeUtils.getBucketLabel(newBucketIndex); + if (TextUtils.isEmpty(newChar)) { + newChar = NUMERIC_OR_SPECIAL_HEADER; + } // if same character - if (newChar == startChar) { + if (newChar.equals(startString)) { // add it appInfos.add(info1); - } else if (isSpecial && !Character.isLetter(newChar)) { - appInfos.add(info1); } } + Collections.sort(appInfos, LauncherModel.getAppNameComparator()); + for (int i = 0; i < appInfos.size(); i += mDeviceProfile.numColumnsBase) { int endIndex = (int) Math.min(i + mDeviceProfile.numColumnsBase, appInfos.size()); ArrayList subList = new ArrayList(appInfos.subList(i, endIndex)); AppItemIndexedInfo indexInfo; - if (isSpecial) { - indexInfo = new AppItemIndexedInfo('#', subList, i != 0); - } else { - indexInfo = new AppItemIndexedInfo(startChar, subList, i != 0); - } + indexInfo = new AppItemIndexedInfo(startString, bucketIndex, subList, i != 0); mHeaderList.add(indexInfo); } @@ -163,7 +170,6 @@ public class AppDrawerListAdapter extends RecyclerView.Adapter list) { if (!LauncherAppState.isDisableAllApps()) { mHeaderList.clear(); - Collections.sort(list, LauncherModel.getAppNameComparator()); populateByCharacter(list); populateSectionHeaders(); mLauncher.updateScrubber(); @@ -179,7 +185,7 @@ public class AppDrawerListAdapter extends RecyclerView.Adapter list) { // We add it in place, in alphabetical order + LocaleUtils localeUtils = LocaleUtils.getInstance(); + int count = list.size(); for (int i = 0; i < count; ++i) { AppInfo info = list.get(i); boolean found = false; AppItemIndexedInfo lastInfoForSection = null; + int bucketIndex = localeUtils.getBucketIndex(info.title.toString()); + String start = localeUtils.getBucketLabel(bucketIndex); + if (TextUtils.isEmpty(start)) { + start = NUMERIC_OR_SPECIAL_HEADER; + bucketIndex = localeUtils.getBucketIndex(start); + } for (int j = 0; j < mHeaderList.size(); ++j) { AppItemIndexedInfo indexedInfo = mHeaderList.get(j); - if (info.title.charAt(0) == indexedInfo.mChar) { + if (start.equals(indexedInfo.mStartString)) { Collections.sort(indexedInfo.mInfo, LauncherModel.getAppNameComparator()); int index = Collections.binarySearch(indexedInfo.mInfo, @@ -255,8 +269,9 @@ public class AppDrawerListAdapter extends RecyclerView.Adapter newInfos = new ArrayList(); newInfos.add(info); AppItemIndexedInfo newInfo = - new AppItemIndexedInfo(info.title.charAt(0), newInfos, false); + new AppItemIndexedInfo(start, bucketIndex, newInfos, false); mHeaderList.add(newInfo); + Collections.sort(mHeaderList); } } } @@ -343,10 +358,10 @@ public class AppDrawerListAdapter extends RecyclerView.Adapter mInfo; - private AppItemIndexedInfo(char startChar, ArrayList info, boolean isChild) { - this.mChar = startChar; + private AppItemIndexedInfo(String startString, int bucketIndex, ArrayList info, + boolean isChild) { + this.mStartString = startString; + this.mStringIndex = bucketIndex; this.mInfo = info; this.isChild = isChild; + + if (mStartString.equals(NUMERIC_OR_SPECIAL_HEADER)) { + this.mStringIndex = 0; + } } - public char getChar() { - return mChar; + public String getString() { + return mStartString; + } + + @Override + public int compareTo(Object o) { + if (o instanceof AppItemIndexedInfo) { + int otherBucketIndex = ((AppItemIndexedInfo) o).mStringIndex; + return Integer.compare(mStringIndex, otherBucketIndex); + } + return 0; } } @@ -486,6 +517,6 @@ public class AppDrawerListAdapter extends RecyclerView.Adapter getTokens(final String input) { + ArrayList tokens = new ArrayList(); + if (!hasChineseTransliterator() || TextUtils.isEmpty(input)) { + // return empty tokens. + return tokens; + } + + final int inputLength = input.length(); + final StringBuilder sb = new StringBuilder(); + int tokenType = Token.LATIN; + Token token = new Token(); + + // Go through the input, create a new token when + // a. Token type changed + // b. Get the Pinyin of current charater. + // c. current character is space. + for (int i = 0; i < inputLength; i++) { + final char character = input.charAt(i); + if (Character.isSpaceChar(character)) { + if (sb.length() > 0) { + addToken(sb, tokens, tokenType); + } + } else { + tokenize(character, token); + if (token.type == Token.PINYIN) { + if (sb.length() > 0) { + addToken(sb, tokens, tokenType); + } + tokens.add(token); + token = new Token(); + } else { + if (tokenType != token.type && sb.length() > 0) { + addToken(sb, tokens, tokenType); + } + sb.append(token.target); + } + tokenType = token.type; + } + } + if (sb.length() > 0) { + addToken(sb, tokens, tokenType); + } + return tokens; + } + + private void addToken( + final StringBuilder sb, final ArrayList tokens, final int tokenType) { + String str = sb.toString(); + tokens.add(new Token(tokenType, str, str)); + sb.setLength(0); + } +} diff --git a/src/com/android/launcher3/locale/LocaleSet.java b/src/com/android/launcher3/locale/LocaleSet.java new file mode 100644 index 000000000..34634ab7e --- /dev/null +++ b/src/com/android/launcher3/locale/LocaleSet.java @@ -0,0 +1,253 @@ +/* + * Copyright (C) 2014 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.launcher3.locale; + +import android.text.TextUtils; +import com.google.common.annotations.VisibleForTesting; +import java.util.Locale; + +public class LocaleSet { + private static final String CHINESE_LANGUAGE = Locale.CHINESE.getLanguage().toLowerCase(); + private static final String JAPANESE_LANGUAGE = Locale.JAPANESE.getLanguage().toLowerCase(); + private static final String KOREAN_LANGUAGE = Locale.KOREAN.getLanguage().toLowerCase(); + + private static class LocaleWrapper { + private final Locale mLocale; + private final String mLanguage; + private final boolean mLocaleIsCJK; + + private static boolean isLanguageCJK(String language) { + return CHINESE_LANGUAGE.equals(language) || + JAPANESE_LANGUAGE.equals(language) || + KOREAN_LANGUAGE.equals(language); + } + + public LocaleWrapper(Locale locale) { + mLocale = locale; + if (mLocale != null) { + mLanguage = mLocale.getLanguage().toLowerCase(); + mLocaleIsCJK = isLanguageCJK(mLanguage); + } else { + mLanguage = null; + mLocaleIsCJK = false; + } + } + + public boolean hasLocale() { + return mLocale != null; + } + + public Locale getLocale() { + return mLocale; + } + + public boolean isLocale(Locale locale) { + return mLocale == null ? (locale == null) : mLocale.equals(locale); + } + + public boolean isLocaleCJK() { + return mLocaleIsCJK; + } + + public boolean isLanguage(String language) { + return mLanguage == null ? (language == null) + : mLanguage.equalsIgnoreCase(language); + } + + public String toString() { + return mLocale != null ? mLocale.toLanguageTag() : "(null)"; + } + } + + public static LocaleSet getDefault() { + return new LocaleSet(Locale.getDefault()); + } + + public LocaleSet(Locale locale) { + this(locale, null); + } + + /** + * Returns locale set for a given set of IETF BCP-47 tags separated by ';'. + * BCP-47 tags are what is used by ICU 52's toLanguageTag/forLanguageTag + * methods to represent individual Locales: "en-US" for Locale.US, + * "zh-CN" for Locale.CHINA, etc. So eg "en-US;zh-CN" specifies the locale + * set LocaleSet(Locale.US, Locale.CHINA). + * + * @param localeString One or more BCP-47 tags separated by ';'. + * @return LocaleSet for specified locale string, or default set if null + * or unable to parse. + */ + public static LocaleSet getLocaleSet(String localeString) { + // Locale.toString() generates strings like "en_US" and "zh_CN_#Hans". + // Locale.toLanguageTag() generates strings like "en-US" and "zh-Hans-CN". + // We can only parse language tags. + if (localeString != null && localeString.indexOf('_') == -1) { + final String[] locales = localeString.split(";"); + final Locale primaryLocale = Locale.forLanguageTag(locales[0]); + // ICU tags undefined/unparseable locales "und" + if (primaryLocale != null && + !TextUtils.equals(primaryLocale.toLanguageTag(), "und")) { + if (locales.length > 1 && locales[1] != null) { + final Locale secondaryLocale = Locale.forLanguageTag(locales[1]); + if (secondaryLocale != null && + !TextUtils.equals(secondaryLocale.toLanguageTag(), "und")) { + return new LocaleSet(primaryLocale, secondaryLocale); + } + } + return new LocaleSet(primaryLocale); + } + } + return getDefault(); + } + + private final LocaleWrapper mPrimaryLocale; + private final LocaleWrapper mSecondaryLocale; + + public LocaleSet(Locale primaryLocale, Locale secondaryLocale) { + mPrimaryLocale = new LocaleWrapper(primaryLocale); + mSecondaryLocale = new LocaleWrapper( + mPrimaryLocale.equals(secondaryLocale) ? null : secondaryLocale); + } + + public LocaleSet normalize() { + final Locale primaryLocale = getPrimaryLocale(); + if (primaryLocale == null) { + return getDefault(); + } + Locale secondaryLocale = getSecondaryLocale(); + // disallow both locales with same language (redundant and/or conflicting) + // disallow both locales CJK (conflicting rules) + if (secondaryLocale == null || + isPrimaryLanguage(secondaryLocale.getLanguage()) || + (isPrimaryLocaleCJK() && isSecondaryLocaleCJK())) { + return new LocaleSet(primaryLocale); + } + // unnecessary to specify English as secondary locale (redundant) + if (isSecondaryLanguage(Locale.ENGLISH.getLanguage())) { + return new LocaleSet(primaryLocale); + } + return this; + } + + public boolean hasSecondaryLocale() { + return mSecondaryLocale.hasLocale(); + } + + public Locale getPrimaryLocale() { + return mPrimaryLocale.getLocale(); + } + + public Locale getSecondaryLocale() { + return mSecondaryLocale.getLocale(); + } + + public boolean isPrimaryLocale(Locale locale) { + return mPrimaryLocale.isLocale(locale); + } + + public boolean isSecondaryLocale(Locale locale) { + return mSecondaryLocale.isLocale(locale); + } + + private static final String SCRIPT_SIMPLIFIED_CHINESE = "Hans"; + private static final String SCRIPT_TRADITIONAL_CHINESE = "Hant"; + + @VisibleForTesting + public static boolean isLocaleSimplifiedChinese(Locale locale) { + // language must match + if (locale == null || !TextUtils.equals(locale.getLanguage(), CHINESE_LANGUAGE)) { + return false; + } + // script is optional but if present must match + if (!TextUtils.isEmpty(locale.getScript())) { + return locale.getScript().equals(SCRIPT_SIMPLIFIED_CHINESE); + } + // if no script, must match known country + return locale.equals(Locale.SIMPLIFIED_CHINESE); + } + + public boolean isPrimaryLocaleSimplifiedChinese() { + return isLocaleSimplifiedChinese(getPrimaryLocale()); + } + + public boolean isSecondaryLocaleSimplifiedChinese() { + return isLocaleSimplifiedChinese(getSecondaryLocale()); + } + + @VisibleForTesting + public static boolean isLocaleTraditionalChinese(Locale locale) { + // language must match + if (locale == null || !TextUtils.equals(locale.getLanguage(), CHINESE_LANGUAGE)) { + return false; + } + // script is optional but if present must match + if (!TextUtils.isEmpty(locale.getScript())) { + return locale.getScript().equals(SCRIPT_TRADITIONAL_CHINESE); + } + // if no script, must match known country + return locale.equals(Locale.TRADITIONAL_CHINESE); + } + + public boolean isPrimaryLocaleTraditionalChinese() { + return isLocaleTraditionalChinese(getPrimaryLocale()); + } + + public boolean isSecondaryLocaleTraditionalChinese() { + return isLocaleTraditionalChinese(getSecondaryLocale()); + } + + public boolean isPrimaryLocaleCJK() { + return mPrimaryLocale.isLocaleCJK(); + } + + public boolean isSecondaryLocaleCJK() { + return mSecondaryLocale.isLocaleCJK(); + } + + public boolean isPrimaryLanguage(String language) { + return mPrimaryLocale.isLanguage(language); + } + + public boolean isSecondaryLanguage(String language) { + return mSecondaryLocale.isLanguage(language); + } + + @Override + public boolean equals(Object object) { + if (object == this) { + return true; + } + if (object instanceof LocaleSet) { + final LocaleSet other = (LocaleSet) object; + return other.isPrimaryLocale(mPrimaryLocale.getLocale()) + && other.isSecondaryLocale(mSecondaryLocale.getLocale()); + } + return false; + } + + @Override + public final String toString() { + StringBuilder builder = new StringBuilder(); + builder.append(mPrimaryLocale.toString()); + if (hasSecondaryLocale()) { + builder.append(";"); + builder.append(mSecondaryLocale.toString()); + } + return builder.toString(); + } +} diff --git a/src/com/android/launcher3/locale/LocaleSetManager.java b/src/com/android/launcher3/locale/LocaleSetManager.java new file mode 100644 index 000000000..b058718f3 --- /dev/null +++ b/src/com/android/launcher3/locale/LocaleSetManager.java @@ -0,0 +1,82 @@ +/* + * Copyright (C) 2014 The CyanogenMod Project + * Copyright (C) 2009 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ +package com.android.launcher3.locale; + +import android.content.Context; +import android.text.TextUtils; +import android.util.Log; + +import com.google.common.annotations.VisibleForTesting; + +import java.util.Locale; + +import libcore.icu.ICU; + +public class LocaleSetManager { + private static final String TAG = LocaleSetManager.class.getSimpleName(); + + private LocaleSet mCurrentLocales; + private final Context mContext; + + public LocaleSetManager(final Context context) { + mContext = context; + } + + /** + * Sets up the locale set + * @param localeSet value to set it to + */ + public void updateLocaleSet(LocaleSet localeSet) { + Log.d(TAG, "Locale Changed from: " + mCurrentLocales + " to " + localeSet); + mCurrentLocales = localeSet; + LocaleUtils.getInstance().setLocales(mCurrentLocales); + } + + /** + * This takes an old and new locale set and creates a combined locale set. If they share a + * primary then the old one is returned + * @return the combined locale set + */ + private static LocaleSet getCombinedLocaleSet(LocaleSet oldLocales, Locale newLocale) { + Locale prevLocale = null; + + if (oldLocales != null) { + prevLocale = oldLocales.getPrimaryLocale(); + // If primary locale is unchanged then no change to locale set. + if (newLocale.equals(prevLocale)) { + return oldLocales; + } + } + + // Otherwise, construct a new locale set based on the new locale + // and the previous primary locale. + return new LocaleSet(newLocale, prevLocale).normalize(); + } + + /** + * @return the system locale set + */ + public LocaleSet getSystemLocaleSet() { + final Locale curLocale = getLocale(); + return getCombinedLocaleSet(mCurrentLocales, curLocale); + } + + @VisibleForTesting + protected Locale getLocale() { + return Locale.getDefault(); + } +} diff --git a/src/com/android/launcher3/locale/LocaleUtils.java b/src/com/android/launcher3/locale/LocaleUtils.java new file mode 100644 index 000000000..cc8277a6c --- /dev/null +++ b/src/com/android/launcher3/locale/LocaleUtils.java @@ -0,0 +1,484 @@ +/* + * Copyright (C) 2010 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License + */ + +package com.android.launcher3.locale; + +import android.provider.ContactsContract.FullNameStyle; +import android.provider.ContactsContract.PhoneticNameStyle; +import android.text.TextUtils; +import android.util.Log; + +import com.android.launcher3.locale.HanziToPinyin.Token; + +import com.google.common.annotations.VisibleForTesting; + +import java.lang.Character.UnicodeBlock; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.Iterator; +import java.util.Locale; +import java.util.Set; + +import libcore.icu.AlphabeticIndex; +import libcore.icu.AlphabeticIndex.ImmutableIndex; +import libcore.icu.Transliterator; + +/** + * This utility class provides specialized handling for locale specific + * information: labels, name lookup keys. + * + * This class has been modified from ContactLocaleUtils.java for now to rip out + * Chinese/Japanese specific Alphabetic Indexers because the MediaProvider's sort + * is using a Collator sort which can result in confusing behavior, so for now we will + * simplify and batch up those results until we later support our own internal databases + * An example of what This is, if we have songs "Able", "Xylophone" and "上" in + * simplified chinese language The media provider would give it to us in that order sorted, + * but the ICU lib would return "A", "X", "S". Unless we write our own db or do our own sort + * there is no good easy solution + */ +public class LocaleUtils { + public static final String TAG = "LauncherLocale"; + + public static final Locale LOCALE_ARABIC = new Locale("ar"); + public static final Locale LOCALE_GREEK = new Locale("el"); + public static final Locale LOCALE_HEBREW = new Locale("he"); + // Serbian and Ukrainian labels are complementary supersets of Russian + public static final Locale LOCALE_SERBIAN = new Locale("sr"); + public static final Locale LOCALE_UKRAINIAN = new Locale("uk"); + public static final Locale LOCALE_THAI = new Locale("th"); + + /** + * This class is the default implementation and should be the base class + * for other locales. + * + * sortKey: same as name + * nameLookupKeys: none + * labels: uses ICU AlphabeticIndex for labels and extends by labeling + * phone numbers "#". Eg English labels are: [A-Z], #, " " + */ + private static class LocaleUtilsBase { + private static final String EMPTY_STRING = ""; + private static final String NUMBER_STRING = "#"; + + protected final ImmutableIndex mAlphabeticIndex; + private final int mAlphabeticIndexBucketCount; + private final int mNumberBucketIndex; + private final boolean mEnableSecondaryLocalePinyin; + + public LocaleUtilsBase(LocaleSet locales) { + // AlphabeticIndex.getBucketLabel() uses a binary search across + // the entire label set so care should be taken about growing this + // set too large. The following set determines for which locales + // we will show labels other than your primary locale. General rules + // of thumb for adding a locale: should be a supported locale; and + // should not be included if from a name it is not deterministic + // which way to label it (so eg Chinese cannot be added because + // the labeling of a Chinese character varies between Simplified, + // Traditional, and Japanese locales). Use English only for all + // Latin based alphabets. Ukrainian and Serbian are chosen for + // Cyrillic because their alphabets are complementary supersets + // of Russian. + final Locale secondaryLocale = locales.getSecondaryLocale(); + mEnableSecondaryLocalePinyin = locales.isSecondaryLocaleSimplifiedChinese(); + AlphabeticIndex ai = new AlphabeticIndex(locales.getPrimaryLocale()) + .setMaxLabelCount(300); + if (secondaryLocale != null) { + ai.addLabels(secondaryLocale); + } + mAlphabeticIndex = ai.addLabels(Locale.ENGLISH) + .addLabels(Locale.JAPANESE) + .addLabels(Locale.KOREAN) + .addLabels(LOCALE_THAI) + .addLabels(LOCALE_ARABIC) + .addLabels(LOCALE_HEBREW) + .addLabels(LOCALE_GREEK) + .addLabels(LOCALE_UKRAINIAN) + .addLabels(LOCALE_SERBIAN) + .getImmutableIndex(); + mAlphabeticIndexBucketCount = mAlphabeticIndex.getBucketCount(); + mNumberBucketIndex = mAlphabeticIndexBucketCount - 1; + } + + public String getSortKey(String name) { + return name; + } + + /** + * Returns the bucket index for the specified string. AlphabeticIndex + * sorts strings into buckets numbered in order from 0 to N, where the + * exact value of N depends on how many representative index labels are + * used in a particular locale. This routine adds one additional bucket + * for phone numbers. It attempts to detect phone numbers and shifts + * the bucket indexes returned by AlphabeticIndex in order to make room + * for the new # bucket, so the returned range becomes 0 to N+1. + */ + public int getBucketIndex(String name) { + boolean prefixIsNumeric = false; + final int length = name.length(); + int offset = 0; + while (offset < length) { + int codePoint = Character.codePointAt(name, offset); + // Ignore standard phone number separators and identify any + // string that otherwise starts with a number. + if (Character.isDigit(codePoint)) { + prefixIsNumeric = true; + break; + } else if (!Character.isSpaceChar(codePoint) && + codePoint != '+' && codePoint != '(' && + codePoint != ')' && codePoint != '.' && + codePoint != '-' && codePoint != '#') { + break; + } + offset += Character.charCount(codePoint); + } + if (prefixIsNumeric) { + return mNumberBucketIndex; + } + + /** + * TODO: ICU 52 AlphabeticIndex doesn't support Simplified Chinese + * as a secondary locale. Remove the following if that is added. + */ + if (mEnableSecondaryLocalePinyin) { + name = HanziToPinyin.getInstance().transliterate(name); + } + final int bucket = mAlphabeticIndex.getBucketIndex(name); + if (bucket < 0) { + return -1; + } + if (bucket >= mNumberBucketIndex) { + return bucket + 1; + } + return bucket; + } + + /** + * Returns the number of buckets in use (one more than AlphabeticIndex + * uses, because this class adds a bucket for phone numbers). + */ + public int getBucketCount() { + return mAlphabeticIndexBucketCount + 1; + } + + /** + * Returns the label for the specified bucket index if a valid index, + * otherwise returns an empty string. '#' is returned for the phone + * number bucket; for all others, the AlphabeticIndex label is returned. + */ + public String getBucketLabel(int bucketIndex) { + if (bucketIndex < 0 || bucketIndex >= getBucketCount()) { + return EMPTY_STRING; + } else if (bucketIndex == mNumberBucketIndex) { + return NUMBER_STRING; + } else if (bucketIndex > mNumberBucketIndex) { + --bucketIndex; + } + return mAlphabeticIndex.getBucketLabel(bucketIndex); + } + + @SuppressWarnings("unused") + public Iterator getNameLookupKeys(String name, int nameStyle) { + return null; + } + + public ArrayList getLabels() { + final int bucketCount = getBucketCount(); + final ArrayList labels = new ArrayList(bucketCount); + for(int i = 0; i < bucketCount; ++i) { + labels.add(getBucketLabel(i)); + } + return labels; + } + } + + /** + * Japanese specific locale overrides. + * + * sortKey: unchanged (same as name) + * nameLookupKeys: unchanged (none) + * labels: extends default labels by labeling unlabeled CJ characters + * with the Japanese character 他 ("misc"). Japanese labels are: + * あ, か, さ, た, な, は, ま, や, ら, わ, 他, [A-Z], #, " " + */ + private static class JapaneseContactUtils extends LocaleUtilsBase { + // \u4ed6 is Japanese character 他 ("misc") + private static final String JAPANESE_MISC_LABEL = "\u4ed6"; + private final int mMiscBucketIndex; + + public JapaneseContactUtils(LocaleSet locales) { + super(locales); + // Determine which bucket AlphabeticIndex is lumping unclassified + // Japanese characters into by looking up the bucket index for + // a representative Kanji/CJK unified ideograph (\u65e5 is the + // character '日'). + mMiscBucketIndex = super.getBucketIndex("\u65e5"); + } + + // Set of UnicodeBlocks for unified CJK (Chinese) characters and + // Japanese characters. This includes all code blocks that might + // contain a character used in Japanese (which is why unified CJK + // blocks are included but Korean Hangul and jamo are not). + private static final Set CJ_BLOCKS; + static { + Set set = new HashSet(); + set.add(UnicodeBlock.HIRAGANA); + set.add(UnicodeBlock.KATAKANA); + set.add(UnicodeBlock.KATAKANA_PHONETIC_EXTENSIONS); + set.add(UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS); + set.add(UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS); + set.add(UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A); + set.add(UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B); + set.add(UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION); + set.add(UnicodeBlock.CJK_RADICALS_SUPPLEMENT); + set.add(UnicodeBlock.CJK_COMPATIBILITY); + set.add(UnicodeBlock.CJK_COMPATIBILITY_FORMS); + set.add(UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS); + set.add(UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT); + CJ_BLOCKS = Collections.unmodifiableSet(set); + } + + /** + * Helper routine to identify unlabeled Chinese or Japanese characters + * to put in a 'misc' bucket. + * + * @return true if the specified Unicode code point is Chinese or + * Japanese + */ + private static boolean isChineseOrJapanese(int codePoint) { + return CJ_BLOCKS.contains(UnicodeBlock.of(codePoint)); + } + + /** + * Returns the bucket index for the specified string. Adds an + * additional 'misc' bucket for Kanji characters to the base class set. + */ + @Override + public int getBucketIndex(String name) { + final int bucketIndex = super.getBucketIndex(name); + if ((bucketIndex == mMiscBucketIndex && + !isChineseOrJapanese(Character.codePointAt(name, 0))) || + bucketIndex > mMiscBucketIndex) { + return bucketIndex + 1; + } + return bucketIndex; + } + + /** + * Returns the number of buckets in use (one more than the base class + * uses, because this class adds a bucket for Kanji). + */ + @Override + public int getBucketCount() { + return super.getBucketCount() + 1; + } + + /** + * Returns the label for the specified bucket index if a valid index, + * otherwise returns an empty string. '他' is returned for unclassified + * Kanji; for all others, the label determined by the base class is + * returned. + */ + @Override + public String getBucketLabel(int bucketIndex) { + if (bucketIndex == mMiscBucketIndex) { + return JAPANESE_MISC_LABEL; + } else if (bucketIndex > mMiscBucketIndex) { + --bucketIndex; + } + return super.getBucketLabel(bucketIndex); + } + + @Override + public Iterator getNameLookupKeys(String name, int nameStyle) { + // Hiragana and Katakana will be positively identified as Japanese. + if (nameStyle == PhoneticNameStyle.JAPANESE) { + return getRomajiNameLookupKeys(name); + } + return null; + } + + private static boolean mInitializedTransliterator; + private static Transliterator mJapaneseTransliterator; + + private static Transliterator getJapaneseTransliterator() { + synchronized(JapaneseContactUtils.class) { + if (!mInitializedTransliterator) { + mInitializedTransliterator = true; + Transliterator t = null; + try { + t = new Transliterator("Hiragana-Latin; Katakana-Latin;" + + " Latin-Ascii"); + } catch (RuntimeException e) { + Log.w(TAG, "Hiragana/Katakana-Latin transliterator data" + + " is missing"); + } + mJapaneseTransliterator = t; + } + return mJapaneseTransliterator; + } + } + + public static Iterator getRomajiNameLookupKeys(String name) { + final Transliterator t = getJapaneseTransliterator(); + if (t == null) { + return null; + } + final String romajiName = t.transliterate(name); + if (TextUtils.isEmpty(romajiName) || + TextUtils.equals(name, romajiName)) { + return null; + } + final HashSet keys = new HashSet(); + keys.add(romajiName); + return keys.iterator(); + } + } + + /** + * Simplified Chinese specific locale overrides. Uses ICU Transliterator + * for generating pinyin transliteration. + * + * sortKey: unchanged (same as name) + * nameLookupKeys: adds additional name lookup keys + * - Chinese character's pinyin and pinyin's initial character. + * - Latin word and initial character. + * labels: unchanged + * Simplified Chinese labels are the same as English: [A-Z], #, " " + */ + private static class SimplifiedChineseContactUtils + extends LocaleUtilsBase { + public SimplifiedChineseContactUtils(LocaleSet locales) { + super(locales); + } + + @Override + public Iterator getNameLookupKeys(String name, int nameStyle) { + if (nameStyle != FullNameStyle.JAPANESE && + nameStyle != FullNameStyle.KOREAN) { + return getPinyinNameLookupKeys(name); + } + return null; + } + + public static Iterator getPinyinNameLookupKeys(String name) { + // TODO : Reduce the object allocation. + HashSet keys = new HashSet(); + ArrayList tokens = HanziToPinyin.getInstance().getTokens(name); + final int tokenCount = tokens.size(); + final StringBuilder keyPinyin = new StringBuilder(); + final StringBuilder keyInitial = new StringBuilder(); + // There is no space among the Chinese Characters, the variant name + // lookup key wouldn't work for Chinese. The keyOriginal is used to + // build the lookup keys for itself. + final StringBuilder keyOriginal = new StringBuilder(); + for (int i = tokenCount - 1; i >= 0; i--) { + final Token token = tokens.get(i); + if (Token.UNKNOWN == token.type) { + continue; + } + if (Token.PINYIN == token.type) { + keyPinyin.insert(0, token.target); + keyInitial.insert(0, token.target.charAt(0)); + } else if (Token.LATIN == token.type) { + // Avoid adding space at the end of String. + if (keyPinyin.length() > 0) { + keyPinyin.insert(0, ' '); + } + if (keyOriginal.length() > 0) { + keyOriginal.insert(0, ' '); + } + keyPinyin.insert(0, token.source); + keyInitial.insert(0, token.source.charAt(0)); + } + keyOriginal.insert(0, token.source); + keys.add(keyOriginal.toString()); + keys.add(keyPinyin.toString()); + keys.add(keyInitial.toString()); + } + return keys.iterator(); + } + } + + private static final String JAPANESE_LANGUAGE = Locale.JAPANESE.getLanguage().toLowerCase(); + private static LocaleUtils sSingleton; + + private final LocaleSet mLocales; + private final LocaleUtilsBase mUtils; + + private LocaleUtils(LocaleSet locales) { + if (locales == null) { + mLocales = LocaleSet.getDefault(); + } else { + mLocales = locales; + } + if (mLocales.isPrimaryLanguage(JAPANESE_LANGUAGE)) { + mUtils = new JapaneseContactUtils(mLocales); + } else if (mLocales.isPrimaryLocaleSimplifiedChinese()) { + mUtils = new SimplifiedChineseContactUtils(mLocales); + } else { + mUtils = new LocaleUtilsBase(mLocales); + } + Log.i(TAG, "AddressBook Labels [" + mLocales.toString() + "]: " + + getLabels().toString()); + } + + public boolean isLocale(LocaleSet locales) { + return mLocales.equals(locales); + } + + public static synchronized LocaleUtils getInstance() { + if (sSingleton == null) { + sSingleton = new LocaleUtils(LocaleSet.getDefault()); + } + return sSingleton; + } + + @VisibleForTesting + public static synchronized void setLocale(Locale locale) { + setLocales(new LocaleSet(locale)); + } + + public static synchronized void setLocales(LocaleSet locales) { + if (sSingleton == null || !sSingleton.isLocale(locales)) { + sSingleton = new LocaleUtils(locales); + } + } + + public String getSortKey(String name, int nameStyle) { + return mUtils.getSortKey(name); + } + + public int getBucketIndex(String name) { + return mUtils.getBucketIndex(name); + } + + public int getBucketCount() { + return mUtils.getBucketCount(); + } + + public String getBucketLabel(int bucketIndex) { + return mUtils.getBucketLabel(bucketIndex); + } + + public String getLabel(String name) { + return getBucketLabel(getBucketIndex(name)); + } + + public ArrayList getLabels() { + return mUtils.getLabels(); + } +} -- cgit v1.2.3