Merge branch 'android:main' into main

pull/618/head
Roy Matero 10 months ago committed by GitHub
commit 298f3ce4db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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

@ -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)).

@ -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)
}

@ -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,
)
},
)

@ -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<HiltComponentActivity>()
@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",
)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

@ -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"

@ -24,9 +24,12 @@ class AndroidApplicationComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.android.application")
// Screenshot Tests
pluginManager.apply("io.github.takahirom.roborazzi")
val extension = extensions.getByType<ApplicationExtension>()
configureAndroidCompose(extension)
}
}
}
}

@ -29,6 +29,7 @@ class AndroidApplicationConventionPlugin : Plugin<Project> {
with(pluginManager) {
apply("com.android.application")
apply("org.jetbrains.kotlin.android")
apply("nowinandroid.android.lint")
}
extensions.configure<ApplicationExtension> {

@ -33,9 +33,10 @@ class AndroidHiltConventionPlugin : Plugin<Project> {
"implementation"(libs.findLibrary("hilt.android").get())
"kapt"(libs.findLibrary("hilt.compiler").get())
"kaptAndroidTest"(libs.findLibrary("hilt.compiler").get())
"kaptTest"(libs.findLibrary("hilt.compiler").get())
}
}
}
}
}

@ -24,9 +24,12 @@ class AndroidLibraryComposeConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply("com.android.library")
// Screenshot Tests
pluginManager.apply("io.github.takahirom.roborazzi")
val extension = extensions.getByType<LibraryExtension>()
configureAndroidCompose(extension)
}
}
}
}

@ -33,6 +33,7 @@ class AndroidLibraryConventionPlugin : Plugin<Project> {
with(pluginManager) {
apply("com.android.library")
apply("org.jetbrains.kotlin.android")
apply("nowinandroid.android.lint")
}
extensions.configure<LibraryExtension> {

@ -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<Project> {
override fun apply(target: Project) {
with(target) {
when {
pluginManager.hasPlugin("com.android.application") ->
configure<ApplicationExtension> { lint(Lint::configure) }
pluginManager.hasPlugin("com.android.library") ->
configure<LibraryExtension> { lint(Lint::configure) }
else -> {
pluginManager.apply("com.android.lint")
configure<Lint>(Lint::configure)
}
}
}
}
}
private fun Lint.configure() {
xmlReport = true
checkDependencies = true
}

@ -23,6 +23,7 @@ class JvmLibraryConventionPlugin : Plugin<Project> {
with(target) {
with(pluginManager) {
apply("org.jetbrains.kotlin.jvm")
apply("nowinandroid.android.lint")
}
configureKotlinJvm()
}

@ -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
}
}
}

@ -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
}
}

@ -23,9 +23,6 @@ android {
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
lint {
checkDependencies = true
}
namespace = "com.google.samples.apps.nowinandroid.core.designsystem"
}

@ -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<String> = 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<NetworkTopic> = listOf(),

@ -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<Instant> {
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())
}

@ -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)

@ -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 <A : ComponentActivity> AndroidComposeTestRule<ActivityScenarioRule<A>, A>.captureMultiDevice(
screenshotName: String,
body: @Composable () -> Unit,
) {
DefaultTestDevices.values().forEach {
this.captureForDevice(it.description, it.spec, screenshotName, body = body)
}
}
fun <A : ComponentActivity> AndroidComposeTestRule<ActivityScenarioRule<A>, 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)

@ -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"))
}
}

@ -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<ComponentActivity>()
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 = {},
)
}
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

@ -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<RecentSearchQuery> = emptyList(),

@ -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<FollowableTopic> = 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
}

@ -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(),
)
}

@ -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<SearchResultUiState> =
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<RecentSearchQueriesUiState> =
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

@ -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()),

@ -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" }

@ -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 {

Loading…
Cancel
Save