Merge branch 'main' into av/use-gradle-build-action

pull/1837/head
Alex Vanyo 3 years ago committed by GitHub
commit 73f607891c

@ -45,14 +45,14 @@ jobs:
run: ./gradlew testDemoDebug testProdDebug --stacktrace run: ./gradlew testDemoDebug testProdDebug --stacktrace
- name: Upload build outputs (APKs) - name: Upload build outputs (APKs)
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
with: with:
name: build-outputs name: build-outputs
path: app/build/outputs path: app/build/outputs
- name: Upload build reports - name: Upload build reports
if: always() if: always()
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
with: with:
name: build-reports name: build-reports
path: app/build/reports path: app/build/reports
@ -88,11 +88,11 @@ jobs:
disable-animations: true disable-animations: true
disk-size: 1500M disk-size: 1500M
heap-size: 512M heap-size: 512M
script: ./gradlew connectedProdDebugAndroidTest -x :benchmark:connectedBenchmarkAndroidTest script: ./gradlew connectedProdDebugAndroidTest -x :benchmark:connectedProdBenchmarkAndroidTest
- name: Upload test reports - name: Upload test reports
if: always() if: always()
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v3
with: with:
name: test-reports name: test-reports
path: '*/build/reports/androidTests' path: '*/build/reports/androidTests'

@ -50,6 +50,12 @@ The Now in Android app follows the
and is described in detail in the and is described in detail in the
[architecture learning journey](docs/ArchitectureLearningJourney.md). [architecture learning journey](docs/ArchitectureLearningJourney.md).
# Modularization
The Now in Android app has been fully modularized and you can find the detailed guidance and
description of the modularization strategy used in
[modularization learning journey](docs/ModularizationLearningJourney.md).
# Build # Build
The app contains the usual `debug` and `release` build variants. The app contains the usual `debug` and `release` build variants.

@ -24,6 +24,7 @@ plugins {
id("jacoco") id("jacoco")
id("dagger.hilt.android.plugin") id("dagger.hilt.android.plugin")
id("nowinandroid.spotless") id("nowinandroid.spotless")
id("nowinandroid.firebase-perf")
} }
android { android {
@ -62,8 +63,6 @@ android {
proguardFiles("benchmark-rules.pro") proguardFiles("benchmark-rules.pro")
// FIXME enabling minification breaks access to demo backend. // FIXME enabling minification breaks access to demo backend.
isMinifyEnabled = false isMinifyEnabled = false
// Keep the build type debuggable so we can attach a debugger if needed.
isDebuggable = true
applicationIdSuffix = ".benchmark" applicationIdSuffix = ".benchmark"
} }
} }

File diff suppressed because it is too large Load Diff

@ -22,12 +22,21 @@ import androidx.activity.compose.setContent
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.metrics.performance.JankStats
import com.google.samples.apps.nowinandroid.ui.NiaApp import com.google.samples.apps.nowinandroid.ui.NiaApp
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class) @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
/**
* Lazily inject [JankStats], which is used to track jank throughout the app.
*/
@Inject
lateinit var lazyStats: dagger.Lazy<JankStats>
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -35,6 +44,18 @@ class MainActivity : ComponentActivity() {
// including IME animations // including IME animations
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { NiaApp(calculateWindowSizeClass(this)) } setContent {
NiaApp(calculateWindowSizeClass(this))
}
}
override fun onResume() {
super.onResume()
lazyStats.get().isTrackingEnabled = true
}
override fun onPause() {
super.onPause()
lazyStats.get().isTrackingEnabled = false
} }
} }

@ -0,0 +1,63 @@
/*
* 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.di
import android.app.Activity
import android.util.Log
import android.view.Window
import androidx.metrics.performance.JankStats
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ActivityComponent
import java.util.concurrent.Executor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asExecutor
@Module
@InstallIn(ActivityComponent::class)
object JankStatsModule {
@Provides
fun providesOnFrameListener(): JankStats.OnFrameListener {
return JankStats.OnFrameListener { frameData ->
// Make sure to only log janky frames.
if (frameData.isJank) {
// We're currently logging this but would better report it to a backend.
Log.v("NiA Jank", frameData.toString())
}
}
}
@Provides
fun providesWindow(activity: Activity): Window {
return activity.window
}
@Provides
fun providesDefaultExecutor(): Executor {
return Dispatchers.Default.asExecutor()
}
@Provides
fun providesJankStats(
window: Window,
executor: Executor,
frameListener: JankStats.OnFrameListener
): JankStats {
return JankStats.createAndTrack(window, executor, frameListener)
}
}

@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.navigation
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.tracing.trace
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon
@ -39,18 +40,20 @@ import com.google.samples.apps.nowinandroid.feature.interests.navigation.Interes
class NiaTopLevelNavigation(private val navController: NavHostController) { class NiaTopLevelNavigation(private val navController: NavHostController) {
fun navigateTo(destination: TopLevelDestination) { fun navigateTo(destination: TopLevelDestination) {
navController.navigate(destination.route) { trace("Navigation: $destination") {
// Pop up to the start destination of the graph to navController.navigate(destination.route) {
// avoid building up a large stack of destinations // Pop up to the start destination of the graph to
// on the back stack as users select items // avoid building up a large stack of destinations
popUpTo(navController.graph.findStartDestination().id) { // on the back stack as users select items
saveState = true popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
} }
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
} }
} }
} }

@ -39,10 +39,14 @@ import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.navigation.NavController
import androidx.navigation.NavDestination import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
@ -55,12 +59,17 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavig
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.ui.JankMetricDisposableEffect
import com.google.samples.apps.nowinandroid.navigation.NiaNavHost import com.google.samples.apps.nowinandroid.navigation.NiaNavHost
import com.google.samples.apps.nowinandroid.navigation.NiaTopLevelNavigation import com.google.samples.apps.nowinandroid.navigation.NiaTopLevelNavigation
import com.google.samples.apps.nowinandroid.navigation.TOP_LEVEL_DESTINATIONS import com.google.samples.apps.nowinandroid.navigation.TOP_LEVEL_DESTINATIONS
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(
ExperimentalMaterial3Api::class,
ExperimentalLayoutApi::class,
ExperimentalComposeUiApi::class
)
@Composable @Composable
fun NiaApp(windowSizeClass: WindowSizeClass) { fun NiaApp(windowSizeClass: WindowSizeClass) {
NiaTheme { NiaTheme {
@ -74,7 +83,9 @@ fun NiaApp(windowSizeClass: WindowSizeClass) {
NiaBackground { NiaBackground {
Scaffold( Scaffold(
modifier = Modifier, modifier = Modifier.semantics {
testTagsAsResourceId = true
},
containerColor = Color.Transparent, containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground, contentColor = MaterialTheme.colorScheme.onBackground,
bottomBar = { bottomBar = {
@ -117,6 +128,17 @@ fun NiaApp(windowSizeClass: WindowSizeClass) {
} }
} }
} }
JankMetricDisposableEffect(navController) { metricsHolder ->
val listener = NavController.OnDestinationChangedListener { _, destination, _ ->
metricsHolder.state?.addState("Navigation", destination.route.toString())
}
navController.addOnDestinationChangedListener(listener)
onDispose {
navController.removeOnDestinationChangedListener(listener)
}
}
} }
} }

@ -13,6 +13,9 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import com.google.samples.apps.nowinandroid.Flavor
import com.google.samples.apps.nowinandroid.FlavorDimension
import com.google.samples.apps.nowinandroid.configureFlavors
plugins { plugins {
id("nowinandroid.android.test") id("nowinandroid.android.test")
@ -25,7 +28,6 @@ android {
defaultConfig { defaultConfig {
minSdk = 23 minSdk = 23
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
missingDimensionStrategy("contentType", "demo")
} }
buildTypes { buildTypes {
@ -33,12 +35,18 @@ android {
// release build (for example, with minification on). It's signed with a debug key // release build (for example, with minification on). It's signed with a debug key
// for easy local/CI testing. // for easy local/CI testing.
val benchmark by creating { val benchmark by creating {
isDebuggable = false // Keep the build type debuggable so we can attach a debugger if needed.
isDebuggable = true
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
matchingFallbacks.add("release") matchingFallbacks.add("release")
} }
} }
// Use the same flavor dimensions as the application to allow generating Baseline Profiles on prod,
// which is more close to what will be shipped to users (no fake data), but has ability to run the
// benchmarks on demo, so we benchmark on stable data.
configureFlavors(this)
targetProjectPath = ":app" targetProjectPath = ":app"
experimentalProperties["android.experimental.self-instrumenting"] = true experimentalProperties["android.experimental.self-instrumenting"] = true
} }
@ -58,4 +66,4 @@ androidComponents {
beforeVariants { beforeVariants {
it.enable = it.buildType == "benchmark" it.enable = it.buildType == "benchmark"
} }
} }

@ -0,0 +1,25 @@
/*
* 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
import com.google.samples.apps.nowinandroid.benchmark.BuildConfig
/**
* Convenience parameter to use proper package name with regards to build type and build flavor.
*/
const val PACKAGE_NAME =
"com.google.samples.apps.nowinandroid.${BuildConfig.FLAVOR}.${BuildConfig.BUILD_TYPE}"

@ -19,7 +19,12 @@ package com.google.samples.apps.nowinandroid.baselineprofile
import androidx.benchmark.macro.ExperimentalBaselineProfilesApi import androidx.benchmark.macro.ExperimentalBaselineProfilesApi
import androidx.benchmark.macro.junit4.BaselineProfileRule import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.uiautomator.By import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.foryou.forYouScrollFeedDownUp
import com.google.samples.apps.nowinandroid.foryou.forYouSelectAuthors
import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent
import com.google.samples.apps.nowinandroid.interests.interestsScrollPeopleDownUp
import com.google.samples.apps.nowinandroid.interests.interestsScrollTopicsDownUp
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -31,27 +36,30 @@ class BaselineProfileGenerator {
@get:Rule val baselineProfileRule = BaselineProfileRule() @get:Rule val baselineProfileRule = BaselineProfileRule()
@Test @Test
fun startup() = fun generate() =
baselineProfileRule.collectBaselineProfile( baselineProfileRule.collectBaselineProfile(PACKAGE_NAME) {
packageName = "com.google.samples.apps.nowinandroid.demo.benchmark"
) {
pressHome()
// This block defines the app's critical user journey. Here we are interested in // This block defines the app's critical user journey. Here we are interested in
// optimizing for app startup. But you can also navigate and scroll // optimizing for app startup. But you can also navigate and scroll
// through your most important UI. // through your most important UI.
pressHome()
startActivityAndWait() startActivityAndWait()
// Scroll the feed critical user journey
forYouWaitForContent()
forYouSelectAuthors()
forYouScrollFeedDownUp()
// Navigate to interests screen
device.findObject(By.text("Interests")).click()
device.waitForIdle()
interestsScrollTopicsDownUp()
// Navigate to people tab
device.findObject(By.text("People")).click()
device.waitForIdle() device.waitForIdle()
device.run { interestsScrollPeopleDownUp()
findObject(By.text("Interests"))
.click()
waitForIdle()
findObject(By.text("Accessibility")).scroll(Direction.DOWN, 2000f)
waitForIdle()
findObject(By.text("People")).click()
waitForIdle()
findObject(By.textStartsWith("Android")).scroll(Direction.DOWN, 2000f)
waitForIdle()
}
} }
} }

@ -0,0 +1,44 @@
/*
* 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.foryou
import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
import androidx.test.uiautomator.Until
fun MacrobenchmarkScope.forYouWaitForContent() {
// Wait until content is loaded
device.wait(Until.hasObject(By.text("What are you interested in?")), 30_000)
}
fun MacrobenchmarkScope.forYouSelectAuthors() {
val authors = device.findObject(By.res("forYou:authors"))
// select some authors to show some feed content
repeat(3) { index ->
val author = authors.children[index % authors.childCount]
author.click()
device.waitForIdle()
}
}
fun MacrobenchmarkScope.forYouScrollFeedDownUp() {
val feedList = device.findObject(By.res("forYou:feed"))
feedList.fling(Direction.DOWN)
device.waitForIdle()
feedList.fling(Direction.UP)
}

@ -0,0 +1,56 @@
/*
* 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.foryou
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.FrameTimingMetric
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner
import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4ClassRunner::class)
class ScrollForYouFeedBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun scrollFeedCompilationNone() = scrollFeed(CompilationMode.None())
@Test
fun scrollFeedCompilationBaselineProfile() = scrollFeed(CompilationMode.Partial())
private fun scrollFeed(compilationMode: CompilationMode) = benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME,
metrics = listOf(FrameTimingMetric()),
compilationMode = compilationMode,
iterations = 10,
startupMode = StartupMode.COLD,
setupBlock = {
// Start the app
pressHome()
startActivityAndWait()
}
) {
forYouWaitForContent()
forYouSelectAuthors()
forYouScrollFeedDownUp()
}
}

@ -0,0 +1,35 @@
/*
* 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.interests
import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Direction
fun MacrobenchmarkScope.interestsScrollTopicsDownUp() {
val topicsList = device.findObject(By.res("interests:topics"))
topicsList.fling(Direction.DOWN)
device.waitForIdle()
topicsList.fling(Direction.UP)
}
fun MacrobenchmarkScope.interestsScrollPeopleDownUp() {
val peopleList = device.findObject(By.res("interests:people"))
peopleList.fling(Direction.DOWN)
device.waitForIdle()
peopleList.fling(Direction.UP)
}

@ -26,6 +26,8 @@ import androidx.benchmark.macro.StartupMode.WARM
import androidx.benchmark.macro.StartupTimingMetric import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner
import com.google.samples.apps.nowinandroid.PACKAGE_NAME
import com.google.samples.apps.nowinandroid.foryou.forYouWaitForContent
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -74,7 +76,7 @@ abstract class AbstractStartupBenchmark(private val startupMode: StartupMode) {
fun startupFullCompilation() = startup(CompilationMode.Full()) fun startupFullCompilation() = startup(CompilationMode.Full())
private fun startup(compilationMode: CompilationMode) = benchmarkRule.measureRepeated( private fun startup(compilationMode: CompilationMode) = benchmarkRule.measureRepeated(
packageName = "com.google.samples.apps.nowinandroid", packageName = PACKAGE_NAME,
metrics = listOf(StartupTimingMetric()), metrics = listOf(StartupTimingMetric()),
compilationMode = compilationMode, compilationMode = compilationMode,
iterations = 10, iterations = 10,
@ -84,5 +86,7 @@ abstract class AbstractStartupBenchmark(private val startupMode: StartupMode) {
} }
) { ) {
startActivityAndWait() startActivityAndWait()
// Waits until the content is ready to capture Time To Full Display
forYouWaitForContent()
} }
} }

@ -69,5 +69,9 @@ gradlePlugin {
id = "nowinandroid.spotless" id = "nowinandroid.spotless"
implementationClass = "SpotlessConventionPlugin" implementationClass = "SpotlessConventionPlugin"
} }
register("firebase-perf") {
id = "nowinandroid.firebase-perf"
implementationClass = "FirebasePerfConventionPlugin"
}
} }
} }

@ -0,0 +1,29 @@
/*
* 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 org.gradle.api.Plugin
import org.gradle.api.Project
class FirebasePerfConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.findPlugin("com.google.firebase.firebase-perf").apply {
version = "1.4.1"
}
}
}
}

@ -19,8 +19,8 @@ package com.google.samples.apps.nowinandroid
import com.android.build.api.dsl.CommonExtension import com.android.build.api.dsl.CommonExtension
import org.gradle.api.Project import org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.getByType
import java.io.File
/** /**
* Configure Compose-specific options * Configure Compose-specific options
@ -38,5 +38,33 @@ internal fun Project.configureAndroidCompose(
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = libs.findVersion("androidxCompose").get().toString() kotlinCompilerExtensionVersion = libs.findVersion("androidxCompose").get().toString()
} }
kotlinOptions {
freeCompilerArgs = freeCompilerArgs + buildComposeMetricsParameters()
}
}
}
private fun Project.buildComposeMetricsParameters(): List<String> {
val metricParameters = mutableListOf<String>()
val enableMetricsProvider = project.providers.gradleProperty("enableComposeCompilerMetrics")
val enableMetrics = (enableMetricsProvider.orNull == "true")
if (enableMetrics) {
val metricsFolder = File(project.buildDir, "compose-metrics")
metricParameters.add("-P")
metricParameters.add(
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + metricsFolder.absolutePath
)
}
val enableReportsProvider = project.providers.gradleProperty("enableComposeCompilerReports")
val enableReports = (enableReportsProvider.orNull == "true")
if (enableReports) {
val reportsFolder = File(project.buildDir, "compose-reports")
metricParameters.add("-P")
metricParameters.add(
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + reportsFolder.absolutePath
)
} }
return metricParameters.toList()
} }

@ -70,6 +70,6 @@ internal fun Project.configureKotlinAndroid(
} }
} }
private fun CommonExtension<*, *, *, *>.kotlinOptions(block: KotlinJvmOptions.() -> Unit) { fun CommonExtension<*, *, *, *>.kotlinOptions(block: KotlinJvmOptions.() -> Unit) {
(this as ExtensionAware).extensions.configure("kotlinOptions", block) (this as ExtensionAware).extensions.configure("kotlinOptions", block)
} }

Binary file not shown.

@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

@ -39,4 +39,7 @@ class OfflineFirstUserDataRepository @Inject constructor(
override suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) = override suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) =
niaPreferencesDataSource.toggleFollowedAuthorId(followedAuthorId, followed) niaPreferencesDataSource.toggleFollowedAuthorId(followedAuthorId, followed)
override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) =
niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked)
} }

@ -45,4 +45,9 @@ interface UserDataRepository {
* Toggles the user's newly followed/unfollowed author * Toggles the user's newly followed/unfollowed author
*/ */
suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean)
/**
* Updates the bookmarked status for a news resource
*/
suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean)
} }

@ -49,4 +49,8 @@ class FakeUserDataRepository @Inject constructor(
override suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) { override suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) {
niaPreferencesDataSource.toggleFollowedAuthorId(followedAuthorId, followed) niaPreferencesDataSource.toggleFollowedAuthorId(followedAuthorId, followed)
} }
override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) {
niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked)
}
} }

@ -47,7 +47,7 @@ class OfflineFirstUserDataRepositoryTest {
} }
@Test @Test
fun offlineFirstTopicsRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() = fun offlineFirstUserDataRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() =
runTest { runTest {
subject.toggleFollowedTopicId(followedTopicId = "0", followed = true) subject.toggleFollowedTopicId(followedTopicId = "0", followed = true)
@ -68,7 +68,8 @@ class OfflineFirstUserDataRepositoryTest {
) )
assertEquals( assertEquals(
niaPreferencesDataSource.followedTopicIds niaPreferencesDataSource.userDataStream
.map { it.followedTopics }
.first(), .first(),
subject.userDataStream subject.userDataStream
.map { it.followedTopics } .map { it.followedTopics }
@ -77,7 +78,7 @@ class OfflineFirstUserDataRepositoryTest {
} }
@Test @Test
fun offlineFirstTopicsRepository_set_followed_topics_logic_delegates_to_nia_preferences() = fun offlineFirstUserDataRepository_set_followed_topics_logic_delegates_to_nia_preferences() =
runTest { runTest {
subject.setFollowedTopicIds(followedTopicIds = setOf("1", "2")) subject.setFollowedTopicIds(followedTopicIds = setOf("1", "2"))
@ -89,11 +90,43 @@ class OfflineFirstUserDataRepositoryTest {
) )
assertEquals( assertEquals(
niaPreferencesDataSource.followedTopicIds niaPreferencesDataSource.userDataStream
.map { it.followedTopics }
.first(), .first(),
subject.userDataStream subject.userDataStream
.map { it.followedTopics } .map { it.followedTopics }
.first() .first()
) )
} }
@Test
fun offlineFirstUserDataRepository_bookmark_news_resource_logic_delegates_to_nia_preferences() =
runTest {
subject.updateNewsResourceBookmark(newsResourceId = "0", bookmarked = true)
assertEquals(
setOf("0"),
subject.userDataStream
.map { it.bookmarkedNewsResources }
.first()
)
subject.updateNewsResourceBookmark(newsResourceId = "1", bookmarked = true)
assertEquals(
setOf("0", "1"),
subject.userDataStream
.map { it.bookmarkedNewsResources }
.first()
)
assertEquals(
niaPreferencesDataSource.userDataStream
.map { it.bookmarkedNewsResources }
.first(),
subject.userDataStream
.map { it.bookmarkedNewsResources }
.first()
)
}
} }

@ -18,55 +18,69 @@ package com.google.samples.apps.nowinandroid.core.datastore
import android.util.Log import android.util.Log
import androidx.datastore.core.DataStore import androidx.datastore.core.DataStore
import com.google.protobuf.kotlin.DslList
import com.google.protobuf.kotlin.DslProxy
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retry
class NiaPreferencesDataSource @Inject constructor( class NiaPreferencesDataSource @Inject constructor(
private val userPreferences: DataStore<UserPreferences> private val userPreferences: DataStore<UserPreferences>
) { ) {
suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) {
try {
userPreferences.updateData {
it.copy {
this.followedTopicIds.clear()
this.followedTopicIds.addAll(followedTopicIds)
}
}
} catch (ioException: IOException) {
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
}
}
suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) { val userDataStream = userPreferences.data
try { .map {
userPreferences.updateData { UserData(
it.copy { bookmarkedNewsResources = it.bookmarkedNewsResourceIdsList.toSet(),
val current = followedTopics = it.followedTopicIdsList.toSet(),
if (followed) { followedAuthors = it.followedAuthorIdsList.toSet(),
followedTopicIds + followedTopicId )
} else {
followedTopicIds - followedTopicId
}
this.followedTopicIds.clear()
this.followedTopicIds.addAll(current)
}
}
} catch (ioException: IOException) {
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
} }
}
val followedTopicIds: Flow<Set<String>> = userPreferences.data suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) =
.retry { userPreferences.setList(
Log.e("NiaPreferences", "Failed to read user preferences", it) listGetter = { it.followedTopicIds },
true listModifier = { followedTopicIds.toList() },
} clear = { it.clear() },
.map { it.followedTopicIdsList.toSet() } addAll = { dslList, editedList -> dslList.addAll(editedList) }
)
suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) =
userPreferences.editList(
add = followed,
value = followedTopicId,
listGetter = { it.followedTopicIds },
clear = { it.clear() },
addAll = { dslList, editedList -> dslList.addAll(editedList) }
)
suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>) =
userPreferences.setList(
listGetter = { it.followedAuthorIds },
listModifier = { followedAuthorIds.toList() },
clear = { it.clear() },
addAll = { dslList, editedList -> dslList.addAll(editedList) }
)
suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) =
userPreferences.editList(
add = followed,
value = followedAuthorId,
listGetter = { it.followedAuthorIds },
clear = { it.clear() },
addAll = { dslList, editedList -> dslList.addAll(editedList) }
)
suspend fun toggleNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) =
userPreferences.editList(
add = bookmarked,
value = newsResourceId,
listGetter = { it.bookmarkedNewsResourceIds },
clear = { it.clear() },
addAll = { dslList, editedList -> dslList.addAll(editedList) }
)
suspend fun getChangeListVersions() = userPreferences.data suspend fun getChangeListVersions() = userPreferences.data
.map { .map {
@ -106,51 +120,47 @@ class NiaPreferencesDataSource @Inject constructor(
} }
} }
suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>) { /**
try { * Adds or removes [value] from the [DslList] provided by [listGetter]
userPreferences.updateData { */
it.copy { private suspend fun <T : DslProxy> DataStore<UserPreferences>.editList(
this.followedAuthorIds.clear() add: Boolean,
this.followedAuthorIds.addAll(followedAuthorIds) value: String,
} listGetter: (UserPreferencesKt.Dsl) -> DslList<String, T>,
} clear: UserPreferencesKt.Dsl.(DslList<String, T>) -> Unit,
} catch (ioException: IOException) { addAll: UserPreferencesKt.Dsl.(DslList<String, T>, Iterable<String>) -> Unit
Log.e("NiaPreferences", "Failed to update user preferences", ioException) ) {
} setList(
listGetter = listGetter,
listModifier = { currentList ->
if (add) currentList + value
else currentList - value
},
clear = clear,
addAll = addAll
)
} }
suspend fun toggleFollowedAuthorId(followedAuthorId: String, followed: Boolean) { /**
* Sets the value provided by [listModifier] into the [DslList] read by [listGetter]
*/
private suspend fun <T : DslProxy> DataStore<UserPreferences>.setList(
listGetter: (UserPreferencesKt.Dsl) -> DslList<String, T>,
listModifier: (DslList<String, T>) -> List<String>,
clear: UserPreferencesKt.Dsl.(DslList<String, T>) -> Unit,
addAll: UserPreferencesKt.Dsl.(DslList<String, T>, List<String>) -> Unit
) {
try { try {
userPreferences.updateData { updateData {
it.copy { it.copy {
val current = val dslList = listGetter(this)
if (followed) { val newList = listModifier(dslList)
followedAuthorIds + followedAuthorId clear(dslList)
} else { addAll(dslList, newList)
followedAuthorIds - followedAuthorId
}
this.followedAuthorIds.clear()
this.followedAuthorIds.addAll(current)
} }
} }
} catch (ioException: IOException) { } catch (ioException: IOException) {
Log.e("NiaPreferences", "Failed to update user preferences", ioException) Log.e("NiaPreferences", "Failed to update user preferences", ioException)
} }
} }
val followedAuthorIds: Flow<Set<String>> = userPreferences.data
.retry {
Log.e("NiaPreferences", "Failed to read user preferences", it)
true
}
.map { it.followedAuthorIdsList.toSet() }
val userDataStream = userPreferences.data
.map {
UserData(
bookmarkedNewsResources = emptySet(),
followedTopics = it.followedTopicIdsList.toSet(),
followedAuthors = it.followedAuthorIdsList.toSet(),
)
}
} }

@ -30,4 +30,5 @@ message UserPreferences {
bool has_done_int_to_string_id_migration = 8; bool has_done_int_to_string_id_migration = 8;
repeated string followed_topic_ids = 9; repeated string followed_topic_ids = 9;
repeated string followed_author_ids = 10; repeated string followed_author_ids = 10;
repeated string bookmarked_news_resource_ids = 11;
} }

@ -65,6 +65,15 @@ class TestUserDataRepository : UserDataRepository {
} }
} }
override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) {
currentUserData.let { current ->
val bookmarkedNews = if (bookmarked) current.bookmarkedNewsResources + newsResourceId
else current.bookmarkedNewsResources - newsResourceId
_userData.tryEmit(current.copy(bookmarkedNewsResources = bookmarkedNews))
}
}
/** /**
* A test-only API to allow querying the current followed topics. * A test-only API to allow querying the current followed topics.
*/ */

@ -16,31 +16,27 @@
package com.google.samples.apps.nowinandroid.core.testing.util package com.google.samples.apps.nowinandroid.core.testing.util
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain import kotlinx.coroutines.test.setMain
import org.junit.rules.TestRule import org.junit.rules.TestRule
import org.junit.rules.TestWatcher
import org.junit.runner.Description import org.junit.runner.Description
import org.junit.runners.model.Statement
/** /**
* A [TestRule] that initializes the main dispatcher to [dispatcher], which defaults to a * A JUnit [TestRule] that sets the Main dispatcher to [testDispatcher]
* [StandardTestDispatcher]. * for the duration of the test.
*/ */
class TestDispatcherRule( class MainDispatcherRule(
private val dispatcher: CoroutineDispatcher = StandardTestDispatcher() val testDispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestRule { ) : TestWatcher() {
override fun apply(base: Statement, description: Description): Statement = override fun starting(description: Description) {
object : Statement() { Dispatchers.setMain(testDispatcher)
override fun evaluate() { }
Dispatchers.setMain(dispatcher)
try { override fun finished(description: Description) {
base.evaluate() Dispatchers.resetMain()
} finally { }
Dispatchers.resetMain()
}
}
}
} }

@ -44,4 +44,6 @@ dependencies {
api(libs.androidx.compose.ui.util) api(libs.androidx.compose.ui.util)
api(libs.androidx.compose.runtime) api(libs.androidx.compose.runtime)
api(libs.androidx.compose.runtime.livedata) api(libs.androidx.compose.runtime.livedata)
api(libs.androidx.metrics)
api(libs.androidx.tracing.ktx)
} }

@ -0,0 +1,90 @@
/*
* 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.ui
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.DisposableEffectResult
import androidx.compose.runtime.DisposableEffectScope
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.platform.LocalView
import androidx.metrics.performance.PerformanceMetricsState
import androidx.metrics.performance.PerformanceMetricsState.MetricsStateHolder
import kotlinx.coroutines.CoroutineScope
/**
* Retrieves [PerformanceMetricsState.MetricsStateHolder] from current [LocalView] and
* remembers it until the View changes.
* @see PerformanceMetricsState.getForHierarchy
*/
@Composable
fun rememberMetricsStateHolder(): MetricsStateHolder {
val localView = LocalView.current
return remember(localView) {
PerformanceMetricsState.getForHierarchy(localView)
}
}
/**
* Convenience function to work with [PerformanceMetricsState] state. The side effect is
* re-launched if any of the [keys] value is not equal to the previous composition.
* @see JankMetricDisposableEffect if you need to work with DisposableEffect to cleanup added state.
*/
@Composable
fun JankMetricEffect(
vararg keys: Any?,
reportMetric: suspend CoroutineScope.(state: MetricsStateHolder) -> Unit
) {
val metrics = rememberMetricsStateHolder()
LaunchedEffect(metrics, *keys) {
reportMetric(metrics)
}
}
/**
* Convenience function to work with [PerformanceMetricsState] state that needs to be cleaned up.
* The side effect is re-launched if any of the [keys] value is not equal to the previous composition.
*/
@Composable
fun JankMetricDisposableEffect(
vararg keys: Any?,
reportMetric: DisposableEffectScope.(state: MetricsStateHolder) -> DisposableEffectResult
) {
val metrics = rememberMetricsStateHolder()
DisposableEffect(metrics, *keys) {
reportMetric(this, metrics)
}
}
@Composable
fun TrackScrollJank(scrollableState: ScrollableState, stateName: String) {
JankMetricEffect(scrollableState) { metricsHolder ->
snapshotFlow { scrollableState.isScrollInProgress }.collect { isScrollInProgress ->
metricsHolder.state?.apply {
if (isScrollInProgress) {
addState(stateName, "Scrolling=true")
} else {
removeState(stateName)
}
}
}
}
}

@ -22,13 +22,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
@ -91,13 +90,7 @@ internal fun AuthorScreen(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
item { item {
Spacer( Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
// TODO: Replace with windowInsetsTopHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
)
)
} }
when (authorState) { when (authorState) {
AuthorUiState.Loading -> { AuthorUiState.Loading -> {
@ -126,13 +119,7 @@ internal fun AuthorScreen(
} }
} }
item { item {
Spacer( Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
// TODO: Replace with windowInsetsBottomHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
)
)
} }
} }
} }

@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid.feature.author package com.google.samples.apps.nowinandroid.feature.author
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
@ -25,9 +24,12 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Vid
import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.TestDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorDestination import com.google.samples.apps.nowinandroid.feature.author.navigation.AuthorDestination
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@ -36,10 +38,14 @@ import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
/**
* To learn more about how this test handles Flows created with stateIn, see
* https://developer.android.com/kotlin/flow/test#statein
*/
class AuthorViewModelTest { class AuthorViewModelTest {
@get:Rule @get:Rule
val dispatcherRule = TestDispatcherRule() val dispatcherRule = MainDispatcherRule()
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val authorsRepository = TestAuthorsRepository() private val authorsRepository = TestAuthorsRepository()
@ -62,90 +68,91 @@ class AuthorViewModelTest {
@Test @Test
fun uiStateAuthor_whenSuccess_matchesAuthorFromRepository() = runTest { fun uiStateAuthor_whenSuccess_matchesAuthorFromRepository() = runTest {
viewModel.uiState.test { val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
awaitItem()
// To make sure AuthorUiState is success
authorsRepository.sendAuthors(testInputAuthors.map(FollowableAuthor::author))
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
val item = awaitItem() // To make sure AuthorUiState is success
assertTrue(item.authorState is AuthorUiState.Success) authorsRepository.sendAuthors(testInputAuthors.map(FollowableAuthor::author))
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
val successAuthorUiState = item.authorState as AuthorUiState.Success val item = viewModel.uiState.value
val authorFromRepository = authorsRepository.getAuthorStream( assertTrue(item.authorState is AuthorUiState.Success)
id = testInputAuthors[0].author.id
).first()
successAuthorUiState.followableAuthor.author val successAuthorUiState = item.authorState as AuthorUiState.Success
assertEquals(authorFromRepository, successAuthorUiState.followableAuthor.author) val authorFromRepository = authorsRepository.getAuthorStream(
} id = testInputAuthors[0].author.id
).first()
successAuthorUiState.followableAuthor.author
assertEquals(authorFromRepository, successAuthorUiState.followableAuthor.author)
collectJob.cancel()
} }
@Test @Test
fun uiStateNews_whenInitialized_thenShowLoading() = runTest { fun uiStateNews_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test { assertEquals(NewsUiState.Loading, viewModel.uiState.value.newsState)
assertEquals(NewsUiState.Loading, awaitItem().newsState)
}
} }
@Test @Test
fun uiStateAuthor_whenInitialized_thenShowLoading() = runTest { fun uiStateAuthor_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test { assertEquals(AuthorUiState.Loading, viewModel.uiState.value.authorState)
assertEquals(AuthorUiState.Loading, awaitItem().authorState)
}
} }
@Test @Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorLoading_thenShowLoading() = runTest { fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorLoading_thenShowLoading() = runTest {
viewModel.uiState.test { val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
assertEquals(AuthorUiState.Loading, awaitItem().authorState) userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
} assertEquals(AuthorUiState.Loading, viewModel.uiState.value.authorState)
collectJob.cancel()
} }
@Test @Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccess_thenAuthorSuccessAndNewsLoading() = fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccess_thenAuthorSuccessAndNewsLoading() =
runTest { runTest {
viewModel.uiState.test { val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
awaitItem()
authorsRepository.sendAuthors(testInputAuthors.map { it.author }) authorsRepository.sendAuthors(testInputAuthors.map { it.author })
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id)) userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
val item = awaitItem() val item = viewModel.uiState.value
assertTrue(item.authorState is AuthorUiState.Success) assertTrue(item.authorState is AuthorUiState.Success)
assertTrue(item.newsState is NewsUiState.Loading) assertTrue(item.newsState is NewsUiState.Loading)
}
collectJob.cancel()
} }
@Test @Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccessAndNewsIsSuccess_thenAllSuccess() = fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccessAndNewsIsSuccess_thenAllSuccess() =
runTest { runTest {
viewModel.uiState.test { val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
awaitItem()
authorsRepository.sendAuthors(testInputAuthors.map { it.author }) authorsRepository.sendAuthors(testInputAuthors.map { it.author })
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id)) userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
val item = awaitItem() val item = viewModel.uiState.value
assertTrue(item.authorState is AuthorUiState.Success) assertTrue(item.authorState is AuthorUiState.Success)
assertTrue(item.newsState is NewsUiState.Success) assertTrue(item.newsState is NewsUiState.Success)
}
collectJob.cancel()
} }
@Test @Test
fun uiStateAuthor_whenFollowingAuthor_thenShowUpdatedAuthor() = runTest { fun uiStateAuthor_whenFollowingAuthor_thenShowUpdatedAuthor() = runTest {
viewModel.uiState val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
.test {
awaitItem() authorsRepository.sendAuthors(testInputAuthors.map { it.author })
authorsRepository.sendAuthors(testInputAuthors.map { it.author }) // Set which author IDs are followed, not including 0.
// Set which author IDs are followed, not including 0. userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
viewModel.followAuthorToggle(true)
viewModel.followAuthorToggle(true)
assertEquals(
assertEquals( AuthorUiState.Success(followableAuthor = testOutputAuthors[0]),
AuthorUiState.Success(followableAuthor = testOutputAuthors[0]), viewModel.uiState.value.authorState
awaitItem().authorState )
)
} collectJob.cancel()
} }
} }

@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ripple.rememberRipple import androidx.compose.material.ripple.rememberRipple
@ -43,6 +44,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
@ -57,6 +59,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons
import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
@Composable @Composable
fun AuthorsCarousel( fun AuthorsCarousel(
@ -64,10 +67,15 @@ fun AuthorsCarousel(
onAuthorClick: (String, Boolean) -> Unit, onAuthorClick: (String, Boolean) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val lazyListState = rememberLazyListState()
val tag = "forYou:authors"
TrackScrollJank(scrollableState = lazyListState, stateName = tag)
LazyRow( LazyRow(
modifier = modifier, modifier = modifier.testTag(tag),
contentPadding = PaddingValues(24.dp), contentPadding = PaddingValues(24.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp) horizontalArrangement = Arrangement.spacedBy(24.dp),
state = lazyListState
) { ) {
items(items = authors, key = { item -> item.author.id }) { followableAuthor -> items(items = authors, key = { item -> item.author.id }) { followableAuthor ->
AuthorItem( AuthorItem(

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.feature.foryou package com.google.samples.apps.nowinandroid.feature.foryou
import android.app.Activity
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.annotation.IntRange import androidx.annotation.IntRange
@ -37,6 +38,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@ -44,7 +46,9 @@ import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -58,6 +62,7 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz
import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -65,6 +70,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@ -73,7 +80,9 @@ import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.trace
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.doOnPreDraw
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilledButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilledButton
@ -90,6 +99,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors
import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources import com.google.samples.apps.nowinandroid.core.model.data.previewNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.NewsResourceCardExpanded import com.google.samples.apps.nowinandroid.core.ui.NewsResourceCardExpanded
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
import kotlin.math.floor import kotlin.math.floor
@Composable @Composable
@ -131,11 +141,11 @@ fun ForYouScreen(
titleRes = R.string.top_app_bar_title, titleRes = R.string.top_app_bar_title,
navigationIcon = NiaIcons.Search, navigationIcon = NiaIcons.Search,
navigationIconContentDescription = stringResource( navigationIconContentDescription = stringResource(
id = R.string.top_app_bar_navigation_button_content_desc id = R.string.for_you_top_app_bar_action_search
), ),
actionIcon = NiaIcons.AccountCircle, actionIcon = NiaIcons.AccountCircle,
actionIconContentDescription = stringResource( actionIconContentDescription = stringResource(
id = R.string.top_app_bar_navigation_button_content_desc id = R.string.for_you_top_app_bar_action_my_account
), ),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors( colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent containerColor = Color.Transparent
@ -160,8 +170,37 @@ fun ForYouScreen(
else -> floor(maxWidth / 300.dp).toInt().coerceAtLeast(1) else -> floor(maxWidth / 300.dp).toInt().coerceAtLeast(1)
} }
// Workaround to call Activity.reportFullyDrawn from Jetpack Compose.
// This code should be called when the UI is ready for use
// and relates to Time To Full Display.
val interestsLoaded =
interestsSelectionState !is ForYouInterestsSelectionUiState.Loading
val feedLoaded = feedState !is ForYouFeedUiState.Loading
if (interestsLoaded && feedLoaded) {
val localView = LocalView.current
// We use Unit to call reportFullyDrawn only on the first recomposition,
// however it will be called again if this composable goes out of scope.
// Activity.reportFullyDrawn() has its own check for this
// and is safe to call multiple times though.
LaunchedEffect(Unit) {
// We're leveraging the fact, that the current view is directly set as content of Activity.
val activity = localView.context as? Activity ?: return@LaunchedEffect
// To be sure not to call in the middle of a frame draw.
localView.doOnPreDraw { activity.reportFullyDrawn() }
}
}
val tag = "forYou:feed"
val lazyListState = rememberLazyListState()
TrackScrollJank(scrollableState = lazyListState, stateName = tag)
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.testTag(tag),
state = lazyListState,
) { ) {
InterestsSelection( InterestsSelection(
interestsSelectionState = interestsSelectionState, interestsSelectionState = interestsSelectionState,
@ -182,13 +221,7 @@ fun ForYouScreen(
) )
item { item {
Spacer( Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
// TODO: Replace with windowInsetsBottomHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
)
)
} }
} }
} }
@ -218,7 +251,8 @@ private fun LazyListScope.InterestsSelection(
NiaLoadingWheel( NiaLoadingWheel(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.wrapContentSize(), .wrapContentSize()
.testTag("forYou:loading"),
contentDesc = stringResource(id = R.string.for_you_loading), contentDesc = stringResource(id = R.string.for_you_loading),
) )
} }
@ -290,8 +324,12 @@ private fun TopicSelection(
interestsSelectionState: ForYouInterestsSelectionUiState.WithInterestsSelection, interestsSelectionState: ForYouInterestsSelectionUiState.WithInterestsSelection,
onTopicCheckedChanged: (String, Boolean) -> Unit, onTopicCheckedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) = trace("TopicSelection") {
val lazyGridState = rememberLazyGridState()
TrackScrollJank(scrollableState = lazyGridState, stateName = "forYou:TopicSelection")
LazyHorizontalGrid( LazyHorizontalGrid(
state = lazyGridState,
rows = GridCells.Fixed(3), rows = GridCells.Fixed(3),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
@ -329,7 +367,7 @@ private fun SingleTopicButton(
imageUrl: String, imageUrl: String,
isSelected: Boolean, isSelected: Boolean,
onClick: (String, Boolean) -> Unit onClick: (String, Boolean) -> Unit
) { ) = trace("SingleTopicButton") {
Surface( Surface(
modifier = Modifier modifier = Modifier
.width(312.dp) .width(312.dp)

@ -24,8 +24,8 @@
<string name="onboarding_guidance_title">What are you interested in?</string> <string name="onboarding_guidance_title">What are you interested in?</string>
<string name="onboarding_guidance_subtitle">Updates from topics you follow will appear here. Follow some things to get started.</string> <string name="onboarding_guidance_subtitle">Updates from topics you follow will appear here. Follow some things to get started.</string>
<string name="top_app_bar_title">Now in Android</string> <string name="top_app_bar_title">Now in Android</string>
<string name="top_app_bar_action_button_content_desc">My account</string> <string name="for_you_top_app_bar_action_my_account">My account</string>
<string name="top_app_bar_navigation_button_content_desc">Search</string> <string name="for_you_top_app_bar_action_search">Search</string>
<!-- Authors--> <!-- Authors-->
<string name="following">You are following</string> <string name="following">You are following</string>

@ -19,10 +19,8 @@ package com.google.samples.apps.nowinandroid.feature.interests
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -45,6 +43,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors import com.google.samples.apps.nowinandroid.core.model.data.previewAuthors
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.JankMetricDisposableEffect
@Composable @Composable
fun InterestsRoute( fun InterestsRoute(
@ -66,6 +65,14 @@ fun InterestsRoute(
switchTab = viewModel::switchTab, switchTab = viewModel::switchTab,
modifier = modifier modifier = modifier
) )
JankMetricDisposableEffect(tabState) { metricsHolder ->
metricsHolder.state?.addState("Interests:TabState", "currentIndex:${tabState.currentIndex}")
onDispose {
metricsHolder.state?.removeState("Interests:TabState")
}
}
} }
@Composable @Composable
@ -83,23 +90,17 @@ fun InterestsScreen(
modifier = modifier, modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
Spacer( Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
// TODO: Replace with windowInsetsTopHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
)
)
NiaTopAppBar( NiaTopAppBar(
titleRes = R.string.interests, titleRes = R.string.interests,
navigationIcon = NiaIcons.Search, navigationIcon = NiaIcons.Search,
navigationIconContentDescription = stringResource( navigationIconContentDescription = stringResource(
id = R.string.top_app_bar_navigation_button_content_desc id = R.string.interests_top_app_bar_action_seearch
), ),
actionIcon = NiaIcons.MoreVert, actionIcon = NiaIcons.MoreVert,
actionIconContentDescription = stringResource( actionIconContentDescription = stringResource(
id = R.string.top_app_bar_navigation_button_content_desc id = R.string.interests_top_app_bar_action_menu
), ),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors( colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
containerColor = Color.Transparent containerColor = Color.Transparent

@ -19,16 +19,15 @@ package com.google.samples.apps.nowinandroid.feature.interests
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
@ -41,7 +40,9 @@ fun TopicsTabContent(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
LazyColumn( LazyColumn(
modifier = modifier.padding(horizontal = 16.dp), modifier = modifier
.padding(horizontal = 16.dp)
.testTag("interests:topics"),
contentPadding = PaddingValues(top = 8.dp) contentPadding = PaddingValues(top = 8.dp)
) { ) {
topics.forEach { followableTopic -> topics.forEach { followableTopic ->
@ -58,13 +59,7 @@ fun TopicsTabContent(
} }
item { item {
Spacer( Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
// TODO: Replace with windowInsetsBottomHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
)
)
} }
} }
} }
@ -77,7 +72,9 @@ fun AuthorsTabContent(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
LazyColumn( LazyColumn(
modifier = modifier.padding(horizontal = 16.dp), modifier = modifier
.padding(horizontal = 16.dp)
.testTag("interests:people"),
contentPadding = PaddingValues(top = 8.dp) contentPadding = PaddingValues(top = 8.dp)
) { ) {
authors.forEach { followableAuthor -> authors.forEach { followableAuthor ->
@ -94,13 +91,7 @@ fun AuthorsTabContent(
} }
item { item {
Spacer( Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
// TODO: Replace with windowInsetsBottomHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
)
)
} }
} }
} }

@ -22,6 +22,6 @@
<string name="interests_empty_header">"No available data"</string> <string name="interests_empty_header">"No available data"</string>
<string name="interests_card_follow_button_content_desc">Follow interest button</string> <string name="interests_card_follow_button_content_desc">Follow interest button</string>
<string name="interests_card_unfollow_button_content_desc">Unfollow interest button</string> <string name="interests_card_unfollow_button_content_desc">Unfollow interest button</string>
<string name="top_app_bar_action_button_content_desc">Menu</string> <string name="interests_top_app_bar_action_menu">Menu</string>
<string name="top_app_bar_navigation_button_content_desc">Search</string> <string name="interests_top_app_bar_action_seearch">Search</string>
</resources> </resources>

@ -16,7 +16,6 @@
package com.google.samples.apps.nowinandroid.interests package com.google.samples.apps.nowinandroid.interests
import app.cash.turbine.test
import com.google.samples.apps.nowinandroid.core.model.data.Author import com.google.samples.apps.nowinandroid.core.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
@ -24,19 +23,26 @@ import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestAuthorsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.TestDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState
import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
/**
* To learn more about how this test handles Flows created with stateIn, see
* https://developer.android.com/kotlin/flow/test#statein
*/
class InterestsViewModelTest { class InterestsViewModelTest {
@get:Rule @get:Rule
val dispatcherRule = TestDispatcherRule() val mainDispatcherRule = MainDispatcherRule()
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val authorsRepository = TestAuthorsRepository() private val authorsRepository = TestAuthorsRepository()
@ -54,135 +60,136 @@ class InterestsViewModelTest {
@Test @Test
fun uiState_whenInitialized_thenShowLoading() = runTest { fun uiState_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test { assertEquals(InterestsUiState.Loading, viewModel.uiState.value)
assertEquals(InterestsUiState.Loading, awaitItem())
}
} }
@Test @Test
fun uiState_whenFollowedTopicsAreLoading_thenShowLoading() = runTest { fun uiState_whenFollowedTopicsAreLoading_thenShowLoading() = runTest {
viewModel.uiState.test { val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
assertEquals(InterestsUiState.Loading, awaitItem())
userDataRepository.setFollowedAuthorIds(setOf("1")) userDataRepository.setFollowedAuthorIds(setOf("1"))
userDataRepository.setFollowedTopicIds(emptySet()) userDataRepository.setFollowedTopicIds(emptySet())
} assertEquals(InterestsUiState.Loading, viewModel.uiState.value)
collectJob.cancel()
} }
@Test @Test
fun uiState_whenFollowedAuthorsAreLoading_thenShowLoading() = runTest { fun uiState_whenFollowedAuthorsAreLoading_thenShowLoading() = runTest {
viewModel.uiState.test { val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
assertEquals(InterestsUiState.Loading, awaitItem())
userDataRepository.setFollowedAuthorIds(emptySet()) userDataRepository.setFollowedAuthorIds(emptySet())
userDataRepository.setFollowedTopicIds(setOf("1")) userDataRepository.setFollowedTopicIds(setOf("1"))
} assertEquals(InterestsUiState.Loading, viewModel.uiState.value)
collectJob.cancel()
} }
@Test @Test
fun uiState_whenFollowingNewTopic_thenShowUpdatedTopics() = runTest { fun uiState_whenFollowingNewTopic_thenShowUpdatedTopics() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
val toggleTopicId = testOutputTopics[1].topic.id val toggleTopicId = testOutputTopics[1].topic.id
viewModel.uiState authorsRepository.sendAuthors(emptyList())
.test { userDataRepository.setFollowedAuthorIds(emptySet())
awaitItem() topicsRepository.sendTopics(testInputTopics.map { it.topic })
authorsRepository.sendAuthors(emptyList()) userDataRepository.setFollowedTopicIds(setOf(testInputTopics[0].topic.id))
userDataRepository.setFollowedAuthorIds(emptySet())
topicsRepository.sendTopics(testInputTopics.map { it.topic }) assertEquals(
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[0].topic.id)) false,
(viewModel.uiState.value as InterestsUiState.Interests)
assertEquals( .topics.first { it.topic.id == toggleTopicId }.isFollowed
false, )
(awaitItem() as InterestsUiState.Interests)
.topics.first { it.topic.id == toggleTopicId }.isFollowed viewModel.followTopic(
) followedTopicId = toggleTopicId,
true
viewModel.followTopic( )
followedTopicId = toggleTopicId,
true assertEquals(
) InterestsUiState.Interests(topics = testOutputTopics, authors = emptyList()),
viewModel.uiState.value
assertEquals( )
InterestsUiState.Interests(topics = testOutputTopics, authors = emptyList()),
awaitItem() collectJob.cancel()
)
}
} }
@Test @Test
fun uiState_whenFollowingNewAuthor_thenShowUpdatedAuthors() = runTest { fun uiState_whenFollowingNewAuthor_thenShowUpdatedAuthors() = runTest {
viewModel.uiState val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
.test {
awaitItem() authorsRepository.sendAuthors(testInputAuthors.map { it.author })
authorsRepository.sendAuthors(testInputAuthors.map { it.author }) userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[0].author.id))
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[0].author.id)) topicsRepository.sendTopics(listOf())
topicsRepository.sendTopics(listOf()) userDataRepository.setFollowedTopicIds(setOf())
userDataRepository.setFollowedTopicIds(setOf())
viewModel.followAuthor(
awaitItem() followedAuthorId = testInputAuthors[1].author.id,
viewModel.followAuthor( followed = true
followedAuthorId = testInputAuthors[1].author.id, )
followed = true
) assertEquals(
InterestsUiState.Interests(topics = emptyList(), authors = testOutputAuthors),
assertEquals( viewModel.uiState.value
InterestsUiState.Interests(topics = emptyList(), authors = testOutputAuthors), )
awaitItem()
) collectJob.cancel()
}
} }
@Test @Test
fun uiState_whenUnfollowingTopics_thenShowUpdatedTopics() = runTest { fun uiState_whenUnfollowingTopics_thenShowUpdatedTopics() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
val toggleTopicId = testOutputTopics[1].topic.id val toggleTopicId = testOutputTopics[1].topic.id
viewModel.uiState
.test { authorsRepository.sendAuthors(emptyList())
awaitItem() userDataRepository.setFollowedAuthorIds(emptySet())
authorsRepository.sendAuthors(emptyList()) topicsRepository.sendTopics(testOutputTopics.map { it.topic })
userDataRepository.setFollowedAuthorIds(emptySet()) userDataRepository.setFollowedTopicIds(
topicsRepository.sendTopics(testOutputTopics.map { it.topic }) setOf(testOutputTopics[0].topic.id, testOutputTopics[1].topic.id)
userDataRepository.setFollowedTopicIds( )
setOf(testOutputTopics[0].topic.id, testOutputTopics[1].topic.id)
) assertEquals(
true,
assertEquals( (viewModel.uiState.value as InterestsUiState.Interests)
true, .topics.first { it.topic.id == toggleTopicId }.isFollowed
(awaitItem() as InterestsUiState.Interests) )
.topics.first { it.topic.id == toggleTopicId }.isFollowed
) viewModel.followTopic(
followedTopicId = toggleTopicId,
viewModel.followTopic( false
followedTopicId = toggleTopicId, )
false
) assertEquals(
InterestsUiState.Interests(topics = testInputTopics, authors = emptyList()),
assertEquals( viewModel.uiState.value
InterestsUiState.Interests(topics = testInputTopics, authors = emptyList()), )
awaitItem()
) collectJob.cancel()
}
} }
@Test @Test
fun uiState_whenUnfollowingAuthors_thenShowUpdatedAuthors() = runTest { fun uiState_whenUnfollowingAuthors_thenShowUpdatedAuthors() = runTest {
viewModel.uiState val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
.test {
awaitItem() authorsRepository.sendAuthors(testOutputAuthors.map { it.author })
authorsRepository.sendAuthors(testOutputAuthors.map { it.author }) userDataRepository.setFollowedAuthorIds(
userDataRepository.setFollowedAuthorIds( setOf(testOutputAuthors[0].author.id, testOutputAuthors[1].author.id)
setOf(testOutputAuthors[0].author.id, testOutputAuthors[1].author.id) )
) topicsRepository.sendTopics(listOf())
topicsRepository.sendTopics(listOf()) userDataRepository.setFollowedTopicIds(setOf())
userDataRepository.setFollowedTopicIds(setOf())
viewModel.followAuthor(
awaitItem() followedAuthorId = testOutputAuthors[1].author.id,
viewModel.followAuthor( followed = false
followedAuthorId = testOutputAuthors[1].author.id, )
followed = false
) assertEquals(
InterestsUiState.Interests(topics = emptyList(), authors = testInputAuthors),
assertEquals( viewModel.uiState.value
InterestsUiState.Interests(topics = emptyList(), authors = testInputAuthors), )
awaitItem()
) collectJob.cancel()
}
} }
} }

@ -23,13 +23,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.material.icons.Icons.Filled import androidx.compose.material.icons.Icons.Filled
@ -91,13 +90,7 @@ internal fun TopicScreen(
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
item { item {
Spacer( Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
// TODO: Replace with windowInsetsTopHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
)
)
} }
when (topicState) { when (topicState) {
Loading -> item { Loading -> item {
@ -124,13 +117,7 @@ internal fun TopicScreen(
} }
} }
item { item {
Spacer( Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
// TODO: Replace with windowInsetsBottomHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
)
)
} }
} }
} }

@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid.feature.topic package com.google.samples.apps.nowinandroid.feature.topic
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
@ -25,9 +24,12 @@ import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.TestDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicDestination import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicDestination
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@ -36,10 +38,14 @@ import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
/**
* To learn more about how this test handles Flows created with stateIn, see
* https://developer.android.com/kotlin/flow/test#statein
*/
class TopicViewModelTest { class TopicViewModelTest {
@get:Rule @get:Rule
val dispatcherRule = TestDispatcherRule() val dispatcherRule = MainDispatcherRule()
private val userDataRepository = TestUserDataRepository() private val userDataRepository = TestUserDataRepository()
private val topicsRepository = TestTopicsRepository() private val topicsRepository = TestTopicsRepository()
@ -59,87 +65,88 @@ class TopicViewModelTest {
@Test @Test
fun uiStateAuthor_whenSuccess_matchesTopicFromRepository() = runTest { fun uiStateAuthor_whenSuccess_matchesTopicFromRepository() = runTest {
viewModel.uiState.test { val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
awaitItem()
topicsRepository.sendTopics(testInputTopics.map(FollowableTopic::topic))
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
val item = awaitItem()
assertTrue(item.topicState is TopicUiState.Success)
val successTopicState = item.topicState as TopicUiState.Success topicsRepository.sendTopics(testInputTopics.map(FollowableTopic::topic))
val topicFromRepository = topicsRepository.getTopic( userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
testInputTopics[0].topic.id val item = viewModel.uiState.value
).first() assertTrue(item.topicState is TopicUiState.Success)
assertEquals(topicFromRepository, successTopicState.followableTopic.topic) val successTopicState = item.topicState as TopicUiState.Success
} val topicFromRepository = topicsRepository.getTopic(
testInputTopics[0].topic.id
).first()
assertEquals(topicFromRepository, successTopicState.followableTopic.topic)
collectJob.cancel()
} }
@Test @Test
fun uiStateNews_whenInitialized_thenShowLoading() = runTest { fun uiStateNews_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test { assertEquals(NewsUiState.Loading, viewModel.uiState.value.newsState)
assertEquals(NewsUiState.Loading, awaitItem().newsState)
}
} }
@Test @Test
fun uiStateTopic_whenInitialized_thenShowLoading() = runTest { fun uiStateTopic_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test { assertEquals(TopicUiState.Loading, viewModel.uiState.value.topicState)
assertEquals(TopicUiState.Loading, awaitItem().topicState)
}
} }
@Test @Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicLoading_thenShowLoading() = runTest { fun uiStateTopic_whenFollowedIdsSuccessAndTopicLoading_thenShowLoading() = runTest {
viewModel.uiState.test { val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
assertEquals(TopicUiState.Loading, awaitItem().topicState) userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
} assertEquals(TopicUiState.Loading, viewModel.uiState.value.topicState)
collectJob.cancel()
} }
@Test @Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccess_thenTopicSuccessAndNewsLoading() = fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccess_thenTopicSuccessAndNewsLoading() =
runTest { runTest {
viewModel.uiState.test { val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
awaitItem()
topicsRepository.sendTopics(testInputTopics.map { it.topic }) topicsRepository.sendTopics(testInputTopics.map { it.topic })
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
val item = awaitItem() val item = viewModel.uiState.value
assertTrue(item.topicState is TopicUiState.Success) assertTrue(item.topicState is TopicUiState.Success)
assertTrue(item.newsState is NewsUiState.Loading) assertTrue(item.newsState is NewsUiState.Loading)
}
collectJob.cancel()
} }
@Test @Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccessAndNewsIsSuccess_thenAllSuccess() = fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccessAndNewsIsSuccess_thenAllSuccess() =
runTest { runTest {
viewModel.uiState.test { val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
awaitItem()
topicsRepository.sendTopics(testInputTopics.map { it.topic }) topicsRepository.sendTopics(testInputTopics.map { it.topic })
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id)) userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
newsRepository.sendNewsResources(sampleNewsResources) newsRepository.sendNewsResources(sampleNewsResources)
val item = awaitItem() val item = viewModel.uiState.value
assertTrue(item.topicState is TopicUiState.Success) assertTrue(item.topicState is TopicUiState.Success)
assertTrue(item.newsState is NewsUiState.Success) assertTrue(item.newsState is NewsUiState.Success)
}
collectJob.cancel()
} }
@Test @Test
fun uiStateTopic_whenFollowingTopic_thenShowUpdatedTopic() = runTest { fun uiStateTopic_whenFollowingTopic_thenShowUpdatedTopic() = runTest {
viewModel.uiState val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
.test {
awaitItem() topicsRepository.sendTopics(testInputTopics.map { it.topic })
topicsRepository.sendTopics(testInputTopics.map { it.topic }) // Set which topic IDs are followed, not including 0.
// Set which topic IDs are followed, not including 0. userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
viewModel.followTopicToggle(true)
viewModel.followTopicToggle(true)
assertEquals(
assertEquals( TopicUiState.Success(followableTopic = testOutputTopics[0]),
TopicUiState.Success(followableTopic = testOutputTopics[0]), viewModel.uiState.value.topicState
awaitItem().topicState )
)
} collectJob.cancel()
} }
} }

@ -4,22 +4,24 @@ androidDesugarJdkLibs = "1.1.5"
androidGradlePlugin = "7.2.1" androidGradlePlugin = "7.2.1"
androidxActivity = "1.4.0" androidxActivity = "1.4.0"
androidxAppCompat = "1.4.2" androidxAppCompat = "1.4.2"
androidxCompose = "1.2.0-beta03" androidxCompose = "1.2.0-rc02"
androidxComposeMaterial3 = "1.0.0-alpha13" androidxComposeMaterial3 = "1.0.0-alpha13"
androidxCore = "1.8.0" androidxCore = "1.8.0"
androidxCustomView = "1.0.0-beta02" androidxCustomView = "1.0.0-rc01"
androidxDataStore = "1.0.0" androidxDataStore = "1.0.0"
androidxEspresso = "3.4.0" androidxEspresso = "3.4.0"
androidxHiltNavigationCompose = "1.0.0" androidxHiltNavigationCompose = "1.0.0"
androidxLifecycle = "2.5.0-rc01" androidxLifecycle = "2.5.0-rc02"
androidxMacroBenchmark = "1.1.0-rc03" androidxMacroBenchmark = "1.1.0"
androidxNavigation = "2.4.2" androidxNavigation = "2.5.0"
androidxProfileinstaller = "1.2.0-beta03" androidxMetrics = "1.0.0-alpha01"
androidxProfileinstaller = "1.2.0-rc01"
androidxSavedState = "1.1.0" androidxSavedState = "1.1.0"
androidxStartup = "1.1.1" androidxStartup = "1.1.1"
androidxWindowManager = "1.0.0" androidxWindowManager = "1.0.0"
androidxTest = "1.4.0" androidxTest = "1.4.0"
androidxTestExt = "1.1.3" androidxTestExt = "1.1.3"
androidxTracing = "1.1.0"
androidxUiAutomator = "2.2.0" androidxUiAutomator = "2.2.0"
androidxWork = "2.7.1" androidxWork = "2.7.1"
coil = "2.1.0" coil = "2.1.0"
@ -28,21 +30,21 @@ hiltExt = "1.0.0"
jacoco = "0.8.7" jacoco = "0.8.7"
junit4 = "4.13.2" junit4 = "4.13.2"
kotlin = "1.6.21" kotlin = "1.6.21"
kotlinxCoroutines = "1.6.2" kotlinxCoroutines = "1.6.3"
kotlinxDatetime = "0.3.3" kotlinxDatetime = "0.3.3"
kotlinxSerializationJson = "1.3.3" kotlinxSerializationJson = "1.3.3"
ksp = "1.6.21-1.0.5" ksp = "1.6.21-1.0.5"
ktlint = "0.43.0" ktlint = "0.43.0"
lint = "30.2.1" lint = "30.2.1"
material3 = "1.6.1" material3 = "1.6.1"
okhttp = "4.9.3" okhttp = "4.10.0"
protobuf = "3.21.1" protobuf = "3.21.1"
protobufPlugin = "0.8.18" protobufPlugin = "0.8.19"
retrofit = "2.9.0" retrofit = "2.9.0"
retrofitKotlinxSerializationJson = "0.8.0" retrofitKotlinxSerializationJson = "0.8.0"
room = "2.4.2" room = "2.4.2"
secrets = "2.0.1" secrets = "2.0.1"
spotless = "6.7.0" spotless = "6.7.2"
turbine = "0.8.0" turbine = "0.8.0"
[libraries] [libraries]
@ -70,6 +72,7 @@ androidx-dataStore-core = { group = "androidx.datastore", name = "datastore", ve
androidx-dataStore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDataStore" } androidx-dataStore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDataStore" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" }
androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
androidx-metrics = { group = "androidx.metrics", name = "metrics-performance", version.ref = "androidxMetrics" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" }
androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "androidxProfileinstaller" } androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "androidxProfileinstaller" }
androidx-savedstate-ktx = { group = "androidx.savedstate", name = "savedstate-ktx", version.ref= "androidxSavedState"} androidx-savedstate-ktx = { group = "androidx.savedstate", name = "savedstate-ktx", version.ref= "androidxSavedState"}
@ -81,6 +84,7 @@ androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espres
androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTest" } androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTest" }
androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTest" } androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTest" }
androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "androidxUiAutomator" } androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "androidxUiAutomator" }
androidx-tracing-ktx = {group = "androidx.tracing", name="tracing-ktx", version.ref = "androidxTracing" }
androidx-work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidxWork" } androidx-work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidxWork" }
androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "androidxWork" } androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "androidxWork" }
coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil"} coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil"}

@ -35,6 +35,7 @@ dependencies {
implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.coroutines.android)
implementation(libs.androidx.tracing.ktx)
implementation(libs.androidx.startup) implementation(libs.androidx.startup)
implementation(libs.androidx.work.ktx) implementation(libs.androidx.work.ktx)
implementation(libs.hilt.ext.work) implementation(libs.hilt.ext.work)

@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.sync.workers
import android.content.Context import android.content.Context
import androidx.hilt.work.HiltWorker import androidx.hilt.work.HiltWorker
import androidx.tracing.traceAsync
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OneTimeWorkRequestBuilder
@ -59,15 +60,17 @@ class SyncWorker @AssistedInject constructor(
appContext.syncForegroundInfo() appContext.syncForegroundInfo()
override suspend fun doWork(): Result = withContext(ioDispatcher) { override suspend fun doWork(): Result = withContext(ioDispatcher) {
// First sync the repositories in parallel traceAsync("Sync", 0) {
val syncedSuccessfully = awaitAll( // First sync the repositories in parallel
async { topicRepository.sync() }, val syncedSuccessfully = awaitAll(
async { authorsRepository.sync() }, async { topicRepository.sync() },
async { newsRepository.sync() }, async { authorsRepository.sync() },
).all { it } async { newsRepository.sync() },
).all { it }
if (syncedSuccessfully) Result.success() if (syncedSuccessfully) Result.success()
else Result.retry() else Result.retry()
}
} }
override suspend fun getChangeListVersions(): ChangeListVersions = override suspend fun getChangeListVersions(): ChangeListVersions =

Loading…
Cancel
Save