From bd606aec10e3a207d7c3b56dcc962d05c59e607b Mon Sep 17 00:00:00 2001
From: Tor Norbye <tnorbye@google.com>
Date: Mon, 11 Feb 2019 19:57:46 +0000
Subject: [PATCH] Generate @apiSince and @deprecatedIn instead of literal text

This is intended for doclava and other tools to do their
own rendering of the doc content to describe a deprecation
level.

Also renames the @since tag to @apiSince to make it less
ambiguous and to not conflict with existing @since tags
that are present in various docs.

Also starts emitting @apiSince into package.html docs,
such that there is a concept of a package API level; this
is the lowest API level for any class in that package.

Also makes sure we don't emit @apiSince for SystemApi
docs, since we don't have accurate historical information
for SystemApi and TestApi sources.

Also starts writing @apiSince tags even when the since tag
is 1 (e.g. for the APIs added from the beginning). This
was omitted for optimization purposes earlier but is added
back to make the doc generation task easier.
I also added some optimizations for these two new tags since
they can be merged more quickly with some special handling
since they never appear in existing sources and can always
be listed last in the docs.

Finally, various fixes to the API lookup data base
generation; this makes the generated database more
closely mirror what's in android.jar, and, importantly
for doc generation, properly tracks in-development APIs,
such that generated docs at the moment shows up as "Q"
instead of "28".

Test: Unit test updated
Change-Id: If25a8075dc1bb2ace184d1b4d6f19717fae2bc83
---
 .../com/android/tools/metalava/DocAnalyzer.kt |  92 ++++--
 .../java/com/android/tools/metalava/Driver.kt |   8 +-
 .../com/android/tools/metalava/Options.kt     |   4 +-
 .../com/android/tools/metalava/Reporter.kt    |   2 +-
 .../metalava/apilevels/AddApisFromCodebase.kt |  82 +++++-
 .../metalava/apilevels/AndroidJarReader.java  |  17 +-
 .../android/tools/metalava/apilevels/Api.java |  23 +-
 .../tools/metalava/apilevels/ApiClass.java    |  87 +++++-
 .../tools/metalava/apilevels/ApiElement.java  |   4 +
 .../tools/metalava/doclava1/Errors.java       |   1 +
 .../android/tools/metalava/model/Codebase.kt  |   2 +-
 .../tools/metalava/model/psi/CodePrinter.kt   |   3 +-
 .../tools/metalava/model/psi/PsiItem.kt       |  64 +++++
 .../tools/metalava/CompatibilityCheckTest.kt  |  63 +++++
 .../android/tools/metalava/DocAnalyzerTest.kt | 266 ++++++++++++++++--
 .../com/android/tools/metalava/DriverTest.kt  |  33 ++-
 .../com/android/tools/metalava/StubsTest.kt   |  58 ++++
 .../metalava/apilevels/ApiGeneratorTest.kt    |  57 ++--
 18 files changed, 773 insertions(+), 93 deletions(-)

diff --git a/src/main/java/com/android/tools/metalava/DocAnalyzer.kt b/src/main/java/com/android/tools/metalava/DocAnalyzer.kt
index 13f02a9..cd3f4d9 100644
--- a/src/main/java/com/android/tools/metalava/DocAnalyzer.kt
+++ b/src/main/java/com/android/tools/metalava/DocAnalyzer.kt
@@ -15,6 +15,7 @@ import com.android.tools.metalava.model.FieldItem
 import com.android.tools.metalava.model.Item
 import com.android.tools.metalava.model.MemberItem
 import com.android.tools.metalava.model.MethodItem
+import com.android.tools.metalava.model.PackageItem
 import com.android.tools.metalava.model.ParameterItem
 import com.android.tools.metalava.model.psi.containsLinkTags
 import com.android.tools.metalava.model.visitors.ApiVisitor
@@ -33,6 +34,7 @@ import java.util.regex.Pattern
  * to convert since tags in the documentation tool used.
  */
 const val ADD_API_LEVEL_TEXT = false
+const val ADD_DEPRECATED_IN_TEXT = false
 
 /**
  * Walk over the API and apply tweaks to the documentation, such as
@@ -660,16 +662,24 @@ class DocAnalyzer(
 
         val apiLookup = ApiLookup.get(client)
 
-        codebase.accept(object : ApiVisitor(visitConstructorsAsMethods = false) {
+        val pkgApi = HashMap<PackageItem, Int?>(300)
+        codebase.accept(object : ApiVisitor(visitConstructorsAsMethods = true) {
             override fun visitMethod(method: MethodItem) {
-                val psiMethod = method.psi() as PsiMethod
+                val psiMethod = method.psi() as? PsiMethod ?: return
                 addApiLevelDocumentation(apiLookup.getMethodVersion(psiMethod), method)
                 addDeprecatedDocumentation(apiLookup.getMethodDeprecatedIn(psiMethod), method)
             }
 
             override fun visitClass(cls: ClassItem) {
                 val psiClass = cls.psi() as PsiClass
-                addApiLevelDocumentation(apiLookup.getClassVersion(psiClass), cls)
+                val since = apiLookup.getClassVersion(psiClass)
+                if (since != -1) {
+                    addApiLevelDocumentation(since, cls)
+
+                    // Compute since version for the package: it's the min of all the classes in the package
+                    val pkg = cls.containingPackage()
+                    pkgApi[pkg] = Math.min(pkgApi[pkg] ?: Integer.MAX_VALUE, since)
+                }
                 addDeprecatedDocumentation(apiLookup.getClassDeprecatedIn(psiClass), cls)
             }
 
@@ -679,30 +689,78 @@ class DocAnalyzer(
                 addDeprecatedDocumentation(apiLookup.getFieldDeprecatedIn(psiField), field)
             }
         })
+
+        val packageDocs = codebase.getPackageDocs()
+        if (packageDocs != null) {
+            for ((pkg, api) in pkgApi.entries) {
+                val code = api ?: 1
+                addApiLevelDocumentation(code, pkg)
+            }
+        }
     }
 
     private fun addApiLevelDocumentation(level: Int, item: Item) {
-        if (level > 1) {
+        if (level > 0) {
+            if (item.originallyHidden) {
+                // @SystemApi, @TestApi etc -- don't apply API levels here since we don't have accurate historical data
+                return
+            }
+            val currentCodeName = options.currentCodeName
+            val code: String = if (currentCodeName != null && level > options.currentApiLevel) {
+                currentCodeName
+            } else {
+                level.toString()
+            }
+
             @Suppress("ConstantConditionIf")
             if (ADD_API_LEVEL_TEXT) { // See 113933920: Remove "Requires API level" from method comment
-                appendDocumentation("Requires API level ${describeApiLevel(level)}", item, false)
+                val description = if (code == currentCodeName) currentCodeName else describeApiLevel(level)
+                appendDocumentation("Requires API level $description", item, false)
             }
             // Also add @since tag, unless already manually entered.
             // TODO: Override it everywhere in case the existing doc is wrong (we know
             // better), and at least for OpenJDK sources we *should* since the since tags
             // are talking about language levels rather than API levels!
-            if (!item.documentation.contains("@since")) {
-                item.appendDocumentation(level.toString(), "@since")
+            if (!item.documentation.contains("@apiSince")) {
+                item.appendDocumentation(code, "@apiSince")
+            } else {
+                reporter.report(
+                    Errors.FORBIDDEN_TAG, item, "Documentation should not specify @apiSince " +
+                        "manually; it's computed and injected at build time by $PROGRAM_NAME"
+                )
             }
         }
     }
 
     private fun addDeprecatedDocumentation(level: Int, item: Item) {
-        if (level > 1) {
-            // TODO: *pre*pend instead!
-            val description =
-                "<p class=\"caution\"><strong>This class was deprecated in API level $level.</strong></p>"
-            item.appendDocumentation(description, "@deprecated", append = false)
+        if (level > 0) {
+            if (item.originallyHidden) {
+                // @SystemApi, @TestApi etc -- don't apply API levels here since we don't have accurate historical data
+                return
+            }
+            val currentCodeName = options.currentCodeName
+            val code: String = if (currentCodeName != null && level > options.currentApiLevel) {
+                currentCodeName
+            } else {
+                level.toString()
+            }
+
+            @Suppress("ConstantConditionIf")
+            if (ADD_DEPRECATED_IN_TEXT) {
+                // TODO: *pre*pend instead!
+                val description =
+                    "<p class=\"caution\"><strong>This class was deprecated in API level $code.</strong></p>"
+                item.appendDocumentation(description, "@deprecated", append = false)
+            }
+
+            if (!item.documentation.contains("@deprecatedSince")) {
+                item.appendDocumentation(code, "@deprecatedSince")
+            } else {
+                reporter.report(
+                    Errors.FORBIDDEN_TAG, item, "Documentation should not specify @deprecatedSince " +
+                        "manually; it's computed and injected at build time by $PROGRAM_NAME"
+                )
+            }
         }
     }
 
@@ -716,12 +774,13 @@ fun ApiLookup.getClassVersion(cls: PsiClass): Int {
     return getClassVersion(owner)
 }
 
+val defaultEvaluator = DefaultJavaEvaluator(null, null)
+
 fun ApiLookup.getMethodVersion(method: PsiMethod): Int {
     val containingClass = method.containingClass ?: return -1
     val owner = containingClass.qualifiedName ?: return -1
-    val evaluator = DefaultJavaEvaluator(null, null)
-    val desc = evaluator.getMethodDescription(method, false, false)
-    return getMethodVersion(owner, method.name, desc)
+    val desc = defaultEvaluator.getMethodDescription(method, false, false)
+    return getMethodVersion(owner, if (method.isConstructor) "<init>" else method.name, desc)
 }
 
 fun ApiLookup.getFieldVersion(field: PsiField): Int {
@@ -738,8 +797,7 @@ fun ApiLookup.getClassDeprecatedIn(cls: PsiClass): Int {
 fun ApiLookup.getMethodDeprecatedIn(method: PsiMethod): Int {
     val containingClass = method.containingClass ?: return -1
     val owner = containingClass.qualifiedName ?: return -1
-    val evaluator = DefaultJavaEvaluator(null, null)
-    val desc = evaluator.getMethodDescription(method, false, false)
+    val desc = defaultEvaluator.getMethodDescription(method, false, false)
     return getMethodDeprecatedIn(owner, method.name, desc)
 }
 
diff --git a/src/main/java/com/android/tools/metalava/Driver.kt b/src/main/java/com/android/tools/metalava/Driver.kt
index 19d6749..d0d640a 100644
--- a/src/main/java/com/android/tools/metalava/Driver.kt
+++ b/src/main/java/com/android/tools/metalava/Driver.kt
@@ -867,7 +867,7 @@ internal fun parseSources(
     classpath: List<File> = options.classpath,
     javaLanguageLevel: LanguageLevel = options.javaLanguageLevel,
     manifest: File? = options.manifest,
-    currentApiLevel: Int = options.currentApiLevel
+    currentApiLevel: Int = options.currentApiLevel + if (options.currentCodeName != null) 1 else 0
 ): PsiBasedCodebase {
     val projectEnvironment = createProjectEnvironment()
     val project = projectEnvironment.project
@@ -1267,7 +1267,11 @@ private fun findRoot(file: File): File? {
         if (before == '/' || before == '\\') {
             return File(path.substring(0, endIndex))
         } else {
-            reporter.report(Errors.IO_ERROR, file, "$PROGRAM_NAME was unable to determine the package name")
+            reporter.report(
+                Errors.IO_ERROR, file, "$PROGRAM_NAME was unable to determine the package name. " +
+                    "This usually means that a source file was where the directory does not seem to match the package " +
+                    "declaration; we expected the path $path to end with /${pkg.replace('.', '/') + '/' + file.name}"
+            )
         }
     }
 
diff --git a/src/main/java/com/android/tools/metalava/Options.kt b/src/main/java/com/android/tools/metalava/Options.kt
index c2c354b..07be606 100644
--- a/src/main/java/com/android/tools/metalava/Options.kt
+++ b/src/main/java/com/android/tools/metalava/Options.kt
@@ -505,6 +505,9 @@ class Options(
     /** The api level of the codebase, or -1 if not known/specified */
     var currentApiLevel = -1
 
+    /** The codename of the codebase, if it's a preview, or null if not specified */
+    var currentCodeName: String? = null
+
     /** API level XML file to generate */
     var generateApiLevelXml: File? = null
 
@@ -589,7 +592,6 @@ class Options(
         }
 
         var androidJarPatterns: MutableList<String>? = null
-        var currentCodeName: String? = null
         var currentJar: File? = null
         var updateBaselineFile: File? = null
         var baselineFile: File? = null
diff --git a/src/main/java/com/android/tools/metalava/Reporter.kt b/src/main/java/com/android/tools/metalava/Reporter.kt
index a7d0a1b..40bb4f2 100644
--- a/src/main/java/com/android/tools/metalava/Reporter.kt
+++ b/src/main/java/com/android/tools/metalava/Reporter.kt
@@ -308,7 +308,7 @@ open class Reporter(private val rootFolder: File? = null) {
 
         val sb = StringBuilder(100)
 
-        if (color) {
+        if (color && !isUnderTest()) {
             sb.append(terminalAttributes(bold = true))
             if (!options.omitLocations) {
                 location?.let {
diff --git a/src/main/java/com/android/tools/metalava/apilevels/AddApisFromCodebase.kt b/src/main/java/com/android/tools/metalava/apilevels/AddApisFromCodebase.kt
index 58afb9a..41db85f 100644
--- a/src/main/java/com/android/tools/metalava/apilevels/AddApisFromCodebase.kt
+++ b/src/main/java/com/android/tools/metalava/apilevels/AddApisFromCodebase.kt
@@ -40,19 +40,79 @@ fun addApisFromCodebase(api: Api, apiLevel: Int, codebase: Codebase) {
             currentClass = newClass
 
             if (cls.isClass()) {
-                // Sadly it looks like the signature files use the non-public references instead
+                // The jar files historically contain package private parents instead of
+                // the real API so we need to correct the data we've already read in
+
                 val filteredSuperClass = cls.filteredSuperclass(filterReference)
                 val superClass = cls.superClass()
                 if (filteredSuperClass != superClass && filteredSuperClass != null) {
                     val existing = newClass.superClasses.firstOrNull()?.name
-                    if (existing == superClass?.internalName()) {
-                        newClass.addSuperClass(superClass?.internalName(), apiLevel)
+                    val superInternalName = superClass?.internalName()
+                    if (existing == superInternalName) {
+                        // The bytecode used to point to the old hidden super class. Point
+                        // to the real one (that the signature files referenced) instead.
+                        val removed = newClass.removeSuperClass(superInternalName)
+                        val since = removed?.since ?: apiLevel
+                        val entry = newClass.addSuperClass(filteredSuperClass.internalName(), since)
+                        // Show that it's also seen here
+                        entry.update(apiLevel)
+
+                        // Also inherit the interfaces from that API level, unless it was added later
+                        val superClassEntry = api.findClass(superInternalName)
+                        if (superClassEntry != null) {
+                            for (interfaceType in superClass!!.filteredInterfaceTypes(filterReference)) {
+                                val interfaceClass = interfaceType.asClass() ?: return
+                                var mergedSince = since
+                                val interfaceName = interfaceClass.internalName()
+                                for (itf in superClassEntry.interfaces) {
+                                    val currentInterface = itf.name
+                                    if (interfaceName == currentInterface) {
+                                        mergedSince = itf.since
+                                        break
+                                    }
+                                }
+                                newClass.addInterface(interfaceClass.internalName(), mergedSince)
+                            }
+                        }
                     } else {
                         newClass.addSuperClass(filteredSuperClass.internalName(), apiLevel)
                     }
                 } else if (superClass != null) {
                     newClass.addSuperClass(superClass.internalName(), apiLevel)
                 }
+            } else if (cls.isInterface()) {
+                val superClass = cls.superClass()
+                if (superClass != null && !superClass.isJavaLangObject()) {
+                    newClass.addInterface(superClass.internalName(), apiLevel)
+                }
+            } else if (cls.isEnum()) {
+                // Implicit super class; match convention from bytecode
+                if (newClass.name != "java/lang/Enum") {
+                    newClass.addSuperClass("java/lang/Enum", apiLevel)
+                }
+
+                // Mimic doclava enum methods
+                newClass.addMethod("valueOf(Ljava/lang/String;)L" + newClass.name + ";", apiLevel, false)
+                newClass.addMethod("values()[L" + newClass.name + ";", apiLevel, false)
+            } else if (cls.isAnnotationType()) {
+                // Implicit super class; match convention from bytecode
+                if (newClass.name != "java/lang/annotation/Annotation") {
+                    newClass.addSuperClass("java/lang/Object", apiLevel)
+                    newClass.addInterface("java/lang/annotation/Annotation", apiLevel)
+                }
+            }
+
+            // Ensure we don't end up with
+            //    -  <extends name="java/lang/Object"/>
+            //    +  <extends name="java/lang/Object" removed="29"/>
+            // which can happen because the bytecode always explicitly contains extends java.lang.Object
+            // but in the source code we don't see it, and the lack of presence of this shouldn't be
+            // taken as a sign that we no longer extend object. But only do this if the class didn't
+            // previously extend object and now extends something else.
+            if ((cls.isClass() || cls.isInterface()) &&
+                newClass.superClasses.size == 1 &&
+                newClass.superClasses[0].name == "java/lang/Object") {
+                newClass.addSuperClass("java/lang/Object", apiLevel)
             }
 
             for (interfaceType in cls.filteredInterfaceTypes(filterReference)) {
@@ -65,12 +125,11 @@ fun addApisFromCodebase(api: Api, apiLevel: Int, codebase: Codebase) {
             if (method.isPrivate || method.isPackagePrivate) {
                 return
             }
-            currentClass?.addMethod(
-                method.internalName() +
-                    // Use "V" instead of the type of the constructor for backwards compatibility
-                    // with the older bytecode
-                    method.internalDesc(voidConstructorTypes = true), apiLevel, method.deprecated
-            )
+            val name = method.internalName() +
+                // Use "V" instead of the type of the constructor for backwards compatibility
+                // with the older bytecode
+                method.internalDesc(voidConstructorTypes = true)
+            currentClass?.addMethod(name, apiLevel, method.deprecated)
         }
 
         override fun visitField(field: FieldItem) {
@@ -78,11 +137,6 @@ fun addApisFromCodebase(api: Api, apiLevel: Int, codebase: Codebase) {
                 return
             }
 
-            // We end up moving constants from interfaces in the codebase but that's not the
-            // case in older bytecode
-            if (field.isCloned()) {
-                return
-            }
             currentClass?.addField(field.internalName(), apiLevel, field.deprecated)
         }
     })
diff --git a/src/main/java/com/android/tools/metalava/apilevels/AndroidJarReader.java b/src/main/java/com/android/tools/metalava/apilevels/AndroidJarReader.java
index 59f467b..bcd8633 100644
--- a/src/main/java/com/android/tools/metalava/apilevels/AndroidJarReader.java
+++ b/src/main/java/com/android/tools/metalava/apilevels/AndroidJarReader.java
@@ -15,6 +15,7 @@
  */
 package com.android.tools.metalava.apilevels;
 
+import com.android.SdkConstants;
 import com.android.tools.metalava.model.Codebase;
 import com.google.common.io.ByteStreams;
 import com.google.common.io.Closeables;
@@ -62,8 +63,14 @@ class AndroidJarReader {
     }
 
     public Api getApi() throws IOException {
-        Api api = new Api();
+        Api api;
         if (mApiLevels != null) {
+            int max = mApiLevels.length - 1;
+            if (mCodebase != null) {
+                max = mCodebase.getApiLevel();
+            }
+
+            api = new Api(max);
             for (int apiLevel = 1; apiLevel < mApiLevels.length; apiLevel++) {
                 File jar = getAndroidJarFile(apiLevel);
                 readJar(api, apiLevel, jar);
@@ -75,6 +82,7 @@ class AndroidJarReader {
                 }
             }
         } else {
+            api = new Api(mCurrentApi);
             // Get all the android.jar. They are in platforms-#
             int apiLevel = mMinApi - 1;
             while (true) {
@@ -96,6 +104,7 @@ class AndroidJarReader {
             }
         }
 
+        api.inlineFromHiddenSuperClasses();
         api.removeImplicitInterfaces();
         api.removeOverridingMethods();
 
@@ -118,17 +127,17 @@ class AndroidJarReader {
         while (entry != null) {
             String name = entry.getName();
 
-            if (name.endsWith(".class")) {
+            if (name.endsWith(SdkConstants.DOT_CLASS)) {
                 byte[] bytes = ByteStreams.toByteArray(zis);
                 ClassReader reader = new ClassReader(bytes);
                 ClassNode classNode = new ClassNode(Opcodes.ASM5);
                 reader.accept(classNode, 0 /*flags*/);
 
-                // TODO: Skip package private classes; use metalava's heuristics
-
                 ApiClass theClass = api.addClass(classNode.name, apiLevel,
                     (classNode.access & Opcodes.ACC_DEPRECATED) != 0);
 
+                theClass.updateHidden(apiLevel, (classNode.access & Opcodes.ACC_PUBLIC) == 0);
+
                 // super class
                 if (classNode.superName != null) {
                     theClass.addSuperClass(classNode.superName, apiLevel);
diff --git a/src/main/java/com/android/tools/metalava/apilevels/Api.java b/src/main/java/com/android/tools/metalava/apilevels/Api.java
index f37e9c3..e2d150f 100644
--- a/src/main/java/com/android/tools/metalava/apilevels/Api.java
+++ b/src/main/java/com/android/tools/metalava/apilevels/Api.java
@@ -24,11 +24,13 @@ import java.util.Map;
  */
 public class Api extends ApiElement {
     private final Map<String, ApiClass> mClasses = new HashMap<>();
+    private final int mMax;
 
-    public Api() {
+    public Api(int max) {
         // Pretend that API started from version 0 to make sure that classes existed in the first version
         // are printed with since="1".
         super("Android API");
+        mMax = max;
     }
 
     /**
@@ -61,6 +63,13 @@ public class Api extends ApiElement {
         return classElement;
     }
 
+    public ApiClass findClass(String name) {
+        if (name == null) {
+            return null;
+        }
+        return mClasses.get(name);
+    }
+
     /**
      * The bytecode visitor registers interfaces listed for a class. However,
      * a class will <b>also</b> implement interfaces implemented by the super classes.
@@ -83,4 +92,16 @@ public class Api extends ApiElement {
             classElement.removeOverridingMethods(mClasses);
         }
     }
+
+    public void inlineFromHiddenSuperClasses() {
+        Map<String, ApiClass> hidden = new HashMap<>();
+        for (ApiClass classElement : mClasses.values()) {
+            if (classElement.getHiddenUntil() < 0) { // hidden in the .jar files? (mMax==codebase, -1: jar files)
+                hidden.put(classElement.getName(), classElement);
+            }
+        }
+        for (ApiClass classElement : mClasses.values()) {
+            classElement.inlineFromHiddenSuperClasses(hidden);
+        }
+    }
 }
diff --git a/src/main/java/com/android/tools/metalava/apilevels/ApiClass.java b/src/main/java/com/android/tools/metalava/apilevels/ApiClass.java
index d215ec0..7f8d079 100644
--- a/src/main/java/com/android/tools/metalava/apilevels/ApiClass.java
+++ b/src/main/java/com/android/tools/metalava/apilevels/ApiClass.java
@@ -34,6 +34,13 @@ public class ApiClass extends ApiElement {
     private final List<ApiElement> mSuperClasses = new ArrayList<>();
     private final List<ApiElement> mInterfaces = new ArrayList<>();
 
+    /**
+     * If negative, never seen as public. The absolute value is the last api level it is seen as hidden in.
+     * E.g. "-5" means a class that was hidden in api levels 1-5, then it was deleted, and "8"
+     * means a class that was hidden in api levels 1-8 then made public in 9.
+     */
+    private int mPrivateUntil; // Package private class?
+
     private final Map<String, ApiElement> mFields = new HashMap<>();
     private final Map<String, ApiElement> mMethods = new HashMap<>();
 
@@ -46,11 +53,23 @@ public class ApiClass extends ApiElement {
     }
 
     public void addMethod(String name, int version, boolean deprecated) {
+        // Correct historical mistake in android.jar files
+        if (name.endsWith(")Ljava/lang/AbstractStringBuilder;")) {
+            name = name.substring(0, name.length() - ")Ljava/lang/AbstractStringBuilder;".length()) + ")L" + getName() + ";";
+        }
         addToMap(mMethods, name, version, deprecated);
     }
 
-    public void addSuperClass(String superClass, int since) {
-        addToArray(mSuperClasses, superClass, since);
+    public ApiElement addSuperClass(String superClass, int since) {
+        return addToArray(mSuperClasses, superClass, since);
+    }
+
+    public ApiElement removeSuperClass(String superClass) {
+        ApiElement entry = findByName(mSuperClasses, superClass);
+        if (entry != null) {
+            mSuperClasses.remove(entry);
+        }
+        return entry;
     }
 
     @NotNull
@@ -58,10 +77,34 @@ public class ApiClass extends ApiElement {
         return mSuperClasses;
     }
 
+    public void updateHidden(int api, boolean hidden) {
+        if (hidden) {
+            mPrivateUntil = -api;
+        } else {
+            mPrivateUntil = Math.abs(api);
+        }
+    }
+
+    public boolean alwaysHidden() {
+        return mPrivateUntil < 0;
+    }
+
+    public int getHiddenUntil() {
+        return mPrivateUntil;
+    }
+
+    public void setHiddenUntil(int api) {
+        mPrivateUntil = api;
+    }
+
     public void addInterface(String interfaceClass, int since) {
         addToArray(mInterfaces, interfaceClass, since);
     }
 
+    public List<ApiElement> getInterfaces() {
+        return mInterfaces;
+    }
+
     private void addToMap(Map<String, ApiElement> elements, String name, int version, boolean deprecated) {
         ApiElement element = elements.get(name);
         if (element == null) {
@@ -72,7 +115,7 @@ public class ApiClass extends ApiElement {
         }
     }
 
-    private void addToArray(Collection<ApiElement> elements, String name, int version) {
+    private ApiElement addToArray(Collection<ApiElement> elements, String name, int version) {
         ApiElement element = findByName(elements, name);
         if (element == null) {
             element = new ApiElement(name, version);
@@ -80,6 +123,7 @@ public class ApiClass extends ApiElement {
         } else {
             element.update(version);
         }
+        return element;
     }
 
     private ApiElement findByName(Collection<ApiElement> collection, String name) {
@@ -93,6 +137,9 @@ public class ApiClass extends ApiElement {
 
     @Override
     public void print(String tag, ApiElement parentElement, String indent, PrintStream stream) {
+        if (mPrivateUntil < 0) {
+            return;
+        }
         super.print(tag, false, parentElement, indent, stream);
         String innerIndent = indent + '\t';
         print(mSuperClasses, "extends", innerIndent, stream);
@@ -177,7 +224,8 @@ public class ApiClass extends ApiElement {
      * @return true if the method is an override
      */
     private boolean isOverride(ApiElement method, Map<String, ApiClass> allClasses) {
-        ApiElement localMethod = mMethods.get(method.getName());
+        String name = method.getName();
+        ApiElement localMethod = mMethods.get(name);
         if (localMethod != null && localMethod.introducedNotLaterThan(method)) {
             // This class has the method and it was introduced in at the same api level
             // as the child method, or before.
@@ -208,4 +256,35 @@ public class ApiClass extends ApiElement {
     public String toString() {
         return getName();
     }
+
+    private boolean haveInlined = false;
+
+    public void inlineFromHiddenSuperClasses(Map<String, ApiClass> hidden) {
+        if (haveInlined) {
+            return;
+        }
+        haveInlined = true;
+        for (ApiElement superClass : getSuperClasses()) {
+            ApiClass hiddenSuper = hidden.get(superClass.getName());
+            if (hiddenSuper != null) {
+                hiddenSuper.inlineFromHiddenSuperClasses(hidden);
+                Map<String, ApiElement> myMethods = this.mMethods;
+                Map<String, ApiElement> myFields = this.mFields;
+                for (Map.Entry<String, ApiElement> entry : hiddenSuper.mMethods.entrySet()) {
+                    String name = entry.getKey();
+                    ApiElement value = entry.getValue();
+                    if (!myMethods.containsKey(name)) {
+                        myMethods.put(name, value);
+                    }
+                }
+                for (Map.Entry<String, ApiElement> entry : hiddenSuper.mFields.entrySet()) {
+                    String name = entry.getKey();
+                    ApiElement value = entry.getValue();
+                    if (!myFields.containsKey(name)) {
+                        myFields.put(name, value);
+                    }
+                }
+            }
+        }
+    }
 }
diff --git a/src/main/java/com/android/tools/metalava/apilevels/ApiElement.java b/src/main/java/com/android/tools/metalava/apilevels/ApiElement.java
index 3e06a74..b9302bb 100644
--- a/src/main/java/com/android/tools/metalava/apilevels/ApiElement.java
+++ b/src/main/java/com/android/tools/metalava/apilevels/ApiElement.java
@@ -68,6 +68,10 @@ public class ApiElement implements Comparable<ApiElement> {
         return mName;
     }
 
+    public int getSince() {
+        return mSince;
+    }
+
     /**
      * Checks if this API element was introduced not later than another API element.
      *
diff --git a/src/main/java/com/android/tools/metalava/doclava1/Errors.java b/src/main/java/com/android/tools/metalava/doclava1/Errors.java
index b467735..8621c5c 100644
--- a/src/main/java/com/android/tools/metalava/doclava1/Errors.java
+++ b/src/main/java/com/android/tools/metalava/doclava1/Errors.java
@@ -257,6 +257,7 @@ public class Errors {
     // The plan is for this to be set as an error once (1) existing code is marked as @deprecated
     // and (2) the principle is adopted by the API council
     public static final Error EXTENDS_DEPRECATED = new Error(161, HIDDEN);
+    public static final Error FORBIDDEN_TAG = new Error(162, ERROR);
 
     // API lint
     public static final Error START_WITH_LOWER = new Error(300, ERROR, Category.API_LINT, "S1");
diff --git a/src/main/java/com/android/tools/metalava/model/Codebase.kt b/src/main/java/com/android/tools/metalava/model/Codebase.kt
index c47efbf..3f4ec1b 100644
--- a/src/main/java/com/android/tools/metalava/model/Codebase.kt
+++ b/src/main/java/com/android/tools/metalava/model/Codebase.kt
@@ -64,7 +64,7 @@ interface Codebase {
 
     /**
      * The package documentation, if any - this returns overview.html files for each package
-     * that provided one. Note all codebases provide this.
+     * that provided one. Not all codebases provide this.
      */
     fun getPackageDocs(): PackageDocs?
 
diff --git a/src/main/java/com/android/tools/metalava/model/psi/CodePrinter.kt b/src/main/java/com/android/tools/metalava/model/psi/CodePrinter.kt
index bbe8f86..868265a 100644
--- a/src/main/java/com/android/tools/metalava/model/psi/CodePrinter.kt
+++ b/src/main/java/com/android/tools/metalava/model/psi/CodePrinter.kt
@@ -16,6 +16,7 @@
 
 package com.android.tools.metalava.model.psi
 
+import com.android.SdkConstants.DOT_CLASS
 import com.android.tools.lint.detector.api.ConstantEvaluator
 import com.android.tools.metalava.doclava1.Errors
 import com.android.tools.metalava.model.Codebase
@@ -89,7 +90,7 @@ open class CodePrinter(
         } else if (value is PsiLiteral) {
             return appendSourceLiteral(value.value, sb, owner)
         } else if (value is PsiClassObjectAccessExpression) {
-            sb.append(value.operand.type.canonicalText).append(".class")
+            sb.append(value.operand.type.canonicalText).append(DOT_CLASS)
             return true
         } else if (value is PsiArrayInitializerMemberValue) {
             sb.append('{')
diff --git a/src/main/java/com/android/tools/metalava/model/psi/PsiItem.kt b/src/main/java/com/android/tools/metalava/model/psi/PsiItem.kt
index a8251a5..c81ba10 100644
--- a/src/main/java/com/android/tools/metalava/model/psi/PsiItem.kt
+++ b/src/main/java/com/android/tools/metalava/model/psi/PsiItem.kt
@@ -121,9 +121,73 @@ abstract class PsiItem(
             return
         }
 
+        // Micro-optimization: we're very often going to be merging @apiSince and to a lesser
+        // extend @deprecatedSince into existing comments, since we're flagging every single
+        // public API. Normally merging into documentation has to be done carefully, since
+        // there could be existing versions of the tag we have to append to, and some parts
+        // of the comment needs to be present in certain places. For example, you can't
+        // just append to the description of a method by inserting something right before "*/"
+        // since you could be appending to a javadoc tag like @return.
+        //
+        // However, for @apiSince and @deprecatedSince specifically, in addition to being frequent,
+        // they will (a) never appear in existing docs, and (b) they're separate tags, which means
+        // it's safe to append them at the end. So we'll special case these two tags here, to
+        // help speed up the builds since these tags are inserted 30,000+ times for each framework
+        // API target (there are many), and each time would have involved constructing a full javadoc
+        // AST with lexical tokens using IntelliJ's javadoc parsing APIs. Instead, we'll just
+        // do some simple string heuristics.
+        if (tagSection == "@apiSince" || tagSection == "@deprecatedSince") {
+            documentation = addUniqueTag(documentation, tagSection, comment)
+            return
+        }
+
         documentation = mergeDocumentation(documentation, element, comment.trim(), tagSection, append)
     }
 
+    private fun addUniqueTag(documentation: String, tagSection: String, commentLine: String): String {
+        assert(commentLine.indexOf('\n') == -1) // Not meant for multi-line comments
+
+        if (documentation.isBlank()) {
+            return "/** $tagSection $commentLine */"
+        }
+
+        // Already single line?
+        if (documentation.indexOf('\n') == -1) {
+            var end = documentation.lastIndexOf("*/")
+            val s = "/**\n *" + documentation.substring(3, end) + "\n * $tagSection $commentLine\n */"
+            return s
+        }
+
+        var end = documentation.lastIndexOf("*/")
+        while (end > 0 && documentation[end - 1].isWhitespace() &&
+            documentation[end - 1] != '\n') {
+            end--
+        }
+        var indent: String
+        var linePrefix = ""
+        val secondLine = documentation.indexOf('\n')
+        if (secondLine == -1) {
+            // Single line comment
+            indent = "\n * "
+        } else {
+            val indentStart = secondLine + 1
+            var indentEnd = indentStart
+            while (indentEnd < documentation.length) {
+                if (!documentation[indentEnd].isWhitespace()) {
+                    break
+                }
+                indentEnd++
+            }
+            indent = documentation.substring(indentStart, indentEnd)
+            // TODO: If it starts with "* " follow that
+            if (documentation.startsWith("* ", indentEnd)) {
+                linePrefix = "* "
+            }
+        }
+        val s = documentation.substring(0, end) + indent + linePrefix + tagSection + " " + commentLine + "\n" + indent + " */"
+        return s
+    }
+
     override fun fullyQualifiedDocumentation(): String {
         return fullyQualifiedDocumentation(documentation)
     }
diff --git a/src/test/java/com/android/tools/metalava/CompatibilityCheckTest.kt b/src/test/java/com/android/tools/metalava/CompatibilityCheckTest.kt
index 7052e28..da4d2d6 100644
--- a/src/test/java/com/android/tools/metalava/CompatibilityCheckTest.kt
+++ b/src/test/java/com/android/tools/metalava/CompatibilityCheckTest.kt
@@ -2656,6 +2656,69 @@ CompatibilityCheckTest : DriverTest() {
         )
     }
 
+    @Test
+    fun `Empty bundle files`() {
+        // Regression test for 124333557
+        // Makes sure we properly handle conflicting definitions of a java file in separate source roots
+        check(
+            warnings = "",
+            compatibilityMode = false,
+            checkCompatibilityApi = """
+                // Signature format: 3.0
+                package com.android.location.provider {
+                  public class LocationProviderBase1 {
+                    ctor public LocationProviderBase1();
+                    method public void onGetStatus(android.os.Bundle!);
+                  }
+                  public class LocationProviderBase2 {
+                    ctor public LocationProviderBase2();
+                    method public void onGetStatus(android.os.Bundle!);
+                  }
+                }
+                """,
+            sourceFiles = *arrayOf(
+                java(
+                    "src2/com/android/location/provider/LocationProviderBase1.java",
+                    """
+                    /** Something */
+                    package com.android.location.provider;
+                    """
+                ),
+                java(
+                    "src/com/android/location/provider/LocationProviderBase1.java",
+                    """
+                    package com.android.location.provider;
+                    import android.os.Bundle;
+
+                    public class LocationProviderBase1 {
+                        public void onGetStatus(Bundle bundle) { }
+                    }
+                    """
+                ),
+                // Try both combinations (empty java file both first on the source path
+                // and second on the source path)
+                java(
+                    "src/com/android/location/provider/LocationProviderBase2.java",
+                    """
+                    /** Something */
+                    package com.android.location.provider;
+                    """
+                ),
+                java(
+                    "src/com/android/location/provider/LocationProviderBase2.java",
+                    """
+                    package com.android.location.provider;
+                    import android.os.Bundle;
+
+                    public class LocationProviderBase2 {
+                        public void onGetStatus(Bundle bundle) { }
+                    }
+                    """
+                )
+            )
+        )
+    }
+
     // TODO: Check method signatures changing incompatibly (look especially out for adding new overloaded
     // methods and comparator getting confused!)
     //   ..equals on the method items should actually be very useful!
diff --git a/src/test/java/com/android/tools/metalava/DocAnalyzerTest.kt b/src/test/java/com/android/tools/metalava/DocAnalyzerTest.kt
index 99d76a3..13a86e4 100644
--- a/src/test/java/com/android/tools/metalava/DocAnalyzerTest.kt
+++ b/src/test/java/com/android/tools/metalava/DocAnalyzerTest.kt
@@ -429,6 +429,7 @@ class DocAnalyzerTest : DriverTest() {
     @Test
     fun `Merge Multiple sections`() {
         check(
+            warnings = "src/android/widget/Toolbar2.java:14: error: Documentation should not specify @apiSince manually; it's computed and injected at build time by metalava [ForbiddenTag]",
             sourceFiles = *arrayOf(
                 java(
                     """
@@ -444,6 +445,14 @@ class DocAnalyzerTest : DriverTest() {
                         public int getCurrentContentInsetEnd() {
                             return 0;
                         }
+
+                        /**
+                        * @apiSince 15
+                        */
+                        @UiThread
+                        public int getCurrentContentInsetRight() {
+                            return 0;
+                        }
                     }
                     """
                 ),
@@ -468,9 +477,7 @@ class DocAnalyzerTest : DriverTest() {
             stubs = arrayOf(
                 """
                 package android.widget;
-                /**
-                 * @since 21
-                 */
+                /** @apiSince 21 */
                 @SuppressWarnings({"unchecked", "deprecation", "all"})
                 public class Toolbar2 {
                 public Toolbar2() { throw new RuntimeException("Stub!"); }
@@ -479,11 +486,19 @@ class DocAnalyzerTest : DriverTest() {
                  * <br>
                  * This method must be called on the thread that originally created
                  * this UI element. This is typically the main thread of your app.
-                 * @since 24
                  * @return blah blah blah
+                 * @apiSince 24
                  */
                 @androidx.annotation.UiThread
                 public int getCurrentContentInsetEnd() { throw new RuntimeException("Stub!"); }
+                /**
+                 * <br>
+                 * This method must be called on the thread that originally created
+                 * this UI element. This is typically the main thread of your app.
+                 * @apiSince 15
+                 */
+                @androidx.annotation.UiThread
+                public int getCurrentContentInsetRight() { throw new RuntimeException("Stub!"); }
                 }
                 """
             )
@@ -1117,16 +1132,14 @@ class DocAnalyzerTest : DriverTest() {
             stubs = arrayOf(
                 """
                 package android.widget;
-                /**
-                 * @since 21
-                 */
+                /** @apiSince 21 */
                 @SuppressWarnings({"unchecked", "deprecation", "all"})
                 public class Toolbar {
                 public Toolbar() { throw new RuntimeException("Stub!"); }
                 /**
                  * Existing documentation for {@linkplain #getCurrentContentInsetEnd()} here.
-                 * @since 24
                  * @return blah blah blah
+                 * @apiSince 24
                  */
                 public int getCurrentContentInsetEnd() { throw new RuntimeException("Stub!"); }
                 }
@@ -1175,20 +1188,19 @@ class DocAnalyzerTest : DriverTest() {
                 /**
                  * The Camera class is used to set image capture settings, start/stop preview.
                  *
-                 * @deprecated
-                 * <p class="caution"><strong>This class was deprecated in API level 21.</strong></p>
-                 *  We recommend using the new {@link android.hardware.camera2} API for new
+                 * @deprecated We recommend using the new {@link android.hardware.camera2} API for new
                  *             applications.*
+                 * @apiSince 1
+                 * @deprecatedSince 21
                  */
                 @SuppressWarnings({"unchecked", "deprecation", "all"})
                 @Deprecated
                 public class Camera {
                 public Camera() { throw new RuntimeException("Stub!"); }
                 /**
-                 * @deprecated
-                 * <p class="caution"><strong>This class was deprecated in API level 19.</strong></p>
-                 *  Use something else.
-                 * @since 14
+                 * @deprecated Use something else.
+                 * @apiSince 14
+                 * @deprecatedSince 19
                  */
                 @Deprecated public static final java.lang.String ACTION_NEW_VIDEO = "android.hardware.action.NEW_VIDEO";
                 }
@@ -1197,6 +1209,226 @@ class DocAnalyzerTest : DriverTest() {
         )
     }
 
+    @Test
+    fun `Api levels around current and preview`() {
+        check(
+            extraArguments = arrayOf(
+                ARG_CURRENT_CODENAME,
+                "Z",
+                ARG_CURRENT_VERSION,
+                "35" // not real api level of Z
+            ),
+            includeSystemApiAnnotations = true,
+            sourceFiles = *arrayOf(
+                java(
+                    """
+                    package android.pkg;
+                    import android.annotation.SystemApi;
+                    public class Test {
+                       public static final String UNIT_TEST_1 = "unit.test.1";
+                       /**
+                         * @hide
+                         */
+                        @SystemApi
+                       public static final String UNIT_TEST_2 = "unit.test.2";
+                    }
+                    """
+                ),
+                systemApiSource
+            ),
+            applyApiLevelsXml = """
+                    <?xml version="1.0" encoding="utf-8"?>
+                    <api version="2">
+                        <class name="android/pkg/Test" since="1">
+                            <field name="UNIT_TEST_1" since="35"/>
+                            <field name="UNIT_TEST_2" since="36"/>
+                        </class>
+                    </api>
+                    """,
+            checkCompilation = true,
+            checkDoclava1 = false,
+            stubs = arrayOf(
+                """
+                package android.pkg;
+                /** @apiSince 1 */
+                @SuppressWarnings({"unchecked", "deprecation", "all"})
+                public class Test {
+                public Test() { throw new RuntimeException("Stub!"); }
+                /** @apiSince 35 */
+                public static final java.lang.String UNIT_TEST_1 = "unit.test.1";
+                /**
+                 * @hide
+                 */
+                public static final java.lang.String UNIT_TEST_2 = "unit.test.2";
+                }
+                """
+            )
+        )
+    }
+
+    @Test
+    fun `No api levels on SystemApi only elements`() {
+        // @SystemApi, @TestApi etc cannot get api versions since we don't have
+        // accurate android.jar files (or even reliable api.txt/api.xml files) for them.
+        check(
+            extraArguments = arrayOf(
+                ARG_CURRENT_CODENAME,
+                "Z",
+                ARG_CURRENT_VERSION,
+                "35" // not real api level of Z
+            ),
+            sourceFiles = *arrayOf(
+                java(
+                    """
+                    package android.pkg;
+                    public class Test {
+                       public Test(int i) { }
+                       public static final String UNIT_TEST_1 = "unit.test.1";
+                       public static final String UNIT_TEST_2 = "unit.test.2";
+                    }
+                    """
+                )
+            ),
+            applyApiLevelsXml = """
+                    <?xml version="1.0" encoding="utf-8"?>
+                    <api version="2">
+                        <class name="android/pkg/Test" since="1">
+                            <method name="&lt;init>(I)V"/>
+                            <field name="UNIT_TEST_1" since="35"/>
+                            <field name="UNIT_TEST_2" since="36"/>
+                        </class>
+                    </api>
+                    """,
+            checkCompilation = true,
+            checkDoclava1 = false,
+            stubs = arrayOf(
+                """
+                package android.pkg;
+                /** @apiSince 1 */
+                @SuppressWarnings({"unchecked", "deprecation", "all"})
+                public class Test {
+                /** @apiSince 1 */
+                public Test(int i) { throw new RuntimeException("Stub!"); }
+                /** @apiSince 35 */
+                public static final java.lang.String UNIT_TEST_1 = "unit.test.1";
+                /** @apiSince Z */
+                public static final java.lang.String UNIT_TEST_2 = "unit.test.2";
+                }
+                """
+            )
+        )
+    }
+
+    @Test
+    fun `Generate API level javadocs`() {
+        // TODO: Check package-info.java conflict
+        // TODO: Test merging
+        // TODO: Test non-merging
+        check(
+            extraArguments = arrayOf(
+                ARG_CURRENT_CODENAME,
+                "Z",
+                ARG_CURRENT_VERSION,
+                "35" // not real api level of Z
+            ),
+            sourceFiles = *arrayOf(
+                java(
+                    """
+                    package android.pkg1;
+                    public class Test1 {
+                    }
+                    """
+                ),
+                java(
+                    """
+                    package android.pkg1;
+                    public class Test2 {
+                    }
+                    """
+                ),
+                source(
+                    "src/android/pkg2/package.html",
+                    """
+                    <body bgcolor="white">
+                    Some existing doc here.
+                    @deprecated
+                    <!-- comment -->
+                    </body>
+                    """
+                ).indented(),
+                java(
+                    """
+                    package android.pkg2;
+                    public class Test1 {
+                    }
+                    """
+                ),
+                java(
+                    """
+                    package android.pkg2;
+                    public class Test2 {
+                    }
+                    """
+                ),
+                java(
+                    """
+                    package android.pkg3;
+                    public class Test1 {
+                    }
+                    """
+                )
+            ),
+            applyApiLevelsXml = """
+                    <?xml version="1.0" encoding="utf-8"?>
+                    <api version="2">
+                        <class name="android/pkg1/Test1" since="15"/>
+                        <class name="android/pkg3/Test1" since="20"/>
+                    </api>
+                    """,
+            checkCompilation = true,
+            checkDoclava1 = false,
+            stubs = arrayOf(
+                """
+                package android.pkg1;
+                /** @apiSince 15 */
+                @SuppressWarnings({"unchecked", "deprecation", "all"})
+                public class Test1 {
+                public Test1() { throw new RuntimeException("Stub!"); }
+                }
+                """,
+                """
+                [android/pkg1/package-info.java]
+                /** @apiSince 15 */
+                package android.pkg1;
+                """,
+                """
+                [android/pkg2/package-info.java]
+                /**
+                 * Some existing doc here.
+                 * @deprecated
+                 * <!-- comment -->
+                 */
+                package android.pkg2;
+                """,
+                """
+                [android/pkg3/package-info.java]
+                /** @apiSince 20 */
+                package android.pkg3;
+                """
+            ),
+            stubsSourceList = """
+                TESTROOT/stubs/android/pkg1/package-info.java
+                TESTROOT/stubs/android/pkg1/Test1.java
+                TESTROOT/stubs/android/pkg1/Test2.java
+                TESTROOT/stubs/android/pkg2/package-info.java
+                TESTROOT/stubs/android/pkg2/Test1.java
+                TESTROOT/stubs/android/pkg2/Test2.java
+                TESTROOT/stubs/android/pkg3/package-info.java
+                TESTROOT/stubs/android/pkg3/Test1.java
+            """
+        )
+    }
+
     @Test
     fun `Generate overview html docs`() {
         // If a codebase provides overview.html files in the a public package,
@@ -1322,9 +1554,7 @@ class DocAnalyzerTest : DriverTest() {
             stubs = arrayOf(
                 """
                 package test.pkg;
-                /**
-                 * @since 21
-                 */
+                /** @apiSince 21 */
                 @SuppressWarnings({"unchecked", "deprecation", "all"})
                 @androidx.annotation.RequiresApi(21)
                 public class MyClass1 {
diff --git a/src/test/java/com/android/tools/metalava/DriverTest.kt b/src/test/java/com/android/tools/metalava/DriverTest.kt
index 9c27ca4..8db14dc 100644
--- a/src/test/java/com/android/tools/metalava/DriverTest.kt
+++ b/src/test/java/com/android/tools/metalava/DriverTest.kt
@@ -420,7 +420,14 @@ abstract class DriverTest {
         if (!sourcePathDir.isDirectory) {
             sourcePathDir.mkdirs()
         }
-        val sourcePath = sourcePathDir.path
+
+        var sourcePath = sourcePathDir.path
+
+        // Make it easy to configure a source path with more than one source root: src and src2
+        if (sourceFiles.any { it.targetPath.startsWith("src2") }) {
+            sourcePath = sourcePath + File.pathSeparator + sourcePath + "2"
+        }
+
         val sourceList =
             if (signatureSource != null) {
                 sourcePathDir.mkdirs()
@@ -1245,14 +1252,26 @@ abstract class DriverTest {
         if (stubs.isNotEmpty() && stubsDir != null) {
             for (i in 0 until stubs.size) {
                 var stub = stubs[i].trimIndent()
-                val sourceFile = sourceFiles[i]
-                val targetPath = if (sourceFile.targetPath.endsWith(DOT_KT)) {
-                    // Kotlin source stubs are rewritten as .java files for now
-                    sourceFile.targetPath.substring(0, sourceFile.targetPath.length - 3) + DOT_JAVA
+
+                var targetPath: String
+                var stubFile: File
+                if (stub.startsWith("[") && stub.contains("]")) {
+                    val pathEnd = stub.indexOf("]\n")
+                    targetPath = stub.substring(1, pathEnd)
+                    stubFile = File(stubsDir, targetPath)
+                    if (stubFile.isFile) {
+                        stub = stub.substring(pathEnd + 2)
+                    }
                 } else {
-                    sourceFile.targetPath
+                    val sourceFile = sourceFiles[i]
+                    targetPath = if (sourceFile.targetPath.endsWith(DOT_KT)) {
+                        // Kotlin source stubs are rewritten as .java files for now
+                        sourceFile.targetPath.substring(0, sourceFile.targetPath.length - 3) + DOT_JAVA
+                    } else {
+                        sourceFile.targetPath
+                    }
+                    stubFile = File(stubsDir, targetPath.substring("src/".length))
                 }
-                var stubFile = File(stubsDir, targetPath.substring("src/".length))
                 if (!stubFile.isFile) {
                     if (stub.startsWith("[") && stub.contains("]")) {
                         val pathEnd = stub.indexOf("]\n")
diff --git a/src/test/java/com/android/tools/metalava/StubsTest.kt b/src/test/java/com/android/tools/metalava/StubsTest.kt
index b8719ed..f5d54cf 100644
--- a/src/test/java/com/android/tools/metalava/StubsTest.kt
+++ b/src/test/java/com/android/tools/metalava/StubsTest.kt
@@ -22,7 +22,9 @@ import com.android.tools.lint.checks.infrastructure.TestFile
 import com.android.tools.metalava.model.SUPPORT_TYPE_USE_ANNOTATIONS
 import org.intellij.lang.annotations.Language
 import org.junit.Test
+import java.io.File
 import java.io.FileNotFoundException
+import kotlin.test.assertEquals
 
 @SuppressWarnings("ALL")
 class StubsTest : DriverTest() {
@@ -4072,6 +4074,62 @@ class StubsTest : DriverTest() {
         )
     }
 
+    @Test
+    fun `Regression test for 124333557`() {
+        // Regression test for 124333557: Handle empty java files
+        check(
+            compatibilityMode = false,
+            warnings = """
+            TESTROOT/src/test/Something2.java: error: metalava was unable to determine the package name. This usually means that a source file was where the directory does not seem to match the package declaration; we expected the path TESTROOT/src/test/Something2.java to end with /test/wrong/Something2.java [IoError]
+            TESTROOT/src/test/Something2.java: error: metalava was unable to determine the package name. This usually means that a source file was where the directory does not seem to match the package declaration; we expected the path TESTROOT/src/test/Something2.java to end with /test/wrong/Something2.java [IoError]
+            """,
+            sourceFiles = *arrayOf(
+                java(
+                    "src/test/pkg/Something.java",
+                    """
+                    /** Nothing much here */
+                    """
+                ),
+                java(
+                    "src/test/pkg/Something2.java",
+                    """
+                    /** Nothing much here */
+                    package test.pkg;
+                    """
+                ),
+                java(
+                    "src/test/Something2.java",
+                    """
+                    /** Wrong package */
+                    package test.wrong;
+                    """
+                ),
+                java(
+                    """
+                    package test.pkg;
+                    public class Test {
+                        private Test() { }
+                    }
+                    """
+                )
+            ),
+            api = """
+                package test.pkg {
+                  public class Test {
+                  }
+                }
+                """,
+            projectSetup = { dir ->
+                // Make sure we handle blank/doc-only java doc files in root extraction
+                val src = listOf(File(dir, "src"))
+                val files = gatherSources(src)
+                val roots = extractRoots(files)
+                assertEquals(1, roots.size)
+                assertEquals(src[0].path, roots[0].path)
+            }
+        )
+    }
+
     // TODO: Test what happens when a class extends a hidden extends a public in separate packages,
     // and the hidden has a @hide constructor so the stub in the leaf class doesn't compile -- I should
     // check for this and fail build.
diff --git a/src/test/java/com/android/tools/metalava/apilevels/ApiGeneratorTest.kt b/src/test/java/com/android/tools/metalava/apilevels/ApiGeneratorTest.kt
index c62331d..c6681d8 100644
--- a/src/test/java/com/android/tools/metalava/apilevels/ApiGeneratorTest.kt
+++ b/src/test/java/com/android/tools/metalava/apilevels/ApiGeneratorTest.kt
@@ -17,10 +17,12 @@
 package com.android.tools.metalava.apilevels
 
 import com.android.tools.metalava.ARG_ANDROID_JAR_PATTERN
+import com.android.tools.metalava.ARG_CURRENT_CODENAME
+import com.android.tools.metalava.ARG_CURRENT_VERSION
 import com.android.tools.metalava.ARG_GENERATE_API_LEVELS
 import com.android.tools.metalava.DriverTest
 import com.android.utils.XmlUtils
-import com.google.common.truth.Truth
+import org.junit.Assert.assertFalse
 import org.junit.Assert.assertNotNull
 import org.junit.Assert.assertTrue
 import org.junit.Test
@@ -30,18 +32,23 @@ import kotlin.text.Charsets.UTF_8
 class ApiGeneratorTest : DriverTest() {
     @Test
     fun `Extract API levels`() {
-        val oldSdkJars = File("prebuilts/tools/common/api-versions")
+        var oldSdkJars = File("prebuilts/tools/common/api-versions")
         if (!oldSdkJars.isDirectory) {
-            println("Ignoring ${ApiGeneratorTest::class.java}: prebuilts not found - is \$PWD set to an Android source tree?")
-            return
+            oldSdkJars = File("../../prebuilts/tools/common/api-versions")
+            if (!oldSdkJars.isDirectory) {
+                println("Ignoring ${ApiGeneratorTest::class.java}: prebuilts not found - is \$PWD set to an Android source tree?")
+                return
+            }
         }
 
-        val platformJars = File("prebuilts/sdk")
+        var platformJars = File("prebuilts/sdk")
         if (!platformJars.isDirectory) {
-            println("Ignoring ${ApiGeneratorTest::class.java}: prebuilts not found: $platformJars")
-            return
+            platformJars = File("../../prebuilts/sdk")
+            if (!platformJars.isDirectory) {
+                println("Ignoring ${ApiGeneratorTest::class.java}: prebuilts not found: $platformJars")
+                return
+            }
         }
-
         val output = File.createTempFile("api-info", "xml")
         output.deleteOnExit()
         val outputPath = output.path
@@ -53,27 +60,33 @@ class ApiGeneratorTest : DriverTest() {
                 ARG_ANDROID_JAR_PATTERN,
                 "${oldSdkJars.path}/android-%/android.jar",
                 ARG_ANDROID_JAR_PATTERN,
-                "${platformJars.path}/%/public/android.jar"
+                "${platformJars.path}/%/public/android.jar",
+                ARG_CURRENT_CODENAME,
+                "Z",
+                ARG_CURRENT_VERSION,
+                "35" // not real api level of Z
             ),
             checkDoclava1 = false,
-            signatureSource = """
-                package test.pkg {
-                  public class MyTest {
-                    ctor public MyTest();
-                    method public int clamp(int);
-                    method public java.lang.Double convert(java.lang.Float);
-                    field public java.lang.Number myNumber;
-                  }
-                }
-                """
+            sourceFiles = *arrayOf(
+                java(
+                    """
+                    package android.pkg;
+                    public class MyTest {
+                    }
+                    """
+                )
+            )
         )
 
         assertTrue(output.isFile)
 
         val xml = output.readText(UTF_8)
-        Truth.assertThat(xml).contains("<class name=\"android/Manifest\$permission\" since=\"1\">")
-        Truth.assertThat(xml)
-            .contains("<field name=\"BIND_CARRIER_MESSAGING_SERVICE\" since=\"22\" deprecated=\"23\"/>")
+        assertTrue(xml.contains("<class name=\"android/Manifest\$permission\" since=\"1\">"))
+        assertTrue(xml.contains("<field name=\"BIND_CARRIER_MESSAGING_SERVICE\" since=\"22\" deprecated=\"23\"/>"))
+        assertTrue(xml.contains("<class name=\"android/pkg/MyTest\" since=\"36\""))
+        assertFalse(xml.contains("<implements name=\"java/lang/annotation/Annotation\" removed=\""))
+        assertFalse(xml.contains("<extends name=\"java/lang/Enum\" removed=\""))
+        assertFalse(xml.contains("<method name=\"append(C)Ljava/lang/AbstractStringBuilder;\""))
 
         val document = XmlUtils.parseDocumentSilently(xml, false)
         assertNotNull(document)
-- 
GitLab