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", "", "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() + "_" + language + "_" + country + "_" + variant + ".properties" 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