import org.gradle.api.Plugin import org.gradle.api.Project import org.objectweb.asm.ClassReader import org.objectweb.asm.tree.AnnotationNode import org.objectweb.asm.tree.ClassNode import org.objectweb.asm.tree.MethodNode import java.util.jar.JarEntry import java.util.jar.JarInputStream import java.util.regex.Pattern import static org.objectweb.asm.Opcodes.ACC_PRIVATE import static org.objectweb.asm.Opcodes.ACC_PROTECTED import static org.objectweb.asm.Opcodes.ACC_PUBLIC import static org.objectweb.asm.Opcodes.ACC_SYNTHETIC class CheckApiChangesPlugin implements Plugin { @Override void apply(Project project) { project.extensions.create("checkApiChanges", CheckApiChangesExtension) project.configurations { checkApiChangesFrom checkApiChangesTo } project.afterEvaluate { project.checkApiChanges.from.each { project.dependencies.checkApiChangesFrom(it) { transitive = false force = true } } project.checkApiChanges.to.findAll { it instanceof String }.each { project.dependencies.checkApiChangesTo(it) { transitive = false force = true } } } project.task('checkForApiChanges', dependsOn: 'jar') { doLast { Map changedClassMethods = new TreeMap<>() def fromUrls = project.configurations.checkApiChangesFrom*.toURI()*.toURL() println "fromUrls = ${fromUrls*.toString()*.replaceAll("^.*/", "")}" def jarUrls = project.checkApiChanges.to .findAll { it instanceof Project } .collect { it.jar.archivePath.toURL() } def toUrls = jarUrls + project.configurations.checkApiChangesTo*.toURI()*.toURL() println "toUrls = ${toUrls*.toString()*.replaceAll("^.*/", "")}" Analysis prev = new Analysis(fromUrls) Analysis cur = new Analysis(toUrls) Set allMethods = new TreeSet<>(prev.classMethods.keySet()) allMethods.addAll(cur.classMethods.keySet()) Set deprecatedNotRemoved = new TreeSet<>() Set newlyDeprecated = new TreeSet<>() for (String classMethodName : allMethods) { ClassMethod prevClassMethod = prev.classMethods.get(classMethodName) ClassMethod curClassMethod = cur.classMethods.get(classMethodName) if (prevClassMethod == null) { // added if (curClassMethod.visible) { changedClassMethods.put(curClassMethod, Change.ADDED) } } else if (curClassMethod == null) { def theClass = prevClassMethod.classNode.name.replace('/', '.') def methodDesc = prevClassMethod.methodDesc while (curClassMethod == null && cur.parents[theClass] != null) { theClass = cur.parents[theClass] def parentMethodName = "${theClass}#${methodDesc}" curClassMethod = cur.classMethods[parentMethodName] } // removed if (curClassMethod == null && prevClassMethod.visible && !prevClassMethod.deprecated) { if (classMethodName.contains("getActivityTitle")) { println "hi!" } changedClassMethods.put(prevClassMethod, Change.REMOVED) } } else { if (prevClassMethod.deprecated) { deprecatedNotRemoved << prevClassMethod; } else if (curClassMethod.deprecated) { newlyDeprecated << prevClassMethod; } // println "changed: $classMethodName" } } String prevClassName = null def introClass = { classMethod -> if (classMethod.className != prevClassName) { prevClassName = classMethod.className println "\n$prevClassName:" } } def entryPoints = project.checkApiChanges.entryPoints Closure matchesEntryPoint = { ClassMethod classMethod -> for (String entryPoint : entryPoints) { if (classMethod.className.matches(entryPoint)) { return true } } return false } def expectedREs = project.checkApiChanges.expectedChanges.collect { Pattern.compile(it) } for (Map.Entry change : changedClassMethods.entrySet()) { def classMethod = change.key def changeType = change.value def showAllChanges = true // todo: only show stuff that's interesting... if (matchesEntryPoint(classMethod) || showAllChanges) { String classMethodDesc = classMethod.desc def expected = expectedREs.any { it.matcher(classMethodDesc).find() } if (!expected) { introClass(classMethod) switch (changeType) { case Change.ADDED: println "+ ${classMethod.methodDesc}" break case Change.REMOVED: println "- ${classMethod.methodDesc}" break } } } } if (!deprecatedNotRemoved.empty) { println "\nDeprecated but not removed:" for (ClassMethod classMethod : deprecatedNotRemoved) { introClass(classMethod) println "* ${classMethod.methodDesc}" } } if (!newlyDeprecated.empty) { println "\nNewly deprecated:" for (ClassMethod classMethod : newlyDeprecated) { introClass(classMethod) println "* ${classMethod.methodDesc}" } } } } } static class Analysis { final Map parents = new HashMap<>() final Map classMethods = new HashMap<>() Analysis(List baseUrls) { for (URL url : baseUrls) { if (url.protocol == 'file') { def file = new File(url.path) def stream = new FileInputStream(file) def jarStream = new JarInputStream(stream) while (true) { JarEntry entry = jarStream.nextJarEntry if (entry == null) break if (!entry.directory && entry.name.endsWith(".class")) { def reader = new ClassReader(jarStream) def classNode = new ClassNode() reader.accept(classNode, ClassReader.SKIP_CODE | ClassReader.SKIP_FRAMES) def superName = classNode.superName.replace('/', '.') if (!"java.lang.Object".equals(superName)) { parents[classNode.name.replace('/', '.')] = superName } if (bitSet(classNode.access, ACC_PUBLIC) || bitSet(classNode.access, ACC_PROTECTED)) { for (MethodNode method : classNode.methods) { def classMethod = new ClassMethod(classNode, method, url) if (!bitSet(method.access, ACC_SYNTHETIC)) { classMethods.put(classMethod.desc, classMethod) } } } } } stream.close() } } classMethods } } static enum Change { REMOVED, ADDED, } static class ClassMethod implements Comparable { final ClassNode classNode final MethodNode methodNode final URL originUrl ClassMethod(ClassNode classNode, MethodNode methodNode, URL originUrl) { this.classNode = classNode this.methodNode = methodNode this.originUrl = originUrl } boolean equals(o) { if (this.is(o)) return true if (getClass() != o.class) return false ClassMethod that = (ClassMethod) o if (classNode.name != that.classNode.name) return false if (methodNode.name != that.methodNode.name) return false if (methodNode.signature != that.methodNode.signature) return false return true } int hashCode() { int result result = (classNode.name != null ? classNode.name.hashCode() : 0) result = 31 * result + (methodNode.name != null ? methodNode.name.hashCode() : 0) result = 31 * result + (methodNode.signature != null ? methodNode.signature.hashCode() : 0) return result } public String getDesc() { return "$className#$methodDesc" } boolean hasParent() { parentClassName() != "java/lang/Object" } String parentClassName() { classNode.superName } private String getMethodDesc() { def args = new StringBuilder() def returnType = new StringBuilder() def buf = args int arrayDepth = 0 def write = { typeName -> if (buf.size() > 0) buf.append(", ") buf.append(typeName) for (; arrayDepth > 0; arrayDepth--) { buf.append("[]") } } def chars = methodNode.desc.toCharArray() def i = 0 def readObj = { if (buf.size() > 0) buf.append(", ") def objNameBuf = new StringBuilder() for (; i < chars.length; i++) { char c = chars[i] if (c == ';' as char) break objNameBuf.append((c == '/' as char) ? '.' : c) } buf.append(objNameBuf.toString().replaceAll(/^java\.lang\./, '')) } for (; i < chars.length;) { def c = chars[i++] switch (c) { case '(': break; case ')': buf = returnType; break; case '[': arrayDepth++; break; case 'Z': write('boolean'); break; case 'B': write('byte'); break; case 'S': write('short'); break; case 'I': write('int'); break; case 'J': write('long'); break; case 'F': write('float'); break; case 'D': write('double'); break; case 'C': write('char'); break; case 'L': readObj(); break; case 'V': write('void'); break; } } "$methodAccessString ${isHiddenApi() ? "@HiddenApi " : ""}${isImplementation() ? "@Implementation " : ""}$methodNode.name(${args.toString()}): ${returnType.toString()}" } @Override public String toString() { internalName } private String getInternalName() { classNode.name + "#$methodInternalName" } private String getMethodInternalName() { "$methodNode.name$methodNode.desc" } private String getSignature() { methodNode.signature == null ? "()V" : methodNode.signature } private String getClassName() { classNode.name.replace('/', '.') } boolean isDeprecated() { containsAnnotation(classNode.visibleAnnotations, "Ljava/lang/Deprecated;") || containsAnnotation(methodNode.visibleAnnotations, "Ljava/lang/Deprecated;") } boolean isImplementation() { containsAnnotation(methodNode.visibleAnnotations, "Lorg/robolectric/annotation/Implementation;") } boolean isHiddenApi() { containsAnnotation(methodNode.visibleAnnotations, "Lorg/robolectric/annotation/HiddenApi;") } String getMethodAccessString() { return getAccessString(methodNode.access) } private String getClassAccessString() { return getAccessString(classNode.access) } String getAccessString(int access) { if (bitSet(access, ACC_PROTECTED)) { return "protected" } else if (bitSet(access, ACC_PUBLIC)) { return "public" } else if (bitSet(access, ACC_PRIVATE)) { return "private" } else { return "[package]" } } boolean isVisible() { (bitSet(classNode.access, ACC_PUBLIC) || bitSet(classNode.access, ACC_PROTECTED)) && (bitSet(methodNode.access, ACC_PUBLIC) || bitSet(methodNode.access, ACC_PROTECTED)) && !bitSet(classNode.access, ACC_SYNTHETIC) && !(classNode.name =~ /\$[0-9]/) && !(methodNode.name =~ /^access\$/ || methodNode.name == '') } private static boolean containsAnnotation(List annotations, String annotationInternalName) { for (AnnotationNode annotationNode : annotations) { if (annotationNode.desc == annotationInternalName) { return true } } return false } @Override int compareTo(ClassMethod o) { internalName <=> o.internalName } } private static boolean bitSet(int field, int bit) { (field & bit) == bit } } class CheckApiChangesExtension { String[] from Object[] to String[] entryPoints String[] expectedChanges }