aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/tools/android/java/com/google/devtools/build
diff options
context:
space:
mode:
authorGravatar Michael Staib <mstaib@google.com>2016-07-07 20:15:55 +0000
committerGravatar Klaus Aehlig <aehlig@google.com>2016-07-08 08:50:35 +0000
commit7823502d2c012b4ee9d1eae63ca9bf4124d06806 (patch)
treefb4e0b06771d2527ed795afae2dc5522ec0c106c /src/tools/android/java/com/google/devtools/build
parent4be7fdbf74d91acf54300678ced0aac3c878a3df (diff)
Enhance AndroidResourceProcessingAction's split detection.
The --splits flag is now --split, since I remembered about how allowMultiple exists. More importantly, split APKs are now correctly moved to filenames which are fairly trivially constructed from the input split flags, allowing Bazel to just do a simple one-character replace on the split flags and know what files the resource processing action is going to output. One more step in the quest for world domination, or rather, split APKs for resources. Next: Actually supporting this in android_binary. -- MOS_MIGRATED_REVID=126838431
Diffstat (limited to 'src/tools/android/java/com/google/devtools/build')
-rw-r--r--src/tools/android/java/com/google/devtools/build/android/AndroidResourceProcessingAction.java6
-rw-r--r--src/tools/android/java/com/google/devtools/build/android/AndroidResourceProcessor.java73
-rw-r--r--src/tools/android/java/com/google/devtools/build/android/SplitConfigurationFilter.java367
3 files changed, 434 insertions, 12 deletions
diff --git a/src/tools/android/java/com/google/devtools/build/android/AndroidResourceProcessingAction.java b/src/tools/android/java/com/google/devtools/build/android/AndroidResourceProcessingAction.java
index 9bbef3dd24..17c4822cbe 100644
--- a/src/tools/android/java/com/google/devtools/build/android/AndroidResourceProcessingAction.java
+++ b/src/tools/android/java/com/google/devtools/build/android/AndroidResourceProcessingAction.java
@@ -25,6 +25,7 @@ import com.google.devtools.build.android.Converters.DependencyAndroidDataListCon
import com.google.devtools.build.android.Converters.PathConverter;
import com.google.devtools.build.android.Converters.UnvalidatedAndroidDataConverter;
import com.google.devtools.build.android.Converters.VariantConfigurationTypeConverter;
+import com.google.devtools.build.android.SplitConfigurationFilter.UnrecognizedSplitsException;
import com.google.devtools.common.options.Converters.CommaSeparatedOptionListConverter;
import com.google.devtools.common.options.Option;
import com.google.devtools.common.options.OptionsBase;
@@ -340,7 +341,10 @@ public class AndroidResourceProcessingAction {
} catch (MergingException e) {
LOGGER.log(java.util.logging.Level.SEVERE, "Error during merging resources", e);
throw e;
- } catch (IOException | InterruptedException | LoggedErrorException e) {
+ } catch (IOException
+ | InterruptedException
+ | LoggedErrorException
+ | UnrecognizedSplitsException e) {
LOGGER.log(java.util.logging.Level.SEVERE, "Error during processing resources", e);
throw e;
} catch (Exception e) {
diff --git a/src/tools/android/java/com/google/devtools/build/android/AndroidResourceProcessor.java b/src/tools/android/java/com/google/devtools/build/android/AndroidResourceProcessor.java
index dd7ba64f89..f403aaf708 100644
--- a/src/tools/android/java/com/google/devtools/build/android/AndroidResourceProcessor.java
+++ b/src/tools/android/java/com/google/devtools/build/android/AndroidResourceProcessor.java
@@ -30,8 +30,8 @@ import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.devtools.build.android.Converters.ExistingPathConverter;
import com.google.devtools.build.android.Converters.FullRevisionConverter;
+import com.google.devtools.build.android.SplitConfigurationFilter.UnrecognizedSplitsException;
import com.google.devtools.build.android.resources.RClassGenerator;
-import com.google.devtools.common.options.Converters.ColonSeparatedOptionListConverter;
import com.google.devtools.common.options.Converters.CommaSeparatedOptionListConverter;
import com.google.devtools.common.options.Option;
import com.google.devtools.common.options.OptionsBase;
@@ -74,6 +74,7 @@ import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
+import java.nio.file.DirectoryStream;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -185,12 +186,28 @@ public class AndroidResourceProcessor {
help = "A list of resource config filters to pass to aapt.")
public List<String> resourceConfigs;
- @Option(name = "splits",
- defaultValue = "",
- converter = ColonSeparatedOptionListConverter.class,
- category = "config",
- help = "A list of splits to pass to aapt, separated by colons."
- + " Each split is a list of qualifiers separated by commas.")
+ private static final String ANDROID_SPLIT_DOCUMENTATION_URL =
+ "https://developer.android.com/guide/topics/resources/providing-resources.html"
+ + "#QualifierRules";
+
+ @Option(
+ name = "split",
+ defaultValue = "required but ignored due to allowMultiple",
+ category = "config",
+ allowMultiple = true,
+ help =
+ "An individual split configuration to pass to aapt."
+ + " Each split is a list of configuration filters separated by commas."
+ + " Configuration filters are lists of configuration qualifiers separated by dashes,"
+ + " as used in resource directory names and described on the Android developer site: "
+ + ANDROID_SPLIT_DOCUMENTATION_URL
+ + " For example, a split might be 'en-television,en-xxhdpi', containing English"
+ + " assets which either are for TV screens or are extra extra high resolution."
+ + " Multiple splits can be specified by passing this flag multiple times."
+ + " Each split flag will produce an additional output file, named by replacing the"
+ + " commas in the split specification with underscores, and appending the result to"
+ + " the output package name following an underscore."
+ )
public List<String> splits;
}
@@ -380,9 +397,7 @@ public class AndroidResourceProcessor {
}
// TODO(bazel-team): Clean up this method call -- 13 params is too many.
- /**
- * Processes resources for generated sources, configs and packaging resources.
- */
+ /** Processes resources for generated sources, configs and packaging resources. */
public void processResources(
Path aapt,
Path androidJar,
@@ -400,7 +415,7 @@ public class AndroidResourceProcessor {
Path proguardOut,
Path mainDexProguardOut,
Path publicResourcesOut)
- throws IOException, InterruptedException, LoggedErrorException {
+ throws IOException, InterruptedException, LoggedErrorException, UnrecognizedSplitsException {
Path androidManifest = primaryData.getManifest();
Path resourceDir = primaryData.getResourceDir();
Path assetsDir = primaryData.getAssetDir();
@@ -470,6 +485,12 @@ public class AndroidResourceProcessor {
}
if (packageOut != null) {
Files.setLastModifiedTime(packageOut, FileTime.fromMillis(0L));
+ if (!splits.isEmpty()) {
+ Iterable<Path> splitFilenames = findAndRenameSplitPackages(packageOut, splits);
+ for (Path splitFilename : splitFilenames) {
+ Files.setLastModifiedTime(splitFilename, FileTime.fromMillis(0L));
+ }
+ }
}
if (publicResourcesOut != null && Files.exists(publicResourcesOut)) {
Files.setLastModifiedTime(publicResourcesOut, FileTime.fromMillis(0L));
@@ -636,6 +657,36 @@ public class AndroidResourceProcessor {
classWriter.write();
}
+ /** Finds aapt's split outputs and renames them according to the input flags. */
+ private Iterable<Path> findAndRenameSplitPackages(Path packageOut, Iterable<String> splits)
+ throws UnrecognizedSplitsException, IOException {
+ String prefix = packageOut.getFileName().toString() + "_";
+ // The regex java string literal below is received as [\\{}\[\]*?] by the regex engine,
+ // which produces a character class containing \{}[]*?
+ // The replacement string literal is received as \\$0 by the regex engine, which places
+ // a backslash before the match.
+ String prefixGlob = prefix.replaceAll("[\\\\{}\\[\\]*?]", "\\\\$0") + "*";
+ Path outputDirectory = packageOut.getParent();
+ ImmutableList.Builder<String> filenameSuffixes = new ImmutableList.Builder<>();
+ try (DirectoryStream<Path> glob = Files.newDirectoryStream(outputDirectory, prefixGlob)) {
+ for (Path file : glob) {
+ filenameSuffixes.add(file.getFileName().toString().substring(prefix.length()));
+ }
+ }
+ Map<String, String> outputs =
+ SplitConfigurationFilter.mapFilenamesToSplitFlags(filenameSuffixes.build(), splits);
+ ImmutableList.Builder<Path> outputPaths = new ImmutableList.Builder<>();
+ for (Map.Entry<String, String> splitMapping : outputs.entrySet()) {
+ Path resultPath = packageOut.resolveSibling(prefix + splitMapping.getValue());
+ outputPaths.add(resultPath);
+ if (!splitMapping.getKey().equals(splitMapping.getValue())) {
+ Path sourcePath = packageOut.resolveSibling(prefix + splitMapping.getKey());
+ Files.move(sourcePath, resultPath);
+ }
+ }
+ return outputPaths.build();
+ }
+
public MergedAndroidData processManifest(
VariantConfiguration.Type variantType,
String customPackageForR,
diff --git a/src/tools/android/java/com/google/devtools/build/android/SplitConfigurationFilter.java b/src/tools/android/java/com/google/devtools/build/android/SplitConfigurationFilter.java
new file mode 100644
index 0000000000..64f32561cc
--- /dev/null
+++ b/src/tools/android/java/com/google/devtools/build/android/SplitConfigurationFilter.java
@@ -0,0 +1,367 @@
+// 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 com.google.common.base.Joiner;
+import com.google.common.base.Optional;
+import com.google.common.base.Predicate;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Ordering;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.TreeSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * A parsed set of configuration filters for a split flag or an output filename.
+ *
+ * <p>The natural ordering of this class sorts by number of configurations, then by highest required
+ * API version, if any, then by other specifiers (case-insensitive), with ties broken by the
+ * filename or split flag originally used to create the instance (case-sensitive).
+ *
+ * <p>This has the following useful property:<br/>
+ * Given two sets of {@link SplitConfigurationFilter}s, one from the input split flags, and
+ * one from aapt's outputs... Each member of the output set can be matched to the greatest member
+ * of the input set for which {@code input.matchesFilterFromFilename(output)} is true.
+ */
+final class SplitConfigurationFilter implements Comparable<SplitConfigurationFilter> {
+
+ /**
+ * Finds a mapping from filename suffixes to the split flags which could have spawned them.
+ *
+ * @param filenames The suffixes of the original apk filenames output by aapt, not including the
+ * underscore used to set it off from the base filename or the base filename itself.
+ * @param splitFlags The split flags originally passed to aapt.
+ * @return A map whose keys are the filenames from {@code filenames} and whose values are
+ * predictable filenames based on the split flags - that is, the commas present in the input
+ * have been replaced with underscores.
+ * @throws UnrecognizedSplitException if any of the inputs are unused or could not be matched
+ */
+ static Map<String, String> mapFilenamesToSplitFlags(
+ Iterable<String> filenames, Iterable<String> splitFlags) throws UnrecognizedSplitsException {
+ TreeSet<SplitConfigurationFilter> filenameFilters = new TreeSet<>();
+ for (String filename : filenames) {
+ filenameFilters.add(SplitConfigurationFilter.fromFilenameSuffix(filename));
+ }
+ TreeSet<SplitConfigurationFilter> flagFilters = new TreeSet<>();
+ for (String splitFlag : splitFlags) {
+ flagFilters.add(SplitConfigurationFilter.fromSplitFlag(splitFlag));
+ }
+ ImmutableMap.Builder<String, String> result = ImmutableMap.builder();
+ List<String> unidentifiedFilenames = new ArrayList<>();
+ for (SplitConfigurationFilter filenameFilter : filenameFilters) {
+ Optional<SplitConfigurationFilter> matched =
+ Iterables.tryFind(flagFilters, new MatchesFilterFromFilename(filenameFilter));
+ if (matched.isPresent()) {
+ result.put(filenameFilter.filename, matched.get().filename);
+ flagFilters.remove(matched.get());
+ } else {
+ unidentifiedFilenames.add(filenameFilter.filename);
+ }
+ }
+ if (!(unidentifiedFilenames.isEmpty() && flagFilters.isEmpty())) {
+ ImmutableList.Builder<String> unidentifiedFlags = ImmutableList.builder();
+ for (SplitConfigurationFilter flagFilter : flagFilters) {
+ unidentifiedFlags.add(flagFilter.filename);
+ }
+ throw new UnrecognizedSplitsException(
+ unidentifiedFlags.build(), unidentifiedFilenames, result.build());
+ }
+ return result.build();
+ }
+
+ /**
+ * Exception thrown when mapFilenamesToSplitFlags fails to find matches for all elements of both
+ * input sets.
+ */
+ static final class UnrecognizedSplitsException extends Exception {
+ private final ImmutableList<String> unidentifiedSplits;
+ private final ImmutableList<String> unidentifiedFilenames;
+ private final ImmutableMap<String, String> identifiedSplits;
+
+ UnrecognizedSplitsException(
+ Iterable<String> unidentifiedSplits,
+ Iterable<String> unidentifiedFilenames,
+ Map<String, String> identifiedSplits) {
+ super(
+ "Could not find matching filenames for these split flags:\n"
+ + Joiner.on("\n").join(unidentifiedSplits)
+ + "\nnor matching split flags for these filenames:\n"
+ + Joiner.on(", ").join(unidentifiedFilenames)
+ + "\nFound these (filename => split flag) matches though:\n"
+ + Joiner.on("\n").withKeyValueSeparator(" => ").join(identifiedSplits));
+ this.unidentifiedSplits = ImmutableList.copyOf(unidentifiedSplits);
+ this.unidentifiedFilenames = ImmutableList.copyOf(unidentifiedFilenames);
+ this.identifiedSplits = ImmutableMap.copyOf(identifiedSplits);
+ }
+
+ /** Returns the list of split flags which did not find a match. */
+ ImmutableList<String> getUnidentifiedSplits() {
+ return unidentifiedSplits;
+ }
+
+ /** Returns the list of filename suffixes which did not find a match. */
+ ImmutableList<String> getUnidentifiedFilenames() {
+ return unidentifiedFilenames;
+ }
+
+ /** Returns the mapping from filename suffix to split flag for splits that did match. */
+ ImmutableMap<String, String> getIdentifiedSplits() {
+ return identifiedSplits;
+ }
+ }
+
+ /** Generates a SplitConfigurationFilter from a split flag. */
+ static SplitConfigurationFilter fromSplitFlag(String flag) {
+ return SplitConfigurationFilter.fromFilenameSuffix(flag.replace(',', '_'));
+ }
+
+ /** Generates a SplitConfigurationFilter from the suffix of a split generated by aapt. */
+ static SplitConfigurationFilter fromFilenameSuffix(String suffix) {
+ ImmutableSortedSet.Builder<ResourceConfiguration> configs = ImmutableSortedSet.reverseOrder();
+ for (String configuration : Splitter.on('_').split(suffix)) {
+ configs.add(ResourceConfiguration.fromString(configuration));
+ }
+ return new SplitConfigurationFilter(suffix, configs.build());
+ }
+
+ /**
+ * The suffix to be appended to the output package for this split configuration.
+ *
+ * <p>When created with {@link fromFilenameSuffix}, this will be the original filename from aapt;
+ * when created with {@link fromSplitFlag}, this will be the filename to rename to.
+ */
+ private final String filename;
+
+ /**
+ * A set of resource configurations which will be included in this split, sorted so that the
+ * configs with the highest API versions come first.
+ *
+ * <p>It's okay for this to collapse duplicates, because aapt forbids duplicate resource
+ * configurations across all splits in the same invocation anyway.
+ */
+ private final ImmutableSortedSet<ResourceConfiguration> configs;
+
+ private SplitConfigurationFilter(
+ String filename, ImmutableSortedSet<ResourceConfiguration> configs) {
+ this.filename = filename;
+ this.configs = configs;
+ }
+
+ /**
+ * Checks if the {@code other} split configuration filter could have been produced as a filename
+ * by aapt based on this configuration filter being passed as a split flag.
+ *
+ * <p>This means that there must be a one-to-one mapping from each configuration in this filter to
+ * a configuration in the {@code other} filter such that the non-API-version specifiers of the two
+ * configurations match and the API version specifier of the {@code other} filter's configuration
+ * is greater than or equal to the API version specifier of this filter's configuration.
+ *
+ * <p>Order of whole configurations doesn't matter, as aapt will reorder the configurations
+ * according to complicated internal logic (yes, logic even more complicated than this!).
+ *
+ * <p>Care is needed with API version specifiers because aapt may add or change minimum
+ * API version specifiers to configurations according to whether they had specifiers which are
+ * only supported in certain versions of Android. It will only ever increase the minimum version
+ * or leave it the same.
+ *
+ * <p>The other (non-wildcard) specifiers should be case-insensitive identical, including order;
+ * aapt will not allow parts of a single configuration to be parsed out of order.
+ *
+ * @see ResourceConfiguration#matchesConfigurationFromFilename(ResourceConfiguration)
+ */
+ boolean matchesFilterFromFilename(SplitConfigurationFilter filenameFilter) {
+ if (filenameFilter.configs.size() != this.configs.size()) {
+ return false;
+ }
+
+ List<ResourceConfiguration> unmatchedConfigs = new ArrayList<>(this.configs);
+ for (ResourceConfiguration filenameConfig : filenameFilter.configs) {
+ Optional<ResourceConfiguration> matched =
+ Iterables.tryFind(
+ unmatchedConfigs,
+ new ResourceConfiguration.MatchesConfigurationFromFilename(filenameConfig));
+ if (!matched.isPresent()) {
+ return false;
+ }
+ unmatchedConfigs.remove(matched.get());
+ }
+ return true;
+ }
+
+ static final class MatchesFilterFromFilename implements Predicate<SplitConfigurationFilter> {
+ private final SplitConfigurationFilter filenameFilter;
+
+ MatchesFilterFromFilename(SplitConfigurationFilter filenameFilter) {
+ this.filenameFilter = filenameFilter;
+ }
+
+ @Override
+ public boolean apply(SplitConfigurationFilter flagFilter) {
+ return flagFilter.matchesFilterFromFilename(filenameFilter);
+ }
+ }
+
+ private static final Ordering<Iterable<ResourceConfiguration>> CONFIG_LEXICOGRAPHICAL =
+ Ordering.natural().lexicographical();
+
+ @Override
+ public int compareTo(SplitConfigurationFilter other) {
+ return ComparisonChain.start()
+ .compare(this.configs.size(), other.configs.size())
+ .compare(this.configs, other.configs, CONFIG_LEXICOGRAPHICAL)
+ .compare(this.filename, other.filename)
+ .result();
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(configs, filename);
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (object instanceof SplitConfigurationFilter) {
+ SplitConfigurationFilter other = (SplitConfigurationFilter) object;
+ // the configs are derived from the filename, so we can be assured they are equal if the
+ // filenames are.
+ return Objects.equals(this.filename, other.filename);
+ }
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return "SplitConfigurationFilter{" + filename + "}";
+ }
+
+ /**
+ * An individual set of configuration specifiers, for the purposes of split name parsing.
+ *
+ * <p>The natural ordering of this class sorts by required API version, if any, then by other
+ * specifiers.
+ *
+ * <p>This has the following useful property:<br/>
+ * Given two sets of {@link ResourceConfiguration}s, one from an input split flag, and
+ * one from aapt's output... Each member of the output set can be matched to the greatest member
+ * of the input set for which {@code input.matchesConfigurationFromFilename(output)} is true.
+ */
+ static final class ResourceConfiguration implements Comparable<ResourceConfiguration> {
+ /**
+ * Pattern to match wildcard parts ("any"), which can be safely ignored - aapt drops them.
+ *
+ * <p>Matches an 'any' part and the dash following it, or for an 'any' part which is the last
+ * specifier, the dash preceding it. In the former case, it must be a full part - that is,
+ * preceded by the beginning of the string or a dash, which will not be consumed.
+ */
+ private static final Pattern WILDCARD_SPECIFIER = Pattern.compile("(?<=^|-)any(?:-|$)|-any$");
+ /**
+ * Pattern to match the API version and capture the version number.
+ *
+ * <p>It must always be the last specifier in a config, although it may also be the first if
+ * there are no other specifiers.
+ */
+ private static final Pattern API_VERSION = Pattern.compile("(?:-|^)v(\\d+)$");
+
+ /** Parses a resource configuration into a form that can be compared to other configurations. */
+ static ResourceConfiguration fromString(String text) {
+ // Case is ignored for resource configurations (aapt lowercases internally),
+ // and wildcards can be dropped.
+ String cleanSpecifiers =
+ WILDCARD_SPECIFIER.matcher(text.toLowerCase(Locale.ENGLISH)).replaceAll("");
+ Matcher apiVersionMatcher = API_VERSION.matcher(cleanSpecifiers);
+ if (apiVersionMatcher.find()) {
+ return new ResourceConfiguration(
+ cleanSpecifiers.substring(0, apiVersionMatcher.start()),
+ Integer.parseInt(apiVersionMatcher.group(1)));
+ } else {
+ return new ResourceConfiguration(cleanSpecifiers, 0);
+ }
+ }
+
+ /** The specifiers for this resource configuration, besides API version, in lowercase. */
+ private final String specifiers;
+
+ /** The API version, or 0 to indicate that no API version was present in the original config. */
+ private final int apiVersion;
+
+ private ResourceConfiguration(String specifiers, int apiVersion) {
+ this.specifiers = specifiers;
+ this.apiVersion = apiVersion;
+ }
+
+ /**
+ * Checks that the {@code other} configuration could be a filename generated from this one.
+ *
+ * @see SplitConfigurationFilter#matchesFilterFromFilename(SplitConfigurationFilter)
+ */
+ boolean matchesConfigurationFromFilename(ResourceConfiguration other) {
+ return Objects.equals(other.specifiers, this.specifiers)
+ && other.apiVersion >= this.apiVersion;
+ }
+
+ static final class MatchesConfigurationFromFilename
+ implements Predicate<ResourceConfiguration> {
+ private final ResourceConfiguration filenameConfig;
+
+ MatchesConfigurationFromFilename(ResourceConfiguration filenameConfig) {
+ this.filenameConfig = filenameConfig;
+ }
+
+ @Override
+ public boolean apply(ResourceConfiguration flagConfig) {
+ return flagConfig.matchesConfigurationFromFilename(filenameConfig);
+ }
+ }
+
+ @Override
+ public int compareTo(ResourceConfiguration other) {
+ return ComparisonChain.start()
+ .compare(this.apiVersion, other.apiVersion)
+ .compare(this.specifiers, other.specifiers)
+ .result();
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(specifiers, apiVersion);
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (object instanceof ResourceConfiguration) {
+ ResourceConfiguration other = (ResourceConfiguration) object;
+ return Objects.equals(this.specifiers, other.specifiers)
+ && this.apiVersion == other.apiVersion;
+ }
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return "ResourceConfiguration{" + specifiers + "-v" + Integer.toString(apiVersion) + "}";
+ }
+ }
+}