AndroIdiots Podcast E18: Custom Lints with Hitanshu Dhawan
Code Quality has always been a central theme in the life of a software developer.
Lints are one of the easiest ways to maintain some sanity over the codebase. Out of the box linters that Android Studio provides gives us the power to analyse the source code to flag programming errors, bugs, stylistic errors.
And in this episode, we have Hitanshu Dhawan from Urbanclap who helps us, deep-dive, into the same.
Lints :
Lint, or a linter, is a tool that analyses source code to flag programming errors, bugs, stylistic errors, and suspicious constructs.
Eg :
Custom Lints :
In any organisation, there are internal code standards and Base classes like CustomViews which ought to be used instead of View classes provided by Android. Creating Custom Lints can ensure that all the other devs also follow the same standard and are warned if they miss something.
Use case: In our app as per the design guidelines all the radio button will have a default margin of 8 dp at the start which needs to be consistent throughout the app, and when a new guy tries to use the android version we want to show an error warning him/her about the custom option which is available
Custom Radio Button Class:
class IdiotRadioButton : AppCompatRadioButton {
private var buttonPadding = 0
constructor(context: Context?) : super(context) {
init()
}
constructor(context: Context?, attrs: AttributeSet?) : super(
context,
attrs
) {
init()
}
constructor(
context: Context?,
attrs: AttributeSet?,
defStyleAttr: Int
) : super(context, attrs, defStyleAttr) {
init()
}
private fun init() {
buttonPadding = resources
.getDimensionPixelOffset(R.dimen.list_item_button_right_margin)
// default margin
}
override fun getCompoundPaddingLeft(): Int {
return buttonPadding + super.getCompoundPaddingLeft()
}
}
Step 1: Create a Java/Kotlin Library
In an existing android studio project create a new java/kotlin module lets name it “AndroidiotLint”
Step 2: Add dependencies
Module build.gradle
//AndroidiotLint/build.gradleapply plugin: 'java-library'
ext {
lintVersion = "26.5.3"
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
// Lint
compileOnly "com.android.tools.lint:lint-api:$lintVersion"
compileOnly "com.android.tools.lint:lint-checks:$lintVersion"
// Lint Testing
testImplementation "com.android.tools.lint:lint:$lintVersion"
testImplementation "com.android.tools.lint:lint-tests:$lintVersion"
testImplementation "junit:junit:4.12"
}
app/ build.gradle
apply plugin: 'com.android.application'
dependencies {
//existing dependencies
lintChecks project(path: ':AndroidiotLint')
}
Important: The lint version should correspond to the Android Gradle Plugin version + 23.If the AGP is 3.5.3, then the lint version should be 26.5.3
Step 3: Create Issue Registry
IssueRegistry class contains the central list of issues that the Linter references while checking the codebase
class IssueRegistry : IssueRegistry() {
override val issues: List<Issue>
get() = listOf(
IdiotCodeIssueDetector.ISSUE,
IdiotXmlIssueDetector.ISSUE
)
}
Step 4: Declare Issue Registry
Above step would be enough if we wanted the linter to only work with gradle. But the real power of custom Lints is how they work out of the box with Android Studio. This can be done by declaring Issue registry file path in
ModuleNam/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry
I.e Inside the Lint module only.
Step 5: Create a Custom Detector Class
A detector is able to find a particular problem. Each problem type is uniquely identified as an Issue.
class IdiotCodeIssueDetector : Detector() {
}
Step 6: Create an Issue
An issue is a potential bug in an Android application. An issue is discovered by a Detector, and has an associated Severity.
class IdiotCodeIssueDetector : Detector() {
companion object {
val ISSUE = Issue.create(
id = "IdiotRadioButtonUsageWarning",
briefDescription = "Android's RadioButton should not be used",
explanation = "Don't use Android Radio button, be an idiot and use IdiotRadioButton instead",
category = Category.CORRECTNESS,
priority = 3,
severity = Severity.WARNING,
implementation = Implementation(
IdiotCodeIssueDetector::class.java,
Scope.RESOURCE_FILE_SCOPE
)
)
}
}
Issue Attributes :
id = It’s a unique identifier for the issue and the one we mention in @SupressWarning(“”).
briefDescription = lines to explain the issue and will be shown for the lint, typically describing the problem rather than the fix.
category = A category is a container for related issues. It can be any one of the following type.
/** Issues related to running lint itself */
@JvmField
val LINT = create("Lint", 110)
/** Issues related to correctness */
@JvmField
val CORRECTNESS = create("Correctness", 100)
/** Issues related to security */
@JvmField
val SECURITY = create("Security", 90)
/** Issues related to legal/compliance */
@JvmField
val COMPLIANCE = create("Compliance", 85)
/** Issues related to performance */
@JvmField
val PERFORMANCE = create("Performance", 80)
/** Issues related to usability */
@JvmField
val USABILITY = create("Usability", 70)
priority: It is defined on a scale of 1–10 which we can use to determine which issues to fix first.
severity: Determines if a lint would be treated as a warning or an error while building the app. An error will cause the build to break while a warning would be just printed to console or a file.
/**
* Fatal: Use sparingly because a warning marked as fatal will be
* considered critical and will abort Export APK etc in ADT
*/
FATAL("Fatal"),
/**
* Errors: The issue is known to be a real error that must be addressed.
*/
ERROR("Error"),
/**
* Warning: Probably a problem.
*/
WARNING("Warning"),
/**
* Information only: Might not be a problem, but the check has found
* something interesting to say about the code.
*/
INFORMATIONAL("Information"),
/**
* Ignore: The user doesn't want to see this issue
*/
IGNORE("Ignore");
implementation: Scope of the issue that it is interested in like manifest, resource files, java/Kotlin source files
Note : Issues and detectors are separate classes because a detector can discover
multiple different issues as it’s analysing code, and we want to be able to
different severities for different issues, the ability to suppress one but
not other issues from the same detector, and so on.
Step 7: Add the Scanner to the Detector
FileScanner : Allows to perform analysis on a file, based on subtype of scanner implemented in the detector.
As we want to scan the XML files to find the usage of RadioButton we will use XmlScanner, define the text reference we are looking for in an XML and the error message, and re-sync the Gradle afterward.
class IdiotCodeIssueDetector : Detector(), XmlScanner {
override fun getApplicableElements(): Collection<String> {
return listOf("RadioButton") // will look for Radio Button //Text in all xml
}
override fun visitElement(context: XmlContext, element: Element) {
context.report(
issue = ISSUE,
location = context.getNameLocation(element),
message = "Usage of RadioButton is prohibited" // Error message
)
}
Now if we open up the XML file we can see the error message
Step 8: Add Lint (Quick) Fix
As only showing error is not enough, we would ideally like to replace the Radio button with our IdiotRadioButton.
In comes LintFixes using which we provide structured data to be used by the IDE to create an actual fix.
class IdiotCodeIssueDetector : Detector(), XmlScanner {
override fun getApplicableElements(): Collection<String> {
return listOf("RadioButton")
}
override fun visitElement(context: XmlContext, element: Element) {
val idiotRadioButtonFix = LintFix.create()
.name("Use IdiotRadioButton")
.replace()
.text("RadioButton")
.with("com.androidiots.playground.IdiotRadioButton")
.robot(true)
.independent(true)
.build()
context.report(
issue = ISSUE,
location = context.getNameLocation(element),
message = "Usage of Radio Button is prohibited",
quickfixData = idiotRadioButtonFix
)
}
Testing Lints :
Unfortunately, we cannot debug our lints just like we debug apps. That’s because lints don’t run on mobile device. What we can do is writing test cases for the lint and run those tests in debug mode
class IdiotCodeIssueDetectorTest {
@Test
fun testRadioButton() {
lint()?.files(
xml(
"res/layout/layout.xml",
"""
<merge>
<RadioButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
/>
</merge>
"""
).indented()
)
?.issues(IdiotCodeIssueDetector.ISSUE)
?.run()
?.expectWarningCount(1)
?.verifyFixes()
?.checkFix(
null,
xml(
"res/layout/layout.xml",
"""
<merge>
<com.androidiots.playground.IdiotRadioButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</merge>
"""
).indented()
)
}
}
This test checks that the actual number of errors in this lint check matches exactly the given count.
Lint Internals :
Java and kotlin files are converted to UAST and we write our lint rules for this UAST.
UAST stands for Universal abstract syntax tree, where it’s a tree representation of the source code. Read more about UAST here.
And along with gradlew and XML file, this UAST is consumed by the Lint Analysis process to point out lint errors.
References:
We are inviting Android Developers to contribute back to the community via AndroIDIOTS. If you are interested in recording a podcast or writing tech articles, we would love to have you on board. Here is a small form you can fill so we can get back to you.
Follow us on Twitter for regular updates and feel free to DM the hosts (Anupam & Vivek) for any queries.
If you have any questions/suggestions/queries about the topic feel free to contact the speaker Hitanshu Dhawan for more info.
Cheers!!