/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ package org.mozilla.gecko.preferences; import java.nio.ByteBuffer; import java.text.Collator; import java.util.Arrays; import java.util.Collection; import java.util.HashSet; import java.util.Locale; import java.util.Set; import org.mozilla.gecko.AppConstants.Versions; import org.mozilla.gecko.BrowserLocaleManager; import org.mozilla.gecko.Locales; import org.mozilla.gecko.R; import android.content.Context; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Paint; import android.preference.ListPreference; import android.text.TextUtils; import android.util.AttributeSet; import android.util.Log; public class LocaleListPreference extends ListPreference { private static final String LOG_TAG = "GeckoLocaleList"; /** * With thanks to for the * initial solution. * * This class encapsulates an approach to checking whether a script * is usable on a device. We attempt to draw a character from the * script (e.g., ব). If the fonts on the device don't have the correct * glyph, Android typically renders whitespace (rather than .notdef). * * Pass in part of the name of the locale in its local representation, * and a whitespace character; this class performs the graphical comparison. * * See Bug 1023451 Comment 24 for extensive explanation. */ private static class CharacterValidator { private static final int BITMAP_WIDTH = 32; private static final int BITMAP_HEIGHT = 48; private final Paint paint = new Paint(); private final byte[] missingCharacter; public CharacterValidator(String missing) { this.missingCharacter = getPixels(drawBitmap(missing)); } private Bitmap drawBitmap(String text){ Bitmap b = Bitmap.createBitmap(BITMAP_WIDTH, BITMAP_HEIGHT, Bitmap.Config.ALPHA_8); Canvas c = new Canvas(b); c.drawText(text, 0, BITMAP_HEIGHT / 2, this.paint); return b; } private static byte[] getPixels(final Bitmap b) { final int byteCount; if (Versions.feature19Plus) { byteCount = b.getAllocationByteCount(); } else { // Close enough for government work. // Equivalent to getByteCount, but works on <12. byteCount = b.getRowBytes() * b.getHeight(); } final ByteBuffer buffer = ByteBuffer.allocate(byteCount); try { b.copyPixelsToBuffer(buffer); } catch (RuntimeException e) { // Android throws this if there's not enough space in the buffer. // This should never occur, but if it does, we don't // really care -- we probably don't need the entire image. // This is awful. I apologize. if ("Buffer not large enough for pixels".equals(e.getMessage())) { return buffer.array(); } throw e; } return buffer.array(); } public boolean characterIsMissingInFont(String ch) { byte[] rendered = getPixels(drawBitmap(ch)); return Arrays.equals(rendered, missingCharacter); } } private volatile Locale entriesLocale; private final CharacterValidator characterValidator; public LocaleListPreference(Context context) { this(context, null); } public LocaleListPreference(Context context, AttributeSet attributes) { super(context, attributes); // Thus far, missing glyphs are replaced by whitespace, not a box // or other Unicode codepoint. this.characterValidator = new CharacterValidator(" "); buildList(); } private static final class LocaleDescriptor implements Comparable { // We use Locale.US here to ensure a stable ordering of entries. private static final Collator COLLATOR = Collator.getInstance(Locale.US); public final String tag; private final String nativeName; public LocaleDescriptor(String tag) { this(Locales.parseLocaleCode(tag), tag); } public LocaleDescriptor(Locale locale, String tag) { this.tag = tag; final String displayName = locale.getDisplayName(locale); if (TextUtils.isEmpty(displayName)) { // There's nothing sane we can do. Log.w(LOG_TAG, "Display name is empty. Using " + locale.toString()); this.nativeName = locale.toString(); return; } // For now, uppercase the first character of LTR locale names. // This is pretty much what Android does. This is a reasonable hack // for Bug 1014602, but it won't generalize to all locales. final byte directionality = Character.getDirectionality(displayName.charAt(0)); if (directionality == Character.DIRECTIONALITY_LEFT_TO_RIGHT) { this.nativeName = displayName.substring(0, 1).toUpperCase(locale) + displayName.substring(1); return; } this.nativeName = displayName; } public String getTag() { return this.tag; } public String getDisplayName() { return this.nativeName; } @Override public String toString() { return this.nativeName; } @Override public int compareTo(LocaleDescriptor another) { // We sort by name, so we use Collator. return COLLATOR.compare(this.nativeName, another.nativeName); } /** * See Bug 1023451 Comment 10 for the research that led to * this method. * * @return true if this locale can be used for displaying UI * on this device without known issues. */ public boolean isUsable(CharacterValidator validator) { if (Versions.preLollipop && this.tag.matches("[a-zA-Z]{3}.*")) { // Earlier versions of Android can't load three-char locale code // resources. return false; } // Oh, for Java 7 switch statements. if (this.tag.equals("bn-IN")) { // Bengali sometimes has an English label if the Bengali script // is missing. This prevents us from simply checking character // rendering for bn-IN; we'll get a false positive for "B", not "ব". // // This doesn't seem to affect other Bengali-script locales // (below), which always have a label in native script. if (!this.nativeName.startsWith("বাংলা")) { // We're on an Android version that doesn't even have // characters to say বাংলা. Definite failure. return false; } } // These locales use a script that is often unavailable // on common Android devices. Make sure we can show them. // See documentation for CharacterValidator. // Note that bn-IN is checked here even if it passed above. if (this.tag.equals("or") || this.tag.equals("my") || this.tag.equals("pa-IN") || this.tag.equals("gu-IN") || this.tag.equals("bn-IN")) { if (validator.characterIsMissingInFont(this.nativeName.substring(0, 1))) { return false; } } return true; } } /** * Not every locale we ship can be used on every device, due to * font or rendering constraints. * * This method filters down the list before generating the descriptor array. */ private LocaleDescriptor[] getUsableLocales() { Collection shippingLocales = BrowserLocaleManager.getPackagedLocaleTags(getContext()); // Future: single-locale builds should be specified, too. if (shippingLocales == null) { final String fallbackTag = BrowserLocaleManager.getInstance().getFallbackLocaleTag(); return new LocaleDescriptor[] { new LocaleDescriptor(fallbackTag) }; } final int initialCount = shippingLocales.size(); final Set locales = new HashSet(initialCount); for (String tag : shippingLocales) { final LocaleDescriptor descriptor = new LocaleDescriptor(tag); if (!descriptor.isUsable(this.characterValidator)) { Log.w(LOG_TAG, "Skipping locale " + tag + " on this device."); continue; } locales.add(descriptor); } final int usableCount = locales.size(); final LocaleDescriptor[] descriptors = locales.toArray(new LocaleDescriptor[usableCount]); Arrays.sort(descriptors, 0, usableCount); return descriptors; } @Override protected void onDialogClosed(boolean positiveResult) { // The superclass will take care of persistence. super.onDialogClosed(positiveResult); // Use this hook to try to fix up the environment ASAP. // Do this so that the redisplayed fragment is inflated // with the right locale. final Locale selectedLocale = getSelectedLocale(); final Context context = getContext(); BrowserLocaleManager.getInstance().updateConfiguration(context, selectedLocale); } private Locale getSelectedLocale() { final String tag = getValue(); if (tag == null || tag.equals("")) { return Locale.getDefault(); } return Locales.parseLocaleCode(tag); } @Override public CharSequence getSummary() { final String value = getValue(); if (TextUtils.isEmpty(value)) { return getContext().getString(R.string.locale_system_default); } // We can't trust super.getSummary() across locale changes, // apparently, so let's do the same work. return new LocaleDescriptor(value).getDisplayName(); } private void buildList() { final Locale currentLocale = Locale.getDefault(); Log.d(LOG_TAG, "Building locales list. Current locale: " + currentLocale); if (currentLocale.equals(this.entriesLocale) && getEntries() != null) { Log.v(LOG_TAG, "No need to build list."); return; } final LocaleDescriptor[] descriptors = getUsableLocales(); final int count = descriptors.length; this.entriesLocale = currentLocale; // We leave room for "System default". final String[] entries = new String[count + 1]; final String[] values = new String[count + 1]; entries[0] = getContext().getString(R.string.locale_system_default); values[0] = ""; for (int i = 0; i < count; ++i) { final String displayName = descriptors[i].getDisplayName(); final String tag = descriptors[i].getTag(); Log.v(LOG_TAG, displayName + " => " + tag); entries[i + 1] = displayName; values[i + 1] = tag; } setEntries(entries); setEntryValues(values); } }