diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml
index d5d98bf36..ccea5dc4f 100644
--- a/.github/workflows/Build.yaml
+++ b/.github/workflows/Build.yaml
@@ -47,7 +47,7 @@ jobs:
path: '**/build/outputs/apk/**/*.apk'
- name: Run local tests
- run: ./gradlew testDemoDebug testProdDebug
+ run: ./gradlew testDemoDebug testProdDebug :lint:test
test:
runs-on: ubuntu-latest
@@ -125,6 +125,9 @@ jobs:
name: lint-reports
path: '**/build/reports/lint-results-*.html'
+ - name: Check badging
+ run: ./gradlew :app:checkProdReleaseBadging
+
androidTest:
needs: build
runs-on: macOS-latest # enables hardware acceleration in the virtual machine
diff --git a/app/prodRelease-badging.txt b/app/prodRelease-badging.txt
new file mode 100644
index 000000000..6c3a859c7
--- /dev/null
+++ b/app/prodRelease-badging.txt
@@ -0,0 +1,121 @@
+package: name='com.google.samples.apps.nowinandroid' versionCode='8' versionName='0.1.2' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34' compileSdkVersionCodename='14'
+sdkVersion:'21'
+targetSdkVersion:'34'
+uses-permission: name='android.permission.INTERNET'
+uses-permission: name='android.permission.ACCESS_NETWORK_STATE'
+uses-permission: name='android.permission.POST_NOTIFICATIONS'
+uses-permission: name='android.permission.WAKE_LOCK'
+uses-permission: name='com.google.android.c2dm.permission.RECEIVE'
+uses-permission: name='com.google.android.finsky.permission.BIND_GET_INSTALL_REFERRER_SERVICE'
+uses-permission: name='android.permission.RECEIVE_BOOT_COMPLETED'
+uses-permission: name='android.permission.FOREGROUND_SERVICE'
+uses-permission: name='com.google.samples.apps.nowinandroid.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION'
+application-label:'Now in Android'
+application-label-af:'Now in Android'
+application-label-am:'Now in Android'
+application-label-ar:'Now in Android'
+application-label-as:'Now in Android'
+application-label-az:'Now in Android'
+application-label-be:'Now in Android'
+application-label-bg:'Now in Android'
+application-label-bn:'Now in Android'
+application-label-bs:'Now in Android'
+application-label-ca:'Now in Android'
+application-label-cs:'Now in Android'
+application-label-da:'Now in Android'
+application-label-de:'Now in Android'
+application-label-el:'Now in Android'
+application-label-en-AU:'Now in Android'
+application-label-en-CA:'Now in Android'
+application-label-en-GB:'Now in Android'
+application-label-en-IN:'Now in Android'
+application-label-en-XC:'Now in Android'
+application-label-es:'Now in Android'
+application-label-es-US:'Now in Android'
+application-label-et:'Now in Android'
+application-label-eu:'Now in Android'
+application-label-fa:'Now in Android'
+application-label-fi:'Now in Android'
+application-label-fr:'Now in Android'
+application-label-fr-CA:'Now in Android'
+application-label-gl:'Now in Android'
+application-label-gu:'Now in Android'
+application-label-hi:'Now in Android'
+application-label-hr:'Now in Android'
+application-label-hu:'Now in Android'
+application-label-hy:'Now in Android'
+application-label-in:'Now in Android'
+application-label-is:'Now in Android'
+application-label-it:'Now in Android'
+application-label-iw:'Now in Android'
+application-label-ja:'Now in Android'
+application-label-ka:'Now in Android'
+application-label-kk:'Now in Android'
+application-label-km:'Now in Android'
+application-label-kn:'Now in Android'
+application-label-ko:'Now in Android'
+application-label-ky:'Now in Android'
+application-label-lo:'Now in Android'
+application-label-lt:'Now in Android'
+application-label-lv:'Now in Android'
+application-label-mk:'Now in Android'
+application-label-ml:'Now in Android'
+application-label-mn:'Now in Android'
+application-label-mr:'Now in Android'
+application-label-ms:'Now in Android'
+application-label-my:'Now in Android'
+application-label-nb:'Now in Android'
+application-label-ne:'Now in Android'
+application-label-nl:'Now in Android'
+application-label-or:'Now in Android'
+application-label-pa:'Now in Android'
+application-label-pl:'Now in Android'
+application-label-pt:'Now in Android'
+application-label-pt-BR:'Now in Android'
+application-label-pt-PT:'Now in Android'
+application-label-ro:'Now in Android'
+application-label-ru:'Now in Android'
+application-label-si:'Now in Android'
+application-label-sk:'Now in Android'
+application-label-sl:'Now in Android'
+application-label-sq:'Now in Android'
+application-label-sr:'Now in Android'
+application-label-sr-Latn:'Now in Android'
+application-label-sv:'Now in Android'
+application-label-sw:'Now in Android'
+application-label-ta:'Now in Android'
+application-label-te:'Now in Android'
+application-label-th:'Now in Android'
+application-label-tl:'Now in Android'
+application-label-tr:'Now in Android'
+application-label-uk:'Now in Android'
+application-label-ur:'Now in Android'
+application-label-uz:'Now in Android'
+application-label-vi:'Now in Android'
+application-label-zh-CN:'Now in Android'
+application-label-zh-HK:'Now in Android'
+application-label-zh-TW:'Now in Android'
+application-label-zu:'Now in Android'
+application-icon-120:'res/mipmap-anydpi-v26/ic_launcher.xml'
+application-icon-160:'res/mipmap-anydpi-v26/ic_launcher.xml'
+application-icon-240:'res/mipmap-anydpi-v26/ic_launcher.xml'
+application-icon-320:'res/mipmap-anydpi-v26/ic_launcher.xml'
+application-icon-480:'res/mipmap-anydpi-v26/ic_launcher.xml'
+application-icon-640:'res/mipmap-anydpi-v26/ic_launcher.xml'
+application-icon-65534:'res/mipmap-anydpi-v26/ic_launcher.xml'
+application: label='Now in Android' icon='res/mipmap-anydpi-v26/ic_launcher.xml'
+launchable-activity: name='com.google.samples.apps.nowinandroid.MainActivity' label='' icon=''
+uses-library-not-required:'androidx.window.extensions'
+uses-library-not-required:'androidx.window.sidecar'
+uses-library-not-required:'android.ext.adservices'
+feature-group: label=''
+ uses-feature: name='android.hardware.faketouch'
+ uses-implied-feature: name='android.hardware.faketouch' reason='default feature for all apps'
+main
+other-activities
+other-receivers
+other-services
+supports-screens: 'small' 'normal' 'large' 'xlarge'
+supports-any-density: 'true'
+locales: '--_--' 'af' 'am' 'ar' 'as' 'az' 'be' 'bg' 'bn' 'bs' 'ca' 'cs' 'da' 'de' 'el' 'en-AU' 'en-CA' 'en-GB' 'en-IN' 'en-XC' 'es' 'es-US' 'et' 'eu' 'fa' 'fi' 'fr' 'fr-CA' 'gl' 'gu' 'hi' 'hr' 'hu' 'hy' 'in' 'is' 'it' 'iw' 'ja' 'ka' 'kk' 'km' 'kn' 'ko' 'ky' 'lo' 'lt' 'lv' 'mk' 'ml' 'mn' 'mr' 'ms' 'my' 'nb' 'ne' 'nl' 'or' 'pa' 'pl' 'pt' 'pt-BR' 'pt-PT' 'ro' 'ru' 'si' 'sk' 'sl' 'sq' 'sr' 'sr-Latn' 'sv' 'sw' 'ta' 'te' 'th' 'tl' 'tr' 'uk' 'ur' 'uz' 'vi' 'zh-CN' 'zh-HK' 'zh-TW' 'zu'
+densities: '120' '160' '240' '320' '480' '640' '65534'
diff --git a/app/src/benchmark/res/values-night/colors.xml b/app/src/benchmark/res/values-night/colors.xml
new file mode 100644
index 000000000..677eb4e03
--- /dev/null
+++ b/app/src/benchmark/res/values-night/colors.xml
@@ -0,0 +1,20 @@
+
+
+
+ #FFFFFF
+ #FF006780
+
diff --git a/app/src/benchmark/res/values/colors.xml b/app/src/benchmark/res/values/colors.xml
new file mode 100644
index 000000000..d33b7ba72
--- /dev/null
+++ b/app/src/benchmark/res/values/colors.xml
@@ -0,0 +1,20 @@
+
+
+
+ #000000
+ #FF006780
+
diff --git a/app/src/debug/res/values-night/colors.xml b/app/src/debug/res/values-night/colors.xml
new file mode 100644
index 000000000..d6a4c98e0
--- /dev/null
+++ b/app/src/debug/res/values-night/colors.xml
@@ -0,0 +1,20 @@
+
+
+
+ #FFFFFF
+ #FFA23F16
+
diff --git a/app/src/debug/res/values/colors.xml b/app/src/debug/res/values/colors.xml
new file mode 100644
index 000000000..6365ddb3f
--- /dev/null
+++ b/app/src/debug/res/values/colors.xml
@@ -0,0 +1,20 @@
+
+
+
+ #000000
+ #FFA23F16
+
diff --git a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsActions.kt b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsActions.kt
index d9c563ebc..c359ae87e 100644
--- a/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsActions.kt
+++ b/benchmarks/src/main/kotlin/com/google/samples/apps/nowinandroid/interests/InterestsActions.kt
@@ -20,6 +20,7 @@ import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import com.google.samples.apps.nowinandroid.flingElementDownUp
+import com.google.samples.apps.nowinandroid.waitAndFindObject
fun MacrobenchmarkScope.goToInterestsScreen() {
device.findObject(By.text("Interests")).click()
@@ -34,7 +35,7 @@ fun MacrobenchmarkScope.goToInterestsScreen() {
}
fun MacrobenchmarkScope.interestsScrollTopicsDownUp() {
- val topicsList = device.wait(Until.findObject(By.res("interests:topics")), 2_000)
+ val topicsList = device.waitAndFindObject(By.res("interests:topics"), 2_000)
device.flingElementDownUp(topicsList)
}
diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt
index 50baf3dc6..f73ed1478 100644
--- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt
+++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt
@@ -17,11 +17,14 @@
import com.android.build.api.dsl.ApplicationExtension
import com.google.samples.apps.nowinandroid.configureGradleManagedDevices
import com.android.build.api.variant.ApplicationAndroidComponentsExtension
+import com.android.build.gradle.BaseExtension
+import com.google.samples.apps.nowinandroid.configureBadgingTasks
import com.google.samples.apps.nowinandroid.configureKotlinAndroid
import com.google.samples.apps.nowinandroid.configurePrintApksTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.getByType
class AndroidApplicationConventionPlugin : Plugin {
override fun apply(target: Project) {
@@ -39,6 +42,7 @@ class AndroidApplicationConventionPlugin : Plugin {
}
extensions.configure {
configurePrintApksTask(this)
+ configureBadgingTasks(extensions.getByType(), this)
}
}
}
diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Badging.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Badging.kt
new file mode 100644
index 000000000..609961a2f
--- /dev/null
+++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Badging.kt
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.samples.apps.nowinandroid
+
+import com.android.build.api.artifact.SingleArtifact
+import com.android.build.api.variant.ApplicationAndroidComponentsExtension
+import com.android.build.gradle.BaseExtension
+import org.gradle.api.DefaultTask
+import org.gradle.api.GradleException
+import org.gradle.api.Project
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.tasks.Copy
+import org.gradle.api.tasks.InputFile
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.TaskAction
+import org.gradle.kotlin.dsl.configure
+import org.gradle.kotlin.dsl.register
+import org.gradle.language.base.plugins.LifecycleBasePlugin
+import org.gradle.process.ExecOperations
+import java.io.File
+import java.nio.file.Files
+import javax.inject.Inject
+
+abstract class GenerateBadgingTask : DefaultTask() {
+
+ @get:OutputFile
+ abstract val badging: RegularFileProperty
+
+ @get:InputFile
+ abstract val apk: RegularFileProperty
+
+ @get:InputFile
+ abstract val aapt2Executable: RegularFileProperty
+
+ @get:Inject
+ abstract val execOperations: ExecOperations
+
+ @TaskAction
+ fun taskAction() {
+ execOperations.exec {
+ commandLine(
+ aapt2Executable.get().asFile.absolutePath,
+ "dump",
+ "badging",
+ apk.get().asFile.absolutePath
+ )
+ standardOutput = badging.asFile.get().outputStream()
+ }
+ }
+}
+
+abstract class CheckBadgingTask : DefaultTask() {
+
+ // In order for the task to be up-to-date when the inputs have not changed,
+ // the task must declare an output, even if it's not used. Tasks with no
+ // output are always run regardless of whether the inputs changed
+ @get:OutputDirectory
+ abstract val output: DirectoryProperty
+
+ @get:InputFile
+ abstract val goldenBadging: RegularFileProperty
+
+ @get:InputFile
+ abstract val generatedBadging: RegularFileProperty
+
+ override fun getGroup(): String = LifecycleBasePlugin.VERIFICATION_GROUP
+
+ @TaskAction
+ fun taskAction() {
+ if (
+ Files.mismatch(
+ goldenBadging.get().asFile.toPath(),
+ generatedBadging.get().asFile.toPath()
+ ) != -1L
+ ) {
+ throw GradleException(
+ "Generated badging is different from golden badging! " +
+ "If this change is intended, run ./gradlew updateBadging"
+ )
+ }
+ }
+}
+
+fun Project.configureBadgingTasks(
+ baseExtension: BaseExtension,
+ componentsExtension: ApplicationAndroidComponentsExtension
+) {
+ // Registers a callback to be called, when a new variant is configured
+ componentsExtension.onVariants { variant ->
+ // Registers a new task to verify the app bundle.
+ val generateBadging = tasks.register("generate${variant.name}Badging") {
+ apk.set(
+ variant.artifacts.get(SingleArtifact.APK_FROM_BUNDLE)
+ )
+ aapt2Executable.set(
+ File(
+ baseExtension.sdkDirectory,
+ "build-tools/${baseExtension.buildToolsVersion}/aapt2"
+ )
+ )
+
+ badging.set(
+ project.layout.buildDirectory.file(
+ "outputs/apk_from_bundle/${variant.name}/${variant.name}-badging.txt"
+ )
+ )
+ }
+
+ tasks.register("update${variant.name}Badging") {
+ from(generateBadging.get().badging)
+ into(project.layout.projectDirectory)
+ }
+
+ val checkBadgingTaskName = "check${variant.name}Badging"
+ tasks.register(checkBadgingTaskName) {
+ goldenBadging.set(
+ project.layout.projectDirectory.file("${variant.name}-badging.txt"))
+ generatedBadging.set(
+ generateBadging.get().badging
+ )
+
+ output.set(
+ project.layout.buildDirectory.dir("intermediates/$checkBadgingTaskName")
+ )
+ }
+ }
+}
diff --git a/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModel.kt b/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModel.kt
index 33bf58a2c..a49c0d512 100644
--- a/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModel.kt
+++ b/feature/settings/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/settings/SettingsViewModel.kt
@@ -87,6 +87,6 @@ data class UserEditableSettings(
)
sealed interface SettingsUiState {
- object Loading : SettingsUiState
+ data object Loading : SettingsUiState
data class Success(val settings: UserEditableSettings) : SettingsUiState
}
diff --git a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt
index 1c39f29da..6adfe0a67 100644
--- a/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt
+++ b/feature/topic/src/main/kotlin/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt
@@ -177,12 +177,12 @@ private fun newsUiState(
sealed interface TopicUiState {
data class Success(val followableTopic: FollowableTopic) : TopicUiState
- object Error : TopicUiState
- object Loading : TopicUiState
+ data object Error : TopicUiState
+ data object Loading : TopicUiState
}
sealed interface NewsUiState {
data class Success(val news: List) : NewsUiState
- object Error : NewsUiState
- object Loading : NewsUiState
+ data object Error : NewsUiState
+ data object Loading : NewsUiState
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 516c4b30d..724b9260b 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -5,7 +5,7 @@ androidGradlePlugin = "8.1.2"
androidxActivity = "1.8.0"
androidxAppCompat = "1.6.1"
androidxBrowser = "1.6.0"
-androidxComposeBom = "2023.10.00"
+androidxComposeBom = "2023.10.01"
androidxComposeCompiler = "1.5.3"
androidxComposeRuntimeTracing = "1.0.0-alpha03"
androidxCore = "1.12.0"
@@ -14,7 +14,7 @@ androidxDataStore = "1.0.0"
androidxEspresso = "3.5.1"
androidxHiltNavigationCompose = "1.0.0"
androidxLifecycle = "2.6.2"
-androidxMacroBenchmark = "1.2.0-rc02"
+androidxMacroBenchmark = "1.2.0"
androidxMetrics = "1.0.0-alpha04"
androidxNavigation = "2.7.4"
androidxProfileinstaller = "1.3.1"
@@ -26,7 +26,7 @@ androidxTestRunner = "1.5.2"
androidxTracing = "1.1.0"
androidxUiAutomator = "2.2.0"
androidxWindowManager = "1.1.0"
-androidxWork = "2.9.0-beta01"
+androidxWork = "2.9.0-rc01"
coil = "2.4.0"
firebaseBom = "32.5.0"
firebaseCrashlyticsPlugin = "2.9.9"
@@ -44,14 +44,14 @@ kotlinxDatetime = "0.4.1"
kotlinxSerializationJson = "1.6.0"
ksp = "1.9.10-1.0.13"
lint = "31.1.2"
-okhttp = "4.11.0"
+okhttp = "4.12.0"
protobuf = "3.24.4"
protobufPlugin = "0.9.4"
retrofit = "2.9.0"
retrofitKotlinxSerializationJson = "1.0.0"
robolectric = "4.10.3"
-roborazzi = "1.5.0"
-room = "2.5.2"
+roborazzi = "1.6.0"
+room = "2.6.0"
secrets = "2.0.1"
turbine = "1.0.0"
@@ -122,6 +122,8 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
lint-api = { group = "com.android.tools.lint", name = "lint-api", version.ref = "lint" }
+lint-checks = { group = "com.android.tools.lint", name = "lint-checks", version.ref = "lint" }
+lint-tests = { group = "com.android.tools.lint", name = "lint-tests", version.ref = "lint" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" }
protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
diff --git a/lint/build.gradle.kts b/lint/build.gradle.kts
index c7032cf40..acb540c3b 100644
--- a/lint/build.gradle.kts
+++ b/lint/build.gradle.kts
@@ -38,4 +38,7 @@ tasks.withType().configureEach {
dependencies {
compileOnly(libs.kotlin.stdlib)
compileOnly(libs.lint.api)
+ testImplementation(libs.lint.checks)
+ testImplementation(libs.lint.tests)
+ testImplementation(kotlin("test"))
}
diff --git a/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemIssueRegistry.kt b/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/NiaIssueRegistry.kt
similarity index 76%
rename from lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemIssueRegistry.kt
rename to lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/NiaIssueRegistry.kt
index bb7e971e3..b806312fd 100644
--- a/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemIssueRegistry.kt
+++ b/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/NiaIssueRegistry.kt
@@ -14,18 +14,20 @@
* limitations under the License.
*/
-package com.google.samples.apps.nowinandroid.lint.designsystem
+package com.google.samples.apps.nowinandroid.lint
import com.android.tools.lint.client.api.IssueRegistry
import com.android.tools.lint.client.api.Vendor
import com.android.tools.lint.detector.api.CURRENT_API
+import com.google.samples.apps.nowinandroid.lint.designsystem.DesignSystemDetector
-/**
- * An issue registry that checks for incorrect usages of Compose Material APIs over equivalents in
- * the Now in Android design system module.
- */
-class DesignSystemIssueRegistry : IssueRegistry() {
- override val issues = listOf(DesignSystemDetector.ISSUE)
+class NiaIssueRegistry : IssueRegistry() {
+
+ override val issues = listOf(
+ DesignSystemDetector.ISSUE,
+ TestMethodNameDetector.FORMAT,
+ TestMethodNameDetector.PREFIX,
+ )
override val api: Int = CURRENT_API
diff --git a/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodNameDetector.kt b/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodNameDetector.kt
new file mode 100644
index 000000000..532994d99
--- /dev/null
+++ b/lint/src/main/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodNameDetector.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.samples.apps.nowinandroid.lint
+
+import com.android.tools.lint.detector.api.AnnotationInfo
+import com.android.tools.lint.detector.api.AnnotationUsageInfo
+import com.android.tools.lint.detector.api.Category.Companion.TESTING
+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.LintFix
+import com.android.tools.lint.detector.api.Scope.JAVA_FILE
+import com.android.tools.lint.detector.api.Scope.TEST_SOURCES
+import com.android.tools.lint.detector.api.Severity.WARNING
+import com.android.tools.lint.detector.api.SourceCodeScanner
+import com.android.tools.lint.detector.api.TextFormat.RAW
+import com.intellij.psi.PsiMethod
+import org.jetbrains.uast.UElement
+import java.util.EnumSet
+import kotlin.io.path.Path
+
+/**
+ * A detector that checks for common patterns in naming the test methods:
+ * - [detectPrefix] removes unnecessary "test" prefix in all unit test.
+ * - [detectFormat] Checks the `given_when_then` format of Android instrumented tests (backticks are not supported).
+ */
+class TestMethodNameDetector : Detector(), SourceCodeScanner {
+
+ override fun applicableAnnotations() = listOf("org.junit.Test")
+
+ override fun visitAnnotationUsage(
+ context: JavaContext,
+ element: UElement,
+ annotationInfo: AnnotationInfo,
+ usageInfo: AnnotationUsageInfo,
+ ) {
+ val method = usageInfo.referenced as? PsiMethod ?: return
+
+ method.detectPrefix(context, usageInfo)
+ method.detectFormat(context, usageInfo)
+ }
+
+ private fun JavaContext.isAndroidTest() = Path("androidTest") in file.toPath()
+
+ private fun PsiMethod.detectPrefix(
+ context: JavaContext,
+ usageInfo: AnnotationUsageInfo,
+ ) {
+ if (!name.startsWith("test")) return
+ context.report(
+ issue = PREFIX,
+ scope = usageInfo.usage,
+ location = context.getNameLocation(this),
+ message = PREFIX.getBriefDescription(RAW),
+ quickfixData = LintFix.create()
+ .name("Remove prefix")
+ .replace().pattern("""test[\s_]*""")
+ .with("")
+ .autoFix()
+ .build(),
+ )
+ }
+
+ private fun PsiMethod.detectFormat(
+ context: JavaContext,
+ usageInfo: AnnotationUsageInfo,
+ ) {
+ if (!context.isAndroidTest()) return
+ if ("""[^\W_]+(_[^\W_]+){1,2}""".toRegex().matches(name)) return
+ context.report(
+ issue = FORMAT,
+ scope = usageInfo.usage,
+ location = context.getNameLocation(this),
+ message = FORMAT.getBriefDescription(RAW),
+ )
+ }
+
+ companion object {
+
+ private fun issue(
+ id: String,
+ briefDescription: String,
+ explanation: String,
+ ): Issue = Issue.create(
+ id = id,
+ briefDescription = briefDescription,
+ explanation = explanation,
+ category = TESTING,
+ priority = 5,
+ severity = WARNING,
+ implementation = Implementation(
+ TestMethodNameDetector::class.java,
+ EnumSet.of(JAVA_FILE, TEST_SOURCES),
+ ),
+ )
+
+ @JvmField
+ val PREFIX: Issue = issue(
+ id = "TestMethodPrefix",
+ briefDescription = "Test method starts with `test`",
+ explanation = "Test method should not start with `test`.",
+ )
+
+ @JvmField
+ val FORMAT: Issue = issue(
+ id = "TestMethodFormat",
+ briefDescription = "Test method does not follow the `given_when_then` or `when_then` format",
+ explanation = "Test method should follow the `given_when_then` or `when_then` format.",
+ )
+ }
+}
diff --git a/lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry b/lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry
index 4b8002da2..e673c27ff 100644
--- a/lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry
+++ b/lint/src/main/resources/META-INF/services/com.android.tools.lint.client.api.IssueRegistry
@@ -14,4 +14,4 @@
# limitations under the License.
#
-com.google.samples.apps.nowinandroid.lint.designsystem.DesignSystemIssueRegistry
+com.google.samples.apps.nowinandroid.lint.NiaIssueRegistry
diff --git a/lint/src/test/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodNameDetectorTest.kt b/lint/src/test/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodNameDetectorTest.kt
new file mode 100644
index 000000000..8da173285
--- /dev/null
+++ b/lint/src/test/kotlin/com/google/samples/apps/nowinandroid/lint/TestMethodNameDetectorTest.kt
@@ -0,0 +1,123 @@
+/*
+ * Copyright 2023 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.samples.apps.nowinandroid.lint
+
+import com.android.tools.lint.checks.infrastructure.TestFile
+import com.android.tools.lint.checks.infrastructure.TestFiles.kotlin
+import com.android.tools.lint.checks.infrastructure.TestLintTask.lint
+import com.google.samples.apps.nowinandroid.lint.TestMethodNameDetector.Companion.FORMAT
+import com.google.samples.apps.nowinandroid.lint.TestMethodNameDetector.Companion.PREFIX
+import org.junit.Test
+
+class TestMethodNameDetectorTest {
+
+ @Test
+ fun `detect prefix`() {
+ lint().issues(PREFIX)
+ .files(
+ JUNIT_TEST_STUB,
+ kotlin(
+ """
+ import org.junit.Test
+ class Test {
+ @Test
+ fun foo() = Unit
+ @Test
+ fun test_foo() = Unit
+ @Test
+ fun `test foo`() = Unit
+ }
+ """,
+ ).indented(),
+ )
+ .run()
+ .expect(
+ """
+ src/Test.kt:6: Warning: Test method starts with test [TestMethodPrefix]
+ fun test_foo() = Unit
+ ~~~~~~~~
+ src/Test.kt:8: Warning: Test method starts with test [TestMethodPrefix]
+ fun `test foo`() = Unit
+ ~~~~~~~~~~
+ 0 errors, 2 warnings
+ """.trimIndent(),
+ )
+ .expectFixDiffs(
+ """
+ Autofix for src/Test.kt line 6: Remove prefix:
+ @@ -6 +6
+ - fun test_foo() = Unit
+ + fun foo() = Unit
+ Autofix for src/Test.kt line 8: Remove prefix:
+ @@ -8 +8
+ - fun `test foo`() = Unit
+ + fun `foo`() = Unit
+ """.trimIndent(),
+ )
+ }
+
+ @Test
+ fun `detect format`() {
+ lint().issues(FORMAT)
+ .files(
+ JUNIT_TEST_STUB,
+ kotlin(
+ "src/androidTest/com/example/Test.kt",
+ """
+ import org.junit.Test
+ class Test {
+ @Test
+ fun when_then() = Unit
+ @Test
+ fun given_when_then() = Unit
+
+ @Test
+ fun foo() = Unit
+ @Test
+ fun foo_bar_baz_qux() = Unit
+ @Test
+ fun `foo bar baz`() = Unit
+ }
+ """,
+ ).indented(),
+ )
+ .run()
+ .expect(
+ """
+ src/androidTest/com/example/Test.kt:9: Warning: Test method does not follow the given_when_then or when_then format [TestMethodFormat]
+ fun foo() = Unit
+ ~~~
+ src/androidTest/com/example/Test.kt:11: Warning: Test method does not follow the given_when_then or when_then format [TestMethodFormat]
+ fun foo_bar_baz_qux() = Unit
+ ~~~~~~~~~~~~~~~
+ src/androidTest/com/example/Test.kt:13: Warning: Test method does not follow the given_when_then or when_then format [TestMethodFormat]
+ fun `foo bar baz`() = Unit
+ ~~~~~~~~~~~~~
+ 0 errors, 3 warnings
+ """.trimIndent(),
+ )
+ }
+
+ private companion object {
+ private val JUNIT_TEST_STUB: TestFile = kotlin(
+ """
+ package org.junit
+ annotation class Test
+ """,
+ ).indented()
+ }
+}