001/*
002 * The contents of this file are subject to the terms of the Common Development and
003 * Distribution License (the License). You may not use this file except in compliance with the
004 * License.
005 *
006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
007 * specific language governing permission and limitations under the License.
008 *
009 * When distributing Covered Software, include this CDDL Header Notice in each file and include
010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
011 * Header, with the fields enclosed by brackets [] replaced by your own identifying
012 * information: "Portions copyright [year] [name of copyright owner]".
013 *
014 * Copyright 2015-2016 ForgeRock AS.
015 */
016
017package org.forgerock.util.i18n;
018
019import static org.forgerock.util.Utils.isNullOrEmpty;
020
021import java.util.Collections;
022import java.util.List;
023import java.util.Locale;
024import java.util.ResourceBundle;
025
026import org.slf4j.Logger;
027import org.slf4j.LoggerFactory;
028
029/**
030 * This class encapsulates an ordered list of preferred locales, and the logic
031 * to use those to retrieve i18n {@code ResourceBundle}s.
032 * <p>
033 * {@code ResourceBundle}s are found by iterating over the preferred locales
034 * and returning the first resource bundle for which a non-ROOT locale is
035 * available, that is not listed later in the list, or the ROOT locale if no
036 * better match is found.
037 * <p>
038 * For example, given available locales of {@code en} and {@code fr}:
039 * <ul>
040 *     <li>
041 *         Preferred locales of {@code fr-FR, en}, resource bundle for locale
042 *         {@code fr} is returned.
043 *     </li>
044 *     <li>
045 *         Preferred locales of {@code fr-FR, en, fr}, resource bundle for locale
046 *         {@code en} is returned ({@code fr} is listed lower than {@code en}).
047 *     </li>
048 *     <li>
049 *         Preferred locales of {@code de}, resource bundle for the ROOT locale
050 *         is returned.
051 *     </li>
052 * </ul>
053 * It is expected that the default resource bundle locale (i.e. the locale for
054 * the bundle properties file that doesn't have a language tag, e.g.
055 * {@code MyBundle.properties}) is {@code en-US}. If this is not the case for an
056 * application, it can be changed by setting the
057 * {@code org.forgerock.defaultBundleLocale} system property.
058 */
059public class PreferredLocales {
060
061    private static final Locale DEFAULT_RESOURCE_BUNDLE_LOCALE =
062            Locale.forLanguageTag(System.getProperty("org.forgerock.defaultBundleLocale", "en-US"));
063    private static final Logger logger = LoggerFactory.getLogger(PreferredLocales.class);
064
065    private final List<Locale> locales;
066    private final int numberLocales;
067
068    /**
069     * Create a new preference of locales by copying the provided locales list.
070     * @param locales The list of locales that are preferred, with the first item the most preferred.
071     */
072    public PreferredLocales(List<Locale> locales) {
073        if (locales == null || locales.isEmpty()) {
074            locales = Collections.singletonList(Locale.ROOT);
075        }
076        this.locales = Collections.unmodifiableList(locales);
077        this.numberLocales = locales.size();
078    }
079
080    /**
081     * Create a new, empty preference of locales.
082     */
083    public PreferredLocales() {
084        this(null);
085    }
086
087    /**
088     * The preferred locale, i.e. the head of the preferred locales list.
089     * @return The most-preferred locale.
090     */
091    public Locale getPreferredLocale() {
092        return locales.get(0);
093    }
094
095    /**
096     * The ordered list of preferred locales.
097     * @return A mutable copy of the preferred locales list.
098     */
099    public List<Locale> getLocales() {
100        return locales;
101    }
102
103    /**
104     * Get a {@code ResourceBundle} using the preferred locale list and using the provided
105     * {@code ClassLoader}.
106     * @param bundleName The of the bundle to load.
107     * @param classLoader The {@code ClassLoader} to use to load the bundle.
108     * @return The bundle in the best matching locale.
109     */
110    public ResourceBundle getBundleInPreferredLocale(String bundleName, ClassLoader classLoader) {
111        logger.debug("Finding best {} bundle for locales {}", bundleName, locales);
112        for (int i = 0; i < numberLocales; i++) {
113            Locale locale = locales.get(i);
114            ResourceBundle candidate = ResourceBundle.getBundle(bundleName, locale, classLoader);
115            Locale candidateLocale = candidate.getLocale();
116            List<Locale> remainingLocales = locales.subList(i + 1, numberLocales);
117            if (matches(locale, candidateLocale, remainingLocales)) {
118                logger.debug("Returning {} bundle in {} locale", bundleName, candidateLocale);
119                return candidate;
120            }
121            if (candidateLocale.equals(Locale.ROOT)
122                    && matches(locale, DEFAULT_RESOURCE_BUNDLE_LOCALE, remainingLocales)) {
123                return ResourceBundle.getBundle(bundleName, Locale.ROOT, classLoader);
124            }
125        }
126        logger.debug("Returning {} bundle in root locale", bundleName);
127        return ResourceBundle.getBundle(bundleName, Locale.ROOT, classLoader);
128    }
129
130    /**
131     * Checks if the candidate locale the best match for the requested locale?
132     * Exclude {@code Locale.ROOT}, as it should be the fallback only when all locales are tried.
133     *
134     * @param requested The requested Locale
135     * @param candidate The candidate Locale
136     * @param remainingLocales The remaining locales
137     * @return The candidate is beast match for requested locale
138     */
139    public static boolean matches(Locale requested, Locale candidate, List<Locale> remainingLocales) {
140        logger.trace("Checking candidate locale {} for match with requested {}", candidate, requested);
141        if (requested.equals(candidate)) {
142            return true;
143        }
144        if (candidate.equals(Locale.ROOT)) {
145            logger.trace("Rejecting root locale as it is the default. Requested {}", requested);
146            return false;
147        }
148        String language = candidate.getLanguage();
149        if (!requested.getLanguage().equals(language)) {
150            return false;
151        }
152        String country = candidate.getCountry();
153        String variant = candidate.getVariant();
154        if (!isNullOrEmpty(variant)
155                && remainingLocales.contains(new Locale(language, country, variant))) {
156            return false;
157        }
158        if ((!isNullOrEmpty(country) || !isNullOrEmpty(variant))
159                && remainingLocales.contains(new Locale(language, country))) {
160            return false;
161        }
162        if (remainingLocales.contains(new Locale(language))) {
163            return false;
164        }
165        return true;
166    }
167
168}