loading...

Dark Mode: three Lint checks to help

dbottillo profile image Daniele Bottillo ・10 min read

Implementing Night Mode in Android is pretty straightforward: you have a theme with attributes and you can just define those attributes in the values-night qualifier and that's it. Five minutes and you are done. Well...

Most likely, reality is different: you have a project 4-5 years old, you don't even have a second theme, you have been using hardcoded colors #FFFFFF everywhere, maybe at times you felt brave and used instead a reference color @color/white . And maybe some other times you had to tint something programmatically with your friend ContextCompat.getColor(context, R.color.white)to tint that icon that comes from an API.

So now your code is polluted with things that will be hard to migrate to night: you have white defined directly, as a reference and in code. Guess what, white is not white in night mode anymore :)

And even if you have a brand new project, how do you make sure someone in future will not do the same, how do you enforce that night mode is implemented in every commit/PR? That's where Lint checks come in to help us!

Most of Android developers are familiar with Lint, but just as a refresh, Lint is a static analysis tool which looks for different types of bugs; you can find out more at https://developer.android.com/studio/write/lint.

Android comes with some Lint checks already made for you, but you can extend them and add your own! Here I'm going to show you how to add three Lint checks to your project: one to detect the usage of a direct color (eg. #FFFFFF), one to detect if you have defined a color and its night mode equivalent, and one to check all those color's name that may be problematic (eg. white, red, green).

I’ve already wrote a post about writing a custom Lint rule (https://dev.to/dbottillo/how-to-write-a-custom-rule-in-lint-23gf) so I'm going to assume you know how to write one and focus on the specific content of the three rules.

The first two rules are heavily inspired from Saurabh Arora's post: https://proandroiddev.com/making-android-lint-theme-aware-6285737b13bc

Direct Color Detector

The first rule is very simple: you shouldn't use any hardcoded color values like #FFFFFF.
Let's do a bit of TDD and write a test to validate what we want from the Lint rule:

    @Test
    fun `should report a direct color violation`() {
        val contentFile = """<?xml version="1.0" encoding="utf-8"?>
                <View 
                        xmlns:android="http://schemas.android.com/apk/res/android"
                        android:id="@+id/toolbar"
                        android:background="#453344"
                        android:foreground="#667788"
                        android:layout_width="match_parent"
                        android:layout_height="wrap_content" />"""
        TestLintTask.lint()
                .files(TestFiles.xml("res/layout/toolbar.xml", contentFile).indented())
                .issues(DIRECT_COLOR_ISSUE)
                .run()
                .expect("""
                    |res/layout/toolbar.xml:5: Error: Avoid direct use of colors in XML files. This will cause issues with different theme (eg. night) support [DirectColorUse]
                    |                    android:background="#453344"
                    |                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                    |res/layout/toolbar.xml:6: Error: Avoid direct use of colors in XML files. This will cause issues with different theme (eg. night) support [DirectColorUse]
                    |                    android:foreground="#667788"
                    |                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                    |2 errors, 0 warnings""".trimMargin())
    }

That's a lot to get through, let's break it down:

    val contentFile = """<?xml version="1.0" encoding="utf-8"?>
                <View 
                        xmlns:android="http://schemas.android.com/apk/res/android"
                        android:id="@+id/toolbar"
                        android:background="#453344"
                        android:foreground="#667788"
                        android:layout_width="match_parent"
                        android:layout_height="wrap_content" />"""

So, here we are defining the content of an hypothetic XML file containing a view tag with few attributes, both background and foreground contain an hardcoded color so we should expect two violations for both of them.
Next, we can build a test lint task, which is a class lint provides to test your test files:

    TestLintTask.lint()
                .files(TestFiles.xml("res/layout/toolbar.xml", contentFile).indented())

With .files() you can simulate to pass a number of files to lint. Think of them like all your project's file, in this case I'm just passing a fake res/layout/toolbar.xml whose contentFile is the xml we defined in the previous section.

    TestLintTask.lint()
    .files(TestFiles.xml("res/layout/toolbar.xml", contentFile).indented())
    .issues(DIRECT_COLOR_ISSUE)

With .issues you can pass a number of issues lint will use to perform the detection, in this case:

    val DIRECT_COLOR_ISSUE = Issue.create("DirectColorUse",
            "Direct color used",
            "Avoid direct use of colors in XML files. This will cause issues with different theme (eg. night) support",
            CORRECTNESS,
            6,
            Severity.ERROR,
            Implementation(DirectColorDetector::class.java, Scope.RESOURCE_FILE_SCOPE)
    )

So this is how you can create a new issue in Lint: DirectColorUse is the id of the issue, then there is a brief description and an explanation, followed by category, priority, severity and the implementation. The DirectColorDetector.kt file is the one responsible for detection. If you try to run the test of course it will fail since this file doesn't exist yet. Let’s create an empty one so we can run the test:

    class DirectColorDetector : ResourceXmlDetector() {
    }

Finally at the end of the test we have:

    TestLintTask.lint()
    .files(TestFiles.xml("res/layout/toolbar.xml", contentFile).indented())
    .issues(DIRECT_COLOR_ISSUE)
    .run()
    .expect("""
                    |res/layout/toolbar.xml:5: Error: Avoid direct use of colors in XML files. This will cause issues with different theme (eg. night) support [DirectColorUse]
                    |                    android:background="#453344"
                    |                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                    |res/layout/toolbar.xml:6: Error: Avoid direct use of colors in XML files. This will cause issues with different theme (eg. night) support [DirectColorUse]
                    |                    android:foreground="#667788"
                    |                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                    |2 errors, 0 warnings""".trimMargin())

run() makes the Lint task running and expect is a convenient method of the TestLintTask to assert what's the outcome of running Lint on those files. Here we are expecting those two violations about the background and the foreground values. Of course if you run this test it will fail since we haven't actually implemented DirectColorDetector yet. Let's do that:

    class DirectColorDetector : ResourceXmlDetector() {

        override fun getApplicableAttributes(): Collection<String>? = listOf(
                "background", "foreground", "src", "textColor", "tint", "color",
                "textColorHighlight", "textColorHint", "textColorLink", 
                "shadowColor", "srcCompat")

        override fun visitAttribute(context: XmlContext, attribute: Attr) {
            if (attribute.value.startsWith("#")) {
                context.report(
                        DIRECT_COLOR_ISSUE,
                        context.getLocation(attribute),
                        DIRECT_COLOR_ISSUE.getExplanation(TextFormat.RAW))
            }
        }
    }

Extending ResourceXmlDetector means we have a chance to do something when Lint is going through XML files, and through overriding some methods we can perform our check. Here we are using getApplicableAttributes() to notify Lint that the detector should be running on those attributes of any XML file. In the visitAttribute we have a chance to look at the attribute value and if it starts with # it means we can report a violation. You can do that by using the context (which is NOT the Android context but the Lint context) and call report on it with the values of the DIRECT_COLOR_ISSUE defined before.

Here we go! Now if you run the test it will be green.

Missing Night Color

This check is about having a color defined without a night version. And as we did before, let's first write a test to validate our assumption:

    @Test
    fun `should report a missing night color violation`() {
        val colorFile = TestFiles.xml("res/values/colors.xml",
                """<?xml version="1.0" encoding="utf-8"?>
                <resources> 
                    <color name="color_primary">#00a7f7</color>
                    <color name="color_secondary">#0193e8</color>
                </resources>""").indented()
        val colorNightFile = TestFiles.xml("res/values-night/colors.xml",
                """<?xml version="1.0" encoding="utf-8"?>
                <resources> 
                    <color name="color_primary">#224411</color>
                </resources>""").indented()
        TestLintTask.lint()
                .files(colorFile, colorNightFile)
                .issues(MISSING_NIGHT_COLOR_ISSUE)
                .run()
                .expect("""
                    |res/values/colors.xml:4: Error: Night color value for this color resource seems to be missing. If your app supports night theme, then you should add an equivalent color resource for it in the night values folder. [MissingNightColor]
                    |                <color name="color_secondary">#0193e8</color>
                    |                ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                    |1 errors, 0 warnings""".trimMargin())
    }

Pretty straightforward, the color color_secondary doesn't have a night definition so it should be reported. Let's have a look at the new Issue:

    val MISSING_NIGHT_COLOR_ISSUE = Issue.create("MissingNightColor",
            "Night color missing",
            "Night color value for this color resource seems to be missing. If your app supports night theme, then you should add an" +
                    " equivalent color resource for it in the night values folder.",
            CORRECTNESS,
            6,
            Severity.ERROR,
            Implementation(MissingNightColorDetector::class.java, Scope.RESOURCE_FILE_SCOPE)
    )

This is similar to the previous one, of course we have a different id, description and explanation. I'm re-using the same category, priority and severity but feel free to define your own based on your needs. Last is the implementation that points to a new detector called MissingNightColorDetector:

    class MissingNightColorDetector : ResourceXmlDetector() {

        private val nightModeColors = mutableListOf<String>()
        private val regularColors = mutableMapOf<String, Location>()

        override fun appliesTo(folderType: ResourceFolderType): Boolean {
            return folderType == ResourceFolderType.VALUES
        }

        override fun getApplicableElements(): Collection<String>? {
            return listOf("color")
        }

        override fun afterCheckEachProject(context: Context) {
            regularColors.forEach { (color, location) ->
                if (!nightModeColors.contains(color))
                    context.report(
                            MISSING_NIGHT_COLOR_ISSUE,
                            location,
                            MISSING_NIGHT_COLOR_ISSUE.getExplanation(TextFormat.RAW)
                    )
            }
        }

        override fun visitElement(context: XmlContext, element: Element) {
            if (context.getFolderConfiguration()!!.isDefault)
                regularColors[element.getAttribute("name")] = context.getLocation(element)
            else if (context.getFolderConfiguration()!!.nightModeQualifier.isValid)
                nightModeColors.add(element.getAttribute("name"))
        }
    }

Ok, this is way more complicated than the previous one! The reason is that for this specific detection, we can't report violations straight away: Lint is going through all project's files as if it was a tree. That means that while it's visiting the res/values/colors folder we don't know anything yet about the res/values-night/colors which is probably going to be visited later on. So, in this case I'm memorising all the res/values/colors in a map of color name and location (we need the location to know where the violation is in the file) and all the night colors, then throw a detection at the end of the project evaluation if they don't match. Let's do it step by step:

    private val nightModeColors = mutableListOf<String>()
    private val regularColors = mutableMapOf<String, Location>()

    override fun appliesTo(folderType: ResourceFolderType): Boolean {
        return folderType == ResourceFolderType.VALUES
    }

    override fun getApplicableElements(): Collection<String>? {
        return listOf("color")
    }

The first two are just internal variables to store the list of night mode colors and the map of colors/location for the regular colors. appliesTo lets you specify that, among all the xml files, you are only interested in the ResourceFolderType.VALUES so it will skip drawables, layouts, etc. Finally in the getApplicableElements() we are telling Lint to call this rule only for the tag color since we don't care about style, bool, etc..

    override fun visitElement(context: XmlContext, element: Element) {
        if (context.getFolderConfiguration()!!.isDefault)
            regularColors[element.getAttribute("name")] = context.getLocation(element)
        else if (context.getFolderConfiguration()!!.nightModeQualifier.isValid)
            nightModeColors.add(element.getAttribute("name"))
    }

visitElement is the method that we can use when the color tag is visited, here it first checks the folder configuration (eg. default or night). If it's default we add name and location to the map of regular colors, if it's night we just save the color name in the night mode colors list.

    override fun afterCheckEachProject(context: Context) {
        regularColors.forEach { (color, location) ->
            if (!nightModeColors.contains(color))
                context.report(
                        MISSING_NIGHT_COLOR_ISSUE,
                        location,
                        MISSING_NIGHT_COLOR_ISSUE.getExplanation(TextFormat.RAW)
                )
        }
    }

Finally with the afterCheckProject we have another chance to report a violation after the whole project has been visited, here we can loop through all the regular colors and if we don't find the equivalent in the night mode colors list, we can report a violation. Please pay attention to the location parameter in the report method: we are basically telling Lint where the violation occurred in the file.

Non Semantic Color

The third check is a bit more specific based on the project, but the idea here is that color names like white or red shouldn't be used. There is a high probability that white is not white in night and that red is maybe red but not that specific red in night. It would be better to use semantic color: instead of red use maybe error and instead of white use surface. So let's write a new test:

    @Test
    fun `should report a non semantic color violation`() {
        val contentFile = """<?xml version="1.0" encoding="utf-8"?>
                <View 
                        xmlns:android="http://schemas.android.com/apk/res/android"
                        android:id="@+id/toolbar"
                        android:background="@color/white"
                        android:foreground="@color/red"
                        android:layout_width="match_parent"
                        android:layout_height="wrap_content" />"""
        TestLintTask.lint()
                .files(TestFiles.xml("res/layout/toolbar.xml", contentFile).indented())
                .issues(NON_SEMANTIC_COLOR_ISSUE)
                .run()
                .expect("""
                    |res/layout/toolbar.xml:5: Error: Avoid non semantic use of colors in XML files. This will cause issues with different theme (eg. night) support. For example, use primary instead of black. [NonSemanticColorUse]
                    |                    android:background="@color/white"
                    |                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                    |res/layout/toolbar.xml:6: Error: Avoid non semantic use of colors in XML files. This will cause issues with different theme (eg. night) support. For example, use primary instead of black. [NonSemanticColorUse]
                    |                    android:foreground="@color/red"
                    |                    ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
                    |2 errors, 0 warnings""".trimMargin())
    }

This is very similar to the first test that we wrote, so I'm not going through the details, we are expecting two violations because background is using @color/white and foreground is using @color/red.

    val NON_SEMANTIC_COLOR_ISSUE = Issue.create("NonSemanticColorUse",
            "Non semantic color used",
            "Avoid non semantic use of colors in XML files. This will cause issues with different theme (eg. night) support. " +
                    "For example, use primary instead of black.",
            CORRECTNESS,
            6,
            Severity.ERROR,
            Implementation(NonSemanticColorDetector::class.java, Scope.RESOURCE_FILE_SCOPE)
    )

Again, nothing new here, it's just a new issue definition for NonSemanticColorUse, let's jump to the detector:

    class NonSemanticColorDetector : ResourceXmlDetector() {

        override fun getApplicableAttributes(): Collection<String>? = listOf(
                "background", "foreground", "src", "textColor", "tint", "color",
                "textColorHighlight", "textColorHint", "textColorLink", 
                "shadowColor", "srcCompat")

        override fun visitAttribute(context: XmlContext, attribute: Attr) {
            if (checkName(attribute.value)) {
                context.report(
                        NON_SEMANTIC_COLOR_ISSUE,
                        context.getLocation(attribute),
                        NON_SEMANTIC_COLOR_ISSUE.getExplanation(TextFormat.RAW))
            }
        }

        private fun checkName(input: String): Boolean {
            return listOf("black", "blue", "green", "orange", 
                          "teal", "white", "orange", "red").any {
                input.contains(it)
            }
        }
    }

This detector is very similar to the first detector about hardcoded colors: we just define which attributes we are interested in and with the visitAttribute you can check the attribute value and if it contains any of black,blue,green,etc.. we report the violation. I also want to mention that this will work for images as well: if you have something like app:srcCompat="@drawable/ic_repeat_black_24dp" it will report and for a good reason! If you don't tint that image then it may not work in night.

Conclusion

With the three new lint checks if you now run Lint you will find them in the reports:

Lint checks report

Of course, this is a starting point, you can decide to go through them and fix all of them in one go or just have a sanity check on how far you are to fix all the potential issue while implementing night mode. My suggestion is to make them not an error in the beginning, otherwise it will fail all your builds, and turning them into error only when you have fixed all of them. Turning them into error helps prevent new code and feature to be added to the project without night mode in mind preventing that to go into the build.

Tip: you can suppress them as you do with any other lint rule:

    <com.google.android.material.floatingactionbutton.FloatingActionButton
            android:id="@+id/button"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            app:srcCompat="@drawable/ic_repeat_black_24dp"
            tools:ignore="NonSemanticColorUse" />

Finally, you can find a full implementation on one of my app here: https://github.com/dbottillo/MTGCardsInfo/commit/9bfd1e051f5c264cb7b5316efd50c6af9723b922

Happy coding!

Discussion

markdown guide
 

Nice post!
Another cool check, for projects following Material guidelines, would be avoiding using color resources instead of attributes, for instance:
android:textColor="@color/black" instead of android:textColor="?android:textColorPrimary".
This would apply to layouts, drawables, colors and styles files.

What do you think? :)

 

yeah I think it would totally make sense! Or even in general not just for Material guidelines, I think checking for using attributes reference instead of color reference should be a good a thing regardless!

 

So I decided to create some lint rules just for fun and - who knows - to use them in my code template :)
I'm struggling with a detail (so I hope):

The given artifact contains a string literal with a package reference ‘android.support.design.widget’ that cannot be safely rewritten. Libraries using reflection such as annotation processors need to be updated manually to add support for androidx.

dependencies {
compileOnly "com.android.tools.lint:lint-api:26.6.3"
testImplementation "com.android.tools.lint:lint-tests:26.6.3"
testImplementation "com.android.tools.lint:lint:26.6.3"
}

If I turn off android.enableJetifier it gets worst. Any idea how to solve it?
Running gradle assemble I get this:

Execution failed for task ':lint-rules:compileDebugKotlin'.

Could not resolve all artifacts for configuration ':lint-rules:debugCompileClasspath'.
Failed to transform artifact 'common.jar (com.android.tools:common:26.6.3)' to match attributes {artifactType=android-classes, >org.gradle.libraryelements=jar, org.gradle.usage=java-runtime}.
> Execution failed for JetifyTransform: >/Users/.../.gradle/caches/modules-2/files-2.1/com.android.tools/common/26.6.3/660d537bd70cc816f63b4b038a529177f402448a/common-26.6.3.jar.
> Failed to transform >'/Users/.../.gradle/caches/modules-2/files-2.1/com.android.tools/common/26.6.3/660d537bd70cc816f63b4b038a529177f402448a/common-26.6.3.jar' using > Jetifier. Reason: The given artifact contains a string literal with a package reference 'android.support.design.widget' that cannot be safely >rewritten. Libraries using reflection such as annotation processors need to be updated manually to add support for androidx.. (Run with --stacktrace >for more details.)

Thanks for your time.

Hi there, I'm not exactly sure what could be the problem there. Did you try on a simple/new project from Android Studio? maybe there is some other dependency interfering!

I've found out!
My build gradle was using apply plugin: 'com.android.library' instead of java-library '^^

nice! I'm glad you fixed it :)

 

your repository shows error on Android Studio 4.1 Canary 7 and Android Studio 3.6.3

can you help with this?

dev-to-uploads.s3.amazonaws.com/i/...

 

Unfortunately I have the same issue with AS, for some reasons it doesn't take in consideration that lint check properly. I disable it on my AS for now, thinking to open a bug ticket on AS !