blob: bb4fc8a1bc345f0f1b3eb776a7e82fb884222336 [file] [log] [blame]
/*******************************************************************************
* 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",&nbsp;"",&nbsp;"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() + &quot;_&quot; + language + &quot;_&quot; + country + &quot;_&quot; + variant + &quot;.properties&quot;
* </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);
}
}