• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*******************************************************************************
2  * Copyright 2011 See AUTHORS file.
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.badlogic.gdx.utils;
18 
19 import java.io.IOException;
20 import java.io.InputStream;
21 import java.io.InputStreamReader;
22 import java.io.Reader;
23 import java.util.ArrayList;
24 import java.util.List;
25 import java.util.Locale;
26 import java.util.MissingResourceException;
27 
28 import com.badlogic.gdx.files.FileHandle;
29 
30 /** A {@code I18NBundle} provides {@code Locale}-specific resources loaded from property files. A bundle contains a number of named
31  * resources, whose names and values are {@code Strings}. A bundle may have a parent bundle, and when a resource is not found in a
32  * bundle, the parent bundle is searched for the resource. If the fallback mechanism reaches the base bundle and still can't find
33  * the resource it throws a {@code MissingResourceException}.
34  *
35  * <ul>
36  * <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
37  * fallback in case none of its children was able to respond to a request.</li>
38  * <li>The first level contains changes between different languages. Only the differences between a language and the language of
39  * the base bundle need to be handled by a language-specific {@code I18NBundle}.</li>
40  * <li>The second level contains changes between different countries that use the same language. Only the differences between a
41  * country and the country of the language bundle need to be handled by a country-specific {@code I18NBundle}.</li>
42  * <li>The third level contains changes that don't have a geographic reason (e.g. changes that where made at some point in time
43  * like {@code PREEURO} where the currency of come countries changed. The country bundle would return the current currency (Euro)
44  * and the {@code PREEURO} variant bundle would return the old currency (e.g. DM for Germany).</li>
45  * </ul>
46  *
47  * <strong>Examples</strong>
48  * <ul>
49  * <li>BaseName (base bundle)
50  * <li>BaseName_de (german language bundle)
51  * <li>BaseName_fr (french language bundle)
52  * <li>BaseName_de_DE (bundle with Germany specific resources in german)
53  * <li>BaseName_de_CH (bundle with Switzerland specific resources in german)
54  * <li>BaseName_fr_CH (bundle with Switzerland specific resources in french)
55  * <li>BaseName_de_DE_PREEURO (bundle with Germany specific resources in german of the time before the Euro)
56  * <li>BaseName_fr_FR_PREEURO (bundle with France specific resources in french of the time before the Euro)
57  * </ul>
58  *
59  * It's also possible to create variants for languages or countries. This can be done by just skipping the country or language
60  * abbreviation: BaseName_us__POSIX or BaseName__DE_PREEURO. But it's not allowed to circumvent both language and country:
61  * BaseName___VARIANT is illegal.
62  *
63  * @see PropertiesUtils
64  *
65  * @author davebaol */
66 public class I18NBundle {
67 
68 	private static final String DEFAULT_ENCODING = "UTF-8";
69 
70 	// Locale.ROOT does not exist in Android API level 8
71 	private static final Locale ROOT_LOCALE = new Locale("", "", "");
72 
73 	private static boolean simpleFormatter = false;
74 	private static boolean exceptionOnMissingKey = true;
75 
76 	/** The parent of this {@code I18NBundle} that is used if this bundle doesn't include the requested resource. */
77 	private I18NBundle parent;
78 
79 	/** The locale for this bundle. */
80 	private Locale locale;
81 
82 	/** The properties for this bundle. */
83 	private ObjectMap<String, String> properties;
84 
85 	/** The formatter used for argument replacement. */
86 	private TextFormatter formatter;
87 
88 	/** Returns the flag indicating whether to use the simplified message pattern syntax (default is false). This flag is always
89 	 * assumed to be true on GWT backend. */
getSimpleFormatter()90 	public static boolean getSimpleFormatter () {
91 		return simpleFormatter;
92 	}
93 
94 	/** Sets the flag indicating whether to use the simplified message pattern. The flag must be set before calling the factory
95 	 * methods {@code createBundle}. Notice that this method has no effect on the GWT backend where it's always assumed to be true. */
setSimpleFormatter(boolean enabled)96 	public static void setSimpleFormatter (boolean enabled) {
97 		simpleFormatter = enabled;
98 	}
99 
100 	/** Returns the flag indicating whether to throw a {@link MissingResourceException} from the {@link #get(String) get(key)}
101 	 * method if no string for the given key can be found. If this flag is {@code false} the missing key surrounded by {@code ???}
102 	 * is returned. */
getExceptionOnMissingKey()103 	public static boolean getExceptionOnMissingKey () {
104 		return exceptionOnMissingKey;
105 	}
106 
107 	/** Sets the flag indicating whether to throw a {@link MissingResourceException} from the {@link #get(String) get(key)} method
108 	 * if no string for the given key can be found. If this flag is {@code false} the missing key surrounded by {@code ???} is
109 	 * returned. */
setExceptionOnMissingKey(boolean enabled)110 	public static void setExceptionOnMissingKey (boolean enabled) {
111 		exceptionOnMissingKey = enabled;
112 	}
113 
114 	/** Creates a new bundle using the specified <code>baseFileHandle</code>, the default locale and the default encoding "UTF-8".
115 	 *
116 	 * @param baseFileHandle the file handle to the base of the bundle
117 	 * @exception NullPointerException if <code>baseFileHandle</code> is <code>null</code>
118 	 * @exception MissingResourceException if no bundle for the specified base file handle can be found
119 	 * @return a bundle for the given base file handle and the default locale */
createBundle(FileHandle baseFileHandle)120 	public static I18NBundle createBundle (FileHandle baseFileHandle) {
121 		return createBundleImpl(baseFileHandle, Locale.getDefault(), DEFAULT_ENCODING);
122 	}
123 
124 	/** Creates a new bundle using the specified <code>baseFileHandle</code> and <code>locale</code>; the default encoding "UTF-8"
125 	 * is used.
126 	 *
127 	 * @param baseFileHandle the file handle to the base of the bundle
128 	 * @param locale the locale for which a bundle is desired
129 	 * @return a bundle for the given base file handle and locale
130 	 * @exception NullPointerException if <code>baseFileHandle</code> or <code>locale</code> is <code>null</code>
131 	 * @exception MissingResourceException if no bundle for the specified base file handle can be found */
createBundle(FileHandle baseFileHandle, Locale locale)132 	public static I18NBundle createBundle (FileHandle baseFileHandle, Locale locale) {
133 		return createBundleImpl(baseFileHandle, locale, DEFAULT_ENCODING);
134 	}
135 
136 	/** Creates a new bundle using the specified <code>baseFileHandle</code> and <code>encoding</code>; the default locale is used.
137 	 *
138 	 * @param baseFileHandle the file handle to the base of the bundle
139 	 * @param encoding the charter encoding
140 	 * @return a bundle for the given base file handle and locale
141 	 * @exception NullPointerException if <code>baseFileHandle</code> or <code>encoding</code> is <code>null</code>
142 	 * @exception MissingResourceException if no bundle for the specified base file handle can be found */
createBundle(FileHandle baseFileHandle, String encoding)143 	public static I18NBundle createBundle (FileHandle baseFileHandle, String encoding) {
144 		return createBundleImpl(baseFileHandle, Locale.getDefault(), encoding);
145 	}
146 
147 	/** Creates a new bundle using the specified <code>baseFileHandle</code>, <code>locale</code> and <code>encoding</code>.
148 	 *
149 	 * @param baseFileHandle the file handle to the base of the bundle
150 	 * @param locale the locale for which a bundle is desired
151 	 * @param encoding the charter encoding
152 	 * @return a bundle for the given base file handle and locale
153 	 * @exception NullPointerException if <code>baseFileHandle</code>, <code>locale</code> or <code>encoding</code> is
154 	 *               <code>null</code>
155 	 * @exception MissingResourceException if no bundle for the specified base file handle can be found */
createBundle(FileHandle baseFileHandle, Locale locale, String encoding)156 	public static I18NBundle createBundle (FileHandle baseFileHandle, Locale locale, String encoding) {
157 		return createBundleImpl(baseFileHandle, locale, encoding);
158 	}
159 
createBundleImpl(FileHandle baseFileHandle, Locale locale, String encoding)160 	private static I18NBundle createBundleImpl (FileHandle baseFileHandle, Locale locale, String encoding) {
161 		if (baseFileHandle == null || locale == null || encoding == null) throw new NullPointerException();
162 
163 		I18NBundle bundle = null;
164 		I18NBundle baseBundle = null;
165 		Locale targetLocale = locale;
166 		do {
167 			// Create the candidate locales
168 			List<Locale> candidateLocales = getCandidateLocales(targetLocale);
169 
170 			// Load the bundle and its parents recursively
171 			bundle = loadBundleChain(baseFileHandle, encoding, candidateLocales, 0, baseBundle);
172 
173 			// Check the loaded bundle (if any)
174 			if (bundle != null) {
175 				Locale bundleLocale = bundle.getLocale(); // WTH? GWT can't access bundle.locale directly
176 				boolean isBaseBundle = bundleLocale.equals(ROOT_LOCALE);
177 
178 				if (!isBaseBundle || bundleLocale.equals(locale)) {
179 					// Found the bundle for the requested locale
180 					break;
181 				}
182 				if (candidateLocales.size() == 1 && bundleLocale.equals(candidateLocales.get(0))) {
183 					// Found the bundle for the only candidate locale
184 					break;
185 				}
186 				if (isBaseBundle && baseBundle == null) {
187 					// Store the base bundle and keep on processing the remaining fallback locales
188 					baseBundle = bundle;
189 				}
190 			}
191 
192 			// Set next fallback locale
193 			targetLocale = getFallbackLocale(targetLocale);
194 
195 		} while (targetLocale != null);
196 
197 		if (bundle == null) {
198 			if (baseBundle == null) {
199 				// No bundle found
200 				throw new MissingResourceException("Can't find bundle for base file handle " + baseFileHandle.path() + ", locale "
201 					+ locale, baseFileHandle + "_" + locale, "");
202 			}
203 			// Set the base bundle to be returned
204 			bundle = baseBundle;
205 		}
206 
207 		return bundle;
208 	}
209 
210 	/** Returns a <code>List</code> of <code>Locale</code>s as candidate locales for the given <code>locale</code>. This method is
211 	 * called by the <code>createBundle</code> factory method each time the factory method tries finding a resource bundle for a
212 	 * target <code>Locale</code>.
213 	 *
214 	 * <p>
215 	 * The sequence of the candidate locales also corresponds to the runtime resource lookup path (also known as the <I>parent
216 	 * chain</I>), if the corresponding resource bundles for the candidate locales exist and their parents are not defined by
217 	 * loaded resource bundles themselves. The last element of the list is always the {@linkplain Locale#ROOT root locale}, meaning
218 	 * that the base bundle is the terminal of the parent chain.
219 	 *
220 	 * <p>
221 	 * If the given locale is equal to <code>Locale.ROOT</code> (the root locale), a <code>List</code> containing only the root
222 	 * <code>Locale</code> is returned. In this case, the <code>createBundle</code> factory method loads only the base bundle as
223 	 * the resulting resource bundle.
224 	 *
225 	 * <p>
226 	 * This implementation returns a <code>List</code> containing <code>Locale</code>s in the following sequence:
227 	 *
228 	 * <pre>
229 	 *     Locale(language, country, variant)
230 	 *     Locale(language, country)
231 	 *     Locale(language)
232 	 *     Locale.ROOT
233 	 * </pre>
234 	 *
235 	 * where <code>language</code>, <code>country</code> and <code>variant</code> are the language, country and variant values of
236 	 * the given <code>locale</code>, respectively. Locales where the final component values are empty strings are omitted.
237 	 *
238 	 * <p>
239 	 * For example, if the given base name is "Messages" and the given <code>locale</code> is
240 	 * <code>Locale("ja",&nbsp;"",&nbsp;"XX")</code>, then a <code>List</code> of <code>Locale</code>s:
241 	 *
242 	 * <pre>
243 	 *     Locale("ja", "", "XX")
244 	 *     Locale("ja")
245 	 *     Locale.ROOT
246 	 * </pre>
247 	 *
248 	 * is returned. And if the resource bundles for the "ja" and "" <code>Locale</code>s are found, then the runtime resource
249 	 * lookup path (parent chain) is:
250 	 *
251 	 * <pre>
252 	 *     Messages_ja -> Messages
253 	 * </pre>
254 	 *
255 	 * @param locale the locale for which a resource bundle is desired
256 	 * @return a <code>List</code> of candidate <code>Locale</code>s for the given <code>locale</code>
257 	 * @exception NullPointerException if <code>locale</code> is <code>null</code> */
getCandidateLocales(Locale locale)258 	private static List<Locale> getCandidateLocales (Locale locale) {
259 		String language = locale.getLanguage();
260 		String country = locale.getCountry();
261 		String variant = locale.getVariant();
262 
263 		List<Locale> locales = new ArrayList<Locale>(4);
264 		if (variant.length() > 0) {
265 			locales.add(locale);
266 		}
267 		if (country.length() > 0) {
268 			locales.add((locales.size() == 0) ? locale : new Locale(language, country));
269 		}
270 		if (language.length() > 0) {
271 			locales.add((locales.size() == 0) ? locale : new Locale(language));
272 		}
273 		locales.add(ROOT_LOCALE);
274 		return locales;
275 	}
276 
277 	/** Returns a <code>Locale</code> to be used as a fallback locale for further bundle searches by the <code>createBundle</code>
278 	 * factory method. This method is called from the factory method every time when no resulting bundle has been found for
279 	 * <code>baseFileHandler</code> and <code>locale</code>, where locale is either the parameter for <code>createBundle</code> or
280 	 * the previous fallback locale returned by this method.
281 	 *
282 	 * <p>
283 	 * This method returns the {@linkplain Locale#getDefault() default <code>Locale</code>} if the given <code>locale</code> isn't
284 	 * the default one. Otherwise, <code>null</code> is returned.
285 	 *
286 	 * @param locale the <code>Locale</code> for which <code>createBundle</code> has been unable to find any resource bundles
287 	 *           (except for the base bundle)
288 	 * @return a <code>Locale</code> for the fallback search, or <code>null</code> if no further fallback search is needed.
289 	 * @exception NullPointerException if <code>locale</code> is <code>null</code> */
getFallbackLocale(Locale locale)290 	private static Locale getFallbackLocale (Locale locale) {
291 		Locale defaultLocale = Locale.getDefault();
292 		return locale.equals(defaultLocale) ? null : defaultLocale;
293 	}
294 
loadBundleChain(FileHandle baseFileHandle, String encoding, List<Locale> candidateLocales, int candidateIndex, I18NBundle baseBundle)295 	private static I18NBundle loadBundleChain (FileHandle baseFileHandle, String encoding, List<Locale> candidateLocales,
296 		int candidateIndex, I18NBundle baseBundle) {
297 		Locale targetLocale = candidateLocales.get(candidateIndex);
298 		I18NBundle parent = null;
299 		if (candidateIndex != candidateLocales.size() - 1) {
300 			// Load recursively the parent having the next candidate locale
301 			parent = loadBundleChain(baseFileHandle, encoding, candidateLocales, candidateIndex + 1, baseBundle);
302 		} else if (baseBundle != null && targetLocale.equals(ROOT_LOCALE)) {
303 			return baseBundle;
304 		}
305 
306 		// Load the bundle
307 		I18NBundle bundle = loadBundle(baseFileHandle, encoding, targetLocale);
308 		if (bundle != null) {
309 			bundle.parent = parent;
310 			return bundle;
311 		}
312 
313 		return parent;
314 	}
315 
316 	// Tries to load the bundle for the given locale.
loadBundle(FileHandle baseFileHandle, String encoding, Locale targetLocale)317 	private static I18NBundle loadBundle (FileHandle baseFileHandle, String encoding, Locale targetLocale) {
318 		I18NBundle bundle = null;
319 		Reader reader = null;
320 		try {
321 			FileHandle fileHandle = toFileHandle(baseFileHandle, targetLocale);
322 			if (checkFileExistence(fileHandle)) {
323 				// Instantiate the bundle
324 				bundle = new I18NBundle();
325 
326 				// Load bundle properties from the stream with the specified encoding
327 				reader = fileHandle.reader(encoding);
328 				bundle.load(reader);
329 			}
330 		} catch (IOException e) {
331 			throw new GdxRuntimeException(e);
332 		}
333 		finally {
334 			StreamUtils.closeQuietly(reader);
335 		}
336 		if (bundle != null) {
337 			bundle.setLocale(targetLocale);
338 		}
339 
340 		return bundle;
341 	}
342 
343 	// On Android this is much faster than fh.exists(), see https://github.com/libgdx/libgdx/issues/2342
344 	// Also this should fix a weird problem on iOS, see https://github.com/libgdx/libgdx/issues/2345
checkFileExistence(FileHandle fh)345 	private static boolean checkFileExistence (FileHandle fh) {
346 		try {
347 			fh.read().close();
348 			return true;
349 		} catch (Exception e) {
350 			return false;
351 		}
352 	}
353 
354 	/** Load the properties from the specified reader.
355 	 *
356 	 * @param reader the reader
357 	 * @throws IOException if an error occurred when reading from the input stream. */
358 	// NOTE:
359 	// This method can't be private otherwise GWT can't access it from loadBundle()
load(Reader reader)360 	protected void load (Reader reader) throws IOException {
361 		properties = new ObjectMap<String, String>();
362 		PropertiesUtils.load(properties, reader);
363 	}
364 
365 	/** Converts the given <code>baseFileHandle</code> and <code>locale</code> to the corresponding file handle.
366 	 *
367 	 * <p>
368 	 * This implementation returns the <code>baseFileHandle</code>'s sibling with following value:
369 	 *
370 	 * <pre>
371 	 * baseFileHandle.name() + &quot;_&quot; + language + &quot;_&quot; + country + &quot;_&quot; + variant + &quot;.properties&quot;
372 	 * </pre>
373 	 *
374 	 * where <code>language</code>, <code>country</code> and <code>variant</code> are the language, country and variant values of
375 	 * <code>locale</code>, respectively. Final component values that are empty Strings are omitted along with the preceding '_'.
376 	 * If all of the values are empty strings, then <code>baseFileHandle.name()</code> is returned with ".properties" appended.
377 	 *
378 	 * @param baseFileHandle the file handle to the base of the bundle
379 	 * @param locale the locale for which a resource bundle should be loaded
380 	 * @return the file handle for the bundle
381 	 * @exception NullPointerException if <code>baseFileHandle</code> or <code>locale</code> is <code>null</code> */
toFileHandle(FileHandle baseFileHandle, Locale locale)382 	private static FileHandle toFileHandle (FileHandle baseFileHandle, Locale locale) {
383 		StringBuilder sb = new StringBuilder(baseFileHandle.name());
384 		if (!locale.equals(ROOT_LOCALE)) {
385 			String language = locale.getLanguage();
386 			String country = locale.getCountry();
387 			String variant = locale.getVariant();
388 			boolean emptyLanguage = "".equals(language);
389 			boolean emptyCountry = "".equals(country);
390 			boolean emptyVariant = "".equals(variant);
391 
392 			if (!(emptyLanguage && emptyCountry && emptyVariant)) {
393 				sb.append('_');
394 				if (!emptyVariant) {
395 					sb.append(language).append('_').append(country).append('_').append(variant);
396 				} else if (!emptyCountry) {
397 					sb.append(language).append('_').append(country);
398 				} else {
399 					sb.append(language);
400 				}
401 			}
402 		}
403 		return baseFileHandle.sibling(sb.append(".properties").toString());
404 	}
405 
406 	/** Returns the locale of this bundle. This method can be used after a call to <code>createBundle()</code> to determine whether
407 	 * the resource bundle returned really corresponds to the requested locale or is a fallback.
408 	 *
409 	 * @return the locale of this bundle */
getLocale()410 	public Locale getLocale () {
411 		return locale;
412 	}
413 
414 	/** Sets the bundle locale. This method is private because a bundle can't change the locale during its life.
415 	 *
416 	 * @param locale */
setLocale(Locale locale)417 	private void setLocale (Locale locale) {
418 		this.locale = locale;
419 		this.formatter = new TextFormatter(locale, !simpleFormatter);
420 	}
421 
422 	/** Gets a string for the given key from this bundle or one of its parents.
423 	 *
424 	 * @param key the key for the desired string
425 	 * @exception NullPointerException if <code>key</code> is <code>null</code>
426 	 * @exception MissingResourceException if no string for the given key can be found and {@link #getExceptionOnMissingKey()}
427 	 *               returns {@code true}
428 	 * @return the string for the given key or the key surrounded by {@code ???} if it cannot be found and
429 	 *         {@link #getExceptionOnMissingKey()} returns {@code false} */
get(String key)430 	public final String get (String key) {
431 		String result = properties.get(key);
432 		if (result == null) {
433 			if (parent != null) result = parent.get(key);
434 			if (result == null) {
435 				if (exceptionOnMissingKey)
436 					throw new MissingResourceException("Can't find bundle key " + key, this.getClass().getName(), key);
437 				else
438 					return "???" + key + "???";
439 			}
440 		}
441 		return result;
442 	}
443 
444 	/** Gets the string with the specified key from this bundle or one of its parent after replacing the given arguments if they
445 	 * occur.
446 	 *
447 	 * @param key the key for the desired string
448 	 * @param args the arguments to be replaced in the string associated to the given key.
449 	 * @exception NullPointerException if <code>key</code> is <code>null</code>
450 	 * @exception MissingResourceException if no string for the given key can be found
451 	 * @return the string for the given key formatted with the given arguments */
format(String key, Object... args)452 	public String format (String key, Object... args) {
453 		return formatter.format(get(key), args);
454 	}
455 
456 }
457