| /* |
| * Copyright (C) 2015 The Android Open Source Project |
| * |
| * 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.android.ide.common.resources.configuration; |
| |
| import com.android.annotations.NonNull; |
| import com.android.annotations.Nullable; |
| import com.android.annotations.VisibleForTesting; |
| import com.google.common.base.Splitter; |
| |
| import java.util.Iterator; |
| import java.util.Locale; |
| |
| /** |
| * A locale qualifier, which can be constructed from: |
| * <ul> |
| * <li>A plain 2-letter language descriptor</li> |
| * <li>A 2-letter language descriptor followed by a -r 2 letter region qualifier</li> |
| * <li>A plain 3-letter language descriptor</li> |
| * <li>A 3-letter language descriptor followed by a -r 2 letter region qualifier</li> |
| * <li>A BCP 47 language tag. The BCP-47 tag uses + instead of - as separators, |
| * and has the prefix b+. Therefore, the BCP-47 tag "zh-Hans-CN" would be |
| * written as "b+zh+Hans+CN" instead.</li> |
| * </ul> |
| */ |
| public final class LocaleQualifier extends ResourceQualifier { |
| public static final String FAKE_VALUE = "__"; //$NON-NLS-1$ |
| public static final String NAME = "Locale"; |
| // TODO: Case insensitive check! |
| public static final String BCP_47_PREFIX = "b+"; //$NON-NLS-1$ |
| |
| @NonNull private String mFull; |
| @NonNull private String mLanguage; |
| @Nullable private String mRegion; |
| @Nullable private String mScript; |
| |
| public LocaleQualifier() { |
| mFull = ""; |
| } |
| |
| public LocaleQualifier(@NonNull String language) { |
| assert language.length() == 2 || language.length() == 3; |
| mLanguage = language; |
| mFull = language; |
| } |
| |
| public LocaleQualifier(@Nullable String full, @NonNull String language, |
| @Nullable String region, @Nullable String script) { |
| if (full == null) { |
| if (region != null && region.length() == 3 || script != null) { |
| StringBuilder sb = new StringBuilder(BCP_47_PREFIX); |
| sb.append(language); |
| if (region != null) { |
| sb.append('+'); |
| sb.append(region); |
| } |
| if (script != null) { |
| sb.append('+'); |
| sb.append(script); |
| } |
| full = sb.toString(); |
| } else if (region != null) { |
| full = language + "-r" + region; |
| } else { |
| full = language; |
| } |
| } |
| mFull = full; |
| mLanguage = language; |
| mRegion = region; |
| mScript = script; |
| } |
| |
| public static boolean isRegionSegment(@NonNull String segment) { |
| return (segment.startsWith("r") || segment.startsWith("R")) && segment.length() == 3 |
| && Character.isLetter(segment.charAt(0)) && Character.isLetter(segment.charAt(1)); |
| } |
| |
| /** |
| * Creates and returns a qualifier from the given folder segment. If the segment is incorrect, |
| * <code>null</code> is returned. |
| * @param segment the folder segment from which to create a qualifier. |
| * @return a new {@link LocaleQualifier} object or <code>null</code> |
| */ |
| @Nullable |
| public static LocaleQualifier getQualifier(@NonNull String segment) { |
| int length = segment.length(); |
| if (length == 2 |
| && Character.isLetter(segment.charAt(0)) |
| && Character.isLetter(segment.charAt(1))) { // to make sure we don't match e.g. "v4" |
| segment = segment.toLowerCase(Locale.US); |
| return new LocaleQualifier(segment, segment, null, null); |
| } else if (length == 3 |
| && Character.isLetter(segment.charAt(0)) |
| && Character.isLetter(segment.charAt(1)) |
| && Character.isLetter(segment.charAt(2))) { |
| segment = segment.toLowerCase(Locale.US); |
| if ("car".equals(segment)) { |
| // Special case: "car" is a valid 3 letter language code, but |
| // it conflicts with the (much older) UI mode constant for |
| // car dock mode, so this specific language string should not be recognized |
| // as a 3 letter language string; it should match car dock mode instead. |
| return null; |
| } |
| return new LocaleQualifier(segment, segment, null, null); |
| } else if (segment.startsWith(BCP_47_PREFIX)) { |
| return parseBcp47(segment); |
| } else if (length == 6 && segment.charAt(2) == '-' |
| && Character.toLowerCase(segment.charAt(3)) == 'r' |
| && Character.isLetter(segment.charAt(0)) |
| && Character.isLetter(segment.charAt(1)) |
| && Character.isLetter(segment.charAt(4)) |
| && Character.isLetter(segment.charAt(5))) { |
| String language = new String(new char[] { |
| Character.toLowerCase(segment.charAt(0)), |
| Character.toLowerCase(segment.charAt(1)) |
| }); |
| String region = new String(new char[] { |
| Character.toUpperCase(segment.charAt(4)), |
| Character.toUpperCase(segment.charAt(5)) |
| }); |
| |
| return new LocaleQualifier(language + "-r" + region, language, region, null); |
| } else if (length == 7 && segment.charAt(3) == '-' |
| && Character.toLowerCase(segment.charAt(4)) == 'r' |
| && Character.isLetter(segment.charAt(0)) |
| && Character.isLetter(segment.charAt(1)) |
| && Character.isLetter(segment.charAt(2)) |
| && Character.isLetter(segment.charAt(5)) |
| && Character.isLetter(segment.charAt(6))) { |
| String language = new String(new char[] { |
| Character.toLowerCase(segment.charAt(0)), |
| Character.toLowerCase(segment.charAt(1)), |
| Character.toLowerCase(segment.charAt(2)) |
| }); |
| String region = new String(new char[] { |
| Character.toUpperCase(segment.charAt(5)), |
| Character.toUpperCase(segment.charAt(6)) |
| }); |
| return new LocaleQualifier(language + "-r" + region, language, region, null); |
| } |
| return null; |
| } |
| |
| /** Given a BCP-47 string, normalizes the case to the recommended casing */ |
| @NonNull |
| public static String normalizeCase(@NonNull String segment) { |
| /* According to the BCP-47 spec: |
| o [ISO639-1] recommends that language codes be written in lowercase |
| ('mn' Mongolian). |
| |
| o [ISO15924] recommends that script codes use lowercase with the |
| initial letter capitalized ('Cyrl' Cyrillic). |
| |
| o [ISO3166-1] recommends that country codes be capitalized ('MN' |
| Mongolia). |
| |
| |
| An implementation can reproduce this format without accessing the |
| registry as follows. All subtags, including extension and private |
| use subtags, use lowercase letters with two exceptions: two-letter |
| and four-letter subtags that neither appear at the start of the tag |
| nor occur after singletons. Such two-letter subtags are all |
| uppercase (as in the tags "en-CA-x-ca" or "sgn-BE-FR") and four- |
| letter subtags are titlecase (as in the tag "az-Latn-x-latn"). |
| */ |
| if (isNormalizedCase(segment)) { |
| return segment; |
| } |
| |
| StringBuilder sb = new StringBuilder(segment.length()); |
| if (segment.startsWith(BCP_47_PREFIX)) { |
| sb.append(BCP_47_PREFIX); |
| assert segment.startsWith(BCP_47_PREFIX); |
| int segmentBegin = BCP_47_PREFIX.length(); |
| int segmentLength = segment.length(); |
| int start = segmentBegin; |
| |
| int lastLength = -1; |
| while (start < segmentLength) { |
| if (start != segmentBegin) { |
| sb.append('+'); |
| } |
| int end = segment.indexOf('+', start); |
| if (end == -1) { |
| end = segmentLength; |
| } |
| int length = end - start; |
| if ((length != 2 && length != 4) || start == segmentBegin || lastLength == 1) { |
| for (int i = start; i < end; i++) { |
| sb.append(Character.toLowerCase(segment.charAt(i))); |
| } |
| } else if (length == 2) { |
| for (int i = start; i < end; i++) { |
| sb.append(Character.toUpperCase(segment.charAt(i))); |
| } |
| } else { |
| assert length == 4 : length; |
| sb.append(Character.toUpperCase(segment.charAt(start))); |
| for (int i = start + 1; i < end; i++) { |
| sb.append(Character.toLowerCase(segment.charAt(i))); |
| } |
| } |
| |
| lastLength = length; |
| start = end + 1; |
| } |
| } else if (segment.length() == 6) { |
| // Language + region: ll-rRR |
| sb.append(Character.toLowerCase(segment.charAt(0))); |
| sb.append(Character.toLowerCase(segment.charAt(1))); |
| sb.append(segment.charAt(2)); // - |
| sb.append(Character.toLowerCase(segment.charAt(3))); // r |
| sb.append(Character.toUpperCase(segment.charAt(4))); |
| sb.append(Character.toUpperCase(segment.charAt(5))); |
| } else if (segment.length() == 7) { |
| // Language + region: lll-rRR |
| sb.append(Character.toLowerCase(segment.charAt(0))); |
| sb.append(Character.toLowerCase(segment.charAt(1))); |
| sb.append(Character.toLowerCase(segment.charAt(2))); |
| sb.append(segment.charAt(3)); // - |
| sb.append(Character.toLowerCase(segment.charAt(4))); // r |
| sb.append(Character.toUpperCase(segment.charAt(5))); |
| sb.append(Character.toUpperCase(segment.charAt(6))); |
| } else { |
| sb.append(segment.toLowerCase(Locale.US)); |
| } |
| |
| return sb.toString(); |
| } |
| |
| /** |
| * Given a BCP-47 string, determines whether the string is already |
| * capitalized correctly (where "correct" means for readability; all strings |
| * should be compared case insensitively) |
| */ |
| @VisibleForTesting |
| static boolean isNormalizedCase(@NonNull String segment) { |
| if (segment.startsWith(BCP_47_PREFIX)) { |
| assert segment.startsWith(BCP_47_PREFIX); |
| int segmentBegin = BCP_47_PREFIX.length(); |
| int segmentLength = segment.length(); |
| int start = segmentBegin; |
| |
| int lastLength = -1; |
| while (start < segmentLength) { |
| int end = segment.indexOf('+', start); |
| if (end == -1) { |
| end = segmentLength; |
| } |
| int length = end - start; |
| if ((length != 2 && length != 4) || start == segmentBegin || lastLength == 1) { |
| if (isNotLowerCase(segment, start, end)) { |
| return false; |
| } |
| } else if (length == 2) { |
| if (isNotUpperCase(segment, start, end)) { |
| return false; |
| } |
| } else { |
| assert length == 4 : length; |
| if (isNotUpperCase(segment, start, start + 1)) { |
| return false; |
| } |
| if (isNotLowerCase(segment, start + 1, end)) { |
| return false; |
| } |
| } |
| |
| lastLength = length; |
| start = end + 1; |
| } |
| |
| return true; |
| } else if (segment.length() == 2) { |
| // Just a language: ll |
| return Character.isLowerCase(segment.charAt(0)) |
| && Character.isLowerCase(segment.charAt(1)); |
| } else if (segment.length() == 3) { |
| // Just a language: lll |
| return Character.isLowerCase(segment.charAt(0)) |
| && Character.isLowerCase(segment.charAt(1)) |
| && Character.isLowerCase(segment.charAt(2)); |
| } else if (segment.length() == 6) { |
| // Language + region: ll-rRR |
| return Character.isLowerCase(segment.charAt(0)) |
| && Character.isLowerCase(segment.charAt(1)) |
| && Character.isLowerCase(segment.charAt(3)) |
| && Character.isUpperCase(segment.charAt(4)) |
| && Character.isUpperCase(segment.charAt(5)); |
| } else if (segment.length() == 7) { |
| // Language + region: lll-rRR |
| return Character.isLowerCase(segment.charAt(0)) |
| && Character.isLowerCase(segment.charAt(1)) |
| && Character.isLowerCase(segment.charAt(2)) |
| && Character.isLowerCase(segment.charAt(4)) |
| && Character.isUpperCase(segment.charAt(5)) |
| && Character.isUpperCase(segment.charAt(6)); |
| } |
| |
| return true; |
| } |
| |
| private static boolean isNotLowerCase(@NonNull String segment, int start, int end) { |
| for (int i = start; i < end; i++) { |
| if (Character.isUpperCase(segment.charAt(i))) { |
| return true; |
| } |
| } |
| |
| return false; |
| } |
| |
| private static boolean isNotUpperCase(@NonNull String segment, int start, int end) { |
| for (int i = start; i < end; i++) { |
| if (Character.isLowerCase(segment.charAt(i))) { |
| return true; |
| } |
| } |
| |
| return false; |
| } |
| |
| @NonNull |
| public String getValue() { |
| return mFull; |
| } |
| |
| @Override |
| public String getName() { |
| return NAME; |
| } |
| |
| @Override |
| public String getShortName() { |
| return NAME; |
| } |
| |
| @Override |
| public int since() { |
| // This was added in Lollipop, but you can for example write b+en+US and aapt handles it |
| // compatibly so we don't want to normalize this in normalize() to append -v21 etc |
| return 1; |
| } |
| |
| @Override |
| public boolean isValid() { |
| //noinspection StringEquality |
| return mFull != FAKE_VALUE; |
| } |
| |
| @Override |
| public boolean hasFakeValue() { |
| //noinspection StringEquality |
| return mFull == FAKE_VALUE; |
| } |
| |
| public boolean hasLanguage() { |
| return !FAKE_VALUE.equals(mLanguage); |
| } |
| |
| public boolean hasRegion() { |
| return mRegion != null && !FAKE_VALUE.equals(mRegion); |
| } |
| |
| @Override |
| public boolean checkAndSet(@NonNull String value, @NonNull FolderConfiguration config) { |
| LocaleQualifier qualifier = getQualifier(value); |
| if (qualifier != null) { |
| config.setLocaleQualifier(qualifier); |
| return true; |
| } |
| |
| return false; |
| } |
| |
| /** |
| * Used only when constructing the qualifier, don't use after it's been assigned to a |
| * {@link FolderConfiguration}. |
| */ |
| void setRegionSegment(@NonNull String segment) { |
| assert segment.length() == 3 : segment; |
| mRegion = new String(new char[] { |
| Character.toUpperCase(segment.charAt(1)), |
| Character.toUpperCase(segment.charAt(2)) |
| }); |
| mFull = mLanguage + "-r" + mRegion; |
| } |
| |
| @SuppressWarnings("RedundantIfStatement") |
| @Override |
| public boolean equals(Object o) { |
| if (this == o) { |
| return true; |
| } |
| if (o == null || getClass() != o.getClass()) { |
| return false; |
| } |
| |
| LocaleQualifier qualifier = (LocaleQualifier) o; |
| |
| if (!mFull.equals(qualifier.mFull)) { |
| return false; |
| } |
| |
| return true; |
| } |
| |
| @Override |
| public int hashCode() { |
| return mFull.hashCode(); |
| } |
| |
| /** |
| * Returns the string used to represent this qualifier in the folder name. |
| */ |
| @Override |
| public String getFolderSegment() { |
| return mFull; |
| } |
| |
| /** BCP 47 tag or "language,region", or language */ |
| @Override |
| public String getShortDisplayValue() { |
| if (mFull.startsWith(BCP_47_PREFIX)) { |
| return mFull; |
| } else if (mRegion != null) { |
| return mLanguage + ',' + mRegion; |
| } else { |
| return mLanguage; |
| } |
| } |
| |
| /** Tag: language, or language-region, or BCP-47 tag */ |
| public String getTag() { |
| if (mFull.startsWith(BCP_47_PREFIX)) { |
| return mFull.substring(BCP_47_PREFIX.length()).replace('+','-'); |
| } else if (mRegion != null) { |
| return mLanguage + '-' + mRegion; |
| } else { |
| return mLanguage; |
| } |
| } |
| |
| @Override |
| public String getLongDisplayValue() { |
| if (mFull.startsWith(BCP_47_PREFIX)) { |
| return String.format("Locale %1$s", mFull); |
| } else if (mRegion != null) { |
| return String.format("Locale %1$s_%2$s", mLanguage, mRegion); |
| } else //noinspection StringEquality |
| if (mFull != FAKE_VALUE) { |
| return String.format("Locale %1$s", mLanguage); |
| } |
| |
| return ""; //$NON-NLS-1$ |
| } |
| |
| /** |
| * Parse an Android BCP-47 string (which differs from BCP-47 in that |
| * it has the prefix "b+" and the separator character has been changed from |
| * - to +. |
| * |
| * @param qualifier the folder name to parse |
| * @return a {@linkplain LocaleQualifier} holding the language, region and script |
| * or null if not a valid Android BCP 47 tag |
| */ |
| @Nullable |
| public static LocaleQualifier parseBcp47(@NonNull String qualifier) { |
| if (qualifier.startsWith(BCP_47_PREFIX)) { |
| qualifier = normalizeCase(qualifier); |
| Iterator<String> iterator = Splitter.on('+').split(qualifier).iterator(); |
| // Skip b+ prefix, already checked above |
| iterator.next(); |
| |
| if (iterator.hasNext()) { |
| String language = iterator.next(); |
| String region = null; |
| String script = null; |
| if (language.length() >= 2 && language.length() <= 3) { |
| if (iterator.hasNext()) { |
| String next = iterator.next(); |
| if (next.length() == 4) { |
| // Script specified; look for next |
| script = next; |
| if (iterator.hasNext()) { |
| next = iterator.next(); |
| } |
| } else if (next.length() >= 5) { |
| // Past region: specifying a variant |
| return new LocaleQualifier(qualifier, language, null, null); |
| } |
| if (next.length() >= 2 && next.length() <= 3) { |
| region = next; |
| } |
| } |
| if (script == null && (region == null || region.length() == 2) |
| && !iterator.hasNext()) { |
| // Switch from BCP 47 syntax to plain |
| qualifier = language.toLowerCase(Locale.US); |
| if (region != null) { |
| qualifier = qualifier + "-r" + region.toUpperCase(Locale.US); |
| } |
| } |
| return new LocaleQualifier(qualifier, language, region, script); |
| } |
| } |
| } |
| |
| return null; |
| } |
| |
| @NonNull |
| public String getLanguage() { |
| return mLanguage; |
| } |
| |
| @Nullable |
| public String getRegion() { |
| return mRegion; |
| } |
| |
| @Nullable |
| public String getScript() { |
| return mScript; |
| } |
| |
| @NonNull |
| public String getFull() { |
| return mFull; |
| } |
| |
| @Override |
| public boolean isMatchFor(ResourceQualifier qualifier) { |
| if (qualifier instanceof LocaleQualifier) { |
| LocaleQualifier other = (LocaleQualifier)qualifier; |
| if (!mLanguage.equals(other.mLanguage)) { |
| return false; |
| } |
| |
| if (mRegion != null && other.mRegion != null && !mRegion.equals(other.mRegion)) { |
| return false; |
| } |
| |
| if (mScript != null && other.mScript != null && !mScript.equals(other.mScript)) { |
| return false; |
| } |
| |
| return true; |
| } |
| return false; |
| } |
| } |