Skip to content
HeyDai blog
Go back

Android Lint, Detekt or ktlint? Ask What the Rule Needs

Every Android team has a comment its reviewers type from memory. In a chat app, a classic one is “please use our Logger, not android.util.Log — the team logger scrubs phone numbers and IDs before anything reaches a log file; Log.d does not. The comment lands on a PR, gets fixed, and lands again two weeks later on a PR from someone who joined after the last time. Nobody was careless. The rule simply lives in the one place a new developer can’t read: a reviewer’s head.

Signal-Android has exactly this rule, and they stopped typing it. Their repo has a lintchecks module with a detector called SignalLogDetector: any call to android.util.Log is reported as LogNotSignal“Using ‘android.util.Log’ instead of a Signal Logger” — with a quick fix, and their root lint.xml promotes it to error. The comment became a red squiggle in the editor and a failed CI job.

That is the easy half. The hard half is the question this post is about: when a team rule deserves to be enforced by a machine, which machine? Android Lint, Detekt, ktlint, Konsist, the Kotlin compiler, the Gradle module graph — every one of them could plausibly enforce “use our logger”. They are not interchangeable, and “which linter is best?” is the wrong question.

Tip

Don’t pick a linter; pick what the rule needs. Does it need resolved types? An IDE quick fix? To see resources or the manifest? To ship inside a library to every consumer? To run on Kotlin Multiplatform code? Each answer narrows the tool. And before any linter: if the compiler or the module graph can refuse the code outright, let it.

This is part 2 of the Quality Gates series. Part 1 turned architecture rules for an E2EE module into CI gates. This one steps back to the everyday layer every Android app has — lint — and is written for the whole team: if you’ve never touched a lint.xml, the setup section is for you; if you run a platform team, the tool-selection table and the “being gated” box are the parts to argue with. Part 3 writes a custom rule and rolls it out across teams.

Table of contents

Open Table of contents

A Rule Has More Than One Place to Live

The instinct, once a comment repeats, is “write a lint rule”. Sometimes that’s right. But a rule can be enforced at several layers, and they differ on more than “strength”. Four questions separate them: how can someone get around it, how soon does the developer hear about it, what does a false positive cost, and who maintains it.

MechanismHow it’s bypassedWhen you hearFalse-positive costUpkeep
Type / visibility (internal, sealed types, value classes)not practicallywhile typing (compiler)~nonelow
Kotlin opt-in marker (@RequiresOptIn)an explicit, greppable @OptInwhile typing~nonelow
@Deprecated(level = ERROR, replaceWith = …) on an API you ownun-deprecating itwhile typing, with a built-in IDE fix~nonelow
Module fence (feature module has no dependency on storage)one line in a build.gradle.ktsat compile~nonelow, but the build file needs its own guard
Lint rule (suppressible)@SuppressLint, baseline, lint.xmlin the IDE if the rule is wired in, else in CIdepends on the rulemedium: lint API changes with every AGP
Lint rule (non-suppressible)editing or deleting the ruleIDE + CImust be near zeromedium
Code review / docsanythingwhenever a human noticesn/ahigh, and invisible

Two things in that table surprise people. First, the module fence — the gate part 1 argued you should reach for first — is one implementation(project(":storage")) away from being gone. It’s the cheapest strong gate, not an unbreakable one. Second, lint is not a single strength level: Android Lint lets a rule author set an issue’s suppressNames to an empty collection, which makes it impossible to suppress with any annotation, comment, lint.xml entry or baseline. For a security rule, that is harder to get around than a module fence.

The practical order still holds: if the compiler can refuse it, don’t lint for it. If your team keeps writing analytics.track("checkout_success") with a raw string, the fix is an API that only accepts a sealed AnalyticsEvent, not a rule that flags string literals. If you own LegacyImageLoader, mark it @Deprecated(level = DeprecationLevel.ERROR, replaceWith = ReplaceWith("ImagePipeline.load(url)")) and the IDE offers the migration for free — see the Kotlin @Deprecated docs. If an internal database API should only be called by the storage module, annotate it with an opt-in requirement and every other caller has to write a visible @OptIn.

Lint earns its place for what the compiler can’t express: call-site rules inside a module (a Composable and its ViewModel live in the same feature module, so no module fence separates them), rules about resources, manifests and Gradle files, and rules about third-party APIs you don’t own — like android.util.Log.

Five Questions That Pick the Tool

Once a rule has earned a linter, the choice between Android Lint, Detekt, ktlint and Konsist comes down to what the rule needs. The common shortcut — “formatting → ktlint, code smells → Detekt, Android stuff → Lint” — is roughly right and wrong at the edges: Android Lint runs happily on plain Kotlin/JVM modules, Slack’s lint rules include a long-method complexity check, and Detekt can resolve types. These questions are the real tie-breakers.

1. Is it formatting? Indentation, trailing commas, import order, wrapping. Then it’s not a lint rule at all — it’s a formatter’s job, and the formatter should fix it, not report it. Pick one of ktlint (rule-based, configurable through .editorconfig) or ktfmt (Google-style, almost no knobs; Block moved to it), and run it before code leaves the laptop. Signal’s lefthook.yml runs ./gradlew format on pre-push and refuses the push if that changed anything. Formatting arguments should never reach a human reviewer, and formatting errors should never be the reason CI is red.

2. Does it need resolved types, an IDE quick fix, or Android files? “Calls to android.util.Log” is a type question: a regex on Log.d( can’t tell android.util.Log from your own Log object or an import alias. Android Lint always works on resolved code (UAST backed by the Kotlin Analysis API), shows the result live in Android Studio, can attach a quick fix, and is the only one of these tools that sees AndroidManifest.xml, resources and Gradle files. If the rule needs any of those, it’s Android Lint.

3. Must the rule ship with a library? If your design-system team owns NiaButton and wants every consumer warned off raw Material Button, the rule should travel with the library. Only Android Lint does this: lintPublish packages the checks into the library’s AAR, and they run in every module that depends on it. Now in Android does exactly this — its DesignSystemDetector ships from core/designsystem via lintPublish(projects.lint) — and so does AndroidX: the Compose runtime publishes its own lint module. In a multi-team app, this is how the team that owns an API also owns the rules for using it.

4. Is it Kotlin-only, or does it run on KMP code? Detekt is a Kotlin static analyzer with a large built-in rule set (complexity, naming, coroutines, potential bugs) and runs on common, JVM, JS and native source sets, where Android Lint doesn’t go. Its trade-off is types: most rules are syntax-only and fast; rules that need types (such as ForbiddenMethodCall) only run in the type-resolution tasks, which compile each source set and are much slower. One user reported a type-resolved Detekt 2.0 run taking ~24 minutes, against about a minute before, on an ~80k-line KMP repo — a single report, driven by every Android variant being compiled, but it shows where the cost lives.

5. Is it a statement about the whole codebase’s structure? “Every class implementing Presenter must not take another presenter in its constructor” is a sweep across declarations, not a check at one call site. Konsist writes that as a JUnit test; part 1 covered it for import boundaries. It has no IDE feedback and no quick fix, but a Konsist rule is readable by anyone who can read a test. One caveat for teams adopting it now: its last release, v0.17.3, is from December 2024 — the repo is still active, but nothing has been released since; Element X, for one, builds against a community fork (com.github.jmartinesp:konsist:0.18.0).

And if the rule can only be judged by running the code — “this screen never blocks the main thread”, “decrypt fails closed” — it’s a test, not a linter.

Put together:

The rule needs…Reach forExample
to be impossible, and you own the APItypes, @RequiresOptIn, @Deprecated(ReplaceWith), module fenceraw-string analytics events → sealed event type
formattingktlint or ktfmt, auto-applied before pushtrailing commas, wrapping
resolved types, a quick fix, resources/manifest/GradleAndroid Lintandroid.util.Log → team Logger; PendingIntent without a mutability flag
to travel with a library to its consumersAndroid Lint via lintPublishdesign-system component rules
KMP source sets, or Kotlin-smell built-insDetektcomplexity thresholds, runCatching bans
a sweep over declarations, readable as a testKonsist”presenters don’t depend on presenters”
runtime behaviortestsfails-closed decryption

What Real Apps Actually Run

Two open-source chat apps make the point better than any taxonomy: they solve the same problems with opposite stacks.

ProjectStack (verified 2026-09-22)Notable
Signal-AndroidAndroid Lint + 9 custom detectors in lintchecks/ (8 with tests), root lint.xml, formatter on pre-push; AGP 9.4.0No Detekt. Custom rules are domain-specific: ThreadIdDatabaseDetector and RecipientIdDatabaseDetector catch mixing up two kinds of database ID. PRs run a faster parse-only linter; full lint runs on main (android.yml) — part 3 unpacks why.
Element X AndroidAndroid Lint with one shared tools/lint/lint.xml, Detekt 1.23.8 + a custom rule set, compose-rules for Detekt (pinned to 0.4.28), ktlint 1.8.0, 16 Konsist test files; AGP 9.3.2, Kotlin 2.4.10 (versions)Architecture rules live in Konsist, not lint. Its AGENTS.md tells coding agents to run ./gradlew lint and ./gradlew ktlintFormat — the gates double as the agent’s instructions.
Now in Android (Google sample)Android Lint applied to every module by a convention plugin — including non-Android modules via com.android.lint — with checkDependencies = true and SARIF outputCustom DesignSystemDetector shipped with the design system via lintPublish.
Slackslack-lints (0.11.1) and compose-lints (1.6.0), both open source”Common lint checks that run on our codebase on every PR” (Slack Engineering). During modernization of the iOS codebase, stricter SwiftLint rules applied only to directories holding “modern” modules, and disabled errors were tracked for later cleanup (Slack Engineering).
AndroidXlibrary-specific lint modules shipped inside the librariesCompose, Fragment and Lifecycle warn you about their own misuse because the rules come in the AAR.
FaireDetekt + published custom rules (v0.5.8, Apache-2.0)An opinionated rule set worth reading before you write your own Detekt rule.

What the table says: nobody runs a canonical stack. Signal bet on Android Lint because its rules are about Android APIs and types; Element X put architecture in Konsist and code style in Detekt + ktlint. What every serious setup shares is narrower: one shared config, applied to every module by a build-logic plugin, and a formatter nobody argues with.

Adopt Before You Write

Most rules you’re about to write already exist. Check four places first.

Built-in Android Lint checks you should promote. Lint ships hundreds of checks, most at warning, and warnings don’t break builds. The high-value move on day one is promoting a handful to error. Signal’s lint.xml is a good menu: StopShip (a // STOPSHIP comment fails the build), StringFormatMatches (a translation whose format arguments don’t match crashes at runtime), HardcodedText, and UnspecifiedImmutableFlag for PendingIntents. Consider SecretInSource too, which flags secrets such as API keys committed in source. The full catalog is at googlesamples.github.io/android-custom-lint-rules/checks.

Detekt’s configurable rules. ForbiddenImport and ForbiddenMethodCall cover a lot of “don’t use X” rules with a few lines of YAML — its default config even ships an example banning kotlin.io.print with the reason “Use a logger instead.” GlobalCoroutineUsage exists but is off by default. The catch: ForbiddenMethodCall needs type resolution, so it silently does nothing in the plain detekt task. It’s a legitimate way to get “use our logger” without writing code — without a quick fix, and without IDE feedback.

Compose rule sets. Compose mistakes (forwarding ViewModels down the tree, unstable parameters, missing modifier parameter) are common enough to have two maintained rule sets from the same Twitter origin: Slack’s compose-lints for Android Lint, and compose-rules for ktlint/Detekt. If you were about to write “a Composable must not take a repository”, look at ComposeViewModelForwarding and ComposeViewModelInjection first. Pick the one that matches the engine you already run, not both — they overlap by design.

Policy and security. Google’s Play Policy Insights ships as lint checks for Play policy issues, and android-security-lints bundles security checks with click-to-fix — useful, though the repo’s last commit was November 2025, so check it against your AGP before depending on it.

Only after those four does writing your own rule make sense — and part 3 argues that writing it is the cheap part.

Day-One Setup

If your project runs lint only when someone remembers, here is a minimal setup that holds up in a multi-module app. It’s modeled on Element X’s and Now in Android’s convention plugins; adapt names to your build.

Put one shared config where every module can see it, and apply it from build logic rather than copying it into each module:

// build-logic: applied to every Android application/library module
fun Lint.teamDefaults(rootDir: File) {
    lintConfig = File(rootDir, "config/lint/lint.xml")  // one severity file for all modules
    abortOnError = true            // errors fail the build
    warningsAsErrors = false       // promote deliberately, in lint.xml, not wholesale
    checkDependencies = false      // per-module tasks stay small and cacheable
    ignoreTestSources = true
    checkGeneratedSources = false
    sarifReport = true             // machine-readable output for CI annotations
}

checkDependencies is a real trade-off, not a default to copy blindly. It’s off by default; turning it on in the app module (Now in Android does) lets lint analyze libraries together and catch cross-module issues such as unused resources, at the cost of one big task instead of many small cached ones. Element X keeps it off. Start off; switch it on if you rely on whole-app checks.

Then the severities, in the shared lint.xml:

<lint>
    <issue id="StopShip" severity="fatal" />
    <issue id="StringFormatMatches" severity="error" />
    <issue id="UnspecifiedImmutableFlag" severity="error" />
    <issue id="HardcodedText" severity="error" />
    <!-- Every ignore carries a reason, or it doesn't get merged. -->
    <issue id="MissingTranslation" severity="ignore" />
</lint>

Then the baseline — the step that makes lint adoptable in an existing codebase. A baseline records every current violation so they stop failing the build, while anything new still does:

android {
    lint {
        baseline = file("lint-baseline.xml")
    }
}
# First run writes the file; the flag stops that run from failing.
./gradlew :app:lintDebug -Dlint.baselines.continue=true
# Later, to regenerate deliberately:
./gradlew :app:updateLintBaseline

Three things beginners trip on, all from the official lint guide and its baseline notes:

Finally, suppression. Sometimes a rule is wrong for one line, and suppressing it is correct. Do it narrowly and say why:

@SuppressLint("HardcodedText") // Debug-only screen, never shipped in release builds

Suppress one issue ID on the smallest scope — the line or function, never the file — with a comment a reviewer can argue with. tools:ignore does the same in XML, and @Suppress("RuleId") for Detekt.

Note

If you’re the one being gated. A lint gate is a contract, and it binds the people who wrote it too. The baseline means legacy violations aren’t yours — if touching an old file fails your PR on code you didn’t write, that’s a rollout bug; report it rather than suppressing it. A suppression is a signed statement: legal, sometimes right, and expect it to be counted. A false positive is a bug in the rule, and the rule’s owner should fix it with a test case. A good message tells you what’s wrong, why the team cares, and what to write instead — “Using android.util.Log instead of a Signal Logger”, with a one-click fix, is the bar. And if a rule costs you more than a coffee’s worth of CI time per PR, that’s a configuration problem the platform team owes you, not a tax you owe them. Part 3 is written for the people on the other side of this box.

Versions as of September 2026

Tooling moves fast enough that version numbers in a lint article rot within a year. As of this writing:

What This Setup Doesn’t Give You

A shared config, a baseline and a formatter get you surprisingly far: most “please don’t” comments are already a built-in check at the wrong severity. What they don’t give you is your rules — the Signal-style “use our logger”, the domain-specific “don’t pass a thread ID where a recipient ID goes”. Those need a custom rule, and a custom rule in a multi-team app is less a piece of code than a small product: it needs an owner, tests that survive lint’s own code rewriting, a message that behaves like an API, a rollout plan, a speed budget and a way to be switched off.

That’s part 3: one rule, built end to end, and what happens when ten teams have to live with it — plus what the iOS side of the same app can and can’t copy.

Sources

Tools and docs

Rule sets

Real projects


Share this post:

Part of the Quality Gates series


Previous Post
CI Says Green — So Why Did the Module Boundary Just Break?
Next Post
Your Custom Lint Rule Works. Will Ten Teams Accept It?