/******************************************************************************* * Copyright 2011 See AUTHORS file. * * 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.badlogic.gdx.utils; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.MissingResourceException; import com.badlogic.gdx.files.FileHandle; /** A {@code I18NBundle} provides {@code Locale}-specific resources loaded from property files. A bundle contains a number of named * resources, whose names and values are {@code Strings}. A bundle may have a parent bundle, and when a resource is not found in a * bundle, the parent bundle is searched for the resource. If the fallback mechanism reaches the base bundle and still can't find * the resource it throws a {@code MissingResourceException}. * * <ul> * <li>All bundles for the same group of resources share a common base bundle. This base bundle acts as the root and is the last * fallback in case none of its children was able to respond to a request.</li> * <li>The first level contains changes between different languages. Only the differences between a language and the language of * the base bundle need to be handled by a language-specific {@code I18NBundle}.</li> * <li>The second level contains changes between different countries that use the same language. Only the differences between a * country and the country of the language bundle need to be handled by a country-specific {@code I18NBundle}.</li> * <li>The third level contains changes that don't have a geographic reason (e.g. changes that where made at some point in time * like {@code PREEURO} where the currency of come countries changed. The country bundle would return the current currency (Euro) * and the {@code PREEURO} variant bundle would return the old currency (e.g. DM for Germany).</li> * </ul> * * <strong>Examples</strong> * <ul> * <li>BaseName (base bundle) * <li>BaseName_de (german language bundle) * <li>BaseName_fr (french language bundle) * <li>BaseName_de_DE (bundle with Germany specific resources in german) * <li>BaseName_de_CH (bundle with Switzerland specific resources in german) * <li>BaseName_fr_CH (bundle with Switzerland specific resources in french) * <li>BaseName_de_DE_PREEURO (bundle with Germany specific resources in german of the time before the Euro) * <li>BaseName_fr_FR_PREEURO (bundle with France specific resources in french of the time before the Euro) * </ul> * * It's also possible to create variants for languages or countries. This can be done by just skipping the country or language * abbreviation: BaseName_us__POSIX or BaseName__DE_PREEURO. But it's not allowed to circumvent both language and country: * BaseName___VARIANT is illegal. * * @see PropertiesUtils * * @author davebaol */ public class I18NBundle { private static final String DEFAULT_ENCODING = "UTF-8"; // Locale.ROOT does not exist in Android API level 8 private static final Locale ROOT_LOCALE = new Locale("", "", ""); private static boolean simpleFormatter = false; private static boolean exceptionOnMissingKey = true; /** The parent of this {@code I18NBundle} that is used if this bundle doesn't include the requested resource. */ private I18NBundle parent; /** The locale for this bundle. */ private Locale locale; /** The properties for this bundle. */ private ObjectMap<String, String> properties; /** The formatter used for argument replacement. */ private TextFormatter formatter; /** Returns the flag indicating whether to use the simplified message pattern syntax (default is false). This flag is always * assumed to be true on GWT backend. */ public static boolean getSimpleFormatter () { return simpleFormatter; } /** Sets the flag indicating whether to use the simplified message pattern. The flag must be set before calling the factory * methods {@code createBundle}. Notice that this method has no effect on the GWT backend where it's always assumed to be true. */ public static void setSimpleFormatter (boolean enabled) { simpleFormatter = enabled; } /** Returns the flag indicating whether to throw a {@link MissingResourceException} from the {@link #get(String) get(key)} * method if no string for the given key can be found. If this flag is {@code false} the missing key surrounded by {@code ???} * is returned. */ public static boolean getExceptionOnMissingKey () { return exceptionOnMissingKey; } /** Sets the flag indicating whether to throw a {@link MissingResourceException} from the {@link #get(String) get(key)} method * if no string for the given key can be found. If this flag is {@code false} the missing key surrounded by {@code ???} is * returned. */ public static void setExceptionOnMissingKey (boolean enabled) { exceptionOnMissingKey = enabled; } /** Creates a new bundle using the specified <code>baseFileHandle</code>, the default locale and the default encoding "UTF-8". * * @param baseFileHandle the file handle to the base of the bundle * @exception NullPointerException if <code>baseFileHandle</code> is <code>null</code> * @exception MissingResourceException if no bundle for the specified base file handle can be found * @return a bundle for the given base file handle and the default locale */ public static I18NBundle createBundle (FileHandle baseFileHandle) { return createBundleImpl(baseFileHandle, Locale.getDefault(), DEFAULT_ENCODING); } /** Creates a new bundle using the specified <code>baseFileHandle</code> and <code>locale</code>; the default encoding "UTF-8" * is used. * * @param baseFileHandle the file handle to the base of the bundle * @param locale the locale for which a bundle is desired * @return a bundle for the given base file handle and locale * @exception NullPointerException if <code>baseFileHandle</code> or <code>locale</code> is <code>null</code> * @exception MissingResourceException if no bundle for the specified base file handle can be found */ public static I18NBundle createBundle (FileHandle baseFileHandle, Locale locale) { return createBundleImpl(baseFileHandle, locale, DEFAULT_ENCODING); } /** Creates a new bundle using the specified <code>baseFileHandle</code> and <code>encoding</code>; the default locale is used. * * @param baseFileHandle the file handle to the base of the bundle * @param encoding the charter encoding * @return a bundle for the given base file handle and locale * @exception NullPointerException if <code>baseFileHandle</code> or <code>encoding</code> is <code>null</code> * @exception MissingResourceException if no bundle for the specified base file handle can be found */ public static I18NBundle createBundle (FileHandle baseFileHandle, String encoding) { return createBundleImpl(baseFileHandle, Locale.getDefault(), encoding); } /** Creates a new bundle using the specified <code>baseFileHandle</code>, <code>locale</code> and <code>encoding</code>. * * @param baseFileHandle the file handle to the base of the bundle * @param locale the locale for which a bundle is desired * @param encoding the charter encoding * @return a bundle for the given base file handle and locale * @exception NullPointerException if <code>baseFileHandle</code>, <code>locale</code> or <code>encoding</code> is * <code>null</code> * @exception MissingResourceException if no bundle for the specified base file handle can be found */ public static I18NBundle createBundle (FileHandle baseFileHandle, Locale locale, String encoding) { return createBundleImpl(baseFileHandle, locale, encoding); } private static I18NBundle createBundleImpl (FileHandle baseFileHandle, Locale locale, String encoding) { if (baseFileHandle == null || locale == null || encoding == null) throw new NullPointerException(); I18NBundle bundle = null; I18NBundle baseBundle = null; Locale targetLocale = locale; do { // Create the candidate locales List<Locale> candidateLocales = getCandidateLocales(targetLocale); // Load the bundle and its parents recursively bundle = loadBundleChain(baseFileHandle, encoding, candidateLocales, 0, baseBundle); // Check the loaded bundle (if any) if (bundle != null) { Locale bundleLocale = bundle.getLocale(); // WTH? GWT can't access bundle.locale directly boolean isBaseBundle = bundleLocale.equals(ROOT_LOCALE); if (!isBaseBundle || bundleLocale.equals(locale)) { // Found the bundle for the requested locale break; } if (candidateLocales.size() == 1 && bundleLocale.equals(candidateLocales.get(0))) { // Found the bundle for the only candidate locale break; } if (isBaseBundle && baseBundle == null) { // Store the base bundle and keep on processing the remaining fallback locales baseBundle = bundle; } } // Set next fallback locale targetLocale = getFallbackLocale(targetLocale); } while (targetLocale != null); if (bundle == null) { if (baseBundle == null) { // No bundle found throw new MissingResourceException("Can't find bundle for base file handle " + baseFileHandle.path() + ", locale " + locale, baseFileHandle + "_" + locale, ""); } // Set the base bundle to be returned bundle = baseBundle; } return bundle; } /** Returns a <code>List</code> of <code>Locale</code>s as candidate locales for the given <code>locale</code>. This method is * called by the <code>createBundle</code> factory method each time the factory method tries finding a resource bundle for a * target <code>Locale</code>. * * <p> * The sequence of the candidate locales also corresponds to the runtime resource lookup path (also known as the <I>parent * chain</I>), if the corresponding resource bundles for the candidate locales exist and their parents are not defined by * loaded resource bundles themselves. The last element of the list is always the {@linkplain Locale#ROOT root locale}, meaning * that the base bundle is the terminal of the parent chain. * * <p> * If the given locale is equal to <code>Locale.ROOT</code> (the root locale), a <code>List</code> containing only the root * <code>Locale</code> is returned. In this case, the <code>createBundle</code> factory method loads only the base bundle as * the resulting resource bundle. * * <p> * This implementation returns a <code>List</code> containing <code>Locale</code>s in the following sequence: * * <pre> * Locale(language, country, variant) * Locale(language, country) * Locale(language) * Locale.ROOT * </pre> * * where <code>language</code>, <code>country</code> and <code>variant</code> are the language, country and variant values of * the given <code>locale</code>, respectively. Locales where the final component values are empty strings are omitted. * * <p> * For example, if the given base name is "Messages" and the given <code>locale</code> is * <code>Locale("ja", "", "XX")</code>, then a <code>List</code> of <code>Locale</code>s: * * <pre> * Locale("ja", "", "XX") * Locale("ja") * Locale.ROOT * </pre> * * is returned. And if the resource bundles for the "ja" and "" <code>Locale</code>s are found, then the runtime resource * lookup path (parent chain) is: * * <pre> * Messages_ja -> Messages * </pre> * * @param locale the locale for which a resource bundle is desired * @return a <code>List</code> of candidate <code>Locale</code>s for the given <code>locale</code> * @exception NullPointerException if <code>locale</code> is <code>null</code> */ private static List<Locale> getCandidateLocales (Locale locale) { String language = locale.getLanguage(); String country = locale.getCountry(); String variant = locale.getVariant(); List<Locale> locales = new ArrayList<Locale>(4); if (variant.length() > 0) { locales.add(locale); } if (country.length() > 0) { locales.add((locales.size() == 0) ? locale : new Locale(language, country)); } if (language.length() > 0) { locales.add((locales.size() == 0) ? locale : new Locale(language)); } locales.add(ROOT_LOCALE); return locales; } /** Returns a <code>Locale</code> to be used as a fallback locale for further bundle searches by the <code>createBundle</code> * factory method. This method is called from the factory method every time when no resulting bundle has been found for * <code>baseFileHandler</code> and <code>locale</code>, where locale is either the parameter for <code>createBundle</code> or * the previous fallback locale returned by this method. * * <p> * This method returns the {@linkplain Locale#getDefault() default <code>Locale</code>} if the given <code>locale</code> isn't * the default one. Otherwise, <code>null</code> is returned. * * @param locale the <code>Locale</code> for which <code>createBundle</code> has been unable to find any resource bundles * (except for the base bundle) * @return a <code>Locale</code> for the fallback search, or <code>null</code> if no further fallback search is needed. * @exception NullPointerException if <code>locale</code> is <code>null</code> */ private static Locale getFallbackLocale (Locale locale) { Locale defaultLocale = Locale.getDefault(); return locale.equals(defaultLocale) ? null : defaultLocale; } private static I18NBundle loadBundleChain (FileHandle baseFileHandle, String encoding, List<Locale> candidateLocales, int candidateIndex, I18NBundle baseBundle) { Locale targetLocale = candidateLocales.get(candidateIndex); I18NBundle parent = null; if (candidateIndex != candidateLocales.size() - 1) { // Load recursively the parent having the next candidate locale parent = loadBundleChain(baseFileHandle, encoding, candidateLocales, candidateIndex + 1, baseBundle); } else if (baseBundle != null && targetLocale.equals(ROOT_LOCALE)) { return baseBundle; } // Load the bundle I18NBundle bundle = loadBundle(baseFileHandle, encoding, targetLocale); if (bundle != null) { bundle.parent = parent; return bundle; } return parent; } // Tries to load the bundle for the given locale. private static I18NBundle loadBundle (FileHandle baseFileHandle, String encoding, Locale targetLocale) { I18NBundle bundle = null; Reader reader = null; try { FileHandle fileHandle = toFileHandle(baseFileHandle, targetLocale); if (checkFileExistence(fileHandle)) { // Instantiate the bundle bundle = new I18NBundle(); // Load bundle properties from the stream with the specified encoding reader = fileHandle.reader(encoding); bundle.load(reader); } } catch (IOException e) { throw new GdxRuntimeException(e); } finally { StreamUtils.closeQuietly(reader); } if (bundle != null) { bundle.setLocale(targetLocale); } return bundle; } // On Android this is much faster than fh.exists(), see https://github.com/libgdx/libgdx/issues/2342 // Also this should fix a weird problem on iOS, see https://github.com/libgdx/libgdx/issues/2345 private static boolean checkFileExistence (FileHandle fh) { try { fh.read().close(); return true; } catch (Exception e) { return false; } } /** Load the properties from the specified reader. * * @param reader the reader * @throws IOException if an error occurred when reading from the input stream. */ // NOTE: // This method can't be private otherwise GWT can't access it from loadBundle() protected void load (Reader reader) throws IOException { properties = new ObjectMap<String, String>(); PropertiesUtils.load(properties, reader); } /** Converts the given <code>baseFileHandle</code> and <code>locale</code> to the corresponding file handle. * * <p> * This implementation returns the <code>baseFileHandle</code>'s sibling with following value: * * <pre> * baseFileHandle.name() + "_" + language + "_" + country + "_" + variant + ".properties" * </pre> * * where <code>language</code>, <code>country</code> and <code>variant</code> are the language, country and variant values of * <code>locale</code>, respectively. Final component values that are empty Strings are omitted along with the preceding '_'. * If all of the values are empty strings, then <code>baseFileHandle.name()</code> is returned with ".properties" appended. * * @param baseFileHandle the file handle to the base of the bundle * @param locale the locale for which a resource bundle should be loaded * @return the file handle for the bundle * @exception NullPointerException if <code>baseFileHandle</code> or <code>locale</code> is <code>null</code> */ private static FileHandle toFileHandle (FileHandle baseFileHandle, Locale locale) { StringBuilder sb = new StringBuilder(baseFileHandle.name()); if (!locale.equals(ROOT_LOCALE)) { String language = locale.getLanguage(); String country = locale.getCountry(); String variant = locale.getVariant(); boolean emptyLanguage = "".equals(language); boolean emptyCountry = "".equals(country); boolean emptyVariant = "".equals(variant); if (!(emptyLanguage && emptyCountry && emptyVariant)) { sb.append('_'); if (!emptyVariant) { sb.append(language).append('_').append(country).append('_').append(variant); } else if (!emptyCountry) { sb.append(language).append('_').append(country); } else { sb.append(language); } } } return baseFileHandle.sibling(sb.append(".properties").toString()); } /** Returns the locale of this bundle. This method can be used after a call to <code>createBundle()</code> to determine whether * the resource bundle returned really corresponds to the requested locale or is a fallback. * * @return the locale of this bundle */ public Locale getLocale () { return locale; } /** Sets the bundle locale. This method is private because a bundle can't change the locale during its life. * * @param locale */ private void setLocale (Locale locale) { this.locale = locale; this.formatter = new TextFormatter(locale, !simpleFormatter); } /** Gets a string for the given key from this bundle or one of its parents. * * @param key the key for the desired string * @exception NullPointerException if <code>key</code> is <code>null</code> * @exception MissingResourceException if no string for the given key can be found and {@link #getExceptionOnMissingKey()} * returns {@code true} * @return the string for the given key or the key surrounded by {@code ???} if it cannot be found and * {@link #getExceptionOnMissingKey()} returns {@code false} */ public final String get (String key) { String result = properties.get(key); if (result == null) { if (parent != null) result = parent.get(key); if (result == null) { if (exceptionOnMissingKey) throw new MissingResourceException("Can't find bundle key " + key, this.getClass().getName(), key); else return "???" + key + "???"; } } return result; } /** Gets the string with the specified key from this bundle or one of its parent after replacing the given arguments if they * occur. * * @param key the key for the desired string * @param args the arguments to be replaced in the string associated to the given key. * @exception NullPointerException if <code>key</code> is <code>null</code> * @exception MissingResourceException if no string for the given key can be found * @return the string for the given key formatted with the given arguments */ public String format (String key, Object... args) { return formatter.format(get(key), args); } }