// Copyright 2016 The Bazel Authors. All rights reserved. // // 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.google.devtools.build.android; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Strings.emptyToNull; import com.android.SdkConstants; import com.android.annotations.VisibleForTesting; import com.android.ide.common.resources.configuration.FolderConfiguration; import com.android.ide.common.resources.configuration.ResourceQualifier; import com.android.resources.ResourceFolderType; import com.android.resources.ResourceType; import com.google.common.base.Joiner; import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; import com.google.common.base.Splitter; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterators; import com.google.common.collect.PeekingIterator; import com.google.devtools.build.android.proto.SerializeFormat; import com.google.devtools.build.android.xml.ResourcesAttribute; import java.io.IOException; import java.io.OutputStream; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.atomic.AtomicInteger; import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.annotation.CheckReturnValue; import javax.annotation.concurrent.Immutable; /** * Represents a fully qualified name for an android resource. * *

Each resource name consists of the resource package, name, type, and qualifiers. */ @Immutable public class FullyQualifiedName implements DataKey { public static final String DEFAULT_PACKAGE = "res-auto"; private static final Joiner DASH_JOINER = Joiner.on('-'); // To save on memory, always return one instance for each FullyQualifiedName. // Using a HashMap to deduplicate the instances -- the key retrieves a single instance. private static final ConcurrentMap instanceCache = new ConcurrentHashMap<>(); private static final AtomicInteger cacheHit = new AtomicInteger(0); private final String pkg; private final ImmutableList qualifiers; private final Type type; private final String name; private FullyQualifiedName(String pkg, ImmutableList qualifiers, Type type, String name) { Preconditions.checkArgument(!pkg.isEmpty()); this.pkg = pkg; this.qualifiers = qualifiers; this.type = type; this.name = name; } private static Type createTypeFrom(String rawType) { ResourceType resourceType = ResourceType.getEnum(rawType); VirtualType virtualType = VirtualType.getEnum(rawType); if (resourceType != null) { return new ResourceTypeWrapper(resourceType); } else if (virtualType != null) { return virtualType; } return null; } /** * Creates a new FullyQualifiedName with normalized qualifiers. * * @param rawPkg The resource package of the name. If unknown the default should be "res-auto" * @param qualifiers The resource qualifiers of the name, such as "en" or "xhdpi". * @param type The type of the name. * @param name The name of the name. * @return A new FullyQualifiedName. */ public static FullyQualifiedName of( String rawPkg, List qualifiers, Type type, String name) { checkNotNull(rawPkg); checkNotNull(qualifiers); checkNotNull(type); checkNotNull(name); ImmutableList immutableQualifiers = ImmutableList.copyOf(qualifiers); String pkg = rawPkg.isEmpty() ? DEFAULT_PACKAGE : rawPkg; // TODO(corysmith): Address the GC thrash this creates by managing a simplified, mutable key to // do the instance check. FullyQualifiedName fqn = new FullyQualifiedName(pkg, immutableQualifiers, type, name); // Use putIfAbsent to get the canonical instance, if there. If it isn't, putIfAbsent will // return null, and we should return the current instance. FullyQualifiedName cached = instanceCache.putIfAbsent(fqn, fqn); if (cached == null) { return fqn; } else { cacheHit.incrementAndGet(); return cached; } } /** * Creates a new FullyQualifiedName with normalized qualifiers. * * @param pkg The resource package of the name. If unknown the default should be "res-auto" * @param qualifiers The resource qualifiers of the name, such as "en" or "xhdpi". * @param type The resource type of the name. * @param name The name of the name. * @return A new FullyQualifiedName. */ static FullyQualifiedName of( String pkg, List qualifiers, ResourceType type, String name) { return of(pkg, qualifiers, new ResourceTypeWrapper(type), name); } public static FullyQualifiedName fromProto(SerializeFormat.DataKey protoKey) { return of( protoKey.getKeyPackage(), protoKey.getQualifiersList(), createTypeFrom(protoKey.getResourceType()), protoKey.getKeyValue()); } static final Pattern QUALIFIED_REFERENCE = Pattern.compile("((?[^:]+):)?(?\\w+)/(?\\w+)"); public static FullyQualifiedName fromReference( String qualifiedReference, Optional packageName) { final Matcher matcher = QUALIFIED_REFERENCE.matcher(qualifiedReference); Preconditions.checkArgument( matcher.find(), "%s is not a reference. Expected %s", qualifiedReference, QUALIFIED_REFERENCE.pattern()); return of( Optional.ofNullable(emptyToNull(matcher.group("package"))) .orElse(packageName.orElse(DEFAULT_PACKAGE)), ImmutableList.of(), ResourceType.getEnum(matcher.group("type")), matcher.group("name")); } public static void logCacheUsage(Logger logger) { logger.fine( String.format( "Total FullyQualifiedName instance cache hits %s out of %s", cacheHit.intValue(), instanceCache.size())); } /** * Returns a string path representation of the FullyQualifiedName. * *

Non-values Android Resource have a well defined file layout: From the resource directory, * they reside in <resource type>[-<qualifier>]/<resource name>[.extension] * * @param source The original source of the file-based resource's FullyQualifiedName * @return A string representation of the FullyQualifiedName with the provided extension. */ public String toPathString(Path source) { String sourceExtension = FullyQualifiedName.Factory.getSourceExtension(source); return Paths.get( DASH_JOINER.join( ImmutableList.builder().add(type.getName()).addAll(qualifiers).build()), name + sourceExtension) .toString(); } @Override public String toPrettyString() { // TODO(corysmith): Add package when we start tracking it. return String.format( "%s%s/%s", DEFAULT_PACKAGE.equals(pkg) ? "" : pkg + ':', DASH_JOINER.join( ImmutableList.builder().add(type.getName()).addAll(qualifiers).build()), name); } /** * Returns the string path representation of the values directory and qualifiers. * *

Certain resource types live in the "values" directory. This will calculate the directory and * ensure the qualifiers are represented. */ // TODO(corysmith): Combine this with toPathString to clean up the interface of FullyQualifiedName // logically, the FullyQualifiedName should just be able to provide the relative path string for // the resource. public String valuesPath() { return Paths.get( DASH_JOINER.join( ImmutableList.builder().add("values").addAll(qualifiers).build()), "values.xml") .toString(); } @VisibleForTesting public String asUnqualifedName() { return String.format( "%s/%s", DASH_JOINER.join( ImmutableList.builder().add(type.getName()).addAll(qualifiers).build()), name); } public String name() { return name; } public boolean isInPackage(String packageName) { return pkg.equals(packageName); } /** Provides the name qualified by the package it belongs to. */ public String qualifiedName() { return (pkg.equals(DEFAULT_PACKAGE) ? "" : pkg + ":") + name; } public String asQualifiedReference() { return String.format( "%s%s/%s", (pkg.equals(DEFAULT_PACKAGE) ? "" : pkg + ":"), type.getName(), name); } public ResourceType type() { if (type instanceof ResourceTypeWrapper) { return ((ResourceTypeWrapper) type).resourceType; } return null; } public boolean isOverwritable() { return type.isOverwritable(this); } /** Creates a FullyQualifiedName from this one with a different package. */ @CheckReturnValue public FullyQualifiedName replacePackage(String newPackage) { if (pkg.equals(newPackage)) { return this; } return of(newPackage, qualifiers, type, name); } @Override public int hashCode() { return Objects.hash(pkg, qualifiers, type, name); } @Override public boolean equals(Object obj) { if (!(obj instanceof FullyQualifiedName)) { return false; } FullyQualifiedName other = getClass().cast(obj); return Objects.equals(pkg, other.pkg) && Objects.equals(type, other.type) && Objects.equals(name, other.name) && Objects.equals(qualifiers, other.qualifiers); } @Override public String toString() { return MoreObjects.toStringHelper(getClass()) .add("pkg", pkg) .add("qualifiers", qualifiers) .add("type", type) .add("name", name) .toString(); } @Override public int compareTo(DataKey otherKey) { if (!(otherKey instanceof FullyQualifiedName)) { return getKeyType().compareTo(otherKey.getKeyType()); } FullyQualifiedName other = (FullyQualifiedName) otherKey; if (!pkg.equals(other.pkg)) { return pkg.compareTo(other.pkg); } if (!type.equals(other.type)) { return type.compareTo(other.type); } if (!name.equals(other.name)) { return name.compareTo(other.name); } if (!qualifiers.equals(other.qualifiers)) { if (qualifiers.size() != other.qualifiers.size()) { return qualifiers.size() - other.qualifiers.size(); } // This works because the qualifiers are always in an ordered sequence. return qualifiers.toString().compareTo(other.qualifiers.toString()); } return 0; } @Override public KeyType getKeyType() { return KeyType.FULL_QUALIFIED_NAME; } @Override public void serializeTo(OutputStream out, int valueSize) throws IOException { toSerializedBuilder().setValueSize(valueSize).build().writeDelimitedTo(out); } public SerializeFormat.DataKey.Builder toSerializedBuilder() { return SerializeFormat.DataKey.newBuilder() .setKeyPackage(pkg) .setResourceType(type.getName()) .addAllQualifiers(qualifiers) .setKeyValue(name); } /** The non-resource {@link Type}s of a {@link FullyQualifiedName}. */ public enum VirtualType implements Type { RESOURCES_ATTRIBUTE("", "Resources Attribute"); private final String name; private final String displayName; private VirtualType(String name, String displayName) { this.name = name; this.displayName = displayName; } /** Returns the enum represented by the {@code name}. */ public static VirtualType getEnum(String name) { for (VirtualType type : values()) { if (type.name.equals(name)) { return type; } } return null; } /** Returns an array with all the names defined by this enum. */ public static String[] getNames() { VirtualType[] values = values(); String[] names = new String[values.length]; for (int i = values.length - 1; i >= 0; --i) { names[i] = values[i].getName(); } return names; } /** Returns the resource type name. */ @Override public String getName() { return name; } /** Returns a translated display name for the resource type. */ public String getDisplayName() { return displayName; } @Override public ConcreteType getType() { return ConcreteType.VIRTUAL_TYPE; } @Override public boolean isOverwritable(FullyQualifiedName fqn) { if (this == RESOURCES_ATTRIBUTE) { return !ResourcesAttribute.AttributeType.from(fqn.name()).isCombining(); } return true; } @Override public int compareTo(Type other) { if (!(other instanceof VirtualType)) { return getType().compareTo(other.getType()); } return compareTo(((VirtualType) other)); } @Override public String toString() { return getName(); } } /** Represents the type of a {@link FullyQualifiedName}. */ public interface Type { String getName(); ConcreteType getType(); boolean isOverwritable(FullyQualifiedName fqn); int compareTo(Type other); @Override boolean equals(Object obj); @Override int hashCode(); @Override String toString(); /** * The category of type that a {@link Type} can be. * *

Note: used for strict ordering of {@link FullyQualifiedName}s. */ enum ConcreteType { RESOURCE_TYPE, VIRTUAL_TYPE; } } private static class ResourceTypeWrapper implements Type { private final ResourceType resourceType; public ResourceTypeWrapper(ResourceType resourceType) { this.resourceType = resourceType; } @Override public String getName() { return resourceType.getName(); } @Override public ConcreteType getType() { return ConcreteType.RESOURCE_TYPE; } @Override public boolean isOverwritable(FullyQualifiedName fqn) { return !(resourceType == ResourceType.ID || resourceType == ResourceType.PUBLIC || resourceType == ResourceType.STYLEABLE); } @Override public int compareTo(Type other) { if (!(other instanceof ResourceTypeWrapper)) { return getType().compareTo(other.getType()); } return resourceType.compareTo(((ResourceTypeWrapper) other).resourceType); } @Override public boolean equals(Object obj) { if (!(obj instanceof ResourceTypeWrapper)) { return false; } ResourceTypeWrapper other = (ResourceTypeWrapper) obj; return Objects.equals(resourceType, other.resourceType); } @Override public int hashCode() { return Objects.hashCode(resourceType); } @Override public String toString() { return resourceType.toString(); } } /** Represents the configuration qualifiers in a resource directory. */ public static class Qualifiers { private static final Qualifiers EMPTY_QUALIFIERS = new Qualifiers(null, ImmutableList.of(), false); // Qualifiers are reasonably expensive to create, so cache them on directory names. private static final ConcurrentMap qualifierCache = new ConcurrentHashMap<>(); public static final String INVALID_QUALIFIERS = "%s contains invalid qualifiers."; private final ResourceFolderType folderType; private final ImmutableList qualifiers; private boolean defaultLocale; private Qualifiers( ResourceFolderType folderType, ImmutableList qualifiers, boolean defaultLocale) { this.folderType = folderType; this.qualifiers = qualifiers; this.defaultLocale = defaultLocale; } public static Qualifiers parseFrom(String directoryName) { return qualifierCache.computeIfAbsent( directoryName, d -> getQualifiers(Splitter.on(SdkConstants.RES_QUALIFIER_SEP).split(d))); } private static Qualifiers getQualifiers(String... dirNameAndQualifiers) { return getQualifiers(Arrays.asList(dirNameAndQualifiers)); } private static Qualifiers getQualifiers(Iterable dirNameAndQualifiers) { PeekingIterator rawQualifiers = Iterators.peekingIterator(dirNameAndQualifiers.iterator()); // Remove directory name final ResourceFolderType folderType = ResourceFolderType.getTypeByName(rawQualifiers.next()); // If there is no folder type, there are no qualifiers to parse. if (folderType == null) { return EMPTY_QUALIFIERS; } List handledQualifiers = new ArrayList<>(); // Do some substitution of language/region qualifiers. while (rawQualifiers.hasNext()) { String qualifier = rawQualifiers.next(); if ("es".equalsIgnoreCase(qualifier) && rawQualifiers.hasNext() && "419".equalsIgnoreCase(rawQualifiers.peek())) { // Replace the es-419. handledQualifiers.add("b+es+419"); // Consume the next value, as it's been replaced. rawQualifiers.next(); } else if ("sr".equalsIgnoreCase(qualifier) && rawQualifiers.hasNext() && "rlatn".equalsIgnoreCase(rawQualifiers.peek())) { // Replace the sr-rLatn. handledQualifiers.add("b+sr+Latn"); // Consume the next value, as it's been replaced. rawQualifiers.next(); } else { // This qualifier can probably be handled by FolderConfiguration. handledQualifiers.add(qualifier); } } // Create a configuration FolderConfiguration config = FolderConfiguration.getConfigFromQualifiers(handledQualifiers); // FolderConfiguration returns an unhelpful null when it considers the qualifiers to be // invalid. if (config == null) { throw new IllegalArgumentException( String.format(INVALID_QUALIFIERS, DASH_JOINER.join(dirNameAndQualifiers))); } config.normalize(); ImmutableList.Builder builder = ImmutableList.builder(); // index 3 is past the country code, network code, and locale indices. for (int i = 0; i < FolderConfiguration.getQualifierCount(); ++i) { addIfNotNull(config.getQualifier(i), builder); } return new Qualifiers(folderType, builder.build(), config.getLocaleQualifier() == null); } private static void addIfNotNull( ResourceQualifier qualifier, ImmutableList.Builder builder) { if (qualifier != null) { builder.add(qualifier.getFolderSegment()); } } /** Returns the qualifiers as a list of strings. */ public List asList() { return qualifiers; } public ResourceFolderType asFolderType() { return folderType; } /** Creates a Qualifiers assuming that they are in the values directory. */ @VisibleForTesting public static Qualifiers forValuesFolderFrom(List qualifiers) { return Qualifiers.getQualifiers( ImmutableList.builder().add("values").addAll(qualifiers).build().toArray(new String[0])); } public boolean containDefaultLocale() { return defaultLocale; } } /** A factory for parsing an generating FullyQualified names with qualifiers and package. */ public static class Factory { public static final String INVALID_QUALIFIED_NAME_MESSAGE_NO_MATCH = String.format( "%%s is not a valid qualified name. " + "It should be in the pattern [package:]{%s}/name", Joiner.on(",") .join( ImmutableList.builder() .add(ResourceType.getNames()) .add(VirtualType.getNames()) .build())); public static final String INVALID_QUALIFIED_NAME_MESSAGE_NO_TYPE_OR_NAME = String.format( "Could not find either resource type (%%s) or name (%%s) in %%s. " + "It should be in the pattern [package:]{%s}/name", Joiner.on(",") .join( ImmutableList.builder() .add(ResourceType.getNames()) .add(VirtualType.getNames()) .build())); private static final Pattern PARSING_REGEX = Pattern.compile( "(?:(?[^:]+):){0,1}(?[^-/]+)(?:[^/]*)/(?:(?:(?\\{[^}]+\\}))" + "|(?:(?[^:]+):)){0,1}(?.+)"); // private final ImmutableList qualifiers; private final String pkg; private final Qualifiers qs; private Factory(Qualifiers qualifiers, String pkg) { // this.qualifiers = qualifiers; this.pkg = pkg; this.qs = qualifiers; } /** Creates a factory with default package from a directory name split on '-'. */ @VisibleForTesting public static Factory fromDirectoryName(String... dirNameAndQualifiers) { return using(Qualifiers.getQualifiers(dirNameAndQualifiers), DEFAULT_PACKAGE); } /** Creates a factory with default package from a directory with '-' separating qualifiers. */ public static Factory fromDirectoryName(String dirNameAndQualifiers) { return using(Qualifiers.parseFrom(dirNameAndQualifiers), DEFAULT_PACKAGE); } @VisibleForTesting public static Factory from(List qualifiers, String pkg) { return using(Qualifiers.forValuesFolderFrom(qualifiers), pkg); } @VisibleForTesting public static Factory from(List qualifiers) { return from(qualifiers, DEFAULT_PACKAGE); } /** Creates a factory with the qualifiers and package. */ public static Factory using(Qualifiers qualifiers) { return using(qualifiers, DEFAULT_PACKAGE); } /** Creates a factory with the qualifiers and package. */ public static Factory using(Qualifiers qualifiers, String pkg) { return new Factory(qualifiers, pkg.isEmpty() ? DEFAULT_PACKAGE : pkg); } private static String deriveRawFullyQualifiedName(Path source) { if (source.getNameCount() < 2) { throw new IllegalArgumentException( String.format( "The resource path %s is too short. " + "The path is expected to be /.", source)); } // Compose the `pathWithExtension` manually to ensure it uses a forward slash. // Using Path.subpath would return a backslash-using path on Windows. String pathWithExtension = source.getParent().getFileName() + "/" + source.getFileName(); int extensionStart = pathWithExtension.indexOf('.'); if (extensionStart > 0) { return pathWithExtension.substring(0, extensionStart); } return pathWithExtension; } // Grabs the extension portion of the path removed by deriveRawFullyQualifiedName. private static String getSourceExtension(Path source) { // TODO(corysmith): Find out if there is a filename parser utility. String fileName = source.getFileName().toString(); int extensionStart = fileName.indexOf('.'); if (extensionStart > 0) { return fileName.substring(extensionStart); } return ""; } public FullyQualifiedName create(Type type, String name, String pkg) { return FullyQualifiedName.of(pkg, qs.asList(), type, name); } public FullyQualifiedName create(ResourceType type, String name) { return create(new ResourceTypeWrapper(type), name, pkg); } public FullyQualifiedName create(ResourceType type, String name, String pkg) { return create(new ResourceTypeWrapper(type), name, pkg); } public FullyQualifiedName create(VirtualType type, String name) { return create(type, name, pkg); } /** * Parses a FullyQualifiedName from a string. * * @param raw A string in the expected format from * [<package>:]<ResourceType.name>/<resource name>. * @throws IllegalArgumentException when the raw string is not valid qualified name. */ public FullyQualifiedName parse(String raw) { Matcher matcher = PARSING_REGEX.matcher(raw); if (!matcher.matches()) { throw new IllegalArgumentException( String.format(INVALID_QUALIFIED_NAME_MESSAGE_NO_MATCH, raw)); } String parsedPackage = firstNonNull(matcher.group("package"), matcher.group("misplacedPackage"), pkg); Type type = createTypeFrom(matcher.group("type")); String name = matcher.group("namespace") != null ? matcher.group("namespace") + matcher.group("name") : matcher.group("name"); if (type == null || name == null) { throw new IllegalArgumentException( String.format(INVALID_QUALIFIED_NAME_MESSAGE_NO_TYPE_OR_NAME, type, name, raw)); } return FullyQualifiedName.of(parsedPackage, qs.asList(), type, name); } private String firstNonNull(String... values) { for (String value : values) { if (value != null) { return value; } } throw new NullPointerException("Expected a nonnull value."); } /** * Generates a FullyQualifiedName for a file-based resource given the source Path. * * @param sourcePath the path of the file-based resource. * @throws IllegalArgumentException if the file-based resource has an invalid filename */ public FullyQualifiedName parse(Path sourcePath) { return parse(deriveRawFullyQualifiedName(sourcePath)); } } }