blob: 26fddd02b187a7e2deeee261c0a7fe151dff7bf1 [file] [log] [blame]
/*
* 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;
}
}