summaryrefslogtreecommitdiffstats
path: root/mobile/android/android-components/components/tooling
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-05-15 03:35:49 +0000
commitd8bbc7858622b6d9c278469aab701ca0b609cddf (patch)
treeeff41dc61d9f714852212739e6b3738b82a2af87 /mobile/android/android-components/components/tooling
parentReleasing progress-linux version 125.0.3-1~progress7.99u1. (diff)
downloadfirefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.tar.xz
firefox-d8bbc7858622b6d9c278469aab701ca0b609cddf.zip
Merging upstream version 126.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'mobile/android/android-components/components/tooling')
-rw-r--r--mobile/android/android-components/components/tooling/detekt/README.md29
-rw-r--r--mobile/android/android-components/components/tooling/detekt/build.gradle28
-rw-r--r--mobile/android/android-components/components/tooling/detekt/src/main/kotlin/mozilla/components/tooling/detekt/MozillaRuleSetProvider.kt24
-rw-r--r--mobile/android/android-components/components/tooling/detekt/src/main/kotlin/mozilla/components/tooling/detekt/ProjectLicenseRule.kt52
-rw-r--r--mobile/android/android-components/components/tooling/detekt/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider1
-rw-r--r--mobile/android/android-components/components/tooling/detekt/src/test/kotlin/ProjectLicenseRuleTest.kt63
-rw-r--r--mobile/android/android-components/components/tooling/fetch-tests/README.md11
-rw-r--r--mobile/android/android-components/components/tooling/fetch-tests/build.gradle38
-rw-r--r--mobile/android/android-components/components/tooling/fetch-tests/lint.xml10
-rw-r--r--mobile/android/android-components/components/tooling/fetch-tests/src/main/AndroidManifest.xml4
-rw-r--r--mobile/android/android-components/components/tooling/fetch-tests/src/main/java/mozilla/components/tooling/fetch/tests/FetchTestCases.kt546
-rw-r--r--mobile/android/android-components/components/tooling/lint/README.md11
-rw-r--r--mobile/android/android-components/components/tooling/lint/build.gradle42
-rw-r--r--mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/AndroidSrcXmlDetector.kt82
-rw-r--r--mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/ConceptFetchDetector.kt249
-rw-r--r--mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/FactCollectDetector.kt103
-rw-r--r--mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/ImageViewAndroidTintXmlDetector.kt81
-rw-r--r--mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/LintIssueRegistry.kt25
-rw-r--r--mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/LintLogChecks.kt61
-rw-r--r--mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/NotificationManagerChecks.kt84
-rw-r--r--mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/TextViewAndroidSrcXmlDetector.kt97
-rw-r--r--mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/AndroidSrcXmlDetectorTest.kt68
-rw-r--r--mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/ConceptFetchDetectorTest.kt277
-rw-r--r--mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/FactCollectDetectorTest.kt220
-rw-r--r--mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/ImageViewAndroidTintXmlDetectorTest.kt69
-rw-r--r--mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/LintLogChecksTest.kt56
-rw-r--r--mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/TextViewAndroidSrcXmlDetectorTest.kt68
-rw-r--r--mobile/android/android-components/components/tooling/lint/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker2
28 files changed, 2401 insertions, 0 deletions
diff --git a/mobile/android/android-components/components/tooling/detekt/README.md b/mobile/android/android-components/components/tooling/detekt/README.md
new file mode 100644
index 0000000000..64c68f4679
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/detekt/README.md
@@ -0,0 +1,29 @@
+# [Android Components](../../../README.md) > Tooling > Detekt
+
+Custom Detekt rules for the components repository.
+
+These additional detekt rules are run as part of the _Android Components_ build pipeline.
+Published for internal usage only.
+
+## Usage
+
+Add into `build.gradle`:
+```
+dependencies {
+ // ...
+
+ detektPlugins "org.mozilla.components:tooling-detekt:$android_components_version"
+}
+```
+
+## Rules
+
+Section `mozilla-rules`:
+
+ - `AbsentOrWrongFileLicense` - check for correct license header in Kotlin files.
+
+## License
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/
diff --git a/mobile/android/android-components/components/tooling/detekt/build.gradle b/mobile/android/android-components/components/tooling/detekt/build.gradle
new file mode 100644
index 0000000000..ad0c3eb038
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/detekt/build.gradle
@@ -0,0 +1,28 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+apply plugin: 'java-library'
+apply plugin: 'kotlin'
+
+dependencies {
+ implementation ComponentsDependencies.tools_detekt_api
+
+ testImplementation ComponentsDependencies.testing_junit
+ testImplementation ComponentsDependencies.tools_detekt_api
+ testImplementation ComponentsDependencies.tools_detekt_test
+}
+
+tasks.register("lintRelease") {
+ doLast {
+ // Do nothing. We execute the same set of tasks for all our modules in parallel on taskcluster.
+ // This project doesn't have a lint task.
+ }
+}
+
+tasks.register("assembleAndroidTest") {
+ doLast {
+ // Do nothing. Like the `lint` task above this is just a dummy task so that this module
+ // behaves like our others and we do not need to special case it in automation.
+ }
+}
diff --git a/mobile/android/android-components/components/tooling/detekt/src/main/kotlin/mozilla/components/tooling/detekt/MozillaRuleSetProvider.kt b/mobile/android/android-components/components/tooling/detekt/src/main/kotlin/mozilla/components/tooling/detekt/MozillaRuleSetProvider.kt
new file mode 100644
index 0000000000..582726b30c
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/detekt/src/main/kotlin/mozilla/components/tooling/detekt/MozillaRuleSetProvider.kt
@@ -0,0 +1,24 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.tooling.detekt
+
+import io.gitlab.arturbosch.detekt.api.Config
+import io.gitlab.arturbosch.detekt.api.RuleSet
+import io.gitlab.arturbosch.detekt.api.RuleSetProvider
+
+/**
+ * Set of custom mozilla rules to be loaded in detekt utility.
+ */
+class MozillaRuleSetProvider : RuleSetProvider {
+
+ override val ruleSetId = "mozilla-rules"
+
+ override fun instance(config: Config) = RuleSet(
+ ruleSetId,
+ listOf(
+ ProjectLicenseRule(config),
+ ),
+ )
+}
diff --git a/mobile/android/android-components/components/tooling/detekt/src/main/kotlin/mozilla/components/tooling/detekt/ProjectLicenseRule.kt b/mobile/android/android-components/components/tooling/detekt/src/main/kotlin/mozilla/components/tooling/detekt/ProjectLicenseRule.kt
new file mode 100644
index 0000000000..2e8f03a587
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/detekt/src/main/kotlin/mozilla/components/tooling/detekt/ProjectLicenseRule.kt
@@ -0,0 +1,52 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.tooling.detekt
+
+import io.gitlab.arturbosch.detekt.api.CodeSmell
+import io.gitlab.arturbosch.detekt.api.Config
+import io.gitlab.arturbosch.detekt.api.Debt
+import io.gitlab.arturbosch.detekt.api.Entity
+import io.gitlab.arturbosch.detekt.api.Issue
+import io.gitlab.arturbosch.detekt.api.Rule
+import io.gitlab.arturbosch.detekt.api.Severity
+import org.jetbrains.kotlin.psi.KtFile
+
+/**
+ * Check header license in Kotlin files.
+ */
+class ProjectLicenseRule(config: Config = Config.empty) : Rule(config) {
+
+ private val expectedLicense = """
+ |/* This Source Code Form is subject to the terms of the Mozilla Public
+ | * License, v. 2.0. If a copy of the MPL was not distributed with this
+ | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+ """.trimMargin()
+
+ override val issue = Issue(
+ id = "AbsentOrWrongFileLicense",
+ severity = Severity.Style,
+ description = "License text is absent or incorrect in the file.",
+ debt = Debt.FIVE_MINS,
+ )
+
+ override fun visitKtFile(file: KtFile) {
+ if (!file.hasValidLicense) {
+ reportCodeSmell(file)
+ }
+ }
+
+ private val KtFile.hasValidLicense: Boolean
+ get() = text.startsWith(expectedLicense)
+
+ private fun reportCodeSmell(file: KtFile) {
+ report(
+ CodeSmell(
+ issue,
+ Entity.from(file),
+ "Expected license not found or incorrect in the file: ${file.name}.",
+ ),
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/tooling/detekt/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider b/mobile/android/android-components/components/tooling/detekt/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider
new file mode 100644
index 0000000000..30cc744210
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/detekt/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider
@@ -0,0 +1 @@
+mozilla.components.tooling.detekt.MozillaRuleSetProvider
diff --git a/mobile/android/android-components/components/tooling/detekt/src/test/kotlin/ProjectLicenseRuleTest.kt b/mobile/android/android-components/components/tooling/detekt/src/test/kotlin/ProjectLicenseRuleTest.kt
new file mode 100644
index 0000000000..ff67988482
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/detekt/src/test/kotlin/ProjectLicenseRuleTest.kt
@@ -0,0 +1,63 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.tooling.detekt
+
+import io.gitlab.arturbosch.detekt.test.lint
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ProjectLicenseRuleTest {
+
+ @Test
+ fun testAbsentLicense() {
+ val findings = ProjectLicenseRule().lint(fileContent)
+
+ assertEquals(1, findings.size)
+ assertEquals(
+ "Expected license not found or incorrect in the file: Test.kt.",
+ findings.first().message,
+ )
+ }
+
+ @Test
+ fun testInvalidLicense() {
+ val file = """
+ |/* This Source Code Form is subject to the terms of the Mozilla Public License.
+ | * You can obtain one at http://mozilla.org/MPL/2.0/. */
+ |
+ $fileContent
+ """.trimMargin()
+ val findings = ProjectLicenseRule().lint(file)
+
+ assertEquals(1, findings.size)
+ assertEquals(
+ "Expected license not found or incorrect in the file: Test.kt.",
+ findings.first().message,
+ )
+ }
+
+ @Test
+ fun testValidLicense() {
+ val file = """
+ |/* This Source Code Form is subject to the terms of the Mozilla Public
+ | * License, v. 2.0. If a copy of the MPL was not distributed with this
+ | * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+ |
+ $fileContent
+ """.trimMargin()
+ val findings = ProjectLicenseRule().lint(file)
+
+ assertEquals(0, findings.size)
+ }
+}
+
+private val fileContent = """
+ |package my.package
+ |
+ |/** My awesome class */
+ |class MyClass () {
+ | fun foo () {}
+ |}
+""".trimMargin()
diff --git a/mobile/android/android-components/components/tooling/fetch-tests/README.md b/mobile/android/android-components/components/tooling/fetch-tests/README.md
new file mode 100644
index 0000000000..367cc7b79b
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/fetch-tests/README.md
@@ -0,0 +1,11 @@
+# [Android Components](../../../README.md) > Tooling > Fetch tests
+
+A generic test suite for components that implement [concept-fetch](../../concept/fetch/README.md).
+
+All implementations of [concept-fetch](../../concept/fetch/README.md) are expected to pass this test suite. A shared test suite guarantees that the HTTP clients are "pluggable" and implementations behave similar enough so that every component can work with every HTTP client.
+
+## License
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/
diff --git a/mobile/android/android-components/components/tooling/fetch-tests/build.gradle b/mobile/android/android-components/components/tooling/fetch-tests/build.gradle
new file mode 100644
index 0000000000..b5c24f0f2d
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/fetch-tests/build.gradle
@@ -0,0 +1,38 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+
+android {
+ defaultConfig {
+ minSdkVersion config.minSdkVersion
+ compileSdk config.compileSdkVersion
+ targetSdkVersion config.targetSdkVersion
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android.txt')
+ }
+ }
+
+ lint {
+ lintConfig file("lint.xml")
+ }
+
+ namespace 'mozilla.components.tooling.fetch.tests'
+
+}
+
+dependencies {
+ implementation project(':concept-fetch')
+
+ implementation ComponentsDependencies.testing_mockwebserver
+ implementation ComponentsDependencies.testing_junit
+ implementation ComponentsDependencies.kotlin_coroutines
+}
diff --git a/mobile/android/android-components/components/tooling/fetch-tests/lint.xml b/mobile/android/android-components/components/tooling/fetch-tests/lint.xml
new file mode 100644
index 0000000000..2e10996a44
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/fetch-tests/lint.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this
+ - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<lint>
+ <issue id="InvalidPackage">
+ <ignore path="**/bcprov-*on-*.jar"/>
+ <ignore path="**/junit-*.jar"/>
+ </issue>
+</lint>
diff --git a/mobile/android/android-components/components/tooling/fetch-tests/src/main/AndroidManifest.xml b/mobile/android/android-components/components/tooling/fetch-tests/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..41078a7325
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/fetch-tests/src/main/AndroidManifest.xml
@@ -0,0 +1,4 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+ - License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ - You can obtain one at http://mozilla.org/MPL/2.0/. -->
+<manifest />
diff --git a/mobile/android/android-components/components/tooling/fetch-tests/src/main/java/mozilla/components/tooling/fetch/tests/FetchTestCases.kt b/mobile/android/android-components/components/tooling/fetch-tests/src/main/java/mozilla/components/tooling/fetch/tests/FetchTestCases.kt
new file mode 100644
index 0000000000..3752b12c23
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/fetch-tests/src/main/java/mozilla/components/tooling/fetch/tests/FetchTestCases.kt
@@ -0,0 +1,546 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.tooling.fetch.tests
+
+import android.annotation.SuppressLint
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.runBlocking
+import mozilla.components.concept.fetch.Client
+import mozilla.components.concept.fetch.MutableHeaders
+import mozilla.components.concept.fetch.Request
+import mozilla.components.concept.fetch.isSuccess
+import okhttp3.Headers
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import okhttp3.mockwebserver.SocketPolicy
+import okio.Buffer
+import okio.GzipSink
+import okio.buffer
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Test
+import java.io.File
+import java.io.IOException
+import java.lang.Exception
+import java.net.SocketTimeoutException
+import java.util.UUID
+import java.util.concurrent.TimeUnit
+
+/**
+ * Generic test cases for concept-fetch implementations.
+ *
+ * We expect any implementation of concept-fetch to pass all test cases here.
+ */
+@Suppress("IllegalIdentifier", "FunctionName", "unused")
+abstract class FetchTestCases {
+ /**
+ * Creates a new [Client] for running a specific test case with it.
+ */
+ abstract fun createNewClient(): Client
+
+ /**
+ * Creates a new [MockWebServer] to accept test requests.
+ */
+ open fun createWebServer(): MockWebServer = MockWebServer()
+
+ @Test
+ open fun get200WithStringBody() = withServerResponding(
+ MockResponse()
+ .setBody("Hello World"),
+ ) { client ->
+ val response = client.fetch(Request(rootUrl()))
+
+ assertEquals(200, response.status)
+ assertEquals("Hello World", response.body.string())
+ }
+
+ @Test
+ open fun get404WithBody() {
+ withServerResponding(
+ MockResponse()
+ .setResponseCode(404)
+ .setBody("Error"),
+ ) { client ->
+ val response = client.fetch(Request(rootUrl()))
+
+ assertEquals(404, response.status)
+ assertEquals("Error", response.body.string())
+ }
+ }
+
+ @Test
+ open fun get200WithHeaders() {
+ withServerResponding(
+ MockResponse(),
+ ) { client ->
+ val response = client.fetch(
+ Request(
+ url = rootUrl(),
+ headers = MutableHeaders()
+ .set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
+ .set("Accept-Encoding", "gzip, deflate")
+ .set("Accept-Language", "en-US,en;q=0.5")
+ .set("Connection", "keep-alive")
+ .set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:65.0) Gecko/20100101 Firefox/65.0"),
+ ),
+ ).also { it.close() }
+ assertEquals(200, response.status)
+
+ val request = takeRequest()
+
+ assertTrue(request.headers.size >= 5)
+
+ val names = request.headers.names()
+ assertTrue(names.contains("Accept"))
+ assertTrue(names.contains("Accept-Encoding"))
+ assertTrue(names.contains("Accept-Language"))
+ assertTrue(names.contains("Connection"))
+ assertTrue(names.contains("User-Agent"))
+
+ assertEquals(
+ "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+ request.headers.get("Accept"),
+ )
+
+ assertEquals(
+ "gzip, deflate",
+ request.headers.get("Accept-Encoding"),
+ )
+
+ assertEquals(
+ "en-US,en;q=0.5",
+ request.headers.get("Accept-Language"),
+ )
+
+ assertEquals(
+ "keep-alive",
+ request.headers.get("Connection"),
+ )
+
+ assertEquals(
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:65.0) Gecko/20100101 Firefox/65.0",
+ request.headers.get("User-Agent"),
+ )
+ }
+ }
+
+ @Test
+ open fun post200WithBody() {
+ withServerResponding(
+ MockResponse(),
+ ) { client ->
+ val response = client.fetch(
+ Request(
+ url = rootUrl(),
+ method = Request.Method.POST,
+ body = Request.Body.fromString("Hello World"),
+ ),
+ ).also { it.close() }
+ assertEquals(200, response.status)
+
+ val request = takeRequest()
+
+ assertEquals("POST", request.method)
+ assertEquals("Hello World", request.body.readUtf8())
+ }
+ }
+
+ @Test
+ open fun get200WithGzippedBody() {
+ withServerResponding(
+ MockResponse()
+ .setBody(gzip("This is compressed"))
+ .addHeader("Content-Encoding: gzip"),
+ ) { client ->
+ val response = client.fetch(Request(rootUrl()))
+ assertEquals(200, response.status)
+
+ assertEquals("This is compressed", response.body.string())
+ }
+ }
+
+ @Test
+ open fun get302FollowRedirects() {
+ withServerResponding(
+ MockResponse().setResponseCode(302)
+ .addHeader("Location", "/x"),
+ MockResponse().setBody("Hello World!"),
+ ) { client ->
+ val response = client.fetch(
+ Request(
+ url = rootUrl(),
+ redirect = Request.Redirect.FOLLOW,
+ ),
+ )
+ assertEquals(200, response.status)
+
+ assertEquals("Hello World!", response.body.string())
+ }
+ }
+
+ @Test
+ open fun get302FollowRedirectsDisabled() {
+ withServerResponding(
+ MockResponse().setResponseCode(302)
+ .addHeader("Location", "/x"),
+ MockResponse().setBody("Hello World!"),
+ ) { client ->
+ val response = client.fetch(
+ Request(
+ url = rootUrl(),
+ redirect = Request.Redirect.MANUAL,
+ cookiePolicy = Request.CookiePolicy.OMIT,
+ ),
+ ).also { it.close() }
+ assertEquals(302, response.status)
+ }
+ }
+
+ @SuppressLint("FetchResponseClose") // intentional failure
+ @Test
+ open fun get200WithReadTimeout() {
+ withServerResponding(
+ MockResponse()
+ .setBody("Yep!")
+ .setSocketPolicy(SocketPolicy.NO_RESPONSE),
+ ) { client ->
+ try {
+ val response = client.fetch(
+ Request(url = rootUrl(), readTimeout = Pair(1, TimeUnit.SECONDS)),
+ )
+
+ // We're doing this the old-fashioned way instead of using the
+ // expected= attribute, because the test is launched on a different
+ // thread (using a different coroutine context) than this block.
+ fail("Expected read timeout (SocketTimeoutException), but got response: ${response.status}")
+ } catch (e: SocketTimeoutException) {
+ // expected
+ } catch (e: Exception) {
+ fail("Expected SocketTimeoutException")
+ }
+ }
+ }
+
+ @Test
+ open fun put201FileUpload() {
+ val file = File.createTempFile(UUID.randomUUID().toString(), UUID.randomUUID().toString())
+ file.writer().use { it.write("I am an image file!") }
+
+ withServerResponding(
+ MockResponse()
+ .setResponseCode(201)
+ .setHeader("Location", "/your-image.png")
+ .setBody("Thank you!"),
+ ) { client ->
+ val response = client.fetch(
+ Request(
+ url = rootUrl(),
+ method = Request.Method.PUT,
+ headers = MutableHeaders(
+ "Content-Type" to "image/png",
+ ),
+ body = Request.Body.fromFile(file),
+ ),
+ )
+
+ // Verify response
+
+ assertTrue(response.isSuccess)
+ assertEquals(201, response.status)
+
+ assertEquals("Thank you!", response.body.string())
+
+ assertTrue(response.headers.contains("Location"))
+
+ assertEquals("/your-image.png", response.headers.get("Location"))
+
+ // Verify request received by server
+
+ val request = takeRequest()
+
+ assertEquals("PUT", request.method)
+
+ assertEquals("image/png", request.getHeader("Content-Type"))
+
+ assertEquals("I am an image file!", request.body.readUtf8())
+ }
+ }
+
+ @Test
+ open fun get200WithDuplicatedCacheControlResponseHeaders() {
+ withServerResponding(
+ MockResponse()
+ .addHeader("Cache-Control", "no-cache")
+ .addHeader("Cache-Control", "no-store")
+ .setBody("I am the content"),
+ ) { client ->
+ val response = client.fetch(Request(rootUrl()))
+
+ response.headers.forEach { (name, value) -> println("$name = $value") }
+
+ assertEquals(200, response.status)
+ assertEquals(3, response.headers.size)
+
+ assertEquals("Cache-Control", response.headers[0].name)
+ assertEquals("Cache-Control", response.headers[1].name)
+ assertEquals("Content-Length", response.headers[2].name)
+
+ assertEquals("no-cache", response.headers[0].value)
+ assertEquals("no-store", response.headers[1].value)
+ assertEquals("16", response.headers[2].value)
+
+ assertEquals("no-store", response.headers.get("Cache-Control"))
+ assertEquals("16", response.headers.get("Content-Length"))
+
+ response.close()
+ }
+ }
+
+ @Test
+ open fun get200WithDuplicatedCacheControlRequestHeaders() {
+ withServerResponding(
+ MockResponse(),
+ ) { client ->
+ val response = client.fetch(
+ Request(
+ url = rootUrl(),
+ headers = MutableHeaders(
+ "Cache-Control" to "no-cache",
+ "Cache-Control" to "no-store",
+ ),
+ ),
+ ).also { it.close() }
+
+ assertEquals(200, response.status)
+
+ val request = takeRequest()
+
+ var cacheHeaders = request.headers.values("Cache-Control")
+
+ assertFalse(cacheHeaders.isEmpty())
+
+ // If multiple headers with the same name are present we accept
+ // implementations that *request* a comma-separated list of values
+ // as well as those adding additional (duplicated) headers.
+ // Technically, comma-separate values are correct:
+ // https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2
+ // Web servers will understand both representations and for responses
+ // we already unify headers across implementations. So, this should
+ // be transparent to users.
+ if (cacheHeaders[0].contains(",")) {
+ cacheHeaders = cacheHeaders[0].split(",")
+ }
+
+ assertEquals(2, cacheHeaders.size)
+ assertEquals("no-cache", cacheHeaders[0].trim())
+ assertEquals("no-store", cacheHeaders[1].trim())
+ }
+ }
+
+ @Test
+ open fun get200OverridingDefaultHeaders() {
+ withServerResponding(
+ MockResponse(),
+ ) { client ->
+ val response = client.fetch(
+ Request(
+ url = rootUrl(),
+ headers = MutableHeaders(
+ "Accept" to "text/html",
+ "Accept-Encoding" to "deflate",
+ "User-Agent" to "SuperBrowser/1.0",
+ "Connection" to "close",
+ ),
+ ),
+ ).also { it.close() }
+
+ assertEquals(200, response.status)
+
+ val request = takeRequest()
+
+ for (i in 0 until request.headers.size) {
+ println(" Header: " + request.headers.name(i) + " = " + request.headers.value(i))
+ }
+
+ val acceptHeaders = request.headers.values("Accept")
+ assertEquals(1, acceptHeaders.size)
+ assertEquals("text/html", acceptHeaders[0])
+ }
+ }
+
+ @Test
+ open fun get200WithCookiePolicy() = withServerResponding(
+ MockResponse().addHeader("Set-Cookie", "name=value"),
+ MockResponse(),
+ MockResponse(),
+ ) { client ->
+
+ val responseWithCookies = client.fetch(Request(rootUrl())).also { it.close() }
+ assertEquals(200, responseWithCookies.status)
+ assertEquals("name=value", responseWithCookies.headers["Set-Cookie"])
+ assertNull(takeRequest().getHeader("Cookie"))
+
+ // Send additional request, using CookiePolicy.INCLUDE, which should
+ // include the cookie set by the previous response.
+ val response1 = client.fetch(
+ Request(url = rootUrl(), cookiePolicy = Request.CookiePolicy.INCLUDE),
+ ).also { it.close() }
+
+ assertEquals(200, response1.status)
+ assertEquals("name=value", takeRequest().getHeader("Cookie"))
+
+ // Send additional request, using CookiePolicy.OMIT, which should
+ // NOT include the cookie.
+ val response2 = client.fetch(
+ Request(url = rootUrl(), cookiePolicy = Request.CookiePolicy.OMIT),
+ ).also { it.close() }
+
+ assertEquals(200, response2.status)
+ assertNull(takeRequest().getHeader("Cookie"))
+ }
+
+ @Test
+ open fun get200WithContentTypeCharset() = withServerResponding(
+ MockResponse()
+ .addHeader("Content-Type", "text/html; charset=ISO-8859-1")
+ .setBody(Buffer().writeString("ÄäÖöÜü", Charsets.ISO_8859_1)),
+ MockResponse()
+ .addHeader("Content-Type", "text/html; charset=invalid")
+ .setBody("Hello World"),
+ ) { client ->
+
+ val response = client.fetch(Request(rootUrl()))
+
+ assertEquals(200, response.status)
+ assertEquals("ÄäÖöÜü", response.body.string())
+
+ val response2 = client.fetch(Request(rootUrl()))
+
+ assertEquals(200, response2.status)
+ assertEquals("Hello World", response2.body.string())
+ }
+
+ @Test
+ open fun get200WithCacheControl() = withServerResponding(
+ MockResponse()
+ .addHeader("Cache-Control", "max-age=600")
+ .setBody("Cache this!"),
+ MockResponse().setBody("Could've cached this!"),
+ ) { client ->
+
+ val responseWithCacheControl = client.fetch(Request(rootUrl()))
+ assertEquals(200, responseWithCacheControl.status)
+ assertEquals("Cache this!", responseWithCacheControl.body.string())
+ assertNotNull(responseWithCacheControl.headers["Cache-Control"])
+
+ // Request should hit cache.
+ val response1 = client.fetch(Request(rootUrl()))
+ assertEquals(200, response1.status)
+ assertEquals("Cache this!", response1.body.string())
+
+ // Request should hit network.
+ val response2 = client.fetch(Request(rootUrl(), useCaches = false))
+ assertEquals(200, response2.status)
+ assertEquals("Could've cached this!", response2.body.string())
+ }
+
+ @SuppressLint("FetchResponseClose") // intentional failure
+ @Test
+ open fun getThrowsIOExceptionWhenHostNotReachable() {
+ try {
+ val client = createNewClient()
+ val response = client.fetch(Request(url = "http://invalid.offline"))
+
+ // We're doing this the old-fashioned way instead of using the
+ // expected= attribute, because the test is launched on a different
+ // thread (using a different coroutine context) than this block.
+ fail("Expected IOException, but got response: ${response.status}")
+ } catch (e: IOException) {
+ // expected
+ } catch (e: Exception) {
+ fail("Expected IOException")
+ }
+ }
+
+ @Test
+ open fun getDataUri() {
+ val client = createNewClient()
+ val response = client.fetch(Request(url = "data:text/plain;charset=utf-8;base64,SGVsbG8sIFdvcmxkIQ=="))
+ assertEquals("13", response.headers["Content-Length"])
+ assertEquals("text/plain;charset=utf-8", response.headers["Content-Type"])
+ assertEquals("Hello, World!", response.body.string())
+
+ val responseNoCharset = client.fetch(Request(url = "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ=="))
+ assertEquals("13", responseNoCharset.headers["Content-Length"])
+ assertEquals("text/plain", responseNoCharset.headers["Content-Type"])
+ assertEquals("Hello, World!", responseNoCharset.body.string())
+
+ val responseNoContentType = client.fetch(Request(url = "data:;base64,SGVsbG8sIFdvcmxkIQ=="))
+ assertEquals("13", responseNoContentType.headers["Content-Length"])
+ assertNull(responseNoContentType.headers["Content-Type"])
+ assertEquals("Hello, World!", responseNoContentType.body.string())
+
+ val responseNoBase64 = client.fetch(Request(url = "data:text/plain;charset=utf-8,Hello%2C%20World%21"))
+ assertEquals("13", responseNoBase64.headers["Content-Length"])
+ assertEquals("text/plain;charset=utf-8", responseNoBase64.headers["Content-Type"])
+ assertEquals("Hello, World!", responseNoBase64.body.string())
+ }
+
+ private inline fun withServerResponding(
+ vararg responses: MockResponse,
+ crossinline block: MockWebServer.(Client) -> Unit,
+ ) {
+ val server = createWebServer()
+
+ responses.forEach {
+ server.enqueue(it)
+ }
+
+ try {
+ val client = createNewClient()
+ // Subclasses (implementation specific tests) might be instrumented
+ // and run on a device so we need to avoid network requests on the
+ // main thread.
+ runBlocking(Dispatchers.IO) {
+ server.start()
+ server.block(client)
+ }
+ } finally {
+ try { server.shutdown() } catch (e: IOException) {}
+ }
+ }
+
+ private fun MockWebServer.rootUrl() = url("/").toString()
+}
+
+@Throws(IOException::class)
+private fun gzip(data: String): Buffer {
+ val result = Buffer()
+ val sink = GzipSink(result).buffer()
+ sink.writeUtf8(data)
+ sink.close()
+ return result
+}
+
+private fun Headers.filtered(): Headers {
+ val builder = newBuilder()
+ ignoredHeaders.forEach { header ->
+ builder.removeAll(header)
+ }
+ return builder.build()
+}
+
+// The following headers are getting ignored when verifying headers sent by a Client implementation
+private val ignoredHeaders = listOf(
+ // GeckoView"s GeckoWebExecutor sends additional "Sec-Fetch-*" headers. Instead of
+ // adding those headers to all our implementations, we are just ignoring them in tests.
+ "Sec-Fetch-Dest",
+ "Sec-Fetch-Mode",
+ "Sec-Fetch-Site",
+)
diff --git a/mobile/android/android-components/components/tooling/lint/README.md b/mobile/android/android-components/components/tooling/lint/README.md
new file mode 100644
index 0000000000..55a7f7fe91
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/lint/README.md
@@ -0,0 +1,11 @@
+# [Android Components](../../../README.md) > Tooling > Lint
+
+Custom Lint rules for the components repository.
+
+These additional lint rules are run as part of the _Android Components_ build pipeline. Currently we do not publish packaged versions of these lint rules for consumption outside of the _Android Components_ repository.
+
+## License
+
+ This Source Code Form is subject to the terms of the Mozilla Public
+ License, v. 2.0. If a copy of the MPL was not distributed with this
+ file, You can obtain one at http://mozilla.org/MPL/2.0/
diff --git a/mobile/android/android-components/components/tooling/lint/build.gradle b/mobile/android/android-components/components/tooling/lint/build.gradle
new file mode 100644
index 0000000000..adee887580
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/lint/build.gradle
@@ -0,0 +1,42 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
+
+apply plugin: 'java-library'
+apply plugin: 'kotlin'
+
+dependencies {
+ compileOnly ComponentsDependencies.tools_lintapi
+ compileOnly ComponentsDependencies.tools_lintchecks
+
+ compileOnly ComponentsDependencies.kotlin_reflect
+ testImplementation ComponentsDependencies.kotlin_reflect
+
+ testImplementation ComponentsDependencies.tools_lint
+ testImplementation ComponentsDependencies.tools_linttests
+ testImplementation ComponentsDependencies.testing_junit
+ testImplementation ComponentsDependencies.testing_mockito
+}
+
+jar {
+ manifest {
+ attributes('Lint-Registry-v2': 'mozilla.components.tooling.lint.LintIssueRegistry')
+ }
+}
+
+tasks.register("lint") {
+ doLast {
+ // Do nothing. We execute the same set of tasks for all our modules in parallel on taskcluster.
+ // This project doesn't have a lint task. To avoid special casing our automation I just added
+ // an empty lint task here.
+ }
+}
+
+tasks.register("assembleAndroidTest") {
+ doLast {
+ // Do nothing. Like the `lint` task above this is just a dummy task so that this module
+ // behaves like our others and we do not need to special case it in automation.
+ }
+}
diff --git a/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/AndroidSrcXmlDetector.kt b/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/AndroidSrcXmlDetector.kt
new file mode 100644
index 0000000000..5799871f4a
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/AndroidSrcXmlDetector.kt
@@ -0,0 +1,82 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.tooling.lint
+
+import com.android.SdkConstants.ATTR_SRC
+import com.android.SdkConstants.FQCN_IMAGE_BUTTON
+import com.android.SdkConstants.FQCN_IMAGE_VIEW
+import com.android.SdkConstants.IMAGE_BUTTON
+import com.android.SdkConstants.IMAGE_VIEW
+import com.android.resources.ResourceFolderType
+import com.android.tools.lint.detector.api.Category
+import com.android.tools.lint.detector.api.Implementation
+import com.android.tools.lint.detector.api.Issue
+import com.android.tools.lint.detector.api.ResourceXmlDetector
+import com.android.tools.lint.detector.api.Scope
+import com.android.tools.lint.detector.api.Severity
+import com.android.tools.lint.detector.api.XmlContext
+import com.google.common.annotations.VisibleForTesting
+import org.w3c.dom.Element
+
+/**
+ * A custom lint check that prohibits not using the app:srcCompat for ImageViews
+ */
+class AndroidSrcXmlDetector : ResourceXmlDetector() {
+ companion object {
+ const val SCHEMA = "http://schemas.android.com/apk/res/android"
+ const val FULLY_QUALIFIED_APP_COMPAT_IMAGE_BUTTON =
+ "androidx.appcompat.widget.AppCompatImageButton"
+ const val FULLY_QUALIFIED_APP_COMPAT_VIEW_CLASS =
+ "androidx.appcompat.widget.AppCompatImageView"
+ const val APP_COMPAT_IMAGE_BUTTON = "AppCompatImageButton"
+ const val APP_COMPAT_IMAGE_VIEW = "AppCompatImageView"
+
+ const val ERROR_MESSAGE = "Using android:src to define resource instead of app:srcCompat"
+
+ @VisibleForTesting
+ val ISSUE_XML_SRC_USAGE = Issue.create(
+ id = "AndroidSrcXmlDetector",
+ briefDescription = "Prohibits using android:src in ImageViews and ImageButtons",
+ explanation = "ImageView (and descendants) images should be declared using app:srcCompat",
+ category = Category.CORRECTNESS,
+ severity = Severity.ERROR,
+ implementation = Implementation(
+ AndroidSrcXmlDetector::class.java,
+ Scope.RESOURCE_FILE_SCOPE,
+ ),
+ )
+ }
+
+ override fun appliesTo(folderType: ResourceFolderType): Boolean {
+ // Return true if we want to analyze resource files in the specified resource
+ // folder type. In this case we only need to analyze layout resource files.
+ return folderType == ResourceFolderType.LAYOUT
+ }
+
+ override fun getApplicableElements(): Collection<String>? {
+ return setOf(
+ FQCN_IMAGE_VIEW,
+ IMAGE_VIEW,
+ FQCN_IMAGE_BUTTON,
+ IMAGE_BUTTON,
+ FULLY_QUALIFIED_APP_COMPAT_IMAGE_BUTTON,
+ FULLY_QUALIFIED_APP_COMPAT_VIEW_CLASS,
+ APP_COMPAT_IMAGE_BUTTON,
+ APP_COMPAT_IMAGE_VIEW,
+ )
+ }
+
+ override fun visitElement(context: XmlContext, element: Element) {
+ if (!element.hasAttributeNS(SCHEMA, ATTR_SRC)) return
+ val node = element.getAttributeNodeNS(SCHEMA, ATTR_SRC)
+
+ context.report(
+ issue = ISSUE_XML_SRC_USAGE,
+ scope = node,
+ location = context.getLocation(node),
+ message = ERROR_MESSAGE,
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/ConceptFetchDetector.kt b/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/ConceptFetchDetector.kt
new file mode 100644
index 0000000000..0a31e61620
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/ConceptFetchDetector.kt
@@ -0,0 +1,249 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.tooling.lint
+
+import com.android.tools.lint.checks.CheckResultDetector
+import com.android.tools.lint.checks.DataFlowAnalyzer
+import com.android.tools.lint.checks.EscapeCheckingDataFlowAnalyzer
+import com.android.tools.lint.checks.TargetMethodDataFlowAnalyzer
+import com.android.tools.lint.checks.isMissingTarget
+import com.android.tools.lint.detector.api.Category
+import com.android.tools.lint.detector.api.Detector
+import com.android.tools.lint.detector.api.Implementation
+import com.android.tools.lint.detector.api.Issue
+import com.android.tools.lint.detector.api.JavaContext
+import com.android.tools.lint.detector.api.Scope
+import com.android.tools.lint.detector.api.Severity
+import com.android.tools.lint.detector.api.SourceCodeScanner
+import com.android.tools.lint.detector.api.getUMethod
+import com.android.tools.lint.detector.api.isJava
+import com.android.tools.lint.detector.api.skipLabeledExpression
+import com.intellij.psi.LambdaUtil
+import com.intellij.psi.PsiElement
+import com.intellij.psi.PsiMethod
+import com.intellij.psi.PsiResourceVariable
+import com.intellij.psi.PsiVariable
+import com.intellij.psi.util.PsiTreeUtil
+import org.jetbrains.uast.UCallExpression
+import org.jetbrains.uast.UCallableReferenceExpression
+import org.jetbrains.uast.UElement
+import org.jetbrains.uast.UMethod
+import org.jetbrains.uast.UQualifiedReferenceExpression
+import org.jetbrains.uast.getParentOfType
+
+/**
+ * Checks for missing [mozilla.components.concept.fetch.Response.close] call on fetches that might not have used the
+ * resources.
+ *
+ * Review the unit tests for examples on what this [Detector] can identify.
+ */
+class ConceptFetchDetector : Detector(), SourceCodeScanner {
+ override fun getApplicableMethodNames(): List<String> {
+ return listOf("fetch")
+ }
+
+ override fun visitMethodCall(
+ context: JavaContext,
+ node: UCallExpression,
+ method: PsiMethod,
+ ) {
+ val containingClass = method.containingClass ?: return
+ val evaluator = context.evaluator
+
+ if (evaluator.extendsClass(
+ containingClass,
+ CLIENT_CLS,
+ false,
+ )
+ ) {
+ val returnType = method.getUMethod()?.returnTypeReference ?: return
+ val qualifiedName = returnType.getQualifiedName()
+ if (qualifiedName != null && qualifiedName == RESPONSE_CLS) {
+ checkClosed(context, node)
+ }
+ }
+ }
+
+ @Suppress("ReturnCount") // Extracted from `CleanupDetector#checkRecycled`.
+ private fun checkClosed(context: JavaContext, node: UCallExpression) {
+ // If it's an AutoCloseable in a try-with-resources clause, don't flag it: these will be
+ // cleaned up automatically
+ if (node.sourcePsi.isTryWithResources()) {
+ return
+ }
+
+ val parentMethod = node.getParentOfType(UMethod::class.java) ?: return
+
+ // Check if any of the 'body' methods are used. They are all closeable; do not report.
+ val bodyMethodTracker = BodyMethodTracker(listOf(node))
+ if (parentMethod.wasMethodCalled(bodyMethodTracker)) {
+ return
+ }
+
+ // Check if response has escaped (particularly through an extension function); do not report.
+ val responseEscapedTracker = ResponseEscapedTracker(listOf(node))
+ if (parentMethod.hasEscaped(responseEscapedTracker)) {
+ return
+ }
+
+ // Check if 'use' or 'close' were called; do not report.
+ val closeableTracker = CloseableTracker(listOf(node), context)
+ if (!parentMethod.isMissingTarget(closeableTracker)) {
+ return
+ }
+
+ context.report(
+ ISSUE_FETCH_RESPONSE_CLOSE,
+ node,
+ context.getCallLocation(node, includeReceiver = true, includeArguments = false),
+ "Response created but not closed: did you forget to call `close()`?",
+ if (CheckResultDetector.isExpressionValueUnused(node)) {
+ fix()
+ .replace()
+ .name("Call close()")
+ .range(context.getLocation(node))
+ .end()
+ .with(".close()")
+ .build()
+ } else {
+ null
+ },
+ )
+ }
+
+ private fun UMethod.hasEscaped(analyzer: EscapeCheckingDataFlowAnalyzer): Boolean {
+ accept(analyzer)
+ return analyzer.escaped
+ }
+
+ private fun UMethod.wasMethodCalled(analyzer: BodyMethodTracker): Boolean {
+ accept(analyzer)
+ return analyzer.found
+ }
+
+ private fun PsiElement?.isTryWithResources(): Boolean {
+ return this != null &&
+ isJava(this) &&
+ PsiTreeUtil.getParentOfType(this, PsiResourceVariable::class.java) != null
+ }
+
+ private class BodyMethodTracker(
+ initial: Collection<UElement>,
+ initialReferences: Collection<PsiVariable> = emptyList(),
+ ) : DataFlowAnalyzer(initial, initialReferences) {
+ var found = false
+ override fun visitQualifiedReferenceExpression(node: UQualifiedReferenceExpression): Boolean {
+ val methodName: String? = with(node.selector as? UCallExpression) {
+ this?.methodName ?: this?.methodIdentifier?.name
+ }
+
+ when (methodName) {
+ USE_STREAM,
+ USE_BUFFERED_READER,
+ STRING,
+ -> {
+ if (node.receiver.getExpressionType()?.canonicalText == BODY_CLS) {
+ // We are using any of the `body` methods which are all closeable.
+ found = true
+ return true
+ }
+ }
+ }
+
+ return super.visitQualifiedReferenceExpression(node)
+ }
+ }
+
+ private class ResponseEscapedTracker(
+ initial: Collection<UElement>,
+ ) : EscapeCheckingDataFlowAnalyzer(initial, emptyList()) {
+ override fun returnsSelf(call: UCallExpression): Boolean {
+ val type = call.receiver?.getExpressionType()?.canonicalText ?: return super.returnsSelf(call)
+ return type == RESPONSE_CLS
+ }
+ }
+
+ // Extracted from `CleanupDetector#checkRecycled#visitor`.
+ private class CloseableTracker(
+ initial: Collection<UElement>,
+ private val context: JavaContext,
+ ) : TargetMethodDataFlowAnalyzer(initial, emptyList()) {
+ override fun isTargetMethodName(name: String): Boolean {
+ return name == USE || name == CLOSE
+ }
+
+ @Suppress("ReturnCount")
+ override fun isTargetMethod(
+ name: String,
+ method: PsiMethod?,
+ call: UCallExpression?,
+ methodRef: UCallableReferenceExpression?,
+ ): Boolean {
+ if (USE == name) {
+ // Kotlin: "use" calls close;
+ // Ensure that "use" call accepts a single lambda parameter, so that it would
+ // loosely match kotlin.io.use() signature and at the same time allow custom
+ // overloads for types not extending Closeable
+ if (call != null && call.valueArgumentCount == 1) {
+ val argumentType =
+ call.valueArguments.first().skipLabeledExpression().getExpressionType()
+ if (argumentType != null && LambdaUtil.isFunctionalType(argumentType)) {
+ return true
+ }
+ }
+ return false
+ }
+
+ if (method != null) {
+ val containingClass = method.containingClass
+ val targetName = containingClass?.qualifiedName ?: return true
+ if (targetName == RESPONSE_CLS) {
+ return true
+ }
+ val recycleClass =
+ context.evaluator.findClass(RESPONSE_CLS) ?: return true
+ return context.evaluator.extendsClass(recycleClass, targetName, false)
+ } else {
+ // Unresolved method call -- assume it's okay
+ return true
+ }
+ }
+ }
+
+ companion object {
+ @JvmField
+ val ISSUE_FETCH_RESPONSE_CLOSE = Issue.create(
+ id = "FetchResponseClose",
+ briefDescription = "Response stream fetched but not closed.",
+ explanation = """
+ A `Client.fetch` returns a `Response` that, on success, is consumed typically with
+ a `use` stream in Kotlin or a try-with-resources in Java. In the failure or manual
+ resource managed cases, we need to ensure that `Response.close` is always called.
+
+ Additionally, all methods on `Response.body` are AutoCloseable so using any of
+ those will release those resources after execution.
+ """.trimIndent(),
+ category = Category.CORRECTNESS,
+ priority = 6,
+ severity = Severity.ERROR,
+ androidSpecific = true,
+ implementation = Implementation(
+ ConceptFetchDetector::class.java,
+ Scope.JAVA_FILE_SCOPE,
+ ),
+ )
+
+ // Target method names
+ private const val CLOSE = "close"
+ private const val USE = "use"
+ private const val USE_STREAM = "useStream"
+ private const val USE_BUFFERED_READER = "useBufferedReader"
+ private const val STRING = "string"
+
+ private const val CLIENT_CLS = "mozilla.components.concept.fetch.Client"
+ private const val RESPONSE_CLS = "mozilla.components.concept.fetch.Response"
+ private const val BODY_CLS = "mozilla.components.concept.fetch.Response.Body"
+ }
+}
diff --git a/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/FactCollectDetector.kt b/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/FactCollectDetector.kt
new file mode 100644
index 0000000000..4cd3f32f97
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/FactCollectDetector.kt
@@ -0,0 +1,103 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.tooling.lint
+
+import com.android.tools.lint.checks.DataFlowAnalyzer
+import com.android.tools.lint.detector.api.Category
+import com.android.tools.lint.detector.api.Detector
+import com.android.tools.lint.detector.api.Implementation
+import com.android.tools.lint.detector.api.Issue
+import com.android.tools.lint.detector.api.JavaContext
+import com.android.tools.lint.detector.api.Scope
+import com.android.tools.lint.detector.api.Severity
+import com.android.tools.lint.detector.api.SourceCodeScanner
+import com.intellij.psi.PsiMethod
+import org.jetbrains.uast.UCallExpression
+import org.jetbrains.uast.UElement
+import org.jetbrains.uast.UMethod
+import org.jetbrains.uast.UReturnExpression
+import org.jetbrains.uast.getParentOfType
+
+/**
+ * A custom lint check that warns if [Fact.collect()] is not called on a newly created [Fact] instance
+ */
+class FactCollectDetector : Detector(), SourceCodeScanner {
+
+ companion object {
+ private const val FULLY_QUALIFIED_FACT_CLASS_NAME =
+ "mozilla.components.support.base.facts.Fact"
+ private const val EXPECTED_METHOD_SIMPLE_NAME =
+ "collect" // The `Fact.collect` extension method
+
+ private val IMPLEMENTATION = Implementation(
+ FactCollectDetector::class.java,
+ Scope.JAVA_FILE_SCOPE,
+ )
+
+ val ISSUE_FACT_COLLECT_CALLED: Issue = Issue
+ .create(
+ id = "FactCollect",
+ briefDescription = "Fact created but not collected",
+ explanation = """
+ An instance of `Fact` was created but not collected. You must call
+ `collect()` on the instance to actually process it.
+ """.trimIndent(),
+ category = Category.CORRECTNESS,
+ priority = 6,
+ severity = Severity.ERROR,
+ implementation = IMPLEMENTATION,
+ )
+ }
+
+ override fun getApplicableConstructorTypes(): List<String> {
+ return listOf(FULLY_QUALIFIED_FACT_CLASS_NAME)
+ }
+
+ override fun visitConstructor(
+ context: JavaContext,
+ node: UCallExpression,
+ constructor: PsiMethod,
+ ) {
+ var isCollectCalled = false
+ var escapes = false
+ val visitor = object : DataFlowAnalyzer(setOf(node)) {
+ override fun receiver(call: UCallExpression) {
+ if (call.methodName == EXPECTED_METHOD_SIMPLE_NAME) {
+ isCollectCalled = true
+ }
+ }
+
+ override fun argument(call: UCallExpression, reference: UElement) {
+ escapes = true
+ }
+
+ override fun field(field: UElement) {
+ escapes = true
+ }
+
+ override fun returns(expression: UReturnExpression) {
+ escapes = true
+ }
+ }
+ val method = node.getParentOfType<UMethod>(UMethod::class.java, true) ?: return
+ method.accept(visitor)
+ if (!isCollectCalled && !escapes) {
+ reportUsage(context, node)
+ }
+ }
+
+ private fun reportUsage(context: JavaContext, node: UCallExpression) {
+ context.report(
+ issue = ISSUE_FACT_COLLECT_CALLED,
+ scope = node,
+ location = context.getCallLocation(
+ call = node,
+ includeReceiver = true,
+ includeArguments = false,
+ ),
+ message = "Fact created but not shown: did you forget to call `collect()` ?",
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/ImageViewAndroidTintXmlDetector.kt b/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/ImageViewAndroidTintXmlDetector.kt
new file mode 100644
index 0000000000..f775f6ee3f
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/ImageViewAndroidTintXmlDetector.kt
@@ -0,0 +1,81 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.tooling.lint
+
+import com.android.SdkConstants.ATTR_TINT
+import com.android.SdkConstants.FQCN_IMAGE_BUTTON
+import com.android.SdkConstants.FQCN_IMAGE_VIEW
+import com.android.SdkConstants.IMAGE_BUTTON
+import com.android.SdkConstants.IMAGE_VIEW
+import com.android.resources.ResourceFolderType
+import com.android.tools.lint.detector.api.Category
+import com.android.tools.lint.detector.api.Implementation
+import com.android.tools.lint.detector.api.Issue
+import com.android.tools.lint.detector.api.ResourceXmlDetector
+import com.android.tools.lint.detector.api.Scope
+import com.android.tools.lint.detector.api.Severity
+import com.android.tools.lint.detector.api.XmlContext
+import org.w3c.dom.Element
+
+/**
+ * A custom lint check that prohibits not using the app:tint for ImageViews
+ */
+class ImageViewAndroidTintXmlDetector : ResourceXmlDetector() {
+ companion object {
+ const val SCHEMA = "http://schemas.android.com/apk/res/android"
+ const val FULLY_QUALIFIED_APP_COMPAT_IMAGE_BUTTON =
+ "androidx.appcompat.widget.AppCompatImageButton"
+ const val FULLY_QUALIFIED_APP_COMPAT_VIEW_CLASS =
+ "androidx.appcompat.widget.AppCompatImageView"
+ const val APP_COMPAT_IMAGE_BUTTON = "AppCompatImageButton"
+ const val APP_COMPAT_IMAGE_VIEW = "AppCompatImageView"
+
+ const val ERROR_MESSAGE =
+ "Using android:tint to tint ImageView instead of app:tint with AppCompatImageView"
+
+ val ISSUE_XML_SRC_USAGE = Issue.create(
+ id = "AndroidSrcXmlDetector",
+ briefDescription = "Prohibits using android:tint in ImageViews and ImageButtons",
+ explanation = "ImageView (and descendants) should be tinted using app:tint",
+ category = Category.CORRECTNESS,
+ severity = Severity.ERROR,
+ implementation = Implementation(
+ ImageViewAndroidTintXmlDetector::class.java,
+ Scope.RESOURCE_FILE_SCOPE,
+ ),
+ )
+ }
+
+ override fun appliesTo(folderType: ResourceFolderType): Boolean {
+ // Return true if we want to analyze resource files in the specified resource
+ // folder type. In this case we only need to analyze layout resource files.
+ return folderType == ResourceFolderType.LAYOUT
+ }
+
+ override fun getApplicableElements(): Collection<String>? {
+ return setOf(
+ FQCN_IMAGE_VIEW,
+ IMAGE_VIEW,
+ FQCN_IMAGE_BUTTON,
+ IMAGE_BUTTON,
+ FULLY_QUALIFIED_APP_COMPAT_IMAGE_BUTTON,
+ FULLY_QUALIFIED_APP_COMPAT_VIEW_CLASS,
+ APP_COMPAT_IMAGE_BUTTON,
+ APP_COMPAT_IMAGE_VIEW,
+ )
+ }
+
+ override fun visitElement(context: XmlContext, element: Element) {
+ if (!element.hasAttributeNS(SCHEMA, ATTR_TINT)) return
+ val node = element.getAttributeNodeNS(SCHEMA, ATTR_TINT)
+
+ context.report(
+ issue = ISSUE_XML_SRC_USAGE,
+ scope = node,
+ location = context.getLocation(node),
+ message = ERROR_MESSAGE,
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/LintIssueRegistry.kt b/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/LintIssueRegistry.kt
new file mode 100644
index 0000000000..31118405dc
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/LintIssueRegistry.kt
@@ -0,0 +1,25 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.tooling.lint
+
+import com.android.tools.lint.client.api.IssueRegistry
+import com.android.tools.lint.detector.api.Issue
+
+/**
+ * Registry which provides a list of our custom lint checks to be performed on an Android project.
+ */
+@Suppress("unused")
+class LintIssueRegistry : IssueRegistry() {
+ override val api: Int = com.android.tools.lint.detector.api.CURRENT_API
+ override val issues: List<Issue> = listOf(
+ LintLogChecks.ISSUE_LOG_USAGE,
+ AndroidSrcXmlDetector.ISSUE_XML_SRC_USAGE,
+ TextViewAndroidSrcXmlDetector.ISSUE_XML_SRC_USAGE,
+ ImageViewAndroidTintXmlDetector.ISSUE_XML_SRC_USAGE,
+ FactCollectDetector.ISSUE_FACT_COLLECT_CALLED,
+ NotificationManagerChecks.ISSUE_NOTIFICATION_USAGE,
+ ConceptFetchDetector.ISSUE_FETCH_RESPONSE_CLOSE,
+ )
+}
diff --git a/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/LintLogChecks.kt b/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/LintLogChecks.kt
new file mode 100644
index 0000000000..a462335162
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/LintLogChecks.kt
@@ -0,0 +1,61 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.tooling.lint
+
+import com.android.tools.lint.detector.api.Category
+import com.android.tools.lint.detector.api.Detector
+import com.android.tools.lint.detector.api.Implementation
+import com.android.tools.lint.detector.api.Issue
+import com.android.tools.lint.detector.api.JavaContext
+import com.android.tools.lint.detector.api.Scope
+import com.android.tools.lint.detector.api.Severity
+import com.intellij.psi.PsiMethod
+import org.jetbrains.uast.UCallExpression
+import org.jetbrains.uast.getContainingUClass
+import java.util.EnumSet
+
+internal const val ANDROID_LOG_CLASS = "android.util.Log"
+internal const val ERROR_MESSAGE = "Using Android Log instead of base component"
+
+/**
+ * Custom lint checks related to logging.
+ */
+class LintLogChecks : Detector(), Detector.UastScanner {
+ private val componentPackages = listOf("mozilla.components", "org.mozilla.telemetry", "org.mozilla.samples")
+
+ override fun getApplicableMethodNames() = listOf("v", "d", "i", "w", "e")
+
+ override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
+ if (context.evaluator.isMemberInClass(method, ANDROID_LOG_CLASS)) {
+ val inComponentPackage = componentPackages.any {
+ node.methodIdentifier?.getContainingUClass()?.qualifiedName?.startsWith(it) == true
+ }
+
+ if (inComponentPackage) {
+ context.report(
+ ISSUE_LOG_USAGE,
+ node,
+ context.getLocation(node),
+ ERROR_MESSAGE,
+ )
+ }
+ }
+ }
+
+ companion object {
+ internal val ISSUE_LOG_USAGE = Issue.create(
+ "LogUsage",
+ "Log/Logger from base component should be used.",
+ """The Log or Logger class from the base component should be used for logging instead of
+ Android's Log class. This will allow the app to control what logs should be accepted
+ and how they should be processed.
+ """.trimIndent(),
+ Category.MESSAGES,
+ 5,
+ Severity.WARNING,
+ Implementation(LintLogChecks::class.java, EnumSet.of(Scope.JAVA_FILE)),
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/NotificationManagerChecks.kt b/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/NotificationManagerChecks.kt
new file mode 100644
index 0000000000..b2141abbbb
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/NotificationManagerChecks.kt
@@ -0,0 +1,84 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.tooling.lint
+
+import com.android.tools.lint.detector.api.Category
+import com.android.tools.lint.detector.api.Detector
+import com.android.tools.lint.detector.api.Implementation
+import com.android.tools.lint.detector.api.Issue
+import com.android.tools.lint.detector.api.JavaContext
+import com.android.tools.lint.detector.api.Scope
+import com.android.tools.lint.detector.api.Severity
+import com.intellij.psi.PsiMethod
+import org.jetbrains.uast.UCallExpression
+import org.jetbrains.uast.getContainingUClass
+import java.util.EnumSet
+
+internal const val ANDROID_NOTIFICATION_MANAGER_COMPAT_CLASS =
+ "androidx.core.app.NotificationManagerCompat"
+internal const val ANDROID_NOTIFICATION_MANAGER_CLASS =
+ "android.app.NotificationManager"
+
+internal const val NOTIFY_ERROR_MESSAGE = "Using Android NOTIFY instead of base component"
+
+/**
+ * Custom lint that ensures [NotificationManagerCompat] and [NotificationManager]'s method [notify]
+ * is not called directly from code.
+ * Calling notify directly from code eludes the checks implemented in [NotificationsDelegate]
+ */
+class NotificationManagerChecks : Detector(), Detector.UastScanner {
+ private val componentPackages =
+ listOf("mozilla.components", "org.mozilla.telemetry", "org.mozilla.samples")
+ private val appPackages = listOf("org.mozilla.fenix", "org.mozilla.focus")
+
+ override fun getApplicableMethodNames() = listOf("notify")
+
+ override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
+ if (context.evaluator.isMemberInClass(method, ANDROID_NOTIFICATION_MANAGER_COMPAT_CLASS) ||
+ context.evaluator.isMemberInClass(method, ANDROID_NOTIFICATION_MANAGER_CLASS)
+ ) {
+ val inComponentPackage = componentPackages.any {
+ node.methodIdentifier?.getContainingUClass()?.qualifiedName?.startsWith(it) == true
+ }
+
+ val inAppPackage = appPackages.any {
+ node.methodIdentifier?.getContainingUClass()?.qualifiedName?.startsWith(it) == true
+ }
+
+ if (inComponentPackage) {
+ context.report(
+ ISSUE_NOTIFICATION_USAGE,
+ node,
+ context.getLocation(node),
+ NOTIFY_ERROR_MESSAGE,
+ )
+ }
+
+ if (inAppPackage) {
+ context.report(
+ ISSUE_NOTIFICATION_USAGE,
+ node,
+ context.getLocation(node),
+ NOTIFY_ERROR_MESSAGE,
+ )
+ }
+ }
+ }
+
+ companion object {
+ internal val ISSUE_NOTIFICATION_USAGE = Issue.create(
+ "NotifyUsage",
+ "NotificationsDelegate should be used instead of NotificationManager.",
+ """NotificationsDelegate should be used for showing notifications instead of a NotificationManager
+ or a NotificationManagerCompat. This will allow the app to control requesting the notification permission
+ when needed and handling the request result.
+ """.trimIndent(),
+ Category.MESSAGES,
+ 5,
+ Severity.WARNING,
+ Implementation(NotificationManagerChecks::class.java, EnumSet.of(Scope.JAVA_FILE)),
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/TextViewAndroidSrcXmlDetector.kt b/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/TextViewAndroidSrcXmlDetector.kt
new file mode 100644
index 0000000000..0be9ab89a8
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/lint/src/main/java/mozilla/components/tooling/lint/TextViewAndroidSrcXmlDetector.kt
@@ -0,0 +1,97 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.tooling.lint
+
+import com.android.SdkConstants.ATTR_DRAWABLE_BOTTOM
+import com.android.SdkConstants.ATTR_DRAWABLE_END
+import com.android.SdkConstants.ATTR_DRAWABLE_LEFT
+import com.android.SdkConstants.ATTR_DRAWABLE_RIGHT
+import com.android.SdkConstants.ATTR_DRAWABLE_START
+import com.android.SdkConstants.ATTR_DRAWABLE_TOP
+import com.android.SdkConstants.FQCN_TEXT_VIEW
+import com.android.SdkConstants.TEXT_VIEW
+import com.android.resources.ResourceFolderType
+import com.android.tools.lint.detector.api.Category
+import com.android.tools.lint.detector.api.Implementation
+import com.android.tools.lint.detector.api.Issue
+import com.android.tools.lint.detector.api.ResourceXmlDetector
+import com.android.tools.lint.detector.api.Scope
+import com.android.tools.lint.detector.api.Severity
+import com.android.tools.lint.detector.api.XmlContext
+import org.w3c.dom.Element
+
+/**
+ * A custom lint check that prohibits not using the app:srcCompat for ImageViews
+ */
+class TextViewAndroidSrcXmlDetector : ResourceXmlDetector() {
+ companion object {
+ const val SCHEMA = "http://schemas.android.com/apk/res/android"
+
+ const val ERROR_MESSAGE =
+ "Using android:drawableX to define resource instead of app:drawableXCompat"
+
+ val ISSUE_XML_SRC_USAGE = Issue.create(
+ id = "TextViewAndroidSrcXmlDetector",
+ briefDescription = "Prohibits using android namespace to define drawables in TextViews",
+ explanation = "TextView drawables should be declared using app:drawableXCompat",
+ category = Category.CORRECTNESS,
+ severity = Severity.ERROR,
+ implementation = Implementation(
+ TextViewAndroidSrcXmlDetector::class.java,
+ Scope.RESOURCE_FILE_SCOPE,
+ ),
+ )
+ }
+
+ override fun appliesTo(folderType: ResourceFolderType): Boolean {
+ // Return true if we want to analyze resource files in the specified resource
+ // folder type. In this case we only need to analyze layout resource files.
+ return folderType == ResourceFolderType.LAYOUT
+ }
+
+ override fun getApplicableElements(): Collection<String>? {
+ return setOf(
+ FQCN_TEXT_VIEW,
+ TEXT_VIEW,
+ )
+ }
+
+ override fun visitElement(context: XmlContext, element: Element) {
+ val node = when {
+ element.hasAttributeNS(SCHEMA, ATTR_DRAWABLE_BOTTOM) -> element.getAttributeNodeNS(
+ SCHEMA,
+ ATTR_DRAWABLE_BOTTOM,
+ )
+ element.hasAttributeNS(SCHEMA, ATTR_DRAWABLE_END) -> element.getAttributeNodeNS(
+ SCHEMA,
+ ATTR_DRAWABLE_END,
+ )
+ element.hasAttributeNS(SCHEMA, ATTR_DRAWABLE_LEFT) -> element.getAttributeNodeNS(
+ SCHEMA,
+ ATTR_DRAWABLE_LEFT,
+ )
+ element.hasAttributeNS(
+ SCHEMA,
+ ATTR_DRAWABLE_RIGHT,
+ ) -> element.getAttributeNodeNS(SCHEMA, ATTR_DRAWABLE_RIGHT)
+ element.hasAttributeNS(
+ SCHEMA,
+ ATTR_DRAWABLE_START,
+ ) -> element.getAttributeNodeNS(SCHEMA, ATTR_DRAWABLE_START)
+ element.hasAttributeNS(SCHEMA, ATTR_DRAWABLE_TOP) -> element.getAttributeNodeNS(
+ SCHEMA,
+ ATTR_DRAWABLE_TOP,
+ )
+ else -> null
+ } ?: return
+
+ context.report(
+ issue = ISSUE_XML_SRC_USAGE,
+ scope = node,
+ location = context.getLocation(node),
+ message = ERROR_MESSAGE,
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/AndroidSrcXmlDetectorTest.kt b/mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/AndroidSrcXmlDetectorTest.kt
new file mode 100644
index 0000000000..bae0412d45
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/AndroidSrcXmlDetectorTest.kt
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.tooling.lint
+
+import com.android.tools.lint.checks.infrastructure.LintDetectorTest
+import com.android.tools.lint.detector.api.Detector
+import com.android.tools.lint.detector.api.Issue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+/**
+ * Tests for the [AndroidSrcXmlDetector] custom lint check.
+ */
+@RunWith(JUnit4::class)
+class AndroidSrcXmlDetectorTest : LintDetectorTest() {
+
+ override fun getIssues(): MutableList<Issue> =
+ mutableListOf(AndroidSrcXmlDetector.ISSUE_XML_SRC_USAGE)
+
+ override fun getDetector(): Detector = AndroidSrcXmlDetector()
+
+ @Test
+ fun expectPass() {
+ lint()
+ .files(
+ xml(
+ "res/layout/layout.xml",
+ """
+<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ />
+""",
+ ),
+ ).allowMissingSdk(true)
+ .run()
+ .expectClean()
+ }
+
+ @Test
+ fun expectFail() {
+ lint()
+ .files(
+ xml(
+ "res/layout/layout.xml",
+ """
+<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:src="@drawable/ic_close"
+ />
+""",
+ ),
+ ).allowMissingSdk(true)
+ .run()
+ .expect(
+ """
+res/layout/layout.xml:5: Error: Using android:src to define resource instead of app:srcCompat [AndroidSrcXmlDetector]
+ android:src="@drawable/ic_close"
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+1 errors, 0 warnings
+ """,
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/ConceptFetchDetectorTest.kt b/mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/ConceptFetchDetectorTest.kt
new file mode 100644
index 0000000000..cd60356015
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/ConceptFetchDetectorTest.kt
@@ -0,0 +1,277 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.tooling.lint
+
+import com.android.tools.lint.checks.infrastructure.TestFiles.gradle
+import com.android.tools.lint.checks.infrastructure.TestFiles.java
+import com.android.tools.lint.checks.infrastructure.TestFiles.kotlin
+import com.android.tools.lint.checks.infrastructure.TestLintTask.lint
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class ConceptFetchDetectorTest {
+
+ @Test
+ fun `should report when close is not invoked on a Response instance`() {
+ lint()
+ .files(
+ kotlin(
+ """
+ package test
+
+ import mozilla.components.concept.fetch.*
+
+ val client = Client()
+
+ fun isSuccessful() : Boolean {
+ val response = client.fetch(Request("https://mozilla.org"))
+ return response.isSuccess
+ }
+ """.trimIndent(),
+ ),
+ responseClassfileStub,
+ clientClassFileStub,
+ )
+ .issues(ConceptFetchDetector.ISSUE_FETCH_RESPONSE_CLOSE)
+ .run()
+ .expect(
+ """
+ src/test/test.kt:8: Error: Response created but not closed: did you forget to call close()? [FetchResponseClose]
+ val response = client.fetch(Request("https://mozilla.org"))
+ ~~~~~~~~~~~~
+ 1 errors, 0 warnings
+ """.trimIndent(),
+ )
+ }
+
+ @Test
+ fun `should not report from a result that is closed in another function`() {
+ lint()
+ .files(
+ kotlin(
+ """
+ package test
+
+ import mozilla.components.concept.fetch.*
+
+ val client = Client()
+
+ fun getResult() {
+ return try {
+ client.fetch(request).toResult()
+ } catch (e: IOException) {
+ Logger.debug(message = "Could not fetch region from location service", throwable = e)
+ null
+ }
+ }
+
+ data class Result(
+ val name: String,
+ )
+
+ private fun Response.toResult(): Region? {
+ if (!this.isSuccess) {
+ close()
+ return null
+ }
+
+ use {
+ return try {
+ Result("{}")
+ } catch (e: JSONException) {
+ Logger.debug(message = "Could not parse JSON returned from service", throwable = e)
+ null
+ }
+ }
+ }
+ """.trimIndent(),
+ ),
+ responseClassfileStub,
+ clientClassFileStub,
+ )
+ .issues(ConceptFetchDetector.ISSUE_FETCH_RESPONSE_CLOSE)
+ .run()
+ .expectClean()
+ }
+
+ @Test
+ fun `should pass when auto-closeable 'use' function is invoked`() {
+ lint()
+ .files(
+ kotlin(
+ """
+ package test
+
+ import mozilla.components.concept.fetch.*
+ import kotlin.io.*
+
+ val client = Client()
+
+ fun getResult() {
+ client.fetch(request).use { response ->
+ response.hashCode()
+ }
+ }
+ """.trimIndent(),
+ ),
+ responseClassfileStub,
+ clientClassFileStub,
+ )
+ .issues(ConceptFetchDetector.ISSUE_FETCH_RESPONSE_CLOSE)
+ .run()
+ .expectClean()
+ }
+
+ @Test
+ fun `should pass if body (auto-closeable methods) is used from the response`() {
+ lint()
+ .files(
+ kotlin(
+ """
+ package test
+
+ import mozilla.components.concept.fetch.*
+ import kotlin.io.*
+
+ val client = Client()
+
+ fun getResult() { // OK
+ val response = client.fetch(request)
+ response?.body.string(Charset.UTF_8)
+ }
+
+ fun getResult2() { // OK
+ client.fetch(request).body.useStream()
+ }
+
+ fun getResult3() { // OK; escaped.
+ val response = client.fetch(request)
+ process(response)
+ }
+
+ fun process(response: Response) {
+ response.hashCode()
+ }
+ """.trimIndent(),
+ ),
+ responseClassfileStub,
+ clientClassFileStub,
+ )
+ .issues(ConceptFetchDetector.ISSUE_FETCH_RESPONSE_CLOSE)
+ .run()
+ .expectClean()
+ }
+
+ @Test
+ fun `should pass if try-with-resources is used from the response`() {
+ lint()
+ .files(
+ gradle(
+ // For `try (cursor)` (without declaration) we'll need level 9
+ // or PSI/UAST will return an empty variable list
+ """
+ android {
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_9
+ targetCompatibility JavaVersion.VERSION_1_9
+ }
+ }
+ """,
+ ).indented(),
+ java(
+ """
+ package test;
+
+ import mozilla.components.concept.fetch.Client;
+ import mozilla.components.concept.fetch.Response;
+ import mozilla.components.concept.fetch.Response.Body;
+ import mozilla.components.concept.fetch.Request;
+
+ public class TryWithResources {
+ public void test(Client client, Request request) {
+ try(Response response = client.fetch(request)) {
+ if (response != null) {
+ //noinspection StatementWithEmptyBody
+ while (response.hashCode()) {
+ // ..
+ }
+ }
+ } catch (Exception e) {
+ // do nothing
+ }
+ }
+ }
+ """.trimIndent(),
+ ),
+ responseClassfileStub,
+ clientClassFileStub,
+ )
+ .issues(ConceptFetchDetector.ISSUE_FETCH_RESPONSE_CLOSE)
+ .run()
+ .expectClean()
+ }
+
+ private val clientClassFileStub = kotlin(
+ """
+ package mozilla.components.concept.fetch
+
+ data class Request(
+ val url: String,
+ val method: Method = Method.GET,
+ val headers: MutableHeaders? = MutableHeaders(),
+ val connectTimeout: Pair<Long, TimeUnit>? = null,
+ val readTimeout: Pair<Long, TimeUnit>? = null,
+ val body: Body? = null,
+ val redirect: Redirect = Redirect.FOLLOW,
+ val cookiePolicy: CookiePolicy = CookiePolicy.INCLUDE,
+ val useCaches: Boolean = true,
+ val private: Boolean = false,
+ )
+
+ class Client {
+ fun fetch(request: Request): Response {
+ return Response(
+ url = "https://mozilla.org",
+ )
+ }
+ }
+ """.trimIndent(),
+ )
+ private val responseClassfileStub = kotlin(
+ """
+ package mozilla.components.concept.fetch
+
+ data class Response(
+ val url: String,
+ val status: Int,
+ val headers: Headers,
+ val body: Body,
+ ) : Closeable {
+ override fun close() {
+ body.close()
+ }
+
+ open class Body(
+ private val stream: InputStream,
+ contentType: String? = null,
+ ) {
+ fun <R> useStream(block: (InputStream) -> R): R {
+ }
+
+ fun <R> useBufferedReader(charset: Charset? = null, block: (BufferedReader) -> R): R = use {
+ }
+
+ fun string(charset: Charset? = null): String = use {
+ }
+ }
+ }
+
+ val Response.isSuccess: Boolean
+ get() = status in SUCCESS_STATUS_RANGE
+ """,
+ ).indented()
+}
diff --git a/mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/FactCollectDetectorTest.kt b/mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/FactCollectDetectorTest.kt
new file mode 100644
index 0000000000..b70eca91c4
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/FactCollectDetectorTest.kt
@@ -0,0 +1,220 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.tooling.lint
+
+import com.android.tools.lint.checks.infrastructure.TestFiles.kotlin
+import com.android.tools.lint.checks.infrastructure.TestLintTask.lint
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+/**
+ * Tests for the [FactCollectDetector] custom lint check.
+ */
+@RunWith(JUnit4::class)
+class FactCollectDetectorTest {
+
+ private val factClassfileStub = kotlin(
+ """
+ package mozilla.components.support.base.facts
+
+ data class Fact(
+ val component: Component,
+ val action: Action,
+ val item: String,
+ val value: String? = null,
+ val metadata: Map<String, Any>? = null
+ )
+
+ fun Fact.collect() = Facts.collect(this)
+ """,
+ ).indented()
+
+ @Test
+ fun `should report when collect is not invoked on Fact instance`() {
+ lint()
+ .files(
+ kotlin(
+ """
+ package test
+
+ import mozilla.components.support.base.facts.Fact
+ import mozilla.components.support.base.facts.collect
+
+ private fun emitAwesomebarFact(
+ action: Action,
+ item: String,
+ value: String? = null,
+ metadata: Map<String, Any>? = null
+ ) {
+ Fact(
+ Component.BROWSER_AWESOMEBAR,
+ action,
+ item,
+ value,
+ metadata
+ )
+ }
+ """,
+ ).indented(),
+ factClassfileStub,
+ )
+ .issues(FactCollectDetector.ISSUE_FACT_COLLECT_CALLED)
+ .run()
+ .expect(
+ """
+ src/test/test.kt:12: Error: Fact created but not shown: did you forget to call collect() ? [FactCollect]
+ Fact(
+ ~~~~
+ 1 errors, 0 warnings
+ """.trimIndent(),
+ )
+ }
+
+ @Test
+ fun `should pass when collect is invoked on Fact instance`() {
+ lint()
+ .files(
+ kotlin(
+ """
+ package test
+
+ import mozilla.components.support.base.facts.Fact
+ import mozilla.components.support.base.facts.collect
+
+ private fun emitAwesomebarFact(
+ action: Action,
+ item: String,
+ value: String? = null,
+ metadata: Map<String, Any>? = null
+ ) {
+ Fact(
+ Component.BROWSER_AWESOMEBAR,
+ action,
+ item,
+ value,
+ metadata
+ ).collect()
+ }
+ """,
+ ).indented(),
+ factClassfileStub,
+ )
+ .issues(FactCollectDetector.ISSUE_FACT_COLLECT_CALLED)
+ .run()
+ .expectClean()
+ }
+
+ @Test
+ fun `should pass when an instance escapes through a return statement`() {
+ lint()
+ .files(
+ kotlin(
+ """
+ package test
+
+ import mozilla.components.support.base.facts.Fact
+ import mozilla.components.support.base.facts.collect
+
+ private fun createFact(
+ action: Action,
+ item: String,
+ value: String? = null,
+ metadata: Map<String, Any>? = null
+ ): Fact {
+ return Fact(
+ Component.BROWSER_AWESOMEBAR,
+ action,
+ item,
+ value,
+ metadata
+ )
+ }
+ """,
+ ).indented(),
+ factClassfileStub,
+ )
+ .issues(FactCollectDetector.ISSUE_FACT_COLLECT_CALLED)
+ .run()
+ .expectClean()
+ }
+
+ @Test
+ fun `should pass when an instance escapes through a method parameter`() {
+ lint()
+ .files(
+ kotlin(
+ """
+ package test
+
+ import mozilla.components.support.base.facts.Fact
+ import mozilla.components.support.base.facts.collect
+
+ private fun createFact(
+ action: Action,
+ item: String,
+ value: String? = null,
+ metadata: Map<String, Any>? = null
+ ) {
+ val fact = Fact(
+ Component.BROWSER_AWESOMEBAR,
+ action,
+ item,
+ value,
+ metadata
+ )
+ method(fact)
+ }
+
+ private fun method(parameter: Fact) {
+
+ }
+ """,
+ ).indented(),
+ factClassfileStub,
+ )
+ .issues(FactCollectDetector.ISSUE_FACT_COLLECT_CALLED)
+ .run()
+ .expectClean()
+ }
+
+ @Test
+ fun `should pass when an instance escapes through a field assignment`() {
+ lint()
+ .files(
+ kotlin(
+ """
+ package test
+
+ import mozilla.components.support.base.facts.Fact
+ import mozilla.components.support.base.facts.collect
+
+ class FactSender {
+ private var fact: Fact? = null
+
+ private fun createFact(
+ action: Action,
+ item: String,
+ value: String? = null,
+ metadata: Map<String, Any>? = null
+ ) {
+ fact = Fact(
+ Component.BROWSER_AWESOMEBAR,
+ action,
+ item,
+ value,
+ metadata
+ )
+ }
+ }
+ """,
+ ).indented(),
+ factClassfileStub,
+ )
+ .issues(FactCollectDetector.ISSUE_FACT_COLLECT_CALLED)
+ .run()
+ .expectClean()
+ }
+}
diff --git a/mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/ImageViewAndroidTintXmlDetectorTest.kt b/mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/ImageViewAndroidTintXmlDetectorTest.kt
new file mode 100644
index 0000000000..0a715407bd
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/ImageViewAndroidTintXmlDetectorTest.kt
@@ -0,0 +1,69 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.tooling.lint
+
+import com.android.tools.lint.checks.infrastructure.LintDetectorTest
+import com.android.tools.lint.detector.api.Detector
+import com.android.tools.lint.detector.api.Issue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+/**
+ * Tests for the [ImageViewAndroidTintXmlDetector] custom lint check.
+ */
+@RunWith(JUnit4::class)
+class ImageViewAndroidTintXmlDetectorTest : LintDetectorTest() {
+
+ override fun getIssues(): MutableList<Issue> =
+ mutableListOf(ImageViewAndroidTintXmlDetector.ISSUE_XML_SRC_USAGE)
+
+ override fun getDetector(): Detector = ImageViewAndroidTintXmlDetector()
+
+ @Test
+ fun expectPass() {
+ lint()
+ .files(
+ xml(
+ "res/layout/layout.xml",
+ """
+<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ />
+""",
+ ),
+ ).allowMissingSdk(true)
+ .run()
+ .expectClean()
+ }
+
+ @Test
+ fun expectFail() {
+ lint()
+ .files(
+ xml(
+ "res/layout/layout.xml",
+ """
+<ImageView xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:src="@drawable/ic_close"
+ android:tint="@color/photonBlue90"
+ />
+""",
+ ),
+ ).allowMissingSdk(true)
+ .run()
+ .expect(
+ """
+res/layout/layout.xml:6: Error: Using android:tint to tint ImageView instead of app:tint with AppCompatImageView [AndroidSrcXmlDetector]
+ android:tint="@color/photonBlue90"
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+1 errors, 0 warnings
+ """,
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/LintLogChecksTest.kt b/mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/LintLogChecksTest.kt
new file mode 100644
index 0000000000..1b10f883fa
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/LintLogChecksTest.kt
@@ -0,0 +1,56 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.tooling.lint
+
+import com.android.tools.lint.client.api.JavaEvaluator
+import com.android.tools.lint.detector.api.JavaContext
+import com.intellij.psi.PsiMethod
+import mozilla.components.tooling.lint.LintLogChecks.Companion.ISSUE_LOG_USAGE
+import org.jetbrains.uast.UCallExpression
+import org.jetbrains.uast.UClass
+import org.jetbrains.uast.UIdentifier
+import org.jetbrains.uast.getContainingUClass
+import org.junit.Test
+import org.mockito.Mockito.mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.times
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when`
+
+class LintLogChecksTest {
+
+ @Test
+ fun `report log error in components code only`() {
+ val evaluator = mock(JavaEvaluator::class.java)
+ val context = mock(JavaContext::class.java)
+ val node = mock(UCallExpression::class.java)
+ val method = mock(PsiMethod::class.java)
+ val methodIdentifier = mock(UIdentifier::class.java)
+ val clazz = mock(UClass::class.java)
+
+ `when`(evaluator.isMemberInClass(method, ANDROID_LOG_CLASS)).thenReturn(true)
+ `when`(context.evaluator).thenReturn(evaluator)
+
+ val logCheck = LintLogChecks()
+ logCheck.visitMethodCall(context, node, method)
+ verify(context, never()).report(ISSUE_LOG_USAGE, node, context.getLocation(node), ERROR_MESSAGE)
+
+ `when`(node.methodIdentifier).thenReturn(methodIdentifier)
+ logCheck.visitMethodCall(context, node, method)
+ verify(context, never()).report(ISSUE_LOG_USAGE, node, context.getLocation(node), ERROR_MESSAGE)
+
+ `when`(methodIdentifier.getContainingUClass()).thenReturn(clazz)
+ logCheck.visitMethodCall(context, node, method)
+ verify(context, never()).report(ISSUE_LOG_USAGE, node, context.getLocation(node), ERROR_MESSAGE)
+
+ `when`(clazz.qualifiedName).thenReturn("com.some.app.Class")
+ logCheck.visitMethodCall(context, node, method)
+ verify(context, never()).report(ISSUE_LOG_USAGE, node, context.getLocation(node), ERROR_MESSAGE)
+
+ `when`(clazz.qualifiedName).thenReturn("mozilla.components.some.Class")
+ logCheck.visitMethodCall(context, node, method)
+ verify(context, times(1)).report(ISSUE_LOG_USAGE, node, context.getLocation(node), ERROR_MESSAGE)
+ }
+}
diff --git a/mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/TextViewAndroidSrcXmlDetectorTest.kt b/mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/TextViewAndroidSrcXmlDetectorTest.kt
new file mode 100644
index 0000000000..f764084350
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/lint/src/test/java/mozilla/components/tooling/lint/TextViewAndroidSrcXmlDetectorTest.kt
@@ -0,0 +1,68 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package mozilla.components.tooling.lint
+
+import com.android.tools.lint.checks.infrastructure.LintDetectorTest
+import com.android.tools.lint.detector.api.Detector
+import com.android.tools.lint.detector.api.Issue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+/**
+ * Tests for the [TextViewAndroidSrcXmlDetector] custom lint check.
+ */
+@RunWith(JUnit4::class)
+class TextViewAndroidSrcXmlDetectorTest : LintDetectorTest() {
+
+ override fun getIssues(): MutableList<Issue> =
+ mutableListOf(TextViewAndroidSrcXmlDetector.ISSUE_XML_SRC_USAGE)
+
+ override fun getDetector(): Detector = TextViewAndroidSrcXmlDetector()
+
+ @Test
+ fun expectPass() {
+ lint()
+ .files(
+ xml(
+ "res/layout/layout.xml",
+ """
+<TextView xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ />
+""",
+ ),
+ ).allowMissingSdk(true)
+ .run()
+ .expectClean()
+ }
+
+ @Test
+ fun expectFail() {
+ lint()
+ .files(
+ xml(
+ "res/layout/layout.xml",
+ """
+<TextView xmlns:android="http://schemas.android.com/apk/res/android"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:drawableStart="@drawable/ic_close"
+ />
+""",
+ ),
+ ).allowMissingSdk(true)
+ .run()
+ .expect(
+ """
+res/layout/layout.xml:5: Error: Using android:drawableX to define resource instead of app:drawableXCompat [TextViewAndroidSrcXmlDetector]
+ android:drawableStart="@drawable/ic_close"
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+1 errors, 0 warnings
+ """,
+ )
+ }
+}
diff --git a/mobile/android/android-components/components/tooling/lint/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/mobile/android/android-components/components/tooling/lint/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
new file mode 100644
index 0000000000..cf1c399ea8
--- /dev/null
+++ b/mobile/android/android-components/components/tooling/lint/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker
@@ -0,0 +1,2 @@
+mock-maker-inline
+// This allows mocking final classes (classes are final by default in Kotlin)