Skip to content

Create lint rules module with detector for requiring dropUnlessResumed - #190

Draft
benwicks wants to merge 1 commit into
android:mainfrom
benwicks:benwicks/setup-dropUnlessResumed-lint-rule
Draft

Create lint rules module with detector for requiring dropUnlessResumed#190
benwicks wants to merge 1 commit into
android:mainfrom
benwicks:benwicks/setup-dropUnlessResumed-lint-rule

Conversation

@benwicks

Copy link
Copy Markdown

on all Button/clickable/IconButton

This is a start at addressing #107

@google-cla

google-cla Bot commented Dec 17, 2025

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello @benwicks, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request establishes a new custom Android Lint rule within a dedicated lint-rules module. The primary goal is to enforce a best practice in Jetpack Compose development: requiring the use of dropUnlessResumed for onClick handlers on interactive UI elements like Button, clickable, and IconButton. This measure is crucial for preventing UI events from being processed in an invalid lifecycle state, thereby enhancing the robustness and reliability of the application's user interface. The changes also include the necessary Gradle configurations and unit tests for the new lint rule.

Highlights

  • New Lint Rule Module: A dedicated Gradle module (lint-rules) has been introduced to house custom Android Lint checks, promoting code quality and consistency.
  • dropUnlessResumed Enforcement: A new lint detector (DropUnlessResumedOnClickDetector) was added to ensure that onClick handlers for Button, clickable, and IconButton composables are wrapped with dropUnlessResumed.
  • Lifecycle Safety: This rule aims to prevent potential issues where click events might be processed after a composable has left the resumed state, thereby improving application stability and preventing crashes.
  • Lint Baseline Management: An app/lint-baseline.xml file has been added to manage and suppress existing lint warnings, allowing developers to focus on new or critical issues identified by the lint tool.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new lint module to enforce the use of dropUnlessResumed for click handlers, which is a great initiative to prevent lifecycle-related issues. I've identified a critical issue in the detector's implementation that could lead to false positives and have suggested a fix. Additionally, I've recommended adding a test case to improve the test suite's robustness by covering this scenario. After addressing these points, this will be a valuable contribution.

Comment on lines +34 to +58
override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: com.intellij.psi.PsiMethod) {
val methodName = node.methodName ?: return
if (methodName == "Button" || methodName == "clickable" || methodName == "IconButton") {
// Check all arguments for a lambda (named or positional)
val lambdaArgs = node.valueArguments.filterIsInstance<ULambdaExpression>()
if (lambdaArgs.isEmpty()) {
// Also check for named arguments
node.valueArguments.forEach { arg ->
if (arg is ULambdaExpression) {
lambdaArgs.plus(arg)
}
}
}
for (lambda in lambdaArgs) {
if (!lambdaCallsDropUnlessResumed(lambda)) {
context.report(
ISSUE,
node,
context.getLocation(node),
"onClick handler must use dropUnlessResumed"
)
}
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The current implementation of visitMethodCall has several issues:

  1. It incorrectly checks all lambda arguments of a function call, not just the onClick handler. This will cause false positives for composables like Button that have other lambda parameters (e.g., content). The lint rule would incorrectly flag the content lambda for not using dropUnlessResumed.
  2. The logic to find lambda arguments between lines 39-46 contains dead code. The plus method on an immutable List returns a new list, which is not being used, so this block has no effect.
  3. The check for method names on line 36 is redundant, as visitMethodCall is only invoked for methods specified in getApplicableMethodNames.

To fix this and make the detector more accurate, we should specifically target the onClick parameter and check only its argument. Here is a suggested replacement for the method that addresses these points.

    override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: com.intellij.psi.PsiMethod) {
        val onClickParameter = method.parameterList.parameters.find { it.name == "onClick" } ?: return
        val onClickArgument = node.getArgumentForParameter(onClickParameter) ?: return

        if (onClickArgument is ULambdaExpression) {
            if (!lambdaCallsDropUnlessResumed(onClickArgument)) {
                context.report(
                    ISSUE,
                    onClickArgument,
                    context.getLocation(onClickArgument),
                    "onClick handler must use dropUnlessResumed"
                )
            }
        }
    }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to this, I tried it out locally and two things to note are:

  • node.getArgumentForParameter(onClickParameter) should be node.getArgumentForParameter(onClickParameter.parameterIndex())
  • In the report call, this uses the argument position, not the node position (which I think makes sense to do). So the expected value in the test needs to be updated if you use the argument position.

).run()
result.expectClean()
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current test suite doesn't cover cases where a composable has multiple lambda arguments, such as Button with its onClick and content lambdas. This is an important scenario to test to prevent false positives, as highlighted in the review of DropUnlessResumedOnClickDetector.

Please add a test case to ensure that the lint check correctly targets only the onClick lambda and ignores other lambdas like content.

    @Test
    fun testButtonWithContentLambda() {
        lint().files(
            buttonStub,
            composableStub,
            kotlin(
                """
                package test
                import androidx.compose.material3.Button
                import androidx.compose.runtime.Composable

                fun dropUnlessResumed(block: () -> Unit) = block()

                @Composable
                fun MyButton() {
                    Button(onClick = { dropUnlessResumed { println("Clicked") } }) {
                        // This content lambda should not be flagged.
                    }
                }
                """.trimIndent()
            )
        ).run().expectClean()
    }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, doesn't even need to be a separate test necessarily, can just add an empty trailing lambda to the existing ones.

@jbw0033
jbw0033 requested review from bsagmoe and jbw0033 January 12, 2026 18:09
@jbw0033

jbw0033 commented Jan 12, 2026

Copy link
Copy Markdown
Collaborator

Please address the initial gemini feedback. @bsagmoe for lint_baseline

Comment thread app/build.gradle.kts
Comment on lines +59 to +61
lintOptions {
baseline(file("lint-baseline.xml"))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you update this to use the non-deprecated format?

lint {
    baseline = file("lint-baseline.xml")
}

@@ -0,0 +1,29 @@
plugins {
id("java-library")
id("org.jetbrains.kotlin.jvm")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add this plugin using the version catalog method?

build.gradle.kts (Project root)

plugins {
    ...
    alias(libs.plugins.kotlin.jvm) apply false
    ...
}
libs.version.toml

...
[plugins]
...
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
...
lint-rules/build.gradle.kts

plugins {
	id("java-library")
    alias(libs.plugins.kotlin.jvm)
}


override fun getApplicableUastTypes() = listOf(UCallExpression::class.java)

override fun getApplicableMethodNames() = listOf("Button", "clickable", "IconButton")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you also add other button composables like ElevatedButton, FilledTonalButton, OutlinedButton, and TextButton?

Comment on lines +34 to +58
override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: com.intellij.psi.PsiMethod) {
val methodName = node.methodName ?: return
if (methodName == "Button" || methodName == "clickable" || methodName == "IconButton") {
// Check all arguments for a lambda (named or positional)
val lambdaArgs = node.valueArguments.filterIsInstance<ULambdaExpression>()
if (lambdaArgs.isEmpty()) {
// Also check for named arguments
node.valueArguments.forEach { arg ->
if (arg is ULambdaExpression) {
lambdaArgs.plus(arg)
}
}
}
for (lambda in lambdaArgs) {
if (!lambdaCallsDropUnlessResumed(lambda)) {
context.report(
ISSUE,
node,
context.getLocation(node),
"onClick handler must use dropUnlessResumed"
)
}
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 to this, I tried it out locally and two things to note are:

  • node.getArgumentForParameter(onClickParameter) should be node.getArgumentForParameter(onClickParameter.parameterIndex())
  • In the report call, this uses the argument position, not the node position (which I think makes sense to do). So the expected value in the test needs to be updated if you use the argument position.

).run()
result.expectClean()
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, doesn't even need to be a separate test necessarily, can just add an empty trailing lambda to the existing ones.

@bsagmoe

bsagmoe commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

In the interim, I've added a rule about this in styleguide.md #272

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants