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

pull/130/head
Alex Vanyo 3 years ago committed by GitHub
commit 346248f79b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

@ -50,6 +50,12 @@ The Now in Android app follows the
and is described in detail in the
[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
The app contains the usual `debug` and `release` build variants.

@ -24,6 +24,7 @@ plugins {
id("jacoco")
id("dagger.hilt.android.plugin")
id("nowinandroid.spotless")
id("nowinandroid.firebase-perf")
}
android {
@ -62,8 +63,6 @@ android {
proguardFiles("benchmark-rules.pro")
// FIXME enabling minification breaks access to demo backend.
isMinifyEnabled = false
// Keep the build type debuggable so we can attach a debugger if needed.
isDebuggable = true
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.calculateWindowSizeClass
import androidx.core.view.WindowCompat
import androidx.metrics.performance.JankStats
import com.google.samples.apps.nowinandroid.ui.NiaApp
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
@AndroidEntryPoint
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?) {
super.onCreate(savedInstanceState)
@ -35,6 +44,18 @@ class MainActivity : ComponentActivity() {
// including IME animations
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.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.DrawableResourceIcon
import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon
@ -39,6 +40,7 @@ import com.google.samples.apps.nowinandroid.feature.interests.navigation.Interes
class NiaTopLevelNavigation(private val navController: NavHostController) {
fun navigateTo(destination: TopLevelDestination) {
trace("Navigation: $destination") {
navController.navigate(destination.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
@ -54,6 +56,7 @@ class NiaTopLevelNavigation(private val navController: NavHostController) {
}
}
}
}
data class TopLevelDestination(
val route: String,

@ -39,10 +39,14 @@ import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
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.Companion.hierarchy
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.ImageVectorIcon
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.NiaTopLevelNavigation
import com.google.samples.apps.nowinandroid.navigation.TOP_LEVEL_DESTINATIONS
import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalLayoutApi::class,
ExperimentalComposeUiApi::class
)
@Composable
fun NiaApp(windowSizeClass: WindowSizeClass) {
NiaTheme {
@ -74,7 +83,9 @@ fun NiaApp(windowSizeClass: WindowSizeClass) {
NiaBackground {
Scaffold(
modifier = Modifier,
modifier = Modifier.semantics {
testTagsAsResourceId = true
},
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onBackground,
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
* 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 {
id("nowinandroid.android.test")
@ -25,7 +28,6 @@ android {
defaultConfig {
minSdk = 23
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
missingDimensionStrategy("contentType", "demo")
}
buildTypes {
@ -33,12 +35,18 @@ android {
// release build (for example, with minification on). It's signed with a debug key
// for easy local/CI testing.
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")
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"
experimentalProperties["android.experimental.self-instrumenting"] = true
}

@ -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.junit4.BaselineProfileRule
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.Test
@ -31,27 +36,30 @@ class BaselineProfileGenerator {
@get:Rule val baselineProfileRule = BaselineProfileRule()
@Test
fun startup() =
baselineProfileRule.collectBaselineProfile(
packageName = "com.google.samples.apps.nowinandroid.demo.benchmark"
) {
pressHome()
fun generate() =
baselineProfileRule.collectBaselineProfile(PACKAGE_NAME) {
// 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
// through your most important UI.
pressHome()
startActivityAndWait()
// Scroll the feed critical user journey
forYouWaitForContent()
forYouSelectAuthors()
forYouScrollFeedDownUp()
// Navigate to interests screen
device.findObject(By.text("Interests")).click()
device.waitForIdle()
device.run {
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()
}
interestsScrollTopicsDownUp()
// Navigate to people tab
device.findObject(By.text("People")).click()
device.waitForIdle()
interestsScrollPeopleDownUp()
}
}

@ -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.junit4.MacrobenchmarkRule
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.Test
import org.junit.runner.RunWith
@ -74,7 +76,7 @@ abstract class AbstractStartupBenchmark(private val startupMode: StartupMode) {
fun startupFullCompilation() = startup(CompilationMode.Full())
private fun startup(compilationMode: CompilationMode) = benchmarkRule.measureRepeated(
packageName = "com.google.samples.apps.nowinandroid",
packageName = PACKAGE_NAME,
metrics = listOf(StartupTimingMetric()),
compilationMode = compilationMode,
iterations = 10,
@ -84,5 +86,7 @@ abstract class AbstractStartupBenchmark(private val startupMode: StartupMode) {
}
) {
startActivityAndWait()
// Waits until the content is ready to capture Time To Full Display
forYouWaitForContent()
}
}

@ -69,5 +69,9 @@ gradlePlugin {
id = "nowinandroid.spotless"
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 org.gradle.api.Project
import org.gradle.api.artifacts.VersionCatalogsExtension
import org.gradle.kotlin.dsl.dependencies
import org.gradle.kotlin.dsl.getByType
import java.io.File
/**
* Configure Compose-specific options
@ -38,5 +38,33 @@ internal fun Project.configureAndroidCompose(
composeOptions {
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)
}

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) =
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
*/
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) {
niaPreferencesDataSource.toggleFollowedAuthorId(followedAuthorId, followed)
}
override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) {
niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked)
}
}

@ -47,7 +47,7 @@ class OfflineFirstUserDataRepositoryTest {
}
@Test
fun offlineFirstTopicsRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() =
fun offlineFirstUserDataRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() =
runTest {
subject.toggleFollowedTopicId(followedTopicId = "0", followed = true)
@ -68,7 +68,8 @@ class OfflineFirstUserDataRepositoryTest {
)
assertEquals(
niaPreferencesDataSource.followedTopicIds
niaPreferencesDataSource.userDataStream
.map { it.followedTopics }
.first(),
subject.userDataStream
.map { it.followedTopics }
@ -77,7 +78,7 @@ class OfflineFirstUserDataRepositoryTest {
}
@Test
fun offlineFirstTopicsRepository_set_followed_topics_logic_delegates_to_nia_preferences() =
fun offlineFirstUserDataRepository_set_followed_topics_logic_delegates_to_nia_preferences() =
runTest {
subject.setFollowedTopicIds(followedTopicIds = setOf("1", "2"))
@ -89,11 +90,43 @@ class OfflineFirstUserDataRepositoryTest {
)
assertEquals(
niaPreferencesDataSource.followedTopicIds
niaPreferencesDataSource.userDataStream
.map { it.followedTopics }
.first(),
subject.userDataStream
.map { it.followedTopics }
.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 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 java.io.IOException
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retry
class NiaPreferencesDataSource @Inject constructor(
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) {
try {
userPreferences.updateData {
it.copy {
val current =
if (followed) {
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 userDataStream = userPreferences.data
.map {
UserData(
bookmarkedNewsResources = it.bookmarkedNewsResourceIdsList.toSet(),
followedTopics = it.followedTopicIdsList.toSet(),
followedAuthors = it.followedAuthorIdsList.toSet(),
)
}
val followedTopicIds: Flow<Set<String>> = userPreferences.data
.retry {
Log.e("NiaPreferences", "Failed to read user preferences", it)
true
}
.map { it.followedTopicIdsList.toSet() }
suspend fun setFollowedTopicIds(followedTopicIds: Set<String>) =
userPreferences.setList(
listGetter = { it.followedTopicIds },
listModifier = { followedTopicIds.toList() },
clear = { it.clear() },
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
.map {
@ -106,51 +120,47 @@ class NiaPreferencesDataSource @Inject constructor(
}
}
suspend fun setFollowedAuthorIds(followedAuthorIds: Set<String>) {
try {
userPreferences.updateData {
it.copy {
this.followedAuthorIds.clear()
this.followedAuthorIds.addAll(followedAuthorIds)
}
}
} catch (ioException: IOException) {
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
}
/**
* Adds or removes [value] from the [DslList] provided by [listGetter]
*/
private suspend fun <T : DslProxy> DataStore<UserPreferences>.editList(
add: Boolean,
value: String,
listGetter: (UserPreferencesKt.Dsl) -> DslList<String, T>,
clear: UserPreferencesKt.Dsl.(DslList<String, T>) -> Unit,
addAll: UserPreferencesKt.Dsl.(DslList<String, T>, Iterable<String>) -> Unit
) {
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 {
userPreferences.updateData {
updateData {
it.copy {
val current =
if (followed) {
followedAuthorIds + followedAuthorId
} else {
followedAuthorIds - followedAuthorId
}
this.followedAuthorIds.clear()
this.followedAuthorIds.addAll(current)
val dslList = listGetter(this)
val newList = listModifier(dslList)
clear(dslList)
addAll(dslList, newList)
}
}
} catch (ioException: 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;
repeated string followed_topic_ids = 9;
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.
*/

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

@ -44,4 +44,6 @@ dependencies {
api(libs.androidx.compose.ui.util)
api(libs.androidx.compose.runtime)
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.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
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.LazyListScope
import androidx.compose.foundation.shape.CircleShape
@ -91,13 +90,7 @@ internal fun AuthorScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Spacer(
// TODO: Replace with windowInsetsTopHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
)
)
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
}
when (authorState) {
AuthorUiState.Loading -> {
@ -126,13 +119,7 @@ internal fun AuthorScreen(
}
}
item {
Spacer(
// TODO: Replace with windowInsetsBottomHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
)
)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
}

@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid.feature.author
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.FollowableAuthor
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.TestNewsRepository
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 kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.Assert.assertEquals
@ -36,10 +38,14 @@ import org.junit.Before
import org.junit.Rule
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 {
@get:Rule
val dispatcherRule = TestDispatcherRule()
val dispatcherRule = MainDispatcherRule()
private val userDataRepository = TestUserDataRepository()
private val authorsRepository = TestAuthorsRepository()
@ -62,13 +68,13 @@ class AuthorViewModelTest {
@Test
fun uiStateAuthor_whenSuccess_matchesAuthorFromRepository() = runTest {
viewModel.uiState.test {
awaitItem()
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
// To make sure AuthorUiState is success
authorsRepository.sendAuthors(testInputAuthors.map(FollowableAuthor::author))
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
val item = awaitItem()
val item = viewModel.uiState.value
assertTrue(item.authorState is AuthorUiState.Success)
val successAuthorUiState = item.authorState as AuthorUiState.Success
@ -78,63 +84,63 @@ class AuthorViewModelTest {
successAuthorUiState.followableAuthor.author
assertEquals(authorFromRepository, successAuthorUiState.followableAuthor.author)
}
collectJob.cancel()
}
@Test
fun uiStateNews_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(NewsUiState.Loading, awaitItem().newsState)
}
assertEquals(NewsUiState.Loading, viewModel.uiState.value.newsState)
}
@Test
fun uiStateAuthor_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(AuthorUiState.Loading, awaitItem().authorState)
}
assertEquals(AuthorUiState.Loading, viewModel.uiState.value.authorState)
}
@Test
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)
}
assertEquals(AuthorUiState.Loading, viewModel.uiState.value.authorState)
collectJob.cancel()
}
@Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccess_thenAuthorSuccessAndNewsLoading() =
runTest {
viewModel.uiState.test {
awaitItem()
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
val item = awaitItem()
val item = viewModel.uiState.value
assertTrue(item.authorState is AuthorUiState.Success)
assertTrue(item.newsState is NewsUiState.Loading)
}
collectJob.cancel()
}
@Test
fun uiStateAuthor_whenFollowedIdsSuccessAndAuthorSuccessAndNewsIsSuccess_thenAllSuccess() =
runTest {
viewModel.uiState.test {
awaitItem()
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
newsRepository.sendNewsResources(sampleNewsResources)
val item = awaitItem()
val item = viewModel.uiState.value
assertTrue(item.authorState is AuthorUiState.Success)
assertTrue(item.newsState is NewsUiState.Success)
}
collectJob.cancel()
}
@Test
fun uiStateAuthor_whenFollowingAuthor_thenShowUpdatedAuthor() = runTest {
viewModel.uiState
.test {
awaitItem()
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
// Set which author IDs are followed, not including 0.
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[1].author.id))
@ -143,9 +149,10 @@ class AuthorViewModelTest {
assertEquals(
AuthorUiState.Success(followableAuthor = testOutputAuthors[0]),
awaitItem().authorState
viewModel.uiState.value.authorState
)
}
collectJob.cancel()
}
}

@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ripple.rememberRipple
@ -43,6 +44,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
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.model.data.Author
import com.google.samples.apps.nowinandroid.core.model.data.FollowableAuthor
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
@Composable
fun AuthorsCarousel(
@ -64,10 +67,15 @@ fun AuthorsCarousel(
onAuthorClick: (String, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
val lazyListState = rememberLazyListState()
val tag = "forYou:authors"
TrackScrollJank(scrollableState = lazyListState, stateName = tag)
LazyRow(
modifier = modifier,
modifier = modifier.testTag(tag),
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 ->
AuthorItem(

@ -16,6 +16,7 @@
package com.google.samples.apps.nowinandroid.feature.foryou
import android.app.Activity
import android.content.Intent
import android.net.Uri
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.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentSize
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.LazyHorizontalGrid
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.rememberLazyListState
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
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.WindowWidthSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
@ -65,6 +70,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
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.stringResource
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.max
import androidx.compose.ui.unit.sp
import androidx.compose.ui.util.trace
import androidx.core.content.ContextCompat
import androidx.core.view.doOnPreDraw
import androidx.hilt.navigation.compose.hiltViewModel
import coil.compose.AsyncImage
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.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.NewsResourceCardExpanded
import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank
import kotlin.math.floor
@Composable
@ -131,11 +141,11 @@ fun ForYouScreen(
titleRes = R.string.top_app_bar_title,
navigationIcon = NiaIcons.Search,
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,
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(
containerColor = Color.Transparent
@ -160,8 +170,37 @@ fun ForYouScreen(
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(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.testTag(tag),
state = lazyListState,
) {
InterestsSelection(
interestsSelectionState = interestsSelectionState,
@ -182,13 +221,7 @@ fun ForYouScreen(
)
item {
Spacer(
// TODO: Replace with windowInsetsBottomHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
)
)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
}
@ -218,7 +251,8 @@ private fun LazyListScope.InterestsSelection(
NiaLoadingWheel(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize(),
.wrapContentSize()
.testTag("forYou:loading"),
contentDesc = stringResource(id = R.string.for_you_loading),
)
}
@ -290,8 +324,12 @@ private fun TopicSelection(
interestsSelectionState: ForYouInterestsSelectionUiState.WithInterestsSelection,
onTopicCheckedChanged: (String, Boolean) -> Unit,
modifier: Modifier = Modifier
) {
) = trace("TopicSelection") {
val lazyGridState = rememberLazyGridState()
TrackScrollJank(scrollableState = lazyGridState, stateName = "forYou:TopicSelection")
LazyHorizontalGrid(
state = lazyGridState,
rows = GridCells.Fixed(3),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
@ -329,7 +367,7 @@ private fun SingleTopicButton(
imageUrl: String,
isSelected: Boolean,
onClick: (String, Boolean) -> Unit
) {
) = trace("SingleTopicButton") {
Surface(
modifier = Modifier
.width(312.dp)

@ -24,8 +24,8 @@
<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="top_app_bar_title">Now in Android</string>
<string name="top_app_bar_action_button_content_desc">My account</string>
<string name="top_app_bar_navigation_button_content_desc">Search</string>
<string name="for_you_top_app_bar_action_my_account">My account</string>
<string name="for_you_top_app_bar_action_search">Search</string>
<!-- Authors-->
<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.Spacer
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.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsTopHeight
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBarDefaults
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.previewAuthors
import com.google.samples.apps.nowinandroid.core.model.data.previewTopics
import com.google.samples.apps.nowinandroid.core.ui.JankMetricDisposableEffect
@Composable
fun InterestsRoute(
@ -66,6 +65,14 @@ fun InterestsRoute(
switchTab = viewModel::switchTab,
modifier = modifier
)
JankMetricDisposableEffect(tabState) { metricsHolder ->
metricsHolder.state?.addState("Interests:TabState", "currentIndex:${tabState.currentIndex}")
onDispose {
metricsHolder.state?.removeState("Interests:TabState")
}
}
}
@Composable
@ -83,23 +90,17 @@ fun InterestsScreen(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(
// TODO: Replace with windowInsetsTopHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Top)
)
)
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing))
NiaTopAppBar(
titleRes = R.string.interests,
navigationIcon = NiaIcons.Search,
navigationIconContentDescription = stringResource(
id = R.string.top_app_bar_navigation_button_content_desc
id = R.string.interests_top_app_bar_action_seearch
),
actionIcon = NiaIcons.MoreVert,
actionIconContentDescription = stringResource(
id = R.string.top_app_bar_navigation_button_content_desc
id = R.string.interests_top_app_bar_action_menu
),
colors = TopAppBarDefaults.centerAlignedTopAppBarColors(
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.Spacer
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.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
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.FollowableTopic
@ -41,7 +40,9 @@ fun TopicsTabContent(
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.padding(horizontal = 16.dp),
modifier = modifier
.padding(horizontal = 16.dp)
.testTag("interests:topics"),
contentPadding = PaddingValues(top = 8.dp)
) {
topics.forEach { followableTopic ->
@ -58,13 +59,7 @@ fun TopicsTabContent(
}
item {
Spacer(
// TODO: Replace with windowInsetsBottomHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
)
)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
}
@ -77,7 +72,9 @@ fun AuthorsTabContent(
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier.padding(horizontal = 16.dp),
modifier = modifier
.padding(horizontal = 16.dp)
.testTag("interests:people"),
contentPadding = PaddingValues(top = 8.dp)
) {
authors.forEach { followableAuthor ->
@ -94,13 +91,7 @@ fun AuthorsTabContent(
}
item {
Spacer(
// TODO: Replace with windowInsetsBottomHeight after
// https://issuetracker.google.com/issues/230383055
Modifier.windowInsetsPadding(
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)
)
)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing))
}
}
}

@ -22,6 +22,6 @@
<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_unfollow_button_content_desc">Unfollow interest button</string>
<string name="top_app_bar_action_button_content_desc">Menu</string>
<string name="top_app_bar_navigation_button_content_desc">Search</string>
<string name="interests_top_app_bar_action_menu">Menu</string>
<string name="interests_top_app_bar_action_seearch">Search</string>
</resources>

@ -16,7 +16,6 @@
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.FollowableAuthor
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.TestTopicsRepository
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.InterestsViewModel
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
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 {
@get:Rule
val dispatcherRule = TestDispatcherRule()
val mainDispatcherRule = MainDispatcherRule()
private val userDataRepository = TestUserDataRepository()
private val authorsRepository = TestAuthorsRepository()
@ -54,35 +60,36 @@ class InterestsViewModelTest {
@Test
fun uiState_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(InterestsUiState.Loading, awaitItem())
}
assertEquals(InterestsUiState.Loading, viewModel.uiState.value)
}
@Test
fun uiState_whenFollowedTopicsAreLoading_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(InterestsUiState.Loading, awaitItem())
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
userDataRepository.setFollowedAuthorIds(setOf("1"))
userDataRepository.setFollowedTopicIds(emptySet())
}
assertEquals(InterestsUiState.Loading, viewModel.uiState.value)
collectJob.cancel()
}
@Test
fun uiState_whenFollowedAuthorsAreLoading_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(InterestsUiState.Loading, awaitItem())
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
userDataRepository.setFollowedAuthorIds(emptySet())
userDataRepository.setFollowedTopicIds(setOf("1"))
}
assertEquals(InterestsUiState.Loading, viewModel.uiState.value)
collectJob.cancel()
}
@Test
fun uiState_whenFollowingNewTopic_thenShowUpdatedTopics() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
val toggleTopicId = testOutputTopics[1].topic.id
viewModel.uiState
.test {
awaitItem()
authorsRepository.sendAuthors(emptyList())
userDataRepository.setFollowedAuthorIds(emptySet())
topicsRepository.sendTopics(testInputTopics.map { it.topic })
@ -90,7 +97,7 @@ class InterestsViewModelTest {
assertEquals(
false,
(awaitItem() as InterestsUiState.Interests)
(viewModel.uiState.value as InterestsUiState.Interests)
.topics.first { it.topic.id == toggleTopicId }.isFollowed
)
@ -101,22 +108,21 @@ class InterestsViewModelTest {
assertEquals(
InterestsUiState.Interests(topics = testOutputTopics, authors = emptyList()),
awaitItem()
viewModel.uiState.value
)
}
collectJob.cancel()
}
@Test
fun uiState_whenFollowingNewAuthor_thenShowUpdatedAuthors() = runTest {
viewModel.uiState
.test {
awaitItem()
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
authorsRepository.sendAuthors(testInputAuthors.map { it.author })
userDataRepository.setFollowedAuthorIds(setOf(testInputAuthors[0].author.id))
topicsRepository.sendTopics(listOf())
userDataRepository.setFollowedTopicIds(setOf())
awaitItem()
viewModel.followAuthor(
followedAuthorId = testInputAuthors[1].author.id,
followed = true
@ -124,17 +130,18 @@ class InterestsViewModelTest {
assertEquals(
InterestsUiState.Interests(topics = emptyList(), authors = testOutputAuthors),
awaitItem()
viewModel.uiState.value
)
}
collectJob.cancel()
}
@Test
fun uiState_whenUnfollowingTopics_thenShowUpdatedTopics() = runTest {
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
val toggleTopicId = testOutputTopics[1].topic.id
viewModel.uiState
.test {
awaitItem()
authorsRepository.sendAuthors(emptyList())
userDataRepository.setFollowedAuthorIds(emptySet())
topicsRepository.sendTopics(testOutputTopics.map { it.topic })
@ -144,7 +151,7 @@ class InterestsViewModelTest {
assertEquals(
true,
(awaitItem() as InterestsUiState.Interests)
(viewModel.uiState.value as InterestsUiState.Interests)
.topics.first { it.topic.id == toggleTopicId }.isFollowed
)
@ -155,16 +162,16 @@ class InterestsViewModelTest {
assertEquals(
InterestsUiState.Interests(topics = testInputTopics, authors = emptyList()),
awaitItem()
viewModel.uiState.value
)
}
collectJob.cancel()
}
@Test
fun uiState_whenUnfollowingAuthors_thenShowUpdatedAuthors() = runTest {
viewModel.uiState
.test {
awaitItem()
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
authorsRepository.sendAuthors(testOutputAuthors.map { it.author })
userDataRepository.setFollowedAuthorIds(
setOf(testOutputAuthors[0].author.id, testOutputAuthors[1].author.id)
@ -172,7 +179,6 @@ class InterestsViewModelTest {
topicsRepository.sendTopics(listOf())
userDataRepository.setFollowedTopicIds(setOf())
awaitItem()
viewModel.followAuthor(
followedAuthorId = testOutputAuthors[1].author.id,
followed = false
@ -180,9 +186,10 @@ class InterestsViewModelTest {
assertEquals(
InterestsUiState.Interests(topics = emptyList(), authors = testInputAuthors),
awaitItem()
viewModel.uiState.value
)
}
collectJob.cancel()
}
}

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

@ -17,7 +17,6 @@
package com.google.samples.apps.nowinandroid.feature.topic
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.NewsResource
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.TestTopicsRepository
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 kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.Assert.assertEquals
@ -36,10 +38,14 @@ import org.junit.Before
import org.junit.Rule
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 {
@get:Rule
val dispatcherRule = TestDispatcherRule()
val dispatcherRule = MainDispatcherRule()
private val userDataRepository = TestUserDataRepository()
private val topicsRepository = TestTopicsRepository()
@ -59,11 +65,11 @@ class TopicViewModelTest {
@Test
fun uiStateAuthor_whenSuccess_matchesTopicFromRepository() = runTest {
viewModel.uiState.test {
awaitItem()
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
topicsRepository.sendTopics(testInputTopics.map(FollowableTopic::topic))
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
val item = awaitItem()
val item = viewModel.uiState.value
assertTrue(item.topicState is TopicUiState.Success)
val successTopicState = item.topicState as TopicUiState.Success
@ -72,63 +78,63 @@ class TopicViewModelTest {
).first()
assertEquals(topicFromRepository, successTopicState.followableTopic.topic)
}
collectJob.cancel()
}
@Test
fun uiStateNews_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(NewsUiState.Loading, awaitItem().newsState)
}
assertEquals(NewsUiState.Loading, viewModel.uiState.value.newsState)
}
@Test
fun uiStateTopic_whenInitialized_thenShowLoading() = runTest {
viewModel.uiState.test {
assertEquals(TopicUiState.Loading, awaitItem().topicState)
}
assertEquals(TopicUiState.Loading, viewModel.uiState.value.topicState)
}
@Test
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)
}
assertEquals(TopicUiState.Loading, viewModel.uiState.value.topicState)
collectJob.cancel()
}
@Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccess_thenTopicSuccessAndNewsLoading() =
runTest {
viewModel.uiState.test {
awaitItem()
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
topicsRepository.sendTopics(testInputTopics.map { it.topic })
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
val item = awaitItem()
val item = viewModel.uiState.value
assertTrue(item.topicState is TopicUiState.Success)
assertTrue(item.newsState is NewsUiState.Loading)
}
collectJob.cancel()
}
@Test
fun uiStateTopic_whenFollowedIdsSuccessAndTopicSuccessAndNewsIsSuccess_thenAllSuccess() =
runTest {
viewModel.uiState.test {
awaitItem()
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
topicsRepository.sendTopics(testInputTopics.map { it.topic })
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
newsRepository.sendNewsResources(sampleNewsResources)
val item = awaitItem()
val item = viewModel.uiState.value
assertTrue(item.topicState is TopicUiState.Success)
assertTrue(item.newsState is NewsUiState.Success)
}
collectJob.cancel()
}
@Test
fun uiStateTopic_whenFollowingTopic_thenShowUpdatedTopic() = runTest {
viewModel.uiState
.test {
awaitItem()
val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.uiState.collect() }
topicsRepository.sendTopics(testInputTopics.map { it.topic })
// Set which topic IDs are followed, not including 0.
userDataRepository.setFollowedTopicIds(setOf(testInputTopics[1].topic.id))
@ -137,9 +143,10 @@ class TopicViewModelTest {
assertEquals(
TopicUiState.Success(followableTopic = testOutputTopics[0]),
awaitItem().topicState
viewModel.uiState.value.topicState
)
}
collectJob.cancel()
}
}

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

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

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

Loading…
Cancel
Save