diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml index 04139b015..9a0dca106 100644 --- a/.github/workflows/Build.yaml +++ b/.github/workflows/Build.yaml @@ -13,7 +13,7 @@ concurrency: jobs: build: runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 90 steps: - name: Checkout @@ -37,27 +37,76 @@ jobs: - name: Check spotless run: ./gradlew spotlessCheck --init-script gradle/init.gradle.kts --no-configuration-cache - - name: Check lint - run: ./gradlew lintDemoDebug - - name: Build all build type and flavor permutations run: ./gradlew assemble - - name: Run local tests - run: ./gradlew testDemoDebug testProdDebug - - name: Upload build outputs (APKs) uses: actions/upload-artifact@v3 with: name: APKs path: '**/build/outputs/apk/**/*.apk' - - name: Upload lint reports (HTML) - if: always() - uses: actions/upload-artifact@v3 + - name: Run local tests + run: ./gradlew testDemoDebug testProdDebug + + test: + runs-on: ubuntu-latest + + permissions: + contents: write + + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Validate Gradle Wrapper + uses: gradle/wrapper-validation-action@v1 + + - name: Copy CI gradle.properties + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - name: Set up JDK 17 + uses: actions/setup-java@v3 with: - name: lint-reports - path: '**/build/reports/lint-results-*.html' + distribution: 'zulu' + java-version: 17 + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 + + - name: Run all local screenshot tests (Roborazzi) + id: screenshotsverify + continue-on-error: true + run: ./gradlew verifyRoborazziDemoDebug + + - name: Prevent pushing new screenshots if this is a fork + id: checkfork + continue-on-error: false + if: steps.screenshotsverify.outcome == 'failure' && github.event.pull_request.head.repo.full_name != github.repository + run: | + echo "::error::Screenshot tests failed, please create a PR in your fork first." && exit 1 + + # Runs if previous job failed + - name: Generate new screenshots if verification failed and it's a PR + id: screenshotsrecord + if: steps.screenshotsverify.outcome == 'failure' && github.event_name == 'pull_request' + run: | + ./gradlew recordRoborazziDemoDebug + + - name: Push new screenshots if available + uses: stefanzweifel/git-auto-commit-action@v4 + if: steps.screenshotsrecord.outcome == 'success' + with: + file_pattern: '*/*.png' + disable_globbing: true + commit_message: "🤖 Updates screenshots" + + # Run local tests after screenshot tests to avoid wrong UP-TO-DATE. TODO: Ignore screenshots. + - name: Run local tests + if: always() + run: ./gradlew testDemoDebug testProdDebug - name: Upload test results (XML) if: always() @@ -66,6 +115,16 @@ jobs: name: test-results path: '**/build/test-results/test*UnitTest/**.xml' + - name: Check lint + run: ./gradlew :app:lintProdRelease :app-nia-catalog:lintRelease :lint:lint + + - name: Upload lint reports (HTML) + if: always() + uses: actions/upload-artifact@v3 + with: + name: lint-reports + path: '**/build/reports/lint-results-*.html' + androidTest: needs: build runs-on: macOS-latest # enables hardware acceleration in the virtual machine @@ -77,7 +136,7 @@ jobs: steps: - name: Checkout uses: actions/checkout@v3 - + - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties @@ -113,7 +172,7 @@ jobs: androidTest-GMD: needs: build runs-on: macOS-latest # enables hardware acceleration in the virtual machine - timeout-minutes: 55 + timeout-minutes: 90 steps: - name: Checkout @@ -138,13 +197,16 @@ jobs: run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest - name: Run instrumented tests with GMD - run: ./gradlew cleanManagedDevices --unused-only && - ./gradlew ciDemoDebugAndroidTest -Dorg.gradle.workers.max=1 + run: ./gradlew ciDemoDebugAndroidTest --no-parallel --max-workers=1 -Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true - name: Upload test reports if: success() || failure() uses: actions/upload-artifact@v3 with: - name: test-reports + name: test-reports-GMD path: '**/build/reports/androidTests' + + - name: Print disk space usage + if: failure() + run: df -h diff --git a/README.md b/README.md index 9ac61c0af..b71427dfe 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,13 @@ Examples: manipulate the state of the `Test` repository and verify the resulting behavior, instead of checking that specific repository methods were called. +## Screenshot tests + +**Now In Android** uses [Roborazzi](https://github.com/takahirom/roborazzi) to do screenshot tests +of certain screens and components. To run these tests, run the `verifyRoborazziDemoDebug` or +`recordRoborazziDemoDebug` tasks. Note that screenshots are recorded on CI, using Linux, and other +platforms might generate slightly different images, making the tests fail. + # UI The app was designed using [Material 3 guidelines](https://m3.material.io/). Learn more about the design process and obtain the design files in the [Now in Android Material 3 Case Study](https://goo.gle/nia-figma) (design assets [also available as a PDF](docs/Now-In-Android-Design-File.pdf)). diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bca633d5d..81947e641 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -120,4 +120,16 @@ dependencies { implementation(libs.androidx.profileinstaller) implementation(libs.kotlinx.coroutines.guava) implementation(libs.coil.kt) + + // Core functions + testImplementation(project(":core:testing")) + testImplementation(project(":core:datastore-test")) + testImplementation(project(":core:data-test")) + testImplementation(project(":core:network")) + testImplementation(libs.androidx.navigation.testing) + testImplementation(libs.accompanist.testharness) + testImplementation(kotlin("test")) + implementation(libs.work.testing) + kaptTest(libs.hilt.compiler) + } diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt index 1d600b53d..3d58ed5a6 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt @@ -49,8 +49,7 @@ fun NiaNavHost( startDestination = startDestination, modifier = modifier, ) { - // TODO: handle topic clicks from each top level destination - forYouScreen(onTopicClick = {}) + forYouScreen(onTopicClick = navController::navigateToTopic) bookmarksScreen( onTopicClick = navController::navigateToTopic, onShowSnackbar = onShowSnackbar, @@ -61,13 +60,11 @@ fun NiaNavHost( onTopicClick = navController::navigateToTopic, ) interestsGraph( - onTopicClick = { topicId -> - navController.navigateToTopic(topicId) - }, + onTopicClick = navController::navigateToTopic, nestedGraphs = { topicScreen( onBackClick = navController::popBackStack, - onTopicClick = {}, + onTopicClick = navController::navigateToTopic, ) }, ) diff --git a/app/src/testDemo/java/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt b/app/src/testDemo/java/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt new file mode 100644 index 000000000..94563abe4 --- /dev/null +++ b/app/src/testDemo/java/com/google/samples/apps/nowinandroid/ui/NiaAppScreenSizesScreenshotTests.kt @@ -0,0 +1,242 @@ +/* + * Copyright 2022 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.ui + +import android.util.Log +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.WindowSizeClass +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.Configuration +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.WorkManagerTestInitHelper +import com.github.takahirom.roborazzi.captureRoboImage +import com.google.accompanist.testharness.TestHarness +import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository +import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository +import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor +import com.google.samples.apps.nowinandroid.core.testing.util.DefaultRoborazziOptions +import com.google.samples.apps.nowinandroid.uitesthiltmanifest.HiltComponentActivity +import dagger.hilt.android.testing.BindValue +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.HiltTestApplication +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.LooperMode +import java.util.TimeZone +import javax.inject.Inject + +/** + * Tests that the navigation UI is rendered correctly on different screen sizes. + */ +@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +// Configure Robolectric to use a very large screen size that can fit all of the test sizes. +// This allows enough room to render the content under test without clipping or scaling. +@Config(application = HiltTestApplication::class, qualifiers = "w1000dp-h1000dp-480dpi", sdk = [33]) +@LooperMode(LooperMode.Mode.PAUSED) +@HiltAndroidTest +class NiaAppScreenSizesScreenshotTests { + + /** + * Manages the components' state and is used to perform injection on your test + */ + @get:Rule(order = 0) + val hiltRule = HiltAndroidRule(this) + + /** + * Create a temporary folder used to create a Data Store file. This guarantees that + * the file is removed in between each test, preventing a crash. + */ + @BindValue + @get:Rule(order = 1) + val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() + + /** + * Use a test activity to set the content on. + */ + @get:Rule(order = 2) + val composeTestRule = createAndroidComposeRule() + + @Inject + lateinit var networkMonitor: NetworkMonitor + + @Inject + lateinit var userDataRepository: UserDataRepository + + @Inject + lateinit var topicsRepository: TopicsRepository + + @Inject + lateinit var userNewsResourceRepository: UserNewsResourceRepository + + @Before + fun setup() { + val config = Configuration.Builder() + .setMinimumLoggingLevel(Log.DEBUG) + .setExecutor(SynchronousExecutor()) + .build() + + // Initialize WorkManager for instrumentation tests. + WorkManagerTestInitHelper.initializeTestWorkManager( + InstrumentationRegistry.getInstrumentation().context, + config, + ) + + hiltRule.inject() + + // Configure user data + runBlocking { + userDataRepository.setShouldHideOnboarding(true) + + userDataRepository.setFollowedTopicIds( + setOf(topicsRepository.getTopics().first().first().id), + ) + } + } + + @Before + fun setTimeZone() { + // Make time zone deterministic in tests + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + + private fun testNiaAppScreenshotWithSize(width: Dp, height: Dp, screenshotName: String) { + composeTestRule.setContent { + CompositionLocalProvider( + LocalInspectionMode provides true, + ) { + TestHarness(size = DpSize(width, height)) { + BoxWithConstraints { + NiaApp( + windowSizeClass = WindowSizeClass.calculateFromSize( + DpSize(maxWidth, maxHeight), + ), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, + ) + } + } + } + } + + composeTestRule.onRoot() + .captureRoboImage( + "src/testDemo/screenshots/$screenshotName.png", + roborazziOptions = DefaultRoborazziOptions, + ) + } + + @Test + fun compactWidth_compactHeight_showsNavigationBar() { + testNiaAppScreenshotWithSize( + 610.dp, + 400.dp, + "compactWidth_compactHeight_showsNavigationBar", + ) + } + + @Test + fun mediumWidth_compactHeight_showsNavigationRail() { + testNiaAppScreenshotWithSize( + 610.dp, + 400.dp, + "mediumWidth_compactHeight_showsNavigationRail", + ) + } + + @Test + fun expandedWidth_compactHeight_showsNavigationRail() { + testNiaAppScreenshotWithSize( + 900.dp, + 400.dp, + "expandedWidth_compactHeight_showsNavigationRail", + ) + } + + @Test + fun compactWidth_mediumHeight_showsNavigationBar() { + testNiaAppScreenshotWithSize( + 400.dp, + 500.dp, + "compactWidth_mediumHeight_showsNavigationBar", + ) + } + + @Test + fun mediumWidth_mediumHeight_showsNavigationRail() { + testNiaAppScreenshotWithSize( + 610.dp, + 500.dp, + "mediumWidth_mediumHeight_showsNavigationRail", + ) + } + + @Test + fun expandedWidth_mediumHeight_showsNavigationRail() { + testNiaAppScreenshotWithSize( + 900.dp, + 500.dp, + "expandedWidth_mediumHeight_showsNavigationRail", + ) + } + + @Test + fun compactWidth_expandedHeight_showsNavigationBar() { + testNiaAppScreenshotWithSize( + 400.dp, + 1000.dp, + "compactWidth_expandedHeight_showsNavigationBar", + ) + } + + @Test + fun mediumWidth_expandedHeight_showsNavigationRail() { + testNiaAppScreenshotWithSize( + 610.dp, + 1000.dp, + "mediumWidth_expandedHeight_showsNavigationRail", + ) + } + + @Test + fun expandedWidth_expandedHeight_showsNavigationRail() { + testNiaAppScreenshotWithSize( + 900.dp, + 1000.dp, + "expandedWidth_expandedHeight_showsNavigationRail", + ) + } +} diff --git a/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png new file mode 100644 index 000000000..56b49457c Binary files /dev/null and b/app/src/testDemo/screenshots/compactWidth_compactHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png new file mode 100644 index 000000000..035cc24cf Binary files /dev/null and b/app/src/testDemo/screenshots/compactWidth_expandedHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/compactWidth_mediumHeight_showsNavigationBar.png b/app/src/testDemo/screenshots/compactWidth_mediumHeight_showsNavigationBar.png new file mode 100644 index 000000000..7749199c5 Binary files /dev/null and b/app/src/testDemo/screenshots/compactWidth_mediumHeight_showsNavigationBar.png differ diff --git a/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationRail.png new file mode 100644 index 000000000..fe5b045aa Binary files /dev/null and b/app/src/testDemo/screenshots/expandedWidth_compactHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png new file mode 100644 index 000000000..25283c111 Binary files /dev/null and b/app/src/testDemo/screenshots/expandedWidth_expandedHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/expandedWidth_mediumHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/expandedWidth_mediumHeight_showsNavigationRail.png new file mode 100644 index 000000000..58d620f21 Binary files /dev/null and b/app/src/testDemo/screenshots/expandedWidth_mediumHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationRail.png new file mode 100644 index 000000000..56b49457c Binary files /dev/null and b/app/src/testDemo/screenshots/mediumWidth_compactHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png new file mode 100644 index 000000000..15ddccf78 Binary files /dev/null and b/app/src/testDemo/screenshots/mediumWidth_expandedHeight_showsNavigationRail.png differ diff --git a/app/src/testDemo/screenshots/mediumWidth_mediumHeight_showsNavigationRail.png b/app/src/testDemo/screenshots/mediumWidth_mediumHeight_showsNavigationRail.png new file mode 100644 index 000000000..d2e4bb8bc Binary files /dev/null and b/app/src/testDemo/screenshots/mediumWidth_mediumHeight_showsNavigationRail.png differ diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index c8e25e17c..9230dd6b7 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -92,6 +92,10 @@ gradlePlugin { id = "nowinandroid.android.application.flavors" implementationClass = "AndroidApplicationFlavorsConventionPlugin" } + register("androidLint") { + id = "nowinandroid.android.lint" + implementationClass = "AndroidLintConventionPlugin" + } register("jvmLibrary") { id = "nowinandroid.jvm.library" implementationClass = "JvmLibraryConventionPlugin" diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt index cf90b17af..bb79715e4 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationComposeConventionPlugin.kt @@ -24,9 +24,12 @@ class AndroidApplicationComposeConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { pluginManager.apply("com.android.application") + // Screenshot Tests + pluginManager.apply("io.github.takahirom.roborazzi") + val extension = extensions.getByType() configureAndroidCompose(extension) } } -} \ No newline at end of file +} diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 432c2400c..50baf3dc6 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -29,6 +29,7 @@ class AndroidApplicationConventionPlugin : Plugin { with(pluginManager) { apply("com.android.application") apply("org.jetbrains.kotlin.android") + apply("nowinandroid.android.lint") } extensions.configure { diff --git a/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt index b98673619..a0e81a27c 100644 --- a/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt @@ -33,9 +33,10 @@ class AndroidHiltConventionPlugin : Plugin { "implementation"(libs.findLibrary("hilt.android").get()) "kapt"(libs.findLibrary("hilt.compiler").get()) "kaptAndroidTest"(libs.findLibrary("hilt.compiler").get()) + "kaptTest"(libs.findLibrary("hilt.compiler").get()) } } } -} \ No newline at end of file +} diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt index ee6192e05..707ca8055 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryComposeConventionPlugin.kt @@ -24,9 +24,12 @@ class AndroidLibraryComposeConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { pluginManager.apply("com.android.library") + // Screenshot Tests + pluginManager.apply("io.github.takahirom.roborazzi") + val extension = extensions.getByType() configureAndroidCompose(extension) } } -} \ No newline at end of file +} diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index 3ea2290f5..a7c245318 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -33,6 +33,7 @@ class AndroidLibraryConventionPlugin : Plugin { with(pluginManager) { apply("com.android.library") apply("org.jetbrains.kotlin.android") + apply("nowinandroid.android.lint") } extensions.configure { diff --git a/build-logic/convention/src/main/kotlin/AndroidLintConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLintConventionPlugin.kt new file mode 100644 index 000000000..1734df930 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidLintConventionPlugin.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2022 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. + */ + +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.LibraryExtension +import com.android.build.api.dsl.Lint +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure + +class AndroidLintConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + when { + pluginManager.hasPlugin("com.android.application") -> + configure { lint(Lint::configure) } + + pluginManager.hasPlugin("com.android.library") -> + configure { lint(Lint::configure) } + + else -> { + pluginManager.apply("com.android.lint") + configure(Lint::configure) + } + } + } + } +} + +private fun Lint.configure() { + xmlReport = true + checkDependencies = true +} diff --git a/build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt index 4067e289b..35932c835 100644 --- a/build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/JvmLibraryConventionPlugin.kt @@ -23,6 +23,7 @@ class JvmLibraryConventionPlugin : Plugin { with(target) { with(pluginManager) { apply("org.jetbrains.kotlin.jvm") + apply("nowinandroid.android.lint") } configureKotlinJvm() } diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt index 7696c6b53..186f0b3d3 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt @@ -41,6 +41,18 @@ internal fun Project.configureAndroidCompose( val bom = libs.findLibrary("androidx-compose-bom").get() add("implementation", platform(bom)) add("androidTestImplementation", platform(bom)) + // Add ComponentActivity to debug manfest + add("debugImplementation", libs.findLibrary("androidx.compose.ui.testManifest").get()) + // Screenshot Tests on JVM + add("testImplementation", libs.findLibrary("robolectric").get()) + add("testImplementation", libs.findLibrary("roborazzi").get()) + } + + testOptions { + unitTests { + // For Robolectric + isIncludeAndroidResources = true + } } } diff --git a/build.gradle.kts b/build.gradle.kts index 1054e6be2..1efa3f8be 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -39,5 +39,6 @@ plugins { alias(libs.plugins.gms) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.ksp) apply false + alias(libs.plugins.roborazzi) apply false alias(libs.plugins.secrets) apply false -} \ No newline at end of file +} diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts index a40926383..cf9873e2c 100644 --- a/core/designsystem/build.gradle.kts +++ b/core/designsystem/build.gradle.kts @@ -23,9 +23,6 @@ android { defaultConfig { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } - lint { - checkDependencies = true - } namespace = "com.google.samples.apps.nowinandroid.core.designsystem" } diff --git a/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/model/NetworkNewsResource.kt b/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/model/NetworkNewsResource.kt index 42fbfa826..89af19c99 100644 --- a/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/model/NetworkNewsResource.kt +++ b/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/model/NetworkNewsResource.kt @@ -17,7 +17,6 @@ package com.google.samples.apps.nowinandroid.core.network.model import com.google.samples.apps.nowinandroid.core.model.data.NewsResource -import com.google.samples.apps.nowinandroid.core.network.model.util.InstantSerializer import kotlinx.datetime.Instant import kotlinx.serialization.Serializable @@ -31,7 +30,6 @@ data class NetworkNewsResource( val content: String, val url: String, val headerImageUrl: String, - @Serializable(InstantSerializer::class) val publishDate: Instant, val type: String, val topics: List = listOf(), @@ -47,7 +45,6 @@ data class NetworkNewsResourceExpanded( val content: String, val url: String, val headerImageUrl: String, - @Serializable(InstantSerializer::class) val publishDate: Instant, val type: String, val topics: List = listOf(), diff --git a/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/model/util/InstantSerializer.kt b/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/model/util/InstantSerializer.kt deleted file mode 100644 index dec73dc10..000000000 --- a/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/model/util/InstantSerializer.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright 2022 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.core.network.model.util - -import kotlinx.datetime.Instant -import kotlinx.datetime.toInstant -import kotlinx.serialization.KSerializer -import kotlinx.serialization.descriptors.PrimitiveKind.STRING -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -object InstantSerializer : KSerializer { - override fun deserialize(decoder: Decoder): Instant = - decoder.decodeString().toInstant() - - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor( - serialName = "Instant", - kind = STRING, - ) - - override fun serialize(encoder: Encoder, value: Instant) = - encoder.encodeString(value.toString()) -} diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index bae91fdb9..6cba0086d 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -24,6 +24,8 @@ android { } dependencies { + api(libs.accompanist.testharness) + api(libs.androidx.activity.compose) api(libs.androidx.compose.ui.test) api(libs.androidx.test.core) api(libs.androidx.test.espresso.core) @@ -32,6 +34,8 @@ dependencies { api(libs.hilt.android.testing) api(libs.junit4) api(libs.kotlinx.coroutines.test) + api(libs.roborazzi) + api(libs.robolectric.shadows) api(libs.turbine) debugApi(libs.androidx.compose.ui.testManifest) diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt new file mode 100644 index 000000000..85a95b796 --- /dev/null +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/ScreenshotHelper.kt @@ -0,0 +1,95 @@ +/* + * 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.core.testing.util + +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.onRoot +import androidx.test.ext.junit.rules.ActivityScenarioRule +import com.github.takahirom.roborazzi.RoborazziOptions +import com.github.takahirom.roborazzi.RoborazziOptions.CompareOptions +import com.github.takahirom.roborazzi.RoborazziOptions.RecordOptions +import com.github.takahirom.roborazzi.captureRoboImage +import com.google.accompanist.testharness.TestHarness +import org.robolectric.RuntimeEnvironment + +val DefaultRoborazziOptions = + RoborazziOptions( + compareOptions = CompareOptions(changeThreshold = 0f), // Pixel-perfect matching + recordOptions = RecordOptions(resizeScale = 0.5), // Reduce the size of the PNGs + ) + +enum class DefaultTestDevices(val description: String, val spec: String) { + PHONE("phone", "spec:shape=Normal,width=640,height=360,unit=dp,dpi=480"), + FOLDABLE("foldable", "spec:shape=Normal,width=673,height=841,unit=dp,dpi=480"), + TABLET("tablet", "spec:shape=Normal,width=1280,height=800,unit=dp,dpi=480"), +} +fun AndroidComposeTestRule, A>.captureMultiDevice( + screenshotName: String, + body: @Composable () -> Unit, +) { + DefaultTestDevices.values().forEach { + this.captureForDevice(it.description, it.spec, screenshotName, body = body) + } +} + +fun AndroidComposeTestRule, A>.captureForDevice( + deviceName: String, + deviceSpec: String, + screenshotName: String, + roborazziOptions: RoborazziOptions = DefaultRoborazziOptions, + darkMode: Boolean = false, + body: @Composable () -> Unit, +) { + val (width, height, dpi) = extractSpecs(deviceSpec) + + // Set qualifiers from specs + RuntimeEnvironment.setQualifiers("w${width}dp-h${height}dp-${dpi}dpi") + + this.activity.setContent { + CompositionLocalProvider( + LocalInspectionMode provides true, + ) { + TestHarness(darkMode = darkMode) { + body() + } + } + } + this.onRoot() + .captureRoboImage( + "src/test/screenshots/${screenshotName}_$deviceName.png", + roborazziOptions = roborazziOptions, + ) +} + +/** + * Extracts some properties from the spec string. Note that this function is not exhaustive. + */ +private fun extractSpecs(deviceSpec: String): TestDeviceSpecs { + val specs = deviceSpec.substringAfter("spec:") + .split(",").map { it.split("=") }.associate { it[0] to it[1] } + val width = specs["width"]?.toInt() ?: 640 + val height = specs["height"]?.toInt() ?: 480 + val dpi = specs["dpi"]?.toInt() ?: 480 + return TestDeviceSpecs(width, height, dpi) +} + +data class TestDeviceSpecs(val width: Int, val height: Int, val dpi: Int) diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index b7280e757..044abedaf 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -44,6 +44,7 @@ dependencies { implementation(project(":core:designsystem")) implementation(project(":core:domain")) implementation(project(":core:model")) + implementation(libs.androidx.activity.compose) implementation(libs.androidx.browser) implementation(libs.androidx.core.ktx) implementation(libs.coil.kt) @@ -51,4 +52,4 @@ dependencies { implementation(libs.kotlinx.datetime) androidTestImplementation(project(":core:testing")) -} \ No newline at end of file +} diff --git a/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenScreenshotTests.kt b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenScreenshotTests.kt new file mode 100644 index 000000000..1c521d419 --- /dev/null +++ b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenScreenshotTests.kt @@ -0,0 +1,195 @@ +/* + * 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.feature.foryou + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground +import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme +import com.google.samples.apps.nowinandroid.core.testing.util.DefaultTestDevices +import com.google.samples.apps.nowinandroid.core.testing.util.captureForDevice +import com.google.samples.apps.nowinandroid.core.testing.util.captureMultiDevice +import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState +import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success +import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider +import com.google.samples.apps.nowinandroid.feature.foryou.OnboardingUiState.Loading +import com.google.samples.apps.nowinandroid.feature.foryou.OnboardingUiState.NotShown +import com.google.samples.apps.nowinandroid.feature.foryou.OnboardingUiState.Shown +import dagger.hilt.android.testing.HiltTestApplication +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.annotation.LooperMode +import java.util.TimeZone + +/** + * Screenshot tests for the [ForYouScreen]. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(application = HiltTestApplication::class, sdk = [33]) +@LooperMode(LooperMode.Mode.PAUSED) +class ForYouScreenScreenshotTests { + + /** + * Use a test activity to set the content on. + */ + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val userNewsResources = UserNewsResourcePreviewParameterProvider().values.first() + + @Before + fun setTimeZone() { + // Make time zone deterministic in tests + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + + @Test + fun forYouScreenPopulatedFeed() { + composeTestRule.captureMultiDevice("ForYouScreenPopulatedFeed") { + NiaTheme { + ForYouScreen( + isSyncing = false, + onboardingUiState = NotShown, + feedState = Success( + feed = userNewsResources, + ), + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, + onTopicClick = {}, + deepLinkedUserNewsResource = null, + onDeepLinkOpened = {}, + ) + } + } + } + + @Test + fun forYouScreenLoading() { + composeTestRule.captureMultiDevice("ForYouScreenLoading") { + NiaTheme { + ForYouScreen( + isSyncing = false, + onboardingUiState = Loading, + feedState = NewsFeedUiState.Loading, + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, + onTopicClick = {}, + deepLinkedUserNewsResource = null, + onDeepLinkOpened = {}, + ) + } + } + } + + @Test + fun forYouScreenTopicSelection() { + composeTestRule.captureMultiDevice("ForYouScreenTopicSelection") { + ForYouScreenTopicSelection() + } + } + + @Test + fun forYouScreenTopicSelection_dark() { + composeTestRule.captureForDevice( + deviceName = "phone_dark", + deviceSpec = DefaultTestDevices.PHONE.spec, + screenshotName = "ForYouScreenTopicSelection", + darkMode = true, + ) { + ForYouScreenTopicSelection() + } + } + + @Test + fun forYouScreenPopulatedAndLoading() { + composeTestRule.captureMultiDevice("ForYouScreenPopulatedAndLoading") { + ForYouScreenPopulatedAndLoading() + } + } + + @Test + fun forYouScreenPopulatedAndLoading_dark() { + composeTestRule.captureForDevice( + deviceName = "phone_dark", + deviceSpec = DefaultTestDevices.PHONE.spec, + screenshotName = "ForYouScreenPopulatedAndLoading", + darkMode = true, + ) { + ForYouScreenPopulatedAndLoading() + } + } + + @Composable + private fun ForYouScreenTopicSelection() { + NiaTheme { + NiaBackground { + ForYouScreen( + isSyncing = false, + onboardingUiState = Shown( + topics = userNewsResources.flatMap { news -> news.followableTopics } + .distinctBy { it.topic.id }, + ), + feedState = Success( + feed = userNewsResources, + ), + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, + onTopicClick = {}, + deepLinkedUserNewsResource = null, + onDeepLinkOpened = {}, + ) + } + } + } + + @Composable + private fun ForYouScreenPopulatedAndLoading() { + NiaTheme { + NiaBackground { + NiaTheme { + ForYouScreen( + isSyncing = true, + onboardingUiState = Loading, + feedState = Success( + feed = userNewsResources, + ), + onTopicCheckedChanged = { _, _ -> }, + saveFollowedTopics = {}, + onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, + onTopicClick = {}, + deepLinkedUserNewsResource = null, + onDeepLinkOpened = {}, + ) + } + } + } + } +} diff --git a/feature/foryou/src/test/screenshots/ForYouScreenLoading_foldable.png b/feature/foryou/src/test/screenshots/ForYouScreenLoading_foldable.png new file mode 100644 index 000000000..92d2978e0 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenLoading_foldable.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenLoading_phone.png b/feature/foryou/src/test/screenshots/ForYouScreenLoading_phone.png new file mode 100644 index 000000000..0e6aedd53 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenLoading_phone.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenLoading_tablet.png b/feature/foryou/src/test/screenshots/ForYouScreenLoading_tablet.png new file mode 100644 index 000000000..88b6ce240 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenLoading_tablet.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png new file mode 100644 index 000000000..a66604bc6 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_foldable.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone.png new file mode 100644 index 000000000..579bc98a8 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone_dark.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone_dark.png new file mode 100644 index 000000000..f013bb40a Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_phone_dark.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_tablet.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_tablet.png new file mode 100644 index 000000000..75d6bc066 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedAndLoading_tablet.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png new file mode 100644 index 000000000..54ae3be02 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_foldable.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_phone.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_phone.png new file mode 100644 index 000000000..0c36a8913 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_phone.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_tablet.png b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_tablet.png new file mode 100644 index 000000000..021958401 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenPopulatedFeed_tablet.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_foldable.png b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_foldable.png new file mode 100644 index 000000000..715889be5 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_foldable.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone.png b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone.png new file mode 100644 index 000000000..0bbe04955 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone_dark.png b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone_dark.png new file mode 100644 index 000000000..1ba8943ef Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_phone_dark.png differ diff --git a/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_tablet.png b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_tablet.png new file mode 100644 index 000000000..9a51764c5 Binary files /dev/null and b/feature/foryou/src/test/screenshots/ForYouScreenTopicSelection_tablet.png differ diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/RecentSearchQueriesUiState.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/RecentSearchQueriesUiState.kt index 8628d2e54..8aa5bb3b8 100644 --- a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/RecentSearchQueriesUiState.kt +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/RecentSearchQueriesUiState.kt @@ -19,7 +19,7 @@ package com.google.samples.apps.nowinandroid.feature.search import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery sealed interface RecentSearchQueriesUiState { - object Loading : RecentSearchQueriesUiState + data object Loading : RecentSearchQueriesUiState data class Success( val recentQueries: List = emptyList(), diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt index 68ea623e8..aaf7dba7d 100644 --- a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt @@ -20,16 +20,16 @@ import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource sealed interface SearchResultUiState { - object Loading : SearchResultUiState + data object Loading : SearchResultUiState /** * The state query is empty or too short. To distinguish the state between the * (initial state or when the search query is cleared) vs the state where no search * result is returned, explicitly define the empty query state. */ - object EmptyQuery : SearchResultUiState + data object EmptyQuery : SearchResultUiState - object LoadFailed : SearchResultUiState + data object LoadFailed : SearchResultUiState data class Success( val topics: List = emptyList(), @@ -42,5 +42,5 @@ sealed interface SearchResultUiState { * A state where the search contents are not ready. This happens when the *Fts tables are not * populated yet. */ - object SearchNotReady : SearchResultUiState + data object SearchNotReady : SearchResultUiState } diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt index fede7766b..944d17630 100644 --- a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt @@ -254,6 +254,7 @@ fun EmptySearchResultBody( text = tryAnotherSearchString, style = MaterialTheme.typography.bodyLarge.merge( TextStyle( + color = MaterialTheme.colorScheme.secondary, textAlign = TextAlign.Center, ), ), @@ -440,9 +441,7 @@ private fun RecentSearchesBody( style = MaterialTheme.typography.headlineSmall, modifier = Modifier .padding(vertical = 16.dp) - .clickable { - onRecentSearchClicked(recentSearch) - } + .clickable { onRecentSearchClicked(recentSearch) } .fillMaxWidth(), ) } diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt index f5b409edf..6dd93ceb6 100644 --- a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt @@ -48,46 +48,43 @@ class SearchViewModel @Inject constructor( private val analyticsHelper: AnalyticsHelper, ) : ViewModel() { - val searchQuery = savedStateHandle.getStateFlow(SEARCH_QUERY, "") + val searchQuery = savedStateHandle.getStateFlow(key = SEARCH_QUERY, initialValue = "") val searchResultUiState: StateFlow = - getSearchContentsCountUseCase().flatMapLatest { totalCount -> - if (totalCount < SEARCH_MIN_FTS_ENTITY_COUNT) { - flowOf(SearchResultUiState.SearchNotReady) - } else { - searchQuery.flatMapLatest { query -> - if (query.length < SEARCH_QUERY_MIN_LENGTH) { - flowOf(SearchResultUiState.EmptyQuery) - } else { - getSearchContentsUseCase(query).asResult().map { - when (it) { - is Result.Success -> { - SearchResultUiState.Success( - topics = it.data.topics, - newsResources = it.data.newsResources, - ) - } - - is Result.Loading -> { - SearchResultUiState.Loading - } + getSearchContentsCountUseCase() + .flatMapLatest { totalCount -> + if (totalCount < SEARCH_MIN_FTS_ENTITY_COUNT) { + flowOf(SearchResultUiState.SearchNotReady) + } else { + searchQuery.flatMapLatest { query -> + if (query.length < SEARCH_QUERY_MIN_LENGTH) { + flowOf(SearchResultUiState.EmptyQuery) + } else { + getSearchContentsUseCase(query) + .asResult() + .map { result -> + when (result) { + is Result.Success -> SearchResultUiState.Success( + topics = result.data.topics, + newsResources = result.data.newsResources, + ) - is Result.Error -> { - SearchResultUiState.LoadFailed + is Result.Loading -> SearchResultUiState.Loading + is Result.Error -> SearchResultUiState.LoadFailed + } } - } } } } - } - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = SearchResultUiState.Loading, - ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = SearchResultUiState.Loading, + ) val recentSearchQueriesUiState: StateFlow = - recentSearchQueriesUseCase().map(RecentSearchQueriesUiState::Success) + recentSearchQueriesUseCase() + .map(RecentSearchQueriesUiState::Success) .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), @@ -107,16 +104,9 @@ class SearchViewModel @Inject constructor( */ fun onSearchTriggered(query: String) { viewModelScope.launch { - recentSearchRepository.insertOrReplaceRecentSearch(query) + recentSearchRepository.insertOrReplaceRecentSearch(searchQuery = query) } - analyticsHelper.logEvent( - AnalyticsEvent( - type = SEARCH_QUERY, - extras = listOf( - Param(SEARCH_QUERY, query), - ), - ), - ) + analyticsHelper.logEventSearchTriggered(query = query) } fun clearRecentSearches() { @@ -126,6 +116,14 @@ class SearchViewModel @Inject constructor( } } +private fun AnalyticsHelper.logEventSearchTriggered(query: String) = + logEvent( + event = AnalyticsEvent( + type = SEARCH_QUERY, + extras = listOf(element = Param(key = SEARCH_QUERY, value = query)), + ), + ) + /** Minimum length where search query is considered as [SearchResultUiState.EmptyQuery] */ private const val SEARCH_QUERY_MIN_LENGTH = 2 diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt index 075e7f881..3dbbe7da8 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt @@ -132,7 +132,7 @@ internal fun TopicScreen( uiState = topicUiState.followableTopic, ) } - TopicBody( + topicBody( name = topicUiState.followableTopic.topic.name, description = topicUiState.followableTopic.topic.longDescription, news = newsUiState, @@ -179,7 +179,7 @@ private fun topicItemsSize( } } -private fun LazyListScope.TopicBody( +private fun LazyListScope.topicBody( name: String, description: String, news: NewsUiState, @@ -253,7 +253,7 @@ private fun LazyListScope.userNewsResourceCards( private fun TopicBodyPreview() { NiaTheme { LazyColumn { - TopicBody( + topicBody( name = "Jetpack Compose", description = "Lorem ipsum maximum", news = NewsUiState.Success(emptyList()), diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 56e658285..1fc850d0a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,6 +49,8 @@ protobuf = "3.24.0" protobufPlugin = "0.9.4" retrofit = "2.9.0" retrofitKotlinxSerializationJson = "1.0.0" +robolectric = "4.10.3" +roborazzi = "1.5.0-alpha-2" room = "2.5.2" secrets = "2.0.1" turbine = "0.12.1" @@ -125,6 +127,9 @@ protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin- protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-kotlin-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationJson" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +robolectric-shadows = { group = "org.robolectric", name = "shadows-framework", version.ref = "robolectric" } +roborazzi = { group = "io.github.takahirom.roborazzi", name = "roborazzi", version.ref = "roborazzi" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } @@ -136,6 +141,7 @@ firebase-crashlytics-gradlePlugin = { group = "com.google.firebase", name = "fir firebase-performance-gradlePlugin = { group = "com.google.firebase", name = "perf-plugin", version.ref = "firebasePerfPlugin" } kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" } +work-testing = { group = "androidx.work", name = "work-testing", version = "2.8.1" } [plugins] android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } @@ -149,4 +155,5 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" } +roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } secrets = { id = "com.google.android.libraries.mapsplatform.secrets-gradle-plugin", version.ref = "secrets" } diff --git a/lint/build.gradle.kts b/lint/build.gradle.kts index 4ae719aa6..35b6ec1e8 100644 --- a/lint/build.gradle.kts +++ b/lint/build.gradle.kts @@ -19,7 +19,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { `java-library` kotlin("jvm") - id("com.android.lint") + id("nowinandroid.android.lint") } java {