Custom lint checks in Android | Part 2 | By Gopal

Gopal
8 min readDec 7, 2023

--

TL;DR

In the 1st section of the article, we explored the fundamentals of linting in the context of Android development. Our focus was on understanding various types of lint in Android and exploring diverse methods to employ and configure lint within Android Studio.

Moving forward in this blog, we will dive into the intriguing process of crafting custom lint from the ground up.

Please make sure to read Part 1 before diving into Part 2 for a comprehensive understanding.

Motivation

In my case, I often found myself frustrated with formatting-related comments on pull requests. There were instances when I would forget to include the @AndroidEntryPoint annotation on an activity/fragment, especially when using Dagger. Other times, I would overlook the need to utilize a specific view/utility according to our organization's standards, among various other challenges that tend to slip through the cracks during development. These experiences led me to realize the importance of a more proactive approach in addressing such issues and inspired me to explore the world of Android linting.

By developing this custom lint rule, our goal is not only to streamline the development workflow but also to foster a coding environment where attention to detail becomes second nature. Let’s start this journey to enhance code quality, eliminate common pitfalls, and empower ourselves to tackle more complex coding challenges with confidence.

How can we solve this Problem?

We can use Ktlint for Kotlin and Checkstyle for Java, but we prefer using Android Lint (which is not just for Android) because it lets us write custom lint rules that work for both Kotlin and Java at the same time. This makes more sense because sometimes we use both languages, and we don’t want to create and handle separate lint rules for each. Plus, integrating these rules would also need to be done twice, making it more complicated. Choosing Android Lint helps us keep things simple and efficient, making our code easier to manage in both languages.

You can read Part 1 of this article to understand how lint works in Android for both Java and kotlin at once.

Why do we still need to care about Java?

While it’s true that Kotlin is gaining popularity and becoming our preferred choice over Java in our code, it might not be the case for larger projects already in use. So, it’s not practical to completely disregard Java just yet. To ensure our code looks good and follows the same standards in both Java and Kotlin, it’s crucial to maintain this rule for both languages. This way, regardless of the language, we can still enjoy well-organized and readable code.

Writing Your first Lint check

  1. Create a new Java or Kotlin Library module.

2. Now add dependencies in your lint module’s build.gradle.

plugins {
id("java-library")
id("org.jetbrains.kotlin.jvm")
}

java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

dependencies {
compileOnly("com.android.tools.lint:lint-api:30.1.0-alpha03") // = agp + 23.0.0
testImplementation("com.android.tools.lint:lint-tests:30.1.0-alpha03")
}

To decide lint library version there is a rule of thumb as stated by Google developers (https://youtu.be/jCmJWOkjbM0?t=92) add 23.0.0 to your Android Gradle plugin ( AGP ) version

3. Make your lint rules visible to other modules

To include this lint rule in your other module we can either use lintPublish or lintChecks. The difference between these two is that lintChecks makes that lint rule available only for that module whereas lintPublish makes it available to all upstream modules as well. This is how I used it.

android {
...
}
dependencies {
...
lintChecks(project(":lint-checks"))
}

Now Sync your project once before moving to the next step.

4. Define Issue Registry
Now in the next step, we need to define our Issue Registry, where we list all the issues we want to detect. Android lint discovers it using a service locator pattern.

In software design, a service locator is a pattern that centralizes the process of obtaining a service or component within a system. In this context, the “service” is the Issue Registry, and the service locator pattern helps Android lint locate and use it effectively. It’s a mechanism that abstracts the process of locating and obtaining services, providing a centralized point for managing dependencies.

To Define an issue Registry we need to extend our class from IssueRegistry() abstract class and return all the issues(which we will define below) we want to detect.

This is what our issue registry looks like (suppress annotation is added as all lint APIs are in beta)

package gopal.lint_checks

import com.android.tools.lint.client.api.IssueRegistry
import com.android.tools.lint.detector.api.CURRENT_API

@Suppress("UnstableApiUsage")
class MyIssueRegistry: IssueRegistry() {

override val issues = listOf() // Add your all Issues

override val api: Int
get() = CURRENT_API

override val minApi: Int
get() = 8
}

Now we need to ensure this registry is visible to Lint for that we create a file with the name com.android.tools.lint.client.api.IssueRegistry at the location “resources/META-INF/services/” and define our registry like this.

gopal.lint_checks.MyIssueRegistry

5. Let’s understand custom lint class terms “Visitor,” “Issue,” and “Detector”

5.1 Visitor: A visitor is a design pattern used to traverse and analyze the Abstract Syntax Tree (AST) of the source code. Think of it as a code explorer that looks at different parts of the code, such as classes, methods, and statements. It helps you navigate through the code’s layout and do specific tasks or checks on each piece

5.2 Issue: An issue represents a specific problem or rule violation that you want to detect in the codebase. It is like a big red flag pointing out a specific problem or mistake in code. It comes with details like an ID, a brief description, and a priority level. Each issue can also have special settings, like how severe it is or any extra options, so you can customize how it works.

5.3 Detector: A detector is responsible for implementing the logic to identify and report issues in the codebase. It is the brains behind finding and reporting code issues. It’s like a code inspector that defines methods to check different parts of the code structure. When certain conditions are met, it raises issues. The detector teams up with the lint framework and, during analysis, gets called by the visitor to inspect every nook and cranny of the code structure

6. Creating your first Issue.

In this example, we will be creating a custom lint rule to check whether an Activity or Fragment has been annotated with @AndroidEntryPoint, a requirement for Dagger Hilt projects.

Our Issue Class

package gopal.lint_checks.checks

import com.android.tools.lint.detector.api.Category
import com.android.tools.lint.detector.api.Implementation
import com.android.tools.lint.detector.api.Issue
import com.android.tools.lint.detector.api.Scope
import com.android.tools.lint.detector.api.Severity
import gopal.lint_checks.detector.AndroidEntryPointDetector

@Suppress("UnstableApiUsage")
object AndroidEntryPointIssue {
/**
* The fixed id of the issue, IDs are also used to suppress any lint
*/
private const val ID = "AndroidEntryPointImplementationIssue"

/**
* The priority, a number from 1 to 10 with 10 being most important/severe
*/
private const val PRIORITY = 7

/**
* Description short summary (typically 5-6 words or less), typically describing
* the problem rather than the fix (e.g. "Missing minSdkVersion")
*/
private const val DESCRIPTION = "Use @AndroidEntryPoint before running the app"

/**
* A full explanation of the issue, with suggestions for how to fix it
*/
private const val EXPLANATION = """
Class is not contain @AndroidEntryPoint Annotation,
Use @AndroidEntryPoint before running the app
"""

/**
* The associated category, if any @see [Category]
*/
private val CATEGORY = Category.CORRECTNESS

/**
* The default severity of the issue
*/
private val SEVERITY = Severity.INFORMATIONAL

val ISSUE = Issue.create(
ID,
DESCRIPTION,
EXPLANATION,
CATEGORY,
PRIORITY,
SEVERITY,
Implementation(
AndroidEntryPointDetector::class.java,
Scope.JAVA_FILE_SCOPE
)
)
}

The final parameter, IMPLEMENTATION, holds the utmost importance, while the other arguments are self-explanatory. Within IMPLEMENTATION, the scope specifies the type of files we aim to analyze — in this case, it includes both Java and Kotlin files. The initial argument is the detector class responsible for identifying the particular issue.

Writing our detector Class

The Detector class is required to extend from the Detector class and implement one of the following interfaces based on the file type we want to examine. Lint simplifies the process by providing callback-style APIs, eliminating the need for manual file scanning. We can specify the elements we’re interested in, and Lint will provide callbacks for them based on the chosen file type:

  • SourceCodeScanner -> Java or Kotlin files
  • ClassScanner -> Bytecode/compiled class files
  • BinaryResourceScanner -> Binary resource files
  • ResourceFolderScanner -> Resource folders directory, not the files within it
  • XmlScanner -> All XML files
  • GradleScanner -> Gradle files
  • OtherFileScanner -> All other files

Now, let’s delve into the code.

package gopal.lint_checks.detector

import com.android.tools.lint.client.api.UElementHandler
import com.android.tools.lint.detector.api.Detector
import com.android.tools.lint.detector.api.JavaContext
import com.android.tools.lint.detector.api.SourceCodeScanner
import gopal.lint_checks.visitor.AndroidEntryPointVisitor
import org.jetbrains.uast.UClass
import org.jetbrains.uast.UElement

@Suppress("UnstableApiUsage")
class AndroidEntryPointDetector : Detector(), SourceCodeScanner {
override fun getApplicableUastTypes(): List<Class<out UElement>>? =
listOf(UClass::class.java)

override fun createUastHandler(context: JavaContext): UElementHandler =
AndroidEntryPointVisitor(context)
}

We extend from the Detector class to implement SourceCodeScanner as we want to scan Java and kotlin files

Writing our Visitor Class

package gopal.lint_checks.visitor

import com.android.tools.lint.client.api.UElementHandler
import com.android.tools.lint.detector.api.JavaContext
import gopal.lint_checks.checks.AndroidEntryPointIssue
import org.jetbrains.uast.UClass

@Suppress("UnstableApiUsage")
class AndroidEntryPointVisitor(private val context: JavaContext) : UElementHandler() {

private val superClassQualifiedNameFragment =
"androidx.appcompat.app.AppCompatActivity"

override fun visitClass(node: UClass) {
if (node.javaPsi.superClass?.qualifiedName == superClassQualifiedNameFragment &&
!node.hasAnnotation("dagger.hilt.android.AndroidEntryPoint")
) {
reportIssue(node)
}
}

private fun reportIssue(node: UClass) {
context.report(
issue = AndroidEntryPointIssue.ISSUE,
scopeClass = node,
location = context.getNameLocation(node),
"Use @AndroidEntryPoint before running the app, Hurray Your First Lint",
)
}
}

Now register your issue in your IssueRegsitry class.

class MyIssueRegistry: IssueRegistry() {

....
override val issues = listOf(AndroidEntryPointIssue.ISSUE)
....
}

Now only write “./gradlew lint” in the Android Studio terminal or you can execute a gradle lint task.

After all the above steps you just need to rebuild your project and you can see your Lint in action.

Congratulations on your first custom lint.

PS: If the above step did not work for you due to any reason you also try adding this line in your lint-check module build gradle. ( Change the path ) and then rebuild your project or you even try to invalidate and restart.

jar {
manifest {
attributes('Lint-Registry-v2': 'gopal.lint_checks.MyIssueRegistry')
}
}

Thank you for reading this post, I hope this was helpful to you, you can view all the code from my GitHub Repository.

Leave a comment below if you have any questions I will try to answer them to the best of my knowledge.

Follow me on LinkedIn and Medium ✌️

--

--