Custom Android XML Lint Rules with Kotlin

The Android app that I’m currently working on is using a design system.

The design system uses a four pixel grid to measure distances between components. This means that we should consistenly use margins of multitudes of four to layout our components. What we’ve did is to extract these into a dimens file which looks like this:

// filename: res/values/dimens.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    //Design system measures
    <dimen name="gap_1">4dp</dimen>
    <dimen name="gap_2">8dp</dimen>
    <dimen name="gap_3">12dp</dimen>
    <dimen name="gap_4">16dp</dimen>
    <dimen name="gap_5">20dp</dimen>
    <dimen name="gap_6">24dp</dimen>
    <dimen name="gap_7">28dp</dimen>
    <dimen name="gap_8">32dp</dimen>
    <dimen name="gap_9">36dp</dimen>
    <dimen name="gap_10">40dp</dimen>
    <dimen name="gap_11">44dp</dimen>
    <dimen name="gap_12">48dp</dimen>
    <dimen name="gap_13">52dp</dimen>
    <dimen name="gap_14">56dp</dimen>
    <dimen name="gap_15">60dp</dimen>
    <dimen name="gap_16">64dp</dimen>
</resources>

During code reviews we’ve had countless comments asking to replace a hard-coded dp with one of these gaps. This feels like something that can be easily automated, right? Well, it can be automated! That is what I’m going to show you.

There’s a tool called Android Lint which already includes a bunch of lint checks. It also has a way that you can add your own and that is what we are going to do.

Add your own lint checks to Android Lint

First step to do is to create a separate module. You can’t directly create a Kotlin library from Android Studio so my suggestion is to do the following:

  1. Create a new module with File > New > New Module.
  2. Select Java Library
  3. Change build.gradle of that module to apply the kotlin plugin instead of the java-library plugin.
  4. Last thing to do is to add compileOnly "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.3.61" to your dependencies block.

Awesome! Now we’re all set and we have a Kotlin module that’ll contain our lint checks.

Next up is to add the dependencies which we need to develop our custom lint check, add these lines to your dependencies block:

  • compileOnly "com.android.tools.lint:lint-api:26.5.3"
  • testImplementation "com.android.tools.lint:lint-tests:26.5.3"

Writing our lint check

Writing a custom lint check is done by creating a class which extends from a particular Detector. Since we want our lint check to lint resource XML files and specifically layout resource XML files (since those would contain the margins/paddings), we can extend the LayoutDetector. So the class definition would look like this:

class GapsDetector : LayoutDetector() {}

The next step is to decide what particular part of the XML we want to check. This is done by overriding the getApplicableAttributes function as follows:

class GapsDetector : LayoutDetector() {
    override fun getApplicableAttributes(): Collection<String>? {
        return listOf(
            SdkConstants.ATTR_LAYOUT_MARGIN,
            SdkConstants.ATTR_LAYOUT_MARGIN_TOP,
            SdkConstants.ATTR_LAYOUT_MARGIN_BOTTOM,
            SdkConstants.ATTR_LAYOUT_MARGIN_START,
            SdkConstants.ATTR_LAYOUT_MARGIN_END
        )
    }
}

This tells Android Lint that we want to check all of the layout_margin* attributes. Next up is to define the actual code that would do the lint check, this is done in the visit* methods. Since we want to visit attributes we should override the visitAttribute function:

class GapsDetector : LayoutDetector() {
  ...

    override fun visitAttribute(context: XmlContext, attribute: Attr) {
        val matchResult = "([0-9]+)dp".toRegex().matchEntire(attribute.value)

        if (matchResult != null) {
            val (amountDp) = matchResult.destructured
            if (amountDp.toInt() != 0 && amountDp.toInt() % 4 == 0) {
                context.report(
                    ISSUE_GAPS_FOR_MARGIN_PADDING,
                    context.getLocation(attribute),
                    ISSUE_GAPS_FOR_MARGIN_PADDING.getExplanation(TextFormat.TEXT)
              )
            }
        }
    }
}

In the visitAttribute function you have access to the actual attribute. This attribute has a couple of properties, two of which are the name and the value. In this case we are interested in the value since that should contain our gap. What we are doing in this piece of code is to first extract the (possible) hardcoded DP of the value. Afterwards we check if this amount of DP is not equal to 0 and if it can be divided by 4. If this is true, this means that we should use one of the gaps that we have defined in our dimens file and therefore we should report an “Issue”.

This issue is another key part in making your custom lint check. The issue contains all the information that Android Lint uses to show an error message to the user.

The issue in our case will look like this:

val ISSUE_GAPS_FOR_MARGIN_PADDING = Issue.create(
    id = "GapsForMarginPadding",
    briefDescription = "Gaps should be used for margin if divisible by four",
    explanation = "Gaps should be used for margin if divisible by four",
    category = Category.CORRECTNESS,
    priority = 5,
    severity = Severity.ERROR,
    implementation = Implementation(GapsDetector::class.java, ALL_RESOURCES_SCOPE)
)

The most important ones here are these:

  • id: used for Android Lint to refer to this issue.
  • explanation: shown to the user when this issue is detected.
  • implementation: decides which class is used to detect this issue and which scope this should run on.

Linking custom lint check to Android Lint

We’ve written our custom lint check now but Android Lint won’t automatically pick this up, there are a few things that you have to do to make Android Lint aware of this new lint check.

Create a LintRegistry:

class LintRegistry : IssueRegistry() {
    override val api: Int
        get() = CURRENT_API

    override val issues: List<Issue>
        get() = listOf(ISSUE_GAPS_FOR_MARGIN_PADDING)
}

Add the reference of the LintRegistry to the jar manifest:

// lint/build.gradle
apply plugin: 'kotlin'

jar {
    manifest {
        attributes("Lint-Registry-v2": "com.joeykaan.lint.LintRegistry")
    }
}

dependencies { ... }

The last thing to do is to use the lint check in the module that you want:

// app/build.gradle
...

dependencies {
  ...
  lintChecks project(":lint")
}

After this step, every time when you run ./gradlew lint in the correct module it would run our custom lint check!

All of this is also available in a repository: https://github.com/jkaan/AndroidLintExample.

Extra: Testing our custom lint check

The Lint APIs also come with handy ways to test our custom detector and I’ll show you how you can make use of them!

First step is to create a test file with the following content:

import com.android.tools.lint.checks.infrastructure.LintDetectorTest

class GapsDetectorTest : LintDetectorTest() {
    override fun getDetector() = GapsDetector()
    override fun getIssues() = listOf(ISSUE_GAPS_FOR_MARGIN_PADDING)
}

As you can see here we extend from LintDetectorTest and override two functions to make our test aware of which detector and issue we are testing.

After this we can use the lint() fluent function to write our test cases:

class GapsDetectorTest : LintDetectorTest() {
    @Test
    fun testGaps_withMarginDivisbleByFour_returnsError() {
        lint()
            .files(
                xml(
                    "/res/layout/test.xml",
                    """
                        <?xml version="1.0" encoding="utf-8"?>
                        <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
                            android:layout_width="match_parent"
                            android:layout_height="144dp"
                            android:layout_marginStart="12dp"
                            android:background="@color/coolBlue" />
                    """.trimIndent()
                )
            ).run()
            .expectErrorCount(1)
    }
}

What we are doing here is creating a test XML file using the .files(xml(fileName, contents)) helper and running our detector and expecting one error. One thing that is important to note here is that the name should be /res/layout/*.xml since our detector runs on layout files (remember the LayoutDetector?), otherwise this test would fail.

Writing these custom lint checks might take some time at first, but in the end this would allow you to focus on the important things during code reviews. In my opinion that is always time well spent!

Published 5 Jan 2020

I blog about Android.
Joey Kaan on Twitter