Synchronizing largely geographically distributed teams with Lint

It’s well known that as a team grows, the communication overhead increases geometrically until it takes up most of the time of the members. Static analysis can help us reduce the time cost, streamline the code review process, as well as to keep big teams synchronized. As you can imagine, in LINE we face challenging problems on a daily basis, one of them being communication. Keeping teams in different parts of the world synchronized is the key to speed up delivery, so anything we can do to reduce communication overhead will have a positive impact in the team’s productivity. 

Static Analysis

Static program analysis is the analysis of computer software that is performed without actually executing programs, in contrast with dynamic analysis.

There are many static analyzers like KtlintDetektLint, etc. We decided to use Lint for its ability to report errors as well as to provide quick fixes in Android Studio, thus helping us detecting errors in our code before submitting it for review. For now, we are targeting two common problems; code conventions and internal APIs.

  • Code conventions: Many teams have internal code conventions but as teams grow, keeping the conventions becomes more difficult. New members are forced to learn the conventions and old members have to pay attention in the code review to make sure the code convention is applied.
  • Internal APIs: The bigger the team the more difficult to follow what other team members are doing. For example, wrappers around open source libraries, utility methods and so on. Whenever we write a wrapper around a library we can write a custom check to let the other members know they can use our wrapper.

As you probably know, Lint is a static analyzer. The interesting part of Lint is that Android Studio implements LintClient so it can run Lint checks on the fly in the editor. In LINE all our custom Lint checks aim to run on the fly. It also provides a quick fix mechanism so the IDE can fix the problem on the fly.

The Lint version is directly related to the gradlePluginVersion for historical reasons. The magic formula is:

lintVersion = gradlePluginVersion + 23.0.0

So, if we are using v3.3.0 as our gradlePluginVersion, then we should use 26.3.0 as our lintVersion.

Lint has two APIs, the Client API, and the Detector API:

  • Client API: This API is the one implemented by the processes running Lint. For example, Android Studio, Gradle and so on. It is important to notice that the Gradle and IDE implementation are different so sometimes we will find that our Detector works when it is invoked through the command line (using the Gradle Client API implementation) but not in the IDE. Unfortunately, this happens more often than it should, but it is getting more stable in every release.
  • Detector API: This API is the one we are going to focus on.

To start writing custom Lint checks, we need an Issue object, which contains the information that will be displayed by the LintClient (IDE, Gradle HTML report, etc.). The following code creates an Issue object.

class MyDetector : Detector(), Detector.UastScanner {
  
    companion object {
        @JvmField
        val ISSUE: Issue = Issue.create(
            "MyDetector",
            "My detector title",
            "My detector **very** *long* `description`.",
            Category.CORRECTNESS,
            6,
            Severity.ERROR,
            Implementation(
                MyDetector::class.java,
                Scope.JAVA_FILE_SCOPE
            )
        )
    }
    ...
}

We also need to register the Issue in an IssueRegistry. Once we have created our issue we have to add it to the IssueRegistry.

@AutoService(IssueRegistry::class)
class MyIssueRegistry : IssueRegistry() {
    override val issues: List<Issue>
        get() = listOf(MyDetector.ISSUE)
 
    override val api: Int = CURRENT_API
 
    override val minApi: Int = MIN_API
 
    companion object {
        const val MIN_API = 2 // Corresponds to android gradle plugin 3.2+
    }
}

Note we are using AutoService to load our registry in the lint.jar so Lint can run our detectors in the code.

Detectors

Detector is responsible for detecting occurrences of an Issue in the source code, multiple Issues can use the same Detector. Lint has a number of Detector specializations which makes a lot easier to write checks. Also, using these interfaces Lint can do all the work in “one pass” of the abstract syntactic tree. We should try to avoid writing detectors from the scratch as much as possible and use these interfaces.

Since this is a lot to absorb, let’s see a concrete example more useful than a simple “Hello, world!”. The following detector checks if fragments within a certain package extend a common “XxxBaseFragment” class. This is a common practice when shared logic is moved to a common “XxxBaseFragment” class (Example: analytics logic, go back behaviour, etc). Some people might argue this is not a good practice, but it is out of the scope of this article to discuss whether this is a good practice or not.

/**
 * A [Detector] to check whether the fragments in a certain package extend
 * BaseFragment.
 */
class BaseFragmentDetector : Detector(), SourceCodeScanner {
    override fun applicableSuperClasses(): List<String>? =
        listOf(CLASS_FRAGMENT)
 
    private fun JavaContext.report(node: UClass) {
        // If we couldn't find the fragment declaration something went wrong and we shouldn't
        // report.
        val fragmentSuperTypeDeclaration = node.uastSuperTypes.find {
            it.type.presentableText.contains("Fragment")
        } ?: return
 
        val fix: LintFix = LintFix.create().replace()
            .name("Replace with BaseFragment")
            .range(getNameLocation(fragmentSuperTypeDeclaration))
            .with("BaseFragment")
            .reformat(true)
            .shortenNames()
            .build()
        report(
            ISSUE,
            node,
            getNameLocation(node),
            "This fragment should extend BaseFragment",
            fix
        )
    }
 
    override fun visitClass(context: JavaContext, declaration: UClass) {
        val evaluator = context.evaluator
        val psiPackage = evaluator.getPackage(declaration.containingFile) ?: return
        if (!psiPackage.qualifiedName.contains(DESTINATION_PACKAGE)) {
            return
        }
        if (!declaration.extendsClass(evaluator, FRAGMENT_BASE_CLASS)) {
            context.report(declaration)
        }
    }
 
    private fun UClass.extendsClass(evaluator: JavaEvaluator, className: String): Boolean =
        evaluator.extendsClass(this, className, true) && this.qualifiedName == className
 
    companion object {
        @JvmField
        val ISSUE: Issue = Issue.create(
            "BaseFragment",
            "Fragments in my.package should extend BaseFragment",
            "Explain why we need to extend BaseFragment.",
            Category.CORRECTNESS,
            6,
            Severity.WARNING,
            Implementation(BaseFragmentDetector::class.java, Scope.JAVA_FILE_SCOPE)
        )
 
        const val CLASS_FRAGMENT = "androidx.fragment.app.Fragment"
        const val DESTINATION_PACKAGE = "my.test"
        const val FRAGMENT_BASE_CLASS = "my.test.BaseFragment"
    }
}

Let’s go step by step, with explanations of the key points to understand how this detector works. First, let’s focus on the definition on the Issue.

companion object {
    @JvmField
    val ISSUE: Issue = Issue.create(
        "BaseFragment",
        "Fragments in my.package should extend BaseFragment",
        "Explain why we need to extend BaseFragment.",
        Category.CORRECTNESS,
        6,
        Severity.WARNING,
        Implementation(BaseFragmentDetector::class.java, Scope.JAVA_FILE_SCOPE)
    )
 
    const val CLASS_FRAGMENT = "androidx.fragment.app.Fragment"
    const val DESTINATION_PACKAGE = "my.test"
    const val FRAGMENT_BASE_CLASS = "my.test.BaseFragment"
}

This Issue will be added to the IssueRegistry we talked previously. The Issue object basically describes what the problem is and what the scope of the Issue is. In this case, we specify the scope as Scope.JAVA_FILE_SCOPE. This scope also works for Kotlin. We are scoping our issue within a single file so Android Studio can run the check on the fly. Currently, Android Studio can only run checks scoped within a single file.

Now that we have defined our Issue, we can start writing our detector. As you can see, our detector extends the SourceCodeScanner interface. This interface will make analyzing Java/Kotlin code much easier. One of the methods of this interface is applicableSuperClasses.

override fun applicableSuperClasses(): List<String>? =
    listOf(CLASS_FRAGMENT)

This method is telling Lint, “call me back if the current class is a subclass of one of the specified superclasses”, so whenever the class we are analyzing is a subclass of CLASS_FRAGMENT, we will be called back in the visitClass method:

override fun visitClass(context: JavaContext, declaration: UClass) {
    val evaluator = context.evaluator
    val psiPackage = evaluator.getPackage(declaration.containingFile) ?: return
    if (!psiPackage.qualifiedName.contains(DESTINATION_PACKAGE)) {
        return
    }
    if (!declaration.extendsClass(evaluator, FRAGMENT_BASE_CLASS)) {
        context.report(declaration)
    }
    private fun UClass.extendsClass(evaluator: JavaEvaluator, className: String): Boolean =
        evaluator.extendsClass(this, className, true) && this.qualifiedName == className
}

Since we know we will be called back in the visitClass only when the current class extends CLASS_FRAGMENT, we just have to check wether the current class is in the desired DESTINATION_PACKAGE and extends FRAGMENT_BASE_CLASS. If it doesn’t, we have to report it:

private fun JavaContext.report(node: UClass) {
    // If we couldn't find the fragment declaration something went wrong and we shouldn't
    // report.
    val fragmentSuperTypeDeclaration = node.uastSuperTypes.find {
        it.type.presentableText.contains("Fragment")
    } ?: return
 
    val fix: LintFix = LintFix.create().replace()
        .name("Replace with BaseFragment")
        .range(getNameLocation(fragmentSuperTypeDeclaration))
        .with("BaseFragment")
        .reformat(true)
        .shortenNames()
        .build()
    report(
        ISSUE,
        node,
        getNameLocation(node),
        "This fragment should extend BaseFragment",
        fix
    )
}

The previous snippet finds the Fragment supertype among all its supertypes. Once we have it, we have to find its location with the getLocationName() function so the editor can highlight it. We also provide a quick fix to replace the Fragment with BaseFragment. Let’s see how it looks in Android Studio:

Now let’s look at how our quick fix looks:

After applying the quick fix:

Even though this is a simple example, the possibilities with Lint are unlimited and we were able to write a useful detector in less than a hundred lines. Writing custom Lint checks brings new challenging problems completely different from what we are used to while programing in Android. Your Lint checks will positively impact your whole team!

Related Post