diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:35:49 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-15 03:35:49 +0000 |
commit | d8bbc7858622b6d9c278469aab701ca0b609cddf (patch) | |
tree | eff41dc61d9f714852212739e6b3738b82a2af87 /mobile/android/android-components/components/tooling | |
parent | Releasing progress-linux version 125.0.3-1~progress7.99u1. (diff) | |
download | firefox-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')
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) |