Add validation for publicAlternatives

Validate all the {@link ...} tags in the publicAlternatives field of
@UnsupportedAppUsage annotations, by cross-referencing them against the
current public API.

Test: atest class2greylisttest
Bug: 130721457
Change-Id: Ic3984687cb7ce323d431975767f9ba6b4ef7b49b
diff --git a/tools/class2greylist/Android.bp b/tools/class2greylist/Android.bp
index 371419e..f54aee7 100644
--- a/tools/class2greylist/Android.bp
+++ b/tools/class2greylist/Android.bp
@@ -21,6 +21,8 @@
         "commons-cli-1.2",
         "apache-bcel",
         "guava",
+        "testng",
+        "hamcrest-library",
     ],
 }
 
diff --git a/tools/class2greylist/src/com/android/class2greylist/AlternativeNotFoundError.java b/tools/class2greylist/src/com/android/class2greylist/AlternativeNotFoundError.java
new file mode 100644
index 0000000..10b2d9a
--- /dev/null
+++ b/tools/class2greylist/src/com/android/class2greylist/AlternativeNotFoundError.java
@@ -0,0 +1,20 @@
+/*
+ * Copyright (C) 2019 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.class2greylist;
+
+public class AlternativeNotFoundError extends Exception {
+}
diff --git a/tools/class2greylist/src/com/android/class2greylist/AnnotationContext.java b/tools/class2greylist/src/com/android/class2greylist/AnnotationContext.java
index 73b74a9..f0f7446 100644
--- a/tools/class2greylist/src/com/android/class2greylist/AnnotationContext.java
+++ b/tools/class2greylist/src/com/android/class2greylist/AnnotationContext.java
@@ -20,7 +20,7 @@
 
 /**
  */
-public abstract class AnnotationContext {
+public abstract class AnnotationContext implements ErrorReporter {
 
   public final Status status;
   public final JavaClass definingClass;
@@ -42,10 +42,4 @@
    * the greylist.
    */
   public abstract String getMemberDescriptor();
-
-  /**
-   * Report an error in this context. The final error message will include
-   * the class and member names, and the source file name.
-   */
-  public abstract void reportError(String message, Object... args);
 }
diff --git a/tools/class2greylist/src/com/android/class2greylist/ApiComponents.java b/tools/class2greylist/src/com/android/class2greylist/ApiComponents.java
new file mode 100644
index 0000000..3da4fe8
--- /dev/null
+++ b/tools/class2greylist/src/com/android/class2greylist/ApiComponents.java
@@ -0,0 +1,327 @@
+/*
+ * Copyright (C) 2019 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.class2greylist;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Class which can parse either dex style signatures (e.g. Lfoo/bar/baz$bat;->foo()V) or javadoc
+ * links to class members (e.g. {@link #toString()} or {@link java.util.List#clear()}).
+ */
+public class ApiComponents {
+    private static final String PRIMITIVE_TYPES = "ZBCSIJFD";
+    private final PackageAndClassName mPackageAndClassName;
+    // The reference can be just to a class, in which case mMemberName should be empty.
+    private final String mMemberName;
+    // If the member being referenced is a field, this will always be empty.
+    private final String mMethodParameterTypes;
+
+    private ApiComponents(PackageAndClassName packageAndClassName, String memberName,
+            String methodParameterTypes) {
+        mPackageAndClassName = packageAndClassName;
+        mMemberName = memberName;
+        mMethodParameterTypes = methodParameterTypes;
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder()
+                .append(mPackageAndClassName.packageName)
+                .append(".")
+                .append(mPackageAndClassName.className);
+        if (!mMemberName.isEmpty()) {
+            sb.append("#").append(mMemberName).append("(").append(mMethodParameterTypes).append(
+                    ")");
+        }
+        return sb.toString();
+    }
+
+    public PackageAndClassName getPackageAndClassName() {
+        return mPackageAndClassName;
+    }
+
+    public String getMemberName() {
+        return mMemberName;
+    }
+
+    public String getMethodParameterTypes() {
+        return mMethodParameterTypes;
+    }
+
+    /**
+     * Parse a JNI class descriptor. e.g. Lfoo/bar/Baz;
+     *
+     * @param sc Cursor over string assumed to contain a JNI class descriptor.
+     * @return The fully qualified class, in 'dot notation' (e.g. foo.bar.Baz for a class named Baz
+     * in the foo.bar package). The cursor will be placed after the semicolon.
+     */
+    private static String parseJNIClassDescriptor(StringCursor sc)
+            throws SignatureSyntaxError, StringCursorOutOfBoundsException {
+        if (sc.peek() != 'L') {
+            throw new SignatureSyntaxError(
+                    "Expected JNI class descriptor to start with L, but instead got " + sc.peek(),
+                    sc);
+        }
+        // Consume the L.
+        sc.next();
+        int semiColonPos = sc.find(';');
+        if (semiColonPos == -1) {
+            throw new SignatureSyntaxError("Expected semicolon at the end of JNI class descriptor",
+                    sc);
+        }
+        String jniClassDescriptor = sc.next(semiColonPos);
+        // Consume the semicolon.
+        sc.next();
+        return jniClassDescriptor.replace("/", ".");
+    }
+
+    /**
+     * Parse a primitive JNI type
+     *
+     * @param sc Cursor over a string assumed to contain a primitive JNI type.
+     * @return String containing parsed primitive JNI type.
+     */
+    private static String parseJNIPrimitiveType(StringCursor sc)
+            throws SignatureSyntaxError, StringCursorOutOfBoundsException {
+        char c = sc.next();
+        switch (c) {
+            case 'Z':
+                return "boolean";
+            case 'B':
+                return "byte";
+            case 'C':
+                return "char";
+            case 'S':
+                return "short";
+            case 'I':
+                return "int";
+            case 'J':
+                return "long";
+            case 'F':
+                return "float";
+            case 'D':
+                return "double";
+            default:
+                throw new SignatureSyntaxError(c + " is not a primitive type!", sc);
+        }
+    }
+
+    /**
+     * Parse a JNI type; can be either a primitive or object type. Arrays are handled separately.
+     *
+     * @param sc Cursor over the string assumed to contain a JNI type.
+     * @return String containing parsed JNI type.
+     */
+    private static String parseJniTypeWithoutArrayDimensions(StringCursor sc)
+            throws SignatureSyntaxError, StringCursorOutOfBoundsException {
+        char c = sc.peek();
+        if (PRIMITIVE_TYPES.indexOf(c) != -1) {
+            return parseJNIPrimitiveType(sc);
+        } else if (c == 'L') {
+            return parseJNIClassDescriptor(sc);
+        }
+        throw new SignatureSyntaxError("Illegal token " + c + " within signature", sc);
+    }
+
+    /**
+     * Parse a JNI type.
+     *
+     * This parameter can be an array, in which case it will be preceded by a number of open square
+     * brackets (corresponding to its dimensionality)
+     *
+     * @param sc Cursor over the string assumed to contain a JNI type.
+     * @return Same as {@link #parseJniTypeWithoutArrayDimensions}, but also handle arrays.
+     */
+    private static String parseJniType(StringCursor sc)
+            throws SignatureSyntaxError, StringCursorOutOfBoundsException {
+        int arrayDimension = 0;
+        while (sc.peek() == '[') {
+            ++arrayDimension;
+            sc.next();
+        }
+        StringBuilder sb = new StringBuilder();
+        sb.append(parseJniTypeWithoutArrayDimensions(sc));
+        for (int i = 0; i < arrayDimension; ++i) {
+            sb.append("[]");
+        }
+        return sb.toString();
+    }
+
+    /**
+     * Converts the parameters of method from JNI notation to Javadoc link notation. e.g.
+     * "(IILfoo/bar/Baz;)V" turns into "int, int, foo.bar.Baz". The parentheses and return type are
+     * discarded.
+     *
+     * @param sc Cursor over the string assumed to contain a JNI method parameters.
+     * @return Comma separated list of parameter types.
+     */
+    private static String convertJNIMethodParametersToJavadoc(StringCursor sc)
+            throws SignatureSyntaxError, StringCursorOutOfBoundsException {
+        List<String> methodParameterTypes = new ArrayList<>();
+        if (sc.next() != '(') {
+            throw new IllegalArgumentException("Trying to parse method params of an invalid dex " +
+                    "signature: " + sc.getOriginalString());
+        }
+        while (sc.peek() != ')') {
+            methodParameterTypes.add(parseJniType(sc));
+        }
+        return String.join(", ", methodParameterTypes);
+    }
+
+    /**
+     * Generate ApiComponents from a dex signature.
+     *
+     * This is used to extract the necessary context for an alternative API to try to infer missing
+     * information.
+     *
+     * @param signature Dex signature.
+     * @return ApiComponents instance with populated package, class name, and parameter types if
+     * applicable.
+     */
+    public static ApiComponents fromDexSignature(String signature) throws SignatureSyntaxError {
+        StringCursor sc = new StringCursor(signature);
+        try {
+            String fullyQualifiedClass = parseJNIClassDescriptor(sc);
+
+            PackageAndClassName packageAndClassName =
+                    PackageAndClassName.splitClassName(fullyQualifiedClass);
+            if (!sc.peek(2).equals("->")) {
+                throw new SignatureSyntaxError("Expected '->'", sc);
+            }
+            // Consume "->"
+            sc.next(2);
+            String memberName = "";
+            String methodParameterTypes = "";
+            int leftParenPos = sc.find('(');
+            if (leftParenPos != -1) {
+                memberName = sc.next(leftParenPos);
+                methodParameterTypes = convertJNIMethodParametersToJavadoc(sc);
+            } else {
+                int colonPos = sc.find(':');
+                if (colonPos == -1) {
+                    throw new IllegalArgumentException("Expected : or -> beyond position "
+                            + sc.position() + " in " + signature);
+                } else {
+                    memberName = sc.next(colonPos);
+                    // Consume the ':'.
+                    sc.next();
+                    // Consume the type.
+                    parseJniType(sc);
+                }
+            }
+            return new ApiComponents(packageAndClassName, memberName, methodParameterTypes);
+        } catch (StringCursorOutOfBoundsException e) {
+            throw new SignatureSyntaxError(
+                    "Unexpectedly reached end of string while trying to parse signature ", sc);
+        }
+    }
+
+    /**
+     * Generate ApiComponents from a link tag.
+     *
+     * @param linkTag          The contents of a link tag.
+     * @param contextSignature The signature of the private API that this is an alternative for.
+     *                         Used to infer unspecified components.
+     */
+    public static ApiComponents fromLinkTag(String linkTag, String contextSignature)
+            throws JavadocLinkSyntaxError {
+        ApiComponents contextAlternative;
+        try {
+            contextAlternative = fromDexSignature(contextSignature);
+        } catch (SignatureSyntaxError e) {
+            throw new RuntimeException(
+                    "Failed to parse the context signature for public alternative!");
+        }
+        StringCursor sc = new StringCursor(linkTag);
+        try {
+
+            String memberName = "";
+            String methodParameterTypes = "";
+
+            int tagPos = sc.find('#');
+            String fullyQualifiedClassName = sc.next(tagPos);
+
+            PackageAndClassName packageAndClassName =
+                    PackageAndClassName.splitClassName(fullyQualifiedClassName);
+
+            if (packageAndClassName.packageName.isEmpty()) {
+                packageAndClassName.packageName = contextAlternative.getPackageAndClassName()
+                        .packageName;
+            }
+
+            if (packageAndClassName.className.isEmpty()) {
+                packageAndClassName.className = contextAlternative.getPackageAndClassName()
+                        .className;
+            }
+
+            if (tagPos == -1) {
+                // This suggested alternative is just a class. We can allow that.
+                return new ApiComponents(packageAndClassName, "", "");
+            } else {
+                // Consume the #.
+                sc.next();
+            }
+
+            int leftParenPos = sc.find('(');
+            memberName = sc.next(leftParenPos);
+            if (leftParenPos != -1) {
+                // Consume the '('.
+                sc.next();
+                int rightParenPos = sc.find(')');
+                if (rightParenPos == -1) {
+                    throw new JavadocLinkSyntaxError(
+                            "Linked method is missing a closing parenthesis", sc);
+                } else {
+                    methodParameterTypes = sc.next(rightParenPos);
+                }
+            }
+
+            return new ApiComponents(packageAndClassName, memberName, methodParameterTypes);
+        } catch (StringCursorOutOfBoundsException e) {
+            throw new JavadocLinkSyntaxError(
+                    "Unexpectedly reached end of string while trying to parse javadoc link", sc);
+        }
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (!(obj instanceof ApiComponents)) {
+            return false;
+        }
+        ApiComponents other = (ApiComponents) obj;
+        return mPackageAndClassName.equals(other.mPackageAndClassName) && mMemberName.equals(
+                other.mMemberName) && mMethodParameterTypes.equals(other.mMethodParameterTypes);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(mPackageAndClassName, mMemberName, mMethodParameterTypes);
+    }
+
+    /**
+     * Less restrictive comparator to use in case a link tag is missing a method's parameters.
+     * e.g. foo.bar.Baz#foo will be considered the same as foo.bar.Baz#foo(int, int) and
+     * foo.bar.Baz#foo(long, long). If the class only has one method with that name, then specifying
+     * its parameter types is optional within the link tag.
+     */
+    public boolean equalsIgnoringParam(ApiComponents other) {
+        return mPackageAndClassName.equals(other.mPackageAndClassName) &&
+                mMemberName.equals(other.mMemberName);
+    }
+}
diff --git a/tools/class2greylist/src/com/android/class2greylist/ApiResolver.java b/tools/class2greylist/src/com/android/class2greylist/ApiResolver.java
new file mode 100644
index 0000000..f07a0af
--- /dev/null
+++ b/tools/class2greylist/src/com/android/class2greylist/ApiResolver.java
@@ -0,0 +1,104 @@
+/*
+ * Copyright (C) 2019 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.class2greylist;
+
+import com.google.common.base.Strings;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+public class ApiResolver {
+    private final List<ApiComponents> mPotentialPublicAlternatives;
+    private final Set<PackageAndClassName> mPublicApiClasses;
+
+    private static final Pattern LINK_TAG_PATTERN = Pattern.compile("\\{@link ([^\\}]+)\\}");
+    private static final Pattern CODE_TAG_PATTERN = Pattern.compile("\\{@code ([^\\}]+)\\}");
+
+    public ApiResolver() {
+        mPotentialPublicAlternatives = null;
+        mPublicApiClasses = null;
+    }
+
+    public ApiResolver(Set<String> publicApis) {
+        mPotentialPublicAlternatives = publicApis.stream()
+                .map(api -> {
+                    try {
+                        return ApiComponents.fromDexSignature(api);
+                    } catch (SignatureSyntaxError e) {
+                        throw new RuntimeException("Could not parse public API signature:", e);
+                    }
+                })
+                .collect(Collectors.toList());
+        mPublicApiClasses = mPotentialPublicAlternatives.stream()
+                .map(api -> api.getPackageAndClassName())
+                .collect(Collectors.toCollection(HashSet::new));
+    }
+
+    /**
+     * Verify that all public alternatives are valid.
+     *
+     * @param publicAlternativesString String containing public alternative explanations.
+     * @param signature                Signature of the member that has the annotation.
+     */
+    public void resolvePublicAlternatives(String publicAlternativesString, String signature)
+            throws JavadocLinkSyntaxError, AlternativeNotFoundError {
+        if (publicAlternativesString != null && mPotentialPublicAlternatives != null) {
+            // Grab all instances of type {@link foo}
+            Matcher matcher = LINK_TAG_PATTERN.matcher(publicAlternativesString);
+            boolean hasLinkAlternative = false;
+            // Validate all link tags
+            while (matcher.find()) {
+                hasLinkAlternative = true;
+                String alternativeString = matcher.group(1);
+                ApiComponents alternative = ApiComponents.fromLinkTag(alternativeString,
+                        signature);
+                if (alternative.getMemberName().isEmpty()) {
+                    // Provided class as alternative
+                    if (!mPublicApiClasses.contains(alternative.getPackageAndClassName())) {
+                        throw new ClassAlternativeNotFoundError(alternative);
+                    }
+                } else if (!mPotentialPublicAlternatives.contains(alternative)) {
+                    // If the link is not a public alternative, it must because the link does not
+                    // contain the method parameter types, e.g. {@link foo.bar.Baz#foo} instead of
+                    // {@link foo.bar.Baz#foo(int)}. If the method name is unique within the class,
+                    // we can handle it.
+                    if (!Strings.isNullOrEmpty(alternative.getMethodParameterTypes())) {
+                        throw new MemberAlternativeNotFoundError(alternative);
+                    }
+                    List<ApiComponents> almostMatches = mPotentialPublicAlternatives.stream()
+                            .filter(api -> api.equalsIgnoringParam(alternative))
+                            .collect(Collectors.toList());
+                    if (almostMatches.size() == 0) {
+                        throw new MemberAlternativeNotFoundError(alternative);
+                    } else if (almostMatches.size() > 1) {
+                        throw new MultipleAlternativesFoundError(alternative, almostMatches);
+                    }
+                }
+            }
+            // No {@link ...} alternatives exist; try looking for {@code ...}
+            if (!hasLinkAlternative) {
+                if (!CODE_TAG_PATTERN.matcher(publicAlternativesString).find()) {
+                    throw new NoAlternativesSpecifiedError();
+                }
+            }
+        }
+    }
+}
diff --git a/tools/class2greylist/src/com/android/class2greylist/Class2Greylist.java b/tools/class2greylist/src/com/android/class2greylist/Class2Greylist.java
index 49b8f81..bfa8076 100644
--- a/tools/class2greylist/src/com/android/class2greylist/Class2Greylist.java
+++ b/tools/class2greylist/src/com/android/class2greylist/Class2Greylist.java
@@ -21,7 +21,6 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableMap.Builder;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Sets;
 import com.google.common.io.Files;
 
 import org.apache.commons.cli.CommandLine;
@@ -35,7 +34,6 @@
 import java.io.File;
 import java.io.IOException;
 import java.nio.charset.Charset;
-import java.util.Arrays;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
diff --git a/tools/class2greylist/src/com/android/class2greylist/ClassAlternativeNotFoundError.java b/tools/class2greylist/src/com/android/class2greylist/ClassAlternativeNotFoundError.java
new file mode 100644
index 0000000..1f398f1
--- /dev/null
+++ b/tools/class2greylist/src/com/android/class2greylist/ClassAlternativeNotFoundError.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2019 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.class2greylist;
+
+public class ClassAlternativeNotFoundError extends AlternativeNotFoundError {
+    public final ApiComponents alternative;
+
+    ClassAlternativeNotFoundError(ApiComponents alternative) {
+        this.alternative = alternative;
+    }
+
+    @Override
+    public String toString() {
+        return "Specified class " + alternative.getPackageAndClassName() + " does not exist!";
+    }
+}
diff --git a/tools/class2greylist/src/com/android/class2greylist/ErrorReporter.java b/tools/class2greylist/src/com/android/class2greylist/ErrorReporter.java
new file mode 100644
index 0000000..24a92f0
--- /dev/null
+++ b/tools/class2greylist/src/com/android/class2greylist/ErrorReporter.java
@@ -0,0 +1,25 @@
+/*
+ * Copyright (C) 2019 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.class2greylist;
+
+public interface ErrorReporter {
+    /**
+     * Report an error in this context. The final error message will include
+     * the class and member names, and the source file name.
+     */
+    void reportError(String message, Object... args);
+}
diff --git a/tools/class2greylist/src/com/android/class2greylist/JavadocLinkSyntaxError.java b/tools/class2greylist/src/com/android/class2greylist/JavadocLinkSyntaxError.java
new file mode 100644
index 0000000..55014cb
--- /dev/null
+++ b/tools/class2greylist/src/com/android/class2greylist/JavadocLinkSyntaxError.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2019 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.class2greylist;
+
+public class JavadocLinkSyntaxError extends Exception {
+    public final String expected;
+    public final int position;
+    public final String context;
+
+    public JavadocLinkSyntaxError(String expected, StringCursor sc) {
+        super(expected + " at position " + sc.position() + " in " + sc.getOriginalString());
+        this.expected = expected;
+        this.position = sc.position();
+        this.context = sc.getOriginalString();
+    }
+}
+
diff --git a/tools/class2greylist/src/com/android/class2greylist/MemberAlternativeNotFoundError.java b/tools/class2greylist/src/com/android/class2greylist/MemberAlternativeNotFoundError.java
new file mode 100644
index 0000000..43f853e
--- /dev/null
+++ b/tools/class2greylist/src/com/android/class2greylist/MemberAlternativeNotFoundError.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2019 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.class2greylist;
+
+public class MemberAlternativeNotFoundError extends AlternativeNotFoundError {
+    public final ApiComponents alternative;
+
+    MemberAlternativeNotFoundError(ApiComponents alternative) {
+        this.alternative = alternative;
+    }
+
+    @Override
+    public String toString() {
+        return "Could not find public api " + alternative + ".";
+    }
+}
diff --git a/tools/class2greylist/src/com/android/class2greylist/MultipleAlternativesFoundError.java b/tools/class2greylist/src/com/android/class2greylist/MultipleAlternativesFoundError.java
new file mode 100644
index 0000000..a598534
--- /dev/null
+++ b/tools/class2greylist/src/com/android/class2greylist/MultipleAlternativesFoundError.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright (C) 2019 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.class2greylist;
+
+import com.google.common.base.Joiner;
+
+import java.util.List;
+
+public class MultipleAlternativesFoundError extends AlternativeNotFoundError {
+    public final ApiComponents alternative;
+    public final List<ApiComponents> almostMatches;
+
+    public MultipleAlternativesFoundError(ApiComponents alternative,
+            List<ApiComponents> almostMatches) {
+        this.alternative = alternative;
+        this.almostMatches = almostMatches;
+    }
+
+    @Override
+    public String toString() {
+        return "Alternative " + alternative + " returned multiple matches: "
+                + Joiner.on(", ").join(almostMatches);
+    }
+}
diff --git a/tools/class2greylist/src/com/android/class2greylist/NoAlternativesSpecifiedError.java b/tools/class2greylist/src/com/android/class2greylist/NoAlternativesSpecifiedError.java
new file mode 100644
index 0000000..28c5003
--- /dev/null
+++ b/tools/class2greylist/src/com/android/class2greylist/NoAlternativesSpecifiedError.java
@@ -0,0 +1,30 @@
+/*
+ * Copyright (C) 2019 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.class2greylist;
+
+public class NoAlternativesSpecifiedError extends AlternativeNotFoundError {
+
+    @Override
+    public String toString() {
+        return "Hidden API has a public alternative annotation field, but no concrete "
+                + "explanations. Please provide either a reference to an SDK method using javadoc "
+                + "syntax, e.g. {@link foo.bar.Baz#bat}, or a small code snippet if the "
+                + "alternative is part of a support library or third party library, e.g. "
+                + "{@code foo.bar.Baz bat = new foo.bar.Baz(); bat.doSomething();}.\n"
+                + "If this is too restrictive for your use case, please contact compat-team@.";
+    }
+}
diff --git a/tools/class2greylist/src/com/android/class2greylist/PackageAndClassName.java b/tools/class2greylist/src/com/android/class2greylist/PackageAndClassName.java
new file mode 100644
index 0000000..709092d
--- /dev/null
+++ b/tools/class2greylist/src/com/android/class2greylist/PackageAndClassName.java
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2019 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.class2greylist;
+
+import java.util.Objects;
+
+class PackageAndClassName{
+    public String packageName;
+    public String className;
+
+    private PackageAndClassName(String packageName, String className) {
+        this.packageName = packageName;
+        this.className = className;
+    }
+
+    /**
+     * Given a potentially fully qualified class name, split it into package and class.
+     *
+     * @param fullyQualifiedClassName potentially fully qualified class name.
+     * @return A pair of strings, containing the package name (or empty if not specified) and
+     * the
+     * class name (or empty if string is empty).
+     */
+    public static PackageAndClassName splitClassName(String fullyQualifiedClassName) {
+        int lastDotIdx = fullyQualifiedClassName.lastIndexOf('.');
+        if (lastDotIdx == -1) {
+            return new PackageAndClassName("", fullyQualifiedClassName);
+        }
+        String packageName = fullyQualifiedClassName.substring(0, lastDotIdx);
+        String className = fullyQualifiedClassName.substring(lastDotIdx + 1);
+        return new PackageAndClassName(packageName, className);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (!(obj instanceof PackageAndClassName)) {
+            return false;
+        }
+        PackageAndClassName other = (PackageAndClassName) obj;
+        return Objects.equals(packageName, other.packageName) && Objects.equals(className,
+                other.className);
+    }
+
+    @Override
+    public String toString() {
+        return packageName + "." + className;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(packageName, className);
+    }
+}
diff --git a/tools/class2greylist/src/com/android/class2greylist/SignatureSyntaxError.java b/tools/class2greylist/src/com/android/class2greylist/SignatureSyntaxError.java
new file mode 100644
index 0000000..7685caa
--- /dev/null
+++ b/tools/class2greylist/src/com/android/class2greylist/SignatureSyntaxError.java
@@ -0,0 +1,29 @@
+/*
+ * Copyright (C) 2019 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.class2greylist;
+
+public class SignatureSyntaxError extends Exception {
+    public final String expected;
+    public final int position;
+    public final String context;
+    public SignatureSyntaxError(String expected, StringCursor sc) {
+        super(expected + " at position " + sc.position() + " in " + sc.getOriginalString());
+        this.expected = expected;
+        this.position = sc.position();
+        this.context = sc.getOriginalString();
+    }
+}
diff --git a/tools/class2greylist/src/com/android/class2greylist/StringCursor.java b/tools/class2greylist/src/com/android/class2greylist/StringCursor.java
new file mode 100644
index 0000000..08e8521
--- /dev/null
+++ b/tools/class2greylist/src/com/android/class2greylist/StringCursor.java
@@ -0,0 +1,131 @@
+/*
+ * Copyright (C) 2019 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.class2greylist;
+
+/**
+ * Utility class to simplify parsing of signatures.
+ */
+public class StringCursor {
+
+    private final String mString;
+    private int mCursor;
+
+    public StringCursor(String str) {
+        mString = str;
+        mCursor = 0;
+    }
+
+    /**
+     * Position of cursor in string.
+     *
+     * @return Current position of cursor in string.
+     */
+    public int position() {
+        return mCursor;
+    }
+
+    /**
+     * Peek current cursor position.
+     *
+     * @return The character at the current cursor position.
+     */
+    public char peek() {
+        return mString.charAt(mCursor);
+    }
+
+    /**
+     * Peek several characters at the current cursor position without moving the cursor.
+     *
+     * @param n The number of characters to peek.
+     * @return A string with x characters from the cursor position. If n is -1, return the whole
+     *        rest of the string.
+     */
+    public String peek(int n) throws StringCursorOutOfBoundsException {
+        if (n == -1) {
+            return mString.substring(mCursor);
+        }
+        if (n < 0 || (n + mCursor) >= mString.length()) {
+            throw new StringCursorOutOfBoundsException();
+        }
+        return mString.substring(mCursor, mCursor + n);
+    }
+
+    /**
+     * Consume the character at the current cursor position and move the cursor forwards.
+     *
+     * @return The character at the current cursor position.
+     */
+    public char next() throws StringCursorOutOfBoundsException {
+        if (!hasNext()) {
+            throw new StringCursorOutOfBoundsException();
+        }
+        return mString.charAt(mCursor++);
+    }
+
+    /**
+     * Consume several characters at the current cursor position and move the cursor further along.
+     *
+     * @param n The number of characters to consume.
+     * @return A string with x characters from the cursor position. If n is -1, return the whole
+     *         rest of the string.
+     */
+    public String next(int n) throws StringCursorOutOfBoundsException {
+        if (n == -1) {
+            String restOfString = mString.substring(mCursor);
+            mCursor = mString.length();
+            return restOfString;
+        }
+        if (n < 0) {
+            throw new StringCursorOutOfBoundsException();
+        }
+        mCursor += n;
+        return mString.substring(mCursor - n, mCursor);
+    }
+
+    /**
+     * Search for the first occurrence of a character beyond the current cursor position.
+     *
+     * @param c The character to search for.
+     * @return The offset of the first occurrence of c in the string beyond the cursor position.
+     * If the character does not exist, return -1.
+     */
+    public int find(char c) {
+        int firstIndex = mString.indexOf(c, mCursor);
+        if (firstIndex == -1) {
+            return -1;
+        }
+        return firstIndex - mCursor;
+    }
+
+    /**
+     * Check if cursor has reached end of string.
+     *
+     * @return Cursor has reached end of string.
+     */
+    public boolean hasNext() {
+        return mCursor < mString.length();
+    }
+
+    @Override
+    public String toString() {
+        return mString.substring(mCursor);
+    }
+
+    public String getOriginalString() {
+        return mString;
+    }
+}
\ No newline at end of file
diff --git a/tools/class2greylist/src/com/android/class2greylist/StringCursorOutOfBoundsException.java b/tools/class2greylist/src/com/android/class2greylist/StringCursorOutOfBoundsException.java
new file mode 100644
index 0000000..caf0bd6
--- /dev/null
+++ b/tools/class2greylist/src/com/android/class2greylist/StringCursorOutOfBoundsException.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright (C) 2019 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.class2greylist;
+
+public class StringCursorOutOfBoundsException extends IndexOutOfBoundsException {
+
+}
diff --git a/tools/class2greylist/src/com/android/class2greylist/UnsupportedAppUsageAnnotationHandler.java b/tools/class2greylist/src/com/android/class2greylist/UnsupportedAppUsageAnnotationHandler.java
index b45e1b3..17ec732 100644
--- a/tools/class2greylist/src/com/android/class2greylist/UnsupportedAppUsageAnnotationHandler.java
+++ b/tools/class2greylist/src/com/android/class2greylist/UnsupportedAppUsageAnnotationHandler.java
@@ -32,12 +32,15 @@
     private static final String EXPECTED_SIGNATURE_PROPERTY = "expectedSignature";
     private static final String MAX_TARGET_SDK_PROPERTY = "maxTargetSdk";
     private static final String IMPLICIT_MEMBER_PROPERTY = "implicitMember";
+    private static final String PUBLIC_ALTERNATIVES_PROPERTY = "publicAlternatives";
 
     private final Status mStatus;
     private final Predicate<ClassMember> mClassMemberFilter;
     private final Map<Integer, String> mSdkVersionToFlagMap;
     private final AnnotationConsumer mAnnotationConsumer;
 
+    private ApiResolver mApiResolver;
+
     /**
      * Represents a member of a class file (a field or method).
      */
@@ -66,6 +69,7 @@
         this(status, annotationConsumer,
                 member -> !(member.isBridgeMethod && publicApis.contains(member.signature)),
                 sdkVersionToFlagMap);
+        mApiResolver = new ApiResolver(publicApis);
     }
 
     @VisibleForTesting
@@ -76,6 +80,7 @@
         mAnnotationConsumer = annotationConsumer;
         mClassMemberFilter = memberFilter;
         mSdkVersionToFlagMap = sdkVersionToFlagMap;
+        mApiResolver = new ApiResolver();
     }
 
     @Override
@@ -85,7 +90,7 @@
             AnnotatedMemberContext memberContext = (AnnotatedMemberContext) context;
             FieldOrMethod member = memberContext.member;
             isBridgeMethod = (member instanceof Method) &&
-                (member.getAccessFlags() & Const.ACC_BRIDGE) != 0;
+                    (member.getAccessFlags() & Const.ACC_BRIDGE) != 0;
             if (isBridgeMethod) {
                 mStatus.debug("Member is a bridge method");
             }
@@ -94,6 +99,7 @@
         String signature = context.getMemberDescriptor();
         Integer maxTargetSdk = null;
         String implicitMemberSignature = null;
+        String publicAlternativesString = null;
 
         for (ElementValuePair property : annotation.getElementValuePairs()) {
             switch (property.getNameString()) {
@@ -102,8 +108,8 @@
                     // Don't enforce for bridge methods; they're generated so won't match.
                     if (!isBridgeMethod && !signature.equals(expected)) {
                         context.reportError("Expected signature does not match generated:\n"
-                                        + "Expected:  %s\n"
-                                        + "Generated: %s", expected, signature);
+                                + "Expected:  %s\n"
+                                + "Generated: %s", expected, signature);
                         return;
                     }
                     break;
@@ -121,23 +127,27 @@
                     implicitMemberSignature = property.getValue().stringifyValue();
                     if (context instanceof AnnotatedClassContext) {
                         signature = String.format("L%s;->%s",
-                            context.getClassDescriptor(), implicitMemberSignature);
+                                context.getClassDescriptor(), implicitMemberSignature);
                     } else {
                         context.reportError(
-                            "Expected annotation with an %s property to be on a class but is on %s",
-                            IMPLICIT_MEMBER_PROPERTY,
-                            signature);
+                                "Expected annotation with an %s property to be on a class but is "
+                                        + "on %s",
+                                IMPLICIT_MEMBER_PROPERTY,
+                                signature);
                         return;
                     }
                     break;
+                case PUBLIC_ALTERNATIVES_PROPERTY:
+                    publicAlternativesString = property.getValue().stringifyValue();
+                    break;
             }
         }
 
         if (context instanceof AnnotatedClassContext && implicitMemberSignature == null) {
             context.reportError(
-                "Missing property %s on annotation on class %s",
-                IMPLICIT_MEMBER_PROPERTY,
-                signature);
+                    "Missing property %s on annotation on class %s",
+                    IMPLICIT_MEMBER_PROPERTY,
+                    signature);
             return;
         }
 
@@ -149,6 +159,11 @@
                     mSdkVersionToFlagMap.keySet());
             return;
         }
+        try {
+            mApiResolver.resolvePublicAlternatives(publicAlternativesString, signature);
+        } catch (JavadocLinkSyntaxError | AlternativeNotFoundError e) {
+            context.reportError(e.toString());
+        }
 
         // Consume this annotation if it matches the predicate.
         if (mClassMemberFilter.test(new ClassMember(signature, isBridgeMethod))) {
diff --git a/tools/class2greylist/test/src/com/android/class2greylist/ApiComponentsTest.java b/tools/class2greylist/test/src/com/android/class2greylist/ApiComponentsTest.java
new file mode 100644
index 0000000..e93d1e1
--- /dev/null
+++ b/tools/class2greylist/test/src/com/android/class2greylist/ApiComponentsTest.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright (C) 2019 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.class2greylist;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.testng.Assert.assertThrows;
+
+import org.junit.Test;
+
+
+public class ApiComponentsTest extends AnnotationHandlerTestBase {
+
+    @Test
+    public void testGetApiComponentsPackageFromSignature() throws SignatureSyntaxError {
+        ApiComponents api = ApiComponents.fromDexSignature("La/b/C;->foo()V");
+        PackageAndClassName packageAndClassName = api.getPackageAndClassName();
+        assertThat(packageAndClassName.packageName).isEqualTo("a.b");
+    }
+
+    @Test
+    public void testGetApiComponentsFromSignature() throws SignatureSyntaxError {
+        ApiComponents api = ApiComponents.fromDexSignature("La/b/C;->foo(IJLfoo2/bar/Baz;)V");
+        PackageAndClassName packageAndClassName = api.getPackageAndClassName();
+        assertThat(packageAndClassName.className).isEqualTo("C");
+        assertThat(api.getMemberName()).isEqualTo("foo");
+        assertThat(api.getMethodParameterTypes()).isEqualTo("int, long, foo2.bar.Baz");
+    }
+
+    @Test
+    public void testInvalidDexSignatureInvalidClassFormat() throws SignatureSyntaxError {
+        assertThrows(SignatureSyntaxError.class, () -> {
+            ApiComponents.fromDexSignature("a/b/C;->foo()V");
+        });
+        assertThrows(SignatureSyntaxError.class, () -> {
+            ApiComponents.fromDexSignature("La/b/C->foo()V");
+        });
+    }
+
+    @Test
+    public void testInvalidDexSignatureInvalidParameterType() throws SignatureSyntaxError {
+        assertThrows(SignatureSyntaxError.class, () -> {
+            ApiComponents.fromDexSignature("a/b/C;->foo(foo)V");
+        });
+    }
+
+    @Test
+    public void testInvalidDexSignatureInvalidReturnType() throws SignatureSyntaxError {
+        assertThrows(SignatureSyntaxError.class, () -> {
+            ApiComponents.fromDexSignature("a/b/C;->foo()foo");
+        });
+    }
+
+    @Test
+    public void testInvalidDexSignatureMissingReturnType() throws SignatureSyntaxError {
+        assertThrows(SignatureSyntaxError.class, () -> {
+            ApiComponents.fromDexSignature("a/b/C;->foo(I)");
+        });
+    }
+
+    @Test
+    public void testInvalidDexSignatureMissingArrowOrColon() throws SignatureSyntaxError {
+        assertThrows(SignatureSyntaxError.class, () -> {
+            ApiComponents.fromDexSignature("La/b/C;foo()V");
+        });
+    }
+
+    @Test
+    public void testGetApiComponentsFromFieldLink() throws JavadocLinkSyntaxError {
+        ApiComponents api = ApiComponents.fromLinkTag("a.b.C#foo(int, long, foo2.bar.Baz)",
+                "La/b/C;->foo:I");
+        PackageAndClassName packageAndClassName = api.getPackageAndClassName();
+        assertThat(packageAndClassName.packageName).isEqualTo("a.b");
+        assertThat(packageAndClassName.className).isEqualTo("C");
+        assertThat(api.getMemberName()).isEqualTo("foo");
+    }
+
+    @Test
+    public void testGetApiComponentsLinkOnlyClass() throws JavadocLinkSyntaxError {
+        ApiComponents api = ApiComponents.fromLinkTag("b.c.D", "La/b/C;->foo:I");
+        PackageAndClassName packageAndClassName = api.getPackageAndClassName();
+        assertThat(packageAndClassName.packageName).isEqualTo("b.c");
+        assertThat(packageAndClassName.className).isEqualTo("D");
+        assertThat(api.getMethodParameterTypes()).isEqualTo("");
+    }
+
+    @Test
+    public void testGetApiComponentsFromLinkOnlyClassDeducePackage() throws JavadocLinkSyntaxError {
+        ApiComponents api = ApiComponents.fromLinkTag("D", "La/b/C;->foo:I");
+        PackageAndClassName packageAndClassName = api.getPackageAndClassName();
+        assertThat(packageAndClassName.packageName).isEqualTo("a.b");
+        assertThat(packageAndClassName.className).isEqualTo("D");
+        assertThat(api.getMemberName().isEmpty()).isTrue();
+        assertThat(api.getMethodParameterTypes().isEmpty()).isTrue();
+    }
+
+    @Test
+    public void testGetApiComponentsParametersFromMethodLink() throws JavadocLinkSyntaxError {
+        ApiComponents api = ApiComponents.fromLinkTag("a.b.C#foo(int, long, foo2.bar.Baz)",
+                "La/b/C;->foo:I");
+        assertThat(api.getMethodParameterTypes()).isEqualTo("int, long, foo2.bar.Baz");
+    }
+
+    @Test
+    public void testDeduceApiComponentsPackageFromLinkUsingContext() throws JavadocLinkSyntaxError {
+        ApiComponents api = ApiComponents.fromLinkTag("C#foo(int, long, foo2.bar.Baz)",
+                "La/b/C;->foo:I");
+        PackageAndClassName packageAndClassName = api.getPackageAndClassName();
+        assertThat(packageAndClassName.packageName).isEqualTo("a.b");
+    }
+
+    @Test
+    public void testDeduceApiComponentsPackageAndClassFromLinkUsingContext()
+            throws JavadocLinkSyntaxError {
+        ApiComponents api = ApiComponents.fromLinkTag("#foo(int, long, foo2.bar.Baz)",
+                "La/b/C;->foo:I");
+        PackageAndClassName packageAndClassName = api.getPackageAndClassName();
+        assertThat(packageAndClassName.packageName).isEqualTo("a.b");
+        assertThat(packageAndClassName.className).isEqualTo("C");
+    }
+
+    @Test
+    public void testInvalidLinkTagUnclosedParenthesis() throws JavadocLinkSyntaxError {
+        assertThrows(JavadocLinkSyntaxError.class, () -> {
+            ApiComponents.fromLinkTag("a.b.C#foo(int,float", "La/b/C;->foo()V");
+        });
+    }
+
+}
\ No newline at end of file
diff --git a/tools/class2greylist/test/src/com/android/class2greylist/ApiResolverTest.java b/tools/class2greylist/test/src/com/android/class2greylist/ApiResolverTest.java
new file mode 100644
index 0000000..8790879
--- /dev/null
+++ b/tools/class2greylist/test/src/com/android/class2greylist/ApiResolverTest.java
@@ -0,0 +1,140 @@
+/*
+ * Copyright (C) 2019 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.class2greylist;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.testng.Assert.expectThrows;
+import static org.testng.Assert.assertThrows;
+import static org.junit.Assert.fail;
+
+import org.hamcrest.Description;
+import org.hamcrest.TypeSafeMatcher;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+public class ApiResolverTest extends AnnotationHandlerTestBase {
+    @Test
+    public void testFindPublicAlternativeExactly()
+            throws JavadocLinkSyntaxError, AlternativeNotFoundError {
+        Set<String> publicApis = Collections.unmodifiableSet(new HashSet<String>(
+                Arrays.asList("La/b/C;->foo(I)V", "La/b/C;->bar(I)V")));
+        ApiResolver resolver = new ApiResolver(publicApis);
+        resolver.resolvePublicAlternatives("{@link a.b.C#foo(int)}", "Lb/c/D;->bar()V");
+    }
+
+    @Test
+    public void testFindPublicAlternativeDeducedPackageName()
+            throws JavadocLinkSyntaxError, AlternativeNotFoundError {
+        Set<String> publicApis = Collections.unmodifiableSet(new HashSet<String>(
+                Arrays.asList("La/b/C;->foo(I)V", "La/b/C;->bar(I)V")));
+        ApiResolver resolver = new ApiResolver(publicApis);
+        resolver.resolvePublicAlternatives("{@link C#foo(int)}", "La/b/D;->bar()V");
+    }
+
+    @Test
+    public void testFindPublicAlternativeDeducedPackageAndClassName()
+            throws JavadocLinkSyntaxError, AlternativeNotFoundError {
+        Set<String> publicApis = Collections.unmodifiableSet(new HashSet<String>(
+                Arrays.asList("La/b/C;->foo(I)V", "La/b/C;->bar(I)V")));
+        ApiResolver resolver = new ApiResolver(publicApis);
+        resolver.resolvePublicAlternatives("{@link #foo(int)}", "La/b/C;->bar()V");
+    }
+
+    @Test
+    public void testFindPublicAlternativeDeducedParameterTypes()
+            throws JavadocLinkSyntaxError, AlternativeNotFoundError {
+        Set<String> publicApis = Collections.unmodifiableSet(new HashSet<String>(
+                Arrays.asList("La/b/C;->foo(I)V", "La/b/C;->bar(I)V")));
+        ApiResolver resolver = new ApiResolver(publicApis);
+        resolver.resolvePublicAlternatives("{@link #foo}", "La/b/C;->bar()V");
+    }
+
+    @Test
+    public void testFindPublicAlternativeFailDueToMultipleParameterTypes()
+            throws JavadocLinkSyntaxError, AlternativeNotFoundError, SignatureSyntaxError {
+        Set<String> publicApis = Collections.unmodifiableSet(new HashSet<String>(
+                Arrays.asList("La/b/C;->foo(I)V", "La/b/C;->bar(I)I", "La/b/C;->foo(II)V")));
+        ApiResolver resolver = new ApiResolver(publicApis);
+        MultipleAlternativesFoundError e = expectThrows(MultipleAlternativesFoundError.class,
+                () -> resolver.resolvePublicAlternatives("{@link #foo}", "La/b/C;->bar()V"));
+        assertThat(e.almostMatches).containsExactly(
+                ApiComponents.fromDexSignature("La/b/C;->foo(I)V"),
+                ApiComponents.fromDexSignature("La/b/C;->foo(II)V")
+        );
+    }
+
+    @Test
+    public void testFindPublicAlternativeFailNoAlternative()
+            throws JavadocLinkSyntaxError, AlternativeNotFoundError {
+        Set<String> publicApis = Collections.unmodifiableSet(new HashSet<String>(
+                Arrays.asList("La/b/C;->bar(I)V")));
+        ApiResolver resolver = new ApiResolver(publicApis);
+        assertThrows(MemberAlternativeNotFoundError.class, ()
+                -> resolver.resolvePublicAlternatives("{@link #foo(int)}", "La/b/C;->bar()V"));
+    }
+
+    @Test
+    public void testFindPublicAlternativeFailNoAlternativeNoParameterTypes()
+            throws JavadocLinkSyntaxError, AlternativeNotFoundError {
+
+        Set<String> publicApis = Collections.unmodifiableSet(new HashSet<String>(
+                Arrays.asList("La/b/C;->bar(I)V")));
+        ApiResolver resolver = new ApiResolver(publicApis);
+        assertThrows(MemberAlternativeNotFoundError.class,
+                () -> resolver.resolvePublicAlternatives("{@link #foo}", "La/b/C;->bar()V"));
+    }
+
+    @Test
+    public void testNoPublicClassAlternatives()
+            throws JavadocLinkSyntaxError, AlternativeNotFoundError {
+        Set<String> publicApis = Collections.unmodifiableSet(new HashSet<String>());
+        ApiResolver resolver = new ApiResolver(publicApis);
+        expectThrows(NoAlternativesSpecifiedError.class,
+                () -> resolver.resolvePublicAlternatives("Foo", "La/b/C;->bar()V"));
+    }
+
+    @Test
+    public void testPublicAlternativesJustPackageAndClassName()
+            throws JavadocLinkSyntaxError, AlternativeNotFoundError {
+        Set<String> publicApis = Collections.unmodifiableSet(new HashSet<String>(
+                Arrays.asList("La/b/C;->bar(I)V")));
+        ApiResolver resolver = new ApiResolver(publicApis);
+        resolver.resolvePublicAlternatives("Foo {@link a.b.C}", "Lb/c/D;->bar()V");
+    }
+
+    @Test
+    public void testPublicAlternativesJustClassName()
+            throws JavadocLinkSyntaxError, AlternativeNotFoundError {
+        Set<String> publicApis = Collections.unmodifiableSet(new HashSet<String>(
+                Arrays.asList("La/b/C;->bar(I)V")));
+        ApiResolver resolver = new ApiResolver(publicApis);
+        resolver.resolvePublicAlternatives("Foo {@link C}", "La/b/D;->bar()V");
+    }
+
+    @Test
+    public void testNoPublicAlternativesButHasExplanation()
+            throws JavadocLinkSyntaxError, AlternativeNotFoundError {
+        Set<String> publicApis = Collections.unmodifiableSet(new HashSet<String>());
+        ApiResolver resolver = new ApiResolver(publicApis);
+        resolver.resolvePublicAlternatives("Foo {@code bar}", "La/b/C;->bar()V");
+    }
+
+}
\ No newline at end of file