From 23f414a05b7fe8cd7319283dd4b36270d6003b97 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 19 Nov 2022 13:27:07 +0000 Subject: [PATCH 01/97] Configure `isReturnDefaultValues` to fix #448 --- core/data/build.gradle.kts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 717082bfe..4360b8621 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -25,6 +25,7 @@ android { testOptions { unitTests { isIncludeAndroidResources = true + isReturnDefaultValues = true } } } @@ -44,4 +45,4 @@ dependencies { implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.serialization.json) -} \ No newline at end of file +} From a3c3b1af199fbc3b538e7be9cc30e1fb8615867a Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 19 Nov 2022 15:33:46 +0100 Subject: [PATCH 02/97] Configure `isReturnDefaultValues` for `:core:datastore` --- core/datastore/build.gradle.kts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 8f3d7ece6..23271a35a 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -33,6 +33,11 @@ android { consumerProguardFiles("consumer-proguard-rules.pro") } namespace = "com.google.samples.apps.nowinandroid.core.datastore" + testOptions { + unitTests { + isReturnDefaultValues = true + } + } } // Setup protobuf configuration, generating lite Java and Kotlin classes From 2f454de21ce05e1521df302cccbadd5031590f78 Mon Sep 17 00:00:00 2001 From: Chris Sinco Date: Wed, 21 Dec 2022 10:22:36 -1000 Subject: [PATCH 03/97] Spacing polish based on design spec --- .../samples/apps/nowinandroid/core/ui/NewsFeed.kt | 5 ++++- .../feature/bookmarks/BookmarksScreen.kt | 2 +- .../nowinandroid/feature/foryou/ForYouScreen.kt | 13 ++++++------- .../nowinandroid/feature/interests/InterestsItem.kt | 2 +- .../nowinandroid/feature/interests/TabContent.kt | 4 ++-- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt index fbc9c0b81..e5b35d64b 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt @@ -21,6 +21,7 @@ import android.net.Uri import androidx.annotation.ColorInt import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyGridScope @@ -31,6 +32,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Devices @@ -67,7 +69,8 @@ fun LazyGridScope.newsFeed( saveableNewsResource.newsResource.id, !saveableNewsResource.isSaved ) - } + }, + modifier = Modifier.padding(horizontal = 8.dp) ) } } diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt index 513c8fbd0..e4a0b8f41 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt @@ -149,7 +149,7 @@ private fun EmptyState(modifier: Modifier = Modifier) { contentDescription = null ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(48.dp)) Text( text = stringResource(id = R.string.bookmarks_empty_error), diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index 1427aecff..dd42398a1 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -38,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.widthIn import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.grid.GridCells @@ -243,14 +244,13 @@ private fun LazyGridScope.onboarding( text = stringResource(R.string.onboarding_guidance_subtitle), modifier = Modifier .fillMaxWidth() - .padding(top = 8.dp, start = 16.dp, end = 16.dp), + .padding(top = 8.dp, start = 24.dp, end = 24.dp), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium ) TopicSelection( onboardingUiState, onTopicCheckedChanged, - Modifier.padding(bottom = 8.dp) ) // Done button Row( @@ -261,8 +261,9 @@ private fun LazyGridScope.onboarding( onClick = saveFollowedTopics, enabled = onboardingUiState.isDismissable, modifier = Modifier - .padding(horizontal = 40.dp) - .width(364.dp) + .padding(horizontal = 24.dp) + .widthIn(364.dp) + .fillMaxWidth() ) { Text( text = stringResource(R.string.done) @@ -381,9 +382,7 @@ fun TopicIcon( model = imageUrl, contentDescription = null, // decorative colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary), - modifier = modifier - .padding(10.dp) - .size(32.dp) + modifier = modifier.size(32.dp) ) } diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt index de04f59f1..b584fa4f6 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt @@ -66,7 +66,7 @@ fun InterestsItem( .padding(vertical = itemSeparation) ) { InterestsIcon(topicImageUrl, iconModifier.size(64.dp)) - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(24.dp)) InterestContent(name, description) } NiaIconToggleButton( diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt index 0cff8c82c..71b58cdb6 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt @@ -38,9 +38,9 @@ fun TopicsTabContent( ) { LazyColumn( modifier = modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 24.dp) .testTag("interests:topics"), - contentPadding = PaddingValues(top = 8.dp) + contentPadding = PaddingValues(vertical = 16.dp) ) { topics.forEach { followableTopic -> val topicId = followableTopic.topic.id From 63da71ecfe668c4357fe01fb923ceff2e29463ce Mon Sep 17 00:00:00 2001 From: Don Turner Date: Wed, 11 Jan 2023 20:45:17 +0000 Subject: [PATCH 04/97] Copy google-services.json from prebuilts folder Change-Id: I6b23a1e12dce1341c7d43e5560c91fa5a148681e --- build_android_release.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/build_android_release.sh b/build_android_release.sh index dfdf37500..4a7c7688f 100755 --- a/build_android_release.sh +++ b/build_android_release.sh @@ -25,8 +25,11 @@ export JAVA_HOME="$(cd $DIR/../../../prebuilts/studio/jdk/jdk11/linux && pwd )" echo "JAVA_HOME=$JAVA_HOME" export ANDROID_HOME="$(cd $DIR/../../../prebuilts/fullsdk/linux && pwd )" - echo "ANDROID_HOME=$ANDROID_HOME" + +echo "Copying google-services.json" +cp $DIR/../nowinandroid-prebuilts/google-services.json $DIR/app + cd $DIR # Build From 7fbedcd89db88d3d7913cd44dd9d0124ea49f5b8 Mon Sep 17 00:00:00 2001 From: Don Turner Date: Wed, 11 Jan 2023 20:53:34 +0000 Subject: [PATCH 05/97] Remove API 23 from list of APIs to test in Firebase Test Lab (it's flaky) Change-Id: I054bd9009c3a3358a2f578cd520ecf73961412dd --- kokoro/build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kokoro/build.sh b/kokoro/build.sh index c283382d7..5c50a814f 100755 --- a/kokoro/build.sh +++ b/kokoro/build.sh @@ -20,7 +20,7 @@ set -e set -x deviceIds=${1:-'Nexus5,Pixel2,Pixel3,Nexus9'} -osVersionIds=${2:-'23,27,30'} +osVersionIds=${2:-'27,30'} GRADLE_FLAGS=() if [[ -n "$GRADLE_DEBUG" ]]; then From f316be1d692fdb9e13596f0ba5cc7d537a2cdfa1 Mon Sep 17 00:00:00 2001 From: Ben Weiss Date: Thu, 1 Dec 2022 09:11:02 +0000 Subject: [PATCH 06/97] Add Firebase dependencies This sets up the project for using Firebase Crashlytics and Performance Monitoring. Change-Id: I8d14cfd2e5c2ba1911f2c3175adc20d6714addb6 --- app/build.gradle.kts | 2 +- build-logic/convention/build.gradle.kts | 6 +-- .../kotlin/AndroidHiltConventionPlugin.kt | 3 -- .../main/kotlin/FirebaseConventionPlugin.kt | 45 +++++++++++++++++++ .../kotlin/FirebasePerfConventionPlugin.kt | 29 ------------ build.gradle.kts | 9 ++++ gradle/libs.versions.toml | 12 +++++ 7 files changed, 70 insertions(+), 36 deletions(-) create mode 100644 build-logic/convention/src/main/kotlin/FirebaseConventionPlugin.kt delete mode 100644 build-logic/convention/src/main/kotlin/FirebasePerfConventionPlugin.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3cc48f284..09efa62fd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -20,8 +20,8 @@ plugins { id("nowinandroid.android.application.compose") id("nowinandroid.android.application.jacoco") id("nowinandroid.android.hilt") + id("nowinandroid.firebase") id("jacoco") - id("nowinandroid.firebase-perf") } android { diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 453085807..d1f3b3ab9 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -68,9 +68,9 @@ gradlePlugin { id = "nowinandroid.android.hilt" implementationClass = "AndroidHiltConventionPlugin" } - register("firebase-perf") { - id = "nowinandroid.firebase-perf" - implementationClass = "FirebasePerfConventionPlugin" + register("firebase") { + id = "nowinandroid.firebase" + implementationClass = "FirebaseConventionPlugin" } } } diff --git a/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt index 772064942..29cb748c2 100644 --- a/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidHiltConventionPlugin.kt @@ -14,14 +14,11 @@ * limitations under the License. */ -import com.android.build.api.variant.LibraryAndroidComponentsExtension -import com.google.samples.apps.nowinandroid.configureJacoco import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType -import org.gradle.kotlin.dsl.kotlin class AndroidHiltConventionPlugin : Plugin { override fun apply(target: Project) { diff --git a/build-logic/convention/src/main/kotlin/FirebaseConventionPlugin.kt b/build-logic/convention/src/main/kotlin/FirebaseConventionPlugin.kt new file mode 100644 index 000000000..70054408d --- /dev/null +++ b/build-logic/convention/src/main/kotlin/FirebaseConventionPlugin.kt @@ -0,0 +1,45 @@ +/* + * 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 +import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.dependencies +import org.gradle.kotlin.dsl.getByType + +class FirebaseConventionPlugin : Plugin { + override fun apply(target: Project) { + // Only apply this to Google Play releases. + if (!target.hasProperty("use-google-services")) + return + with(target) { + with(pluginManager) { + apply("com.google.gms.google-services") + apply("com.google.firebase.firebase-perf") + apply("com.google.firebase.crashlytics") + } + + val libs = extensions.getByType().named("libs") + dependencies { + val bom = libs.findLibrary("firebase-bom").get() + add("releaseImplementation", platform(bom)) + "releaseImplementation"(libs.findLibrary("firebase.analytics").get()) + "releaseImplementation"(libs.findLibrary("firebase.crashlytics").get()) + "releaseImplementation"(libs.findLibrary("firebase.performance").get()) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/FirebasePerfConventionPlugin.kt b/build-logic/convention/src/main/kotlin/FirebasePerfConventionPlugin.kt deleted file mode 100644 index 48f750678..000000000 --- a/build-logic/convention/src/main/kotlin/FirebasePerfConventionPlugin.kt +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import org.gradle.api.Plugin -import org.gradle.api.Project - -class FirebasePerfConventionPlugin : Plugin { - override fun apply(target: Project) { - with(target) { - pluginManager.findPlugin("com.google.firebase.firebase-perf").apply { - version = "1.4.1" - } - } - } - -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 6f6d375d7..bd395ceac 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,12 +23,21 @@ buildscript { maven { url = uri("../nowinandroid-prebuilts/m2repository") } } + dependencies { + if (project.hasProperty("use-google-services")) { + classpath(libs.firebase.crashlytics.gradle) + } + } } +// Lists all plugins used throughout the project without applying them. plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.firebase.crashlytics) apply false + alias(libs.plugins.firebase.perf) apply false + alias(libs.plugins.gms) apply false alias(libs.plugins.hilt) apply false alias(libs.plugins.secrets) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 887769cbf..4d6b694cc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,6 +28,10 @@ androidxUiAutomator = "2.2.0" androidxWindowManager = "1.0.0" androidxWork = "2.7.1" coil = "2.2.2" +firebaseBom = "31.0.3" +firebaseCrashlyticsPlugin = "2.9.2" +firebasePerfPlugin = "1.4.2" +gmsPlugin = "4.3.14" hilt = "2.44.2" hiltExt = "1.0.0" jacoco = "0.8.7" @@ -96,6 +100,11 @@ androidx-work-testing = { group = "androidx.work", name = "work-testing", versio coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil" } coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref="firebaseBom"} +firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx"} +firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx"} +firebase-crashlytics-gradle = { group = "com.google.firebase", name="firebase-crashlytics-gradle", version.ref = "firebaseCrashlyticsPlugin"} +firebase-performance = { group = "com.google.firebase", name = "firebase-perf-ktx"} hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } @@ -126,6 +135,9 @@ kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-pl android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" } +firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin"} +firebase-perf = { id = "com.google.firebase.firebase-perf", version.ref = "firebasePerfPlugin"} +gms = { id = "com.google.gms.google-services", version.ref = "gmsPlugin"} hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } From ec9e641e351eb09ee86a5663a03e32c5773d0fd6 Mon Sep 17 00:00:00 2001 From: Don Turner Date: Wed, 18 Jan 2023 16:40:56 +0000 Subject: [PATCH 07/97] Adding a file which can be modified to trigger an internal build Change-Id: Iac2732df6717be0900c6df631a913a490a93d2f7 --- .google/BUILDME | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .google/BUILDME diff --git a/.google/BUILDME b/.google/BUILDME new file mode 100644 index 000000000..6d57608d7 --- /dev/null +++ b/.google/BUILDME @@ -0,0 +1,2 @@ +# This file can be used to trigger an internal build by changing the number below +1 From ee94c22b619d7b2d521bc19571a02e0975533960 Mon Sep 17 00:00:00 2001 From: Ben Weiss Date: Wed, 18 Jan 2023 21:08:59 +0000 Subject: [PATCH 08/97] Increment internal build trigger Change-Id: I96a03fb6f0c42a7198fbf4a2049ef7120fae205a --- .google/BUILDME | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.google/BUILDME b/.google/BUILDME index 6d57608d7..d6b23eab3 100644 --- a/.google/BUILDME +++ b/.google/BUILDME @@ -1,2 +1,2 @@ # This file can be used to trigger an internal build by changing the number below -1 +2 From 5210c29eb3fef076df1245c133e1b308fa44240e Mon Sep 17 00:00:00 2001 From: Don Turner Date: Wed, 18 Jan 2023 21:13:23 +0000 Subject: [PATCH 09/97] New build trigger Change-Id: I80a70e20124054348844ecd87e42ecd540826161 --- .google/BUILDME | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.google/BUILDME b/.google/BUILDME index d6b23eab3..5295ed188 100644 --- a/.google/BUILDME +++ b/.google/BUILDME @@ -1,2 +1,2 @@ # This file can be used to trigger an internal build by changing the number below -2 +3 From 68afef6c26a29a6d22e51cb5adacd8b9dda42b48 Mon Sep 17 00:00:00 2001 From: Don Turner Date: Thu, 19 Jan 2023 09:44:20 +0000 Subject: [PATCH 10/97] Revert "New build trigger" This reverts commit 5210c29eb3fef076df1245c133e1b308fa44240e. Reason for revert: Trigger another build (wow, this is such a great process!) Change-Id: Iee146e0069f92f338789016f81c91dad18f5f7b3 --- .google/BUILDME | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.google/BUILDME b/.google/BUILDME index 5295ed188..d6b23eab3 100644 --- a/.google/BUILDME +++ b/.google/BUILDME @@ -1,2 +1,2 @@ # This file can be used to trigger an internal build by changing the number below -3 +2 From 43f0b9f693de21a9534c2d8ad49cb1e32289c6ec Mon Sep 17 00:00:00 2001 From: Ben Weiss Date: Thu, 19 Jan 2023 12:35:38 +0000 Subject: [PATCH 11/97] Revert "Revert "New build trigger"" This reverts commit 68afef6c26a29a6d22e51cb5adacd8b9dda42b48. Reason for revert: Reverting the revert to kick the build. Change-Id: Icc1602b188fcc19cc88ba019e5a96a827e69543f --- .google/BUILDME | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.google/BUILDME b/.google/BUILDME index d6b23eab3..5295ed188 100644 --- a/.google/BUILDME +++ b/.google/BUILDME @@ -1,2 +1,2 @@ # This file can be used to trigger an internal build by changing the number below -2 +3 From 71303b6f6126968e4a6a923d9ee9c93a08aa2454 Mon Sep 17 00:00:00 2001 From: Ben Weiss Date: Thu, 26 Jan 2023 09:00:22 +0000 Subject: [PATCH 12/97] Revert "Revert "Revert "New build trigger""" This reverts commit 43f0b9f693de21a9534c2d8ad49cb1e32289c6ec. Reason for revert: Kicking the build again Change-Id: I316e46a870f63e9561a1e0c1fb0a8114bbe81d98 --- .google/BUILDME | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.google/BUILDME b/.google/BUILDME index 5295ed188..d6b23eab3 100644 --- a/.google/BUILDME +++ b/.google/BUILDME @@ -1,2 +1,2 @@ # This file can be used to trigger an internal build by changing the number below -3 +2 From 6dd82ea03ea395bf42c3031f36d2cc935fa210cf Mon Sep 17 00:00:00 2001 From: Ben Weiss Date: Mon, 30 Jan 2023 15:22:48 +0000 Subject: [PATCH 13/97] Enable use-google-services in release builds Change-Id: Idb1ca4a4e218e493cac1c26b3dd2582a19914cfe --- build_android_release.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build_android_release.sh b/build_android_release.sh index 4a7c7688f..eccdc45ef 100755 --- a/build_android_release.sh +++ b/build_android_release.sh @@ -33,7 +33,7 @@ cp $DIR/../nowinandroid-prebuilts/google-services.json $DIR/app cd $DIR # Build -GRADLE_PARAMS=" --stacktrace" +GRADLE_PARAMS=" --stacktrace -Puse-google-services" $DIR/gradlew :app:clean :app:assemble ${GRADLE_PARAMS} BUILD_RESULT=$? @@ -68,4 +68,4 @@ cp $APP_OUT/bundle/prodRelease/app-prod-release.aab $DIST_DIR/app-prod-release.a #cp $APP_OUT/mapping/prodRelease/mapping.txt $DIST_DIR/mobile-release-aab-mapping.txt BUILD_RESULT=$? -exit $BUILD_RESULT \ No newline at end of file +exit $BUILD_RESULT From 8d0f28170d8b713bcfa37a42cc724f2abf766a55 Mon Sep 17 00:00:00 2001 From: Ben Weiss Date: Mon, 30 Jan 2023 16:43:43 +0000 Subject: [PATCH 14/97] Build release artifacts separately, using google-services Change-Id: I17e3fb3bfa598ae729623be685af65dffd366e95 --- build_android_release.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/build_android_release.sh b/build_android_release.sh index eccdc45ef..2fe22a57a 100755 --- a/build_android_release.sh +++ b/build_android_release.sh @@ -33,10 +33,13 @@ cp $DIR/../nowinandroid-prebuilts/google-services.json $DIR/app cd $DIR # Build -GRADLE_PARAMS=" --stacktrace -Puse-google-services" +GRADLE_PARAMS=" --stacktrace" $DIR/gradlew :app:clean :app:assemble ${GRADLE_PARAMS} BUILD_RESULT=$? +GRADLE_RELEASE_PARAMS="$GRADLE_PARAMS -Puse-google-services" +$DIR/gradlew :app:assembleDemoRelease :app:assembleProdRelease ${GRADLE_RELEASE_PARAMS} + # Demo debug cp $APP_OUT/apk/demo/debug/app-demo-debug.apk $DIST_DIR From ef4b189f7e45f2ce71e7decde4c9b8f1dec10e56 Mon Sep 17 00:00:00 2001 From: Ben Weiss Date: Tue, 31 Jan 2023 09:31:15 +0000 Subject: [PATCH 15/97] Build aab with play services enabled Change-Id: I8715fb7f95ece046f48629c97d207e2cb52e0fb2 --- build_android_release.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build_android_release.sh b/build_android_release.sh index 2fe22a57a..d1b5cb30a 100755 --- a/build_android_release.sh +++ b/build_android_release.sh @@ -38,7 +38,7 @@ $DIR/gradlew :app:clean :app:assemble ${GRADLE_PARAMS} BUILD_RESULT=$? GRADLE_RELEASE_PARAMS="$GRADLE_PARAMS -Puse-google-services" -$DIR/gradlew :app:assembleDemoRelease :app:assembleProdRelease ${GRADLE_RELEASE_PARAMS} +$DIR/gradlew :app:assembleDemoRelease :app:assembleProdRelease :app:bundleDemoRelease :app:bundleProdRelease ${GRADLE_RELEASE_PARAMS} # Demo debug cp $APP_OUT/apk/demo/debug/app-demo-debug.apk $DIST_DIR From 19684abc5f3b5a1dc6a1951de0a73b139843b82b Mon Sep 17 00:00:00 2001 From: Don Turner Date: Tue, 31 Jan 2023 16:52:41 +0000 Subject: [PATCH 16/97] Remove .prod from the applicationIdSuffix in preparation for Play launch Change-Id: I30f7120bf6ce3f101ca9fc1ad28d3cecf2048f9a --- .../kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt index 5cafdf7ce..dec592542 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/NiaFlavor.kt @@ -16,8 +16,8 @@ enum class FlavorDimension { // These two product flavors reflect this behaviour. @Suppress("EnumEntryName") enum class NiaFlavor(val dimension: FlavorDimension, val applicationIdSuffix: String? = null) { - demo(FlavorDimension.contentType), - prod(FlavorDimension.contentType, ".prod") + demo(FlavorDimension.contentType, applicationIdSuffix = ".demo"), + prod(FlavorDimension.contentType, ) } fun Project.configureFlavors( From ab470ea99beaddd2781d9e859951c705b16ecf49 Mon Sep 17 00:00:00 2001 From: Ben Weiss Date: Tue, 31 Jan 2023 18:04:31 +0000 Subject: [PATCH 17/97] Fix exit codes Change-Id: Ib4b07ffe2cf43b37f81e42cda903114260afebbd --- build_android_release.sh | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/build_android_release.sh b/build_android_release.sh index d1b5cb30a..02af96608 100755 --- a/build_android_release.sh +++ b/build_android_release.sh @@ -32,14 +32,11 @@ cp $DIR/../nowinandroid-prebuilts/google-services.json $DIR/app cd $DIR -# Build -GRADLE_PARAMS=" --stacktrace" -$DIR/gradlew :app:clean :app:assemble ${GRADLE_PARAMS} +# Build the prodRelease variant +GRADLE_PARAMS=" --stacktrace -Puse-google-services" +$DIR/gradlew :app:clean :app:assembleProdRelease :app:bundleProdRelease ${GRADLE_PARAMS} BUILD_RESULT=$? -GRADLE_RELEASE_PARAMS="$GRADLE_PARAMS -Puse-google-services" -$DIR/gradlew :app:assembleDemoRelease :app:assembleProdRelease :app:bundleDemoRelease :app:bundleProdRelease ${GRADLE_RELEASE_PARAMS} - # Demo debug cp $APP_OUT/apk/demo/debug/app-demo-debug.apk $DIST_DIR @@ -57,18 +54,19 @@ cp $APP_OUT/apk/prod/release/app-prod-release.apk $DIST_DIR/app-prod-release.apk # Don't clean here, otherwise all apks are gone. $DIR/gradlew :app:bundle ${GRADLE_PARAMS} -# Demo debug -cp $APP_OUT/bundle/demoDebug/app-demo-debug.aab $DIST_DIR/app-demo-debug.aab - -# Demo release -cp $APP_OUT/bundle/demoRelease/app-demo-release.aab $DIST_DIR/app-demo-release.aab - # Prod debug cp $APP_OUT/bundle/prodDebug/app-prod-debug.aab $DIST_DIR/app-prod-debug.aab # Prod release cp $APP_OUT/bundle/prodRelease/app-prod-release.aab $DIST_DIR/app-prod-release.aab #cp $APP_OUT/mapping/prodRelease/mapping.txt $DIST_DIR/mobile-release-aab-mapping.txt -BUILD_RESULT=$? - -exit $BUILD_RESULT +COPY_RESULT=$? + +if [ $BUILD_RESULT -eq 0 ] && [ $RELEASE_BUILD_RESULT -eq 0 ] && [ $COPY_RESULT -eq 0 ] +then + echo "All parts successful" + exit 0 +else + echo "Something failed. Check logs for details." + exit 1 +fi From 3f935bd958b5ad80b245b61bf6d6d75e503e2290 Mon Sep 17 00:00:00 2001 From: Don Turner Date: Tue, 31 Jan 2023 18:19:10 +0000 Subject: [PATCH 18/97] Copy local.properties during build Change-Id: I48fdf41747663462fb00979eaf3037310fe257de --- build_android_release.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build_android_release.sh b/build_android_release.sh index 02af96608..a2fd82f9c 100755 --- a/build_android_release.sh +++ b/build_android_release.sh @@ -30,6 +30,9 @@ echo "ANDROID_HOME=$ANDROID_HOME" echo "Copying google-services.json" cp $DIR/../nowinandroid-prebuilts/google-services.json $DIR/app +echo "Copying local.properties" +cp $DIR/../nowinandroid-prebuilts/local.properties $DIR + cd $DIR # Build the prodRelease variant From 3cf2a06a858ec0f2b894b799f55c93e90e2192ab Mon Sep 17 00:00:00 2001 From: Ben Weiss Date: Wed, 1 Feb 2023 10:09:06 +0000 Subject: [PATCH 19/97] Simplify build script Change-Id: Ia2444cdab959fa0f0e401dd98e1b6b8bccd6b4f1 --- build_android_release.sh | 35 +++++------------------------------ 1 file changed, 5 insertions(+), 30 deletions(-) diff --git a/build_android_release.sh b/build_android_release.sh index a2fd82f9c..c7e5fc835 100755 --- a/build_android_release.sh +++ b/build_android_release.sh @@ -40,36 +40,11 @@ GRADLE_PARAMS=" --stacktrace -Puse-google-services" $DIR/gradlew :app:clean :app:assembleProdRelease :app:bundleProdRelease ${GRADLE_PARAMS} BUILD_RESULT=$? -# Demo debug -cp $APP_OUT/apk/demo/debug/app-demo-debug.apk $DIST_DIR - -# Demo release -cp $APP_OUT/apk/demo/release/app-demo-release.apk $DIST_DIR - -# Prod debug -cp $APP_OUT/apk/prod/debug/app-prod-debug.apk $DIST_DIR/app-prod-debug.apk - -# Prod release +# Prod release apk cp $APP_OUT/apk/prod/release/app-prod-release.apk $DIST_DIR/app-prod-release.apk -#cp $APP_OUT/mapping/release/mapping.txt $DIST_DIR/mobile-release-apk-mapping.txt - -# Build App Bundles -# Don't clean here, otherwise all apks are gone. -$DIR/gradlew :app:bundle ${GRADLE_PARAMS} - -# Prod debug -cp $APP_OUT/bundle/prodDebug/app-prod-debug.aab $DIST_DIR/app-prod-debug.aab - -# Prod release +# Prod release bundle cp $APP_OUT/bundle/prodRelease/app-prod-release.aab $DIST_DIR/app-prod-release.aab -#cp $APP_OUT/mapping/prodRelease/mapping.txt $DIST_DIR/mobile-release-aab-mapping.txt -COPY_RESULT=$? +# Prod release bundle mapping +cp $APP_OUT/mapping/prodRelease/mapping.txt $DIST_DIR/mobile-release-aab-mapping.txt -if [ $BUILD_RESULT -eq 0 ] && [ $RELEASE_BUILD_RESULT -eq 0 ] && [ $COPY_RESULT -eq 0 ] -then - echo "All parts successful" - exit 0 -else - echo "Something failed. Check logs for details." - exit 1 -fi +exit $BUILD_RESULT From f9a3533df301a7417c22c5726c7dd2a5a4dec675 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 4 Feb 2023 12:15:39 +0100 Subject: [PATCH 20/97] Updgrade androidx-lifecycle from 2.6.0-alpha03 to 2.6.0-alpha05 > `Transformations` is now written in Kotlin. This is a *source incompatible change* for those classes written in Kotlin that were directly using syntax such as `Transformations.map` > Kotlin code *must* now use the Kotlin extension method syntax that was previously only available when using `lifecycle-livedata-ktx`. > The `collectAsStateWithLifecycle()` APIs of `lifecycle-runtime-compose` are no longer in experimental status. (I09d42, b/258835424) Changelog: https://developer.android.com/jetpack/androidx/releases/lifecycle#2.6.0-alpha05 --- .../com/google/samples/apps/nowinandroid/ui/NiaApp.kt | 2 -- .../nowinandroid/feature/bookmarks/BookmarksScreen.kt | 2 -- .../apps/nowinandroid/feature/foryou/ForYouScreen.kt | 2 -- .../nowinandroid/feature/interests/InterestsScreen.kt | 2 -- .../apps/nowinandroid/feature/settings/SettingsDialog.kt | 2 -- .../apps/nowinandroid/feature/topic/TopicScreen.kt | 2 -- gradle/libs.versions.toml | 2 +- .../sync/status/WorkManagerSyncStatusMonitor.kt | 8 +++----- 8 files changed, 4 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index 14bc11992..0251c1d6b 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -50,7 +50,6 @@ 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.lifecycle.compose.ExperimentalLifecycleComposeApi import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy @@ -77,7 +76,6 @@ import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class, - ExperimentalLifecycleComposeApi::class, ) @Composable fun NiaApp( diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt index 1169f5777..a9d5a71af 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt @@ -50,7 +50,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme @@ -63,7 +62,6 @@ import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.newsFeed -@OptIn(ExperimentalLifecycleComposeApi::class) @Composable internal fun BookmarksRoute( modifier: Modifier = Modifier, diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index 492660142..8fb44326b 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -74,7 +74,6 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.util.trace import androidx.core.view.doOnPreDraw import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton @@ -89,7 +88,6 @@ import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.newsFeed -@OptIn(ExperimentalLifecycleComposeApi::class) @Composable internal fun ForYouRoute( modifier: Modifier = Modifier, diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt index fd45c7608..abe1569e7 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt @@ -25,7 +25,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel @@ -34,7 +33,6 @@ import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParameterProvider -@OptIn(ExperimentalLifecycleComposeApi::class) @Composable internal fun InterestsRoute( navigateToTopic: (String) -> Unit, diff --git a/feature/settings/src/main/java/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt index bed230d0d..4c4ba727b 100644 --- a/feature/settings/src/main/java/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/main/java/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt @@ -50,7 +50,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.supportsDynamicTheming @@ -65,7 +64,6 @@ import com.google.samples.apps.nowinandroid.feature.settings.R.string import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Loading import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success -@ExperimentalLifecycleComposeApi @Composable fun SettingsDialog( onDismiss: () -> Unit, diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt index e7b218072..656c7d961 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt @@ -44,7 +44,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.ExperimentalLifecycleComposeApi import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground @@ -61,7 +60,6 @@ import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems import com.google.samples.apps.nowinandroid.feature.topic.R.string import com.google.samples.apps.nowinandroid.feature.topic.TopicUiState.Loading -@OptIn(ExperimentalLifecycleComposeApi::class) @Composable internal fun TopicRoute( onBackClick: () -> Unit, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 828e38f12..8af6b9484 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ androidxCoreSplashscreen = "1.0.0" androidxDataStore = "1.0.0" androidxEspresso = "3.5.0" androidxHiltNavigationCompose = "1.0.0" -androidxLifecycle = "2.6.0-alpha03" +androidxLifecycle = "2.6.0-alpha05" androidxMacroBenchmark = "1.1.1" androidxMetrics = "1.0.0-alpha03" androidxNavigation = "2.5.3" diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncStatusMonitor.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncStatusMonitor.kt index 9edb630eb..f4f9d02cb 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncStatusMonitor.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncStatusMonitor.kt @@ -17,8 +17,8 @@ package com.google.samples.apps.nowinandroid.sync.status import android.content.Context -import androidx.lifecycle.Transformations import androidx.lifecycle.asFlow +import androidx.lifecycle.map import androidx.work.WorkInfo import androidx.work.WorkInfo.State import androidx.work.WorkManager @@ -36,10 +36,8 @@ class WorkManagerSyncStatusMonitor @Inject constructor( @ApplicationContext context: Context, ) : SyncStatusMonitor { override val isSyncing: Flow = - Transformations.map( - WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(SyncWorkName), - MutableList::anyRunning, - ) + WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(SyncWorkName) + .map(MutableList::anyRunning) .asFlow() .conflate() } From 9091abf09ef252be1eeac67cfd16fff4a8522a62 Mon Sep 17 00:00:00 2001 From: Keisuke Takagi Date: Wed, 8 Feb 2023 22:52:45 +0900 Subject: [PATCH 21/97] Provide true to LocalInspectionMode --- .../nowinandroid/core/ui/NewsResourceCard.kt | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt index ae6cadbfa..90d2266f0 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt @@ -35,6 +35,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -301,14 +302,18 @@ private fun ExpandedNewsResourcePreview( @PreviewParameter(UserNewsResourcePreviewParameterProvider::class) userNewsResources: List, ) { - NiaTheme { - Surface { - NewsResourceCardExpanded( - userNewsResource = userNewsResources[0], - isBookmarked = true, - onToggleBookmark = {}, - onClick = {}, - ) + CompositionLocalProvider( + LocalInspectionMode provides true + ) { + NiaTheme { + Surface { + NewsResourceCardExpanded( + userNewsResource = userNewsResources[0], + isBookmarked = true, + onToggleBookmark = {}, + onClick = {}, + ) + } } } } From 2a4d0434a5c8a794f86410850e25d66e846eb97f Mon Sep 17 00:00:00 2001 From: Don Turner Date: Tue, 7 Feb 2023 22:23:50 +0000 Subject: [PATCH 22/97] Add Firebase analytics. See go/nia-firebase-services. Change-Id: I0cbbda0bba761d8019241f6165db231fe94fb689 --- app/benchmark-rules.pro | 14 +- app/build.gradle.kts | 4 +- app/google-services.json | 125 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 4 + .../samples/apps/nowinandroid/MainActivity.kt | 26 ++-- app/src/prod/AndroidManifest.xml | 26 ++++ build-logic/convention/build.gradle.kts | 13 +- .../AndroidApplicationConventionPlugin.kt | 4 +- ...oidApplicationFirebaseConventionPlugin.kt} | 29 ++-- ...droidApplicationFlavorsConventionPlugin.kt | 31 +++++ .../kotlin/AndroidFeatureConventionPlugin.kt | 1 + .../kotlin/AndroidLibraryConventionPlugin.kt | 1 - build.gradle.kts | 6 - core/analytics/.gitignore | 1 + core/analytics/build.gradle.kts | 33 +++++ .../core/analytics/AnalyticsModule.kt | 29 ++++ core/analytics/src/main/AndroidManifest.xml | 17 +++ .../core/analytics/AnalyticsEvent.kt | 58 ++++++++ .../core/analytics/AnalyticsHelper.kt | 25 ++++ .../core/analytics/NoOpAnalyticsHelper.kt | 24 ++++ .../core/analytics/StubAnalyticsHelper.kt | 34 +++++ .../nowinandroid/core/analytics/UiHelpers.kt | 28 ++++ .../core/analytics/AnalyticsModule.kt | 29 ++++ .../core/analytics/FirebaseAnalyticsHelper.kt | 44 ++++++ core/data/build.gradle.kts | 1 + .../data/repository/AnalyticsExtensions.kt | 84 ++++++++++++ .../OfflineFirstUserDataRepository.kt | 31 ++++- .../OfflineFirstUserDataRepositoryTest.kt | 4 + core/ui/build.gradle.kts | 1 + .../core/ui/AnalyticsExtensions.kt | 63 +++++++++ .../apps/nowinandroid/core/ui/NewsFeed.kt | 10 +- .../core/ui/NewsResourceCardList.kt | 6 + .../feature/bookmarks/BookmarksScreen.kt | 2 + .../feature/foryou/ForYouScreen.kt | 2 + .../feature/interests/InterestsScreen.kt | 2 + .../feature/settings/SettingsDialog.kt | 2 + .../nowinandroid/feature/topic/TopicScreen.kt | 2 + .../feature/topic/TopicViewModel.kt | 2 + .../feature/topic/TopicViewModelTest.kt | 4 + gradle/libs.versions.toml | 3 +- settings.gradle.kts | 2 + sync/work/build.gradle.kts | 1 + .../sync/workers/AnalyticsExtensions.kt | 32 +++++ .../nowinandroid/sync/workers/SyncWorker.kt | 6 + 44 files changed, 826 insertions(+), 40 deletions(-) create mode 100644 app/google-services.json create mode 100644 app/src/prod/AndroidManifest.xml rename build-logic/convention/src/main/kotlin/{FirebaseConventionPlugin.kt => AndroidApplicationFirebaseConventionPlugin.kt} (53%) create mode 100644 build-logic/convention/src/main/kotlin/AndroidApplicationFlavorsConventionPlugin.kt create mode 100644 core/analytics/.gitignore create mode 100644 core/analytics/build.gradle.kts create mode 100644 core/analytics/src/demo/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt create mode 100644 core/analytics/src/main/AndroidManifest.xml create mode 100644 core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsEvent.kt create mode 100644 core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsHelper.kt create mode 100644 core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/NoOpAnalyticsHelper.kt create mode 100644 core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/StubAnalyticsHelper.kt create mode 100644 core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/UiHelpers.kt create mode 100644 core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt create mode 100644 core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/FirebaseAnalyticsHelper.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/AnalyticsExtensions.kt create mode 100644 core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AnalyticsExtensions.kt create mode 100644 sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/AnalyticsExtensions.kt diff --git a/app/benchmark-rules.pro b/app/benchmark-rules.pro index ddecd591b..96b67f2d1 100644 --- a/app/benchmark-rules.pro +++ b/app/benchmark-rules.pro @@ -3,4 +3,16 @@ # Obsfuscation must be disabled for the build variant that generates Baseline Profile, otherwise # wrong symbols would be generated. The generated Baseline Profile will be properly applied when generated # without obfuscation and your app is being obfuscated. --dontobfuscate \ No newline at end of file +-dontobfuscate + +# Please add these rules to your existing keep rules in order to suppress warnings. +# This is generated automatically by the Android Gradle plugin. +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ab9d35284..1f34810ed 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,10 +19,11 @@ import com.android.build.api.dsl.ManagedVirtualDevice plugins { id("nowinandroid.android.application") id("nowinandroid.android.application.compose") + id("nowinandroid.android.application.flavors") id("nowinandroid.android.application.jacoco") id("nowinandroid.android.hilt") - id("nowinandroid.firebase") id("jacoco") + id("nowinandroid.android.application.firebase") } android { @@ -101,6 +102,7 @@ dependencies { implementation(project(":core:designsystem")) implementation(project(":core:data")) implementation(project(":core:model")) + implementation(project(":core:analytics")) implementation(project(":sync:work")) diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 000000000..69f0b5da3 --- /dev/null +++ b/app/google-services.json @@ -0,0 +1,125 @@ +{ + "project_info": { + "project_number": "YourProjectId", + "project_id": "abc", + "storage_bucket": "abc" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "Your:App:Id", + "android_client_info": { + "package_name": "com.google.samples.apps.nowinandroid" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "APlaceholderAPIKeyWith-ThirtyNineCharsX" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "Your:App:Id", + "android_client_info": { + "package_name": "com.google.samples.apps.nowinandroid.demo.debug" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "APlaceholderAPIKeyWith-ThirtyNineCharsX" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "Your:App:Id", + "android_client_info": { + "package_name": "com.google.samples.apps.nowinandroid.demo.benchmark" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "APlaceholderAPIKeyWith-ThirtyNineCharsX" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "Your:App:Id", + "android_client_info": { + "package_name": "com.google.samples.apps.nowinandroid.benchmark" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "APlaceholderAPIKeyWith-ThirtyNineCharsX" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "Your:App:Id", + "android_client_info": { + "package_name": "com.google.samples.apps.nowinandroid.debug" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "APlaceholderAPIKeyWith-ThirtyNineCharsX" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "Your:App:Id", + "android_client_info": { + "package_name": "com.google.samples.apps.nowinandroid.demo" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "APlaceholderAPIKeyWith-ThirtyNineCharsX" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + + "configuration_version": "1" +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5c3b889d2..23b2e7862 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -39,6 +39,10 @@ + + + diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt index e46d2156a..5fc9d0525 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -37,6 +38,8 @@ import androidx.metrics.performance.JankStats import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading import com.google.samples.apps.nowinandroid.MainActivityUiState.Success +import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper +import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig @@ -61,6 +64,9 @@ class MainActivity : ComponentActivity() { @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var analyticsHelper: AnalyticsHelper + val viewModel: MainActivityViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -104,15 +110,17 @@ class MainActivity : ComponentActivity() { onDispose {} } - NiaTheme( - darkTheme = darkTheme, - androidTheme = shouldUseAndroidTheme(uiState), - disableDynamicTheming = shouldDisableDynamicTheming(uiState), - ) { - NiaApp( - networkMonitor = networkMonitor, - windowSizeClass = calculateWindowSizeClass(this), - ) + CompositionLocalProvider(LocalAnalyticsHelper provides analyticsHelper) { + NiaTheme( + darkTheme = darkTheme, + androidTheme = shouldUseAndroidTheme(uiState), + disableDynamicTheming = shouldDisableDynamicTheming(uiState), + ) { + NiaApp( + networkMonitor = networkMonitor, + windowSizeClass = calculateWindowSizeClass(this), + ) + } } } } diff --git a/app/src/prod/AndroidManifest.xml b/app/src/prod/AndroidManifest.xml new file mode 100644 index 000000000..2f8a8592a --- /dev/null +++ b/app/src/prod/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + \ No newline at end of file diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 11b8a5de1..9d7af2372 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -28,6 +28,8 @@ java { dependencies { compileOnly(libs.android.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin) + compileOnly(libs.firebase.performance.gradle) + compileOnly(libs.firebase.crashlytics.gradle) } gradlePlugin { @@ -68,9 +70,14 @@ gradlePlugin { id = "nowinandroid.android.hilt" implementationClass = "AndroidHiltConventionPlugin" } - register("firebase") { - id = "nowinandroid.firebase" - implementationClass = "FirebaseConventionPlugin" + register("androidFirebase") { + id = "nowinandroid.android.application.firebase" + implementationClass = "AndroidApplicationFirebaseConventionPlugin" } + register("androidFlavors") { + id = "nowinandroid.android.application.flavors" + implementationClass = "AndroidApplicationFlavorsConventionPlugin" + } + } } diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 612eb6ad4..dfbea8394 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -14,9 +14,8 @@ * limitations under the License. */ -import com.android.build.api.variant.ApplicationAndroidComponentsExtension import com.android.build.api.dsl.ApplicationExtension -import com.google.samples.apps.nowinandroid.configureFlavors +import com.android.build.api.variant.ApplicationAndroidComponentsExtension import com.google.samples.apps.nowinandroid.configureKotlinAndroid import com.google.samples.apps.nowinandroid.configurePrintApksTask import org.gradle.api.Plugin @@ -34,7 +33,6 @@ class AndroidApplicationConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) defaultConfig.targetSdk = 33 - configureFlavors(this) } extensions.configure { configurePrintApksTask(this) diff --git a/build-logic/convention/src/main/kotlin/FirebaseConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt similarity index 53% rename from build-logic/convention/src/main/kotlin/FirebaseConventionPlugin.kt rename to build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt index 70054408d..598da727d 100644 --- a/build-logic/convention/src/main/kotlin/FirebaseConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt @@ -14,17 +14,17 @@ * limitations under the License. */ +import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension +import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType -class FirebaseConventionPlugin : Plugin { +class AndroidApplicationFirebaseConventionPlugin : Plugin { override fun apply(target: Project) { - // Only apply this to Google Play releases. - if (!target.hasProperty("use-google-services")) - return with(target) { with(pluginManager) { apply("com.google.gms.google-services") @@ -35,10 +35,23 @@ class FirebaseConventionPlugin : Plugin { val libs = extensions.getByType().named("libs") dependencies { val bom = libs.findLibrary("firebase-bom").get() - add("releaseImplementation", platform(bom)) - "releaseImplementation"(libs.findLibrary("firebase.analytics").get()) - "releaseImplementation"(libs.findLibrary("firebase.crashlytics").get()) - "releaseImplementation"(libs.findLibrary("firebase.performance").get()) + add("implementation", platform(bom)) + "implementation"(libs.findLibrary("firebase.analytics").get()) + "implementation"(libs.findLibrary("firebase.performance").get()) + "implementation"(libs.findLibrary("firebase.crashlytics").get()) + } + + extensions.configure { + finalizeDsl { + it.buildTypes.forEach { buildType -> + // Disable the Crashlytics mapping file upload. This feature should only be + // enabled if a Firebase backend is available and configured in + // google-services.json. + buildType.configure { + mappingFileUploadEnabled = false + } + } + } } } } diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationFlavorsConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationFlavorsConventionPlugin.kt new file mode 100644 index 000000000..46b019d7a --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationFlavorsConventionPlugin.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import com.android.build.api.dsl.ApplicationExtension +import com.google.samples.apps.nowinandroid.configureFlavors +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure + +class AndroidApplicationFlavorsConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + extensions.configure { + configureFlavors(this) + } + } + } +} \ No newline at end of file diff --git a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt index a637b78c0..9c5ab9d44 100644 --- a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt @@ -46,6 +46,7 @@ class AndroidFeatureConventionPlugin : Plugin { add("implementation", project(":core:data")) add("implementation", project(":core:common")) add("implementation", project(":core:domain")) + add("implementation", project(":core:analytics")) add("testImplementation", kotlin("test")) add("testImplementation", project(":core:testing")) diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index f7d6d184a..6132e07b5 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -14,7 +14,6 @@ * limitations under the License. */ -import com.android.build.api.variant.AndroidComponentsExtension import com.android.build.api.variant.LibraryAndroidComponentsExtension import com.android.build.gradle.LibraryExtension import com.google.samples.apps.nowinandroid.configureFlavors diff --git a/build.gradle.kts b/build.gradle.kts index bd395ceac..ca7937fb5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,12 +22,6 @@ buildscript { // Android Build Server maven { url = uri("../nowinandroid-prebuilts/m2repository") } } - - dependencies { - if (project.hasProperty("use-google-services")) { - classpath(libs.firebase.crashlytics.gradle) - } - } } // Lists all plugins used throughout the project without applying them. diff --git a/core/analytics/.gitignore b/core/analytics/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/core/analytics/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/analytics/build.gradle.kts b/core/analytics/build.gradle.kts new file mode 100644 index 000000000..e42499769 --- /dev/null +++ b/core/analytics/build.gradle.kts @@ -0,0 +1,33 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + id("nowinandroid.android.library") + id("nowinandroid.android.library.compose") + id("nowinandroid.android.hilt") +} + +android { + namespace = "com.google.samples.apps.nowinandroid.core.analytics" +} + +dependencies { + implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.core.ktx) + + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.analytics) +} diff --git a/core/analytics/src/demo/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt b/core/analytics/src/demo/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt new file mode 100644 index 000000000..78ebec9e5 --- /dev/null +++ b/core/analytics/src/demo/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.analytics + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class AnalyticsModule { + @Binds + abstract fun bindsAnalyticsHelper(analyticsHelperImpl: StubAnalyticsHelper): AnalyticsHelper +} diff --git a/core/analytics/src/main/AndroidManifest.xml b/core/analytics/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a522a4c23 --- /dev/null +++ b/core/analytics/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + \ No newline at end of file diff --git a/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsEvent.kt b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsEvent.kt new file mode 100644 index 000000000..97ae76b56 --- /dev/null +++ b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsEvent.kt @@ -0,0 +1,58 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.analytics + +/** + * Represents an analytics event. + * + * @param type - the event type. Wherever possible use one of the standard + * event `Types`, however, if there is no suitable event type already defined, a custom event can be + * defined as long as it is configured in your backend analytics system (for example, by creating a + * Firebase Analytics custom event). + * + * @param extras - list of parameters which supply additional context to the event. See `Param`. + */ +data class AnalyticsEvent( + val type: String, + val extras: List = emptyList(), +) { + // Standard analytics types. + class Types { + companion object { + const val SCREEN_VIEW = "screen_view" // (extras: SCREEN_NAME) + } + } + + /** + * A key-value pair used to supply extra context to an analytics event. + * + * @param key - the parameter key. Wherever possible use one of the standard `ParamKeys`, + * however, if no suitable key is available you can define your own as long as it is configured + * in your backend analytics system (for example, by creating a Firebase Analytics custom + * parameter). + * + * @param value - the parameter value. + */ + data class Param(val key: String, val value: String) + + // Standard parameter keys. + class ParamKeys { + companion object { + const val SCREEN_NAME = "screen_name" + } + } +} diff --git a/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsHelper.kt b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsHelper.kt new file mode 100644 index 000000000..f9e6dad44 --- /dev/null +++ b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsHelper.kt @@ -0,0 +1,25 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.analytics + +/** + * Interface for logging analytics events. See `FirebaseAnalyticsHelper` and + * `StubAnalyticsHelper` for implementations. + */ +interface AnalyticsHelper { + fun logEvent(event: AnalyticsEvent) +} diff --git a/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/NoOpAnalyticsHelper.kt b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/NoOpAnalyticsHelper.kt new file mode 100644 index 000000000..16a193439 --- /dev/null +++ b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/NoOpAnalyticsHelper.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.analytics + +/** + * Implementation of AnalyticsHelper which does nothing. Useful for tests and previews. + */ +class NoOpAnalyticsHelper : AnalyticsHelper { + override fun logEvent(event: AnalyticsEvent) = Unit +} diff --git a/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/StubAnalyticsHelper.kt b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/StubAnalyticsHelper.kt new file mode 100644 index 000000000..2ff022287 --- /dev/null +++ b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/StubAnalyticsHelper.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.analytics + +import android.util.Log +import javax.inject.Inject +import javax.inject.Singleton + +private const val TAG = "StubAnalyticsHelper" + +/** + * An implementation of AnalyticsHelper just writes the events to logcat. Used in builds where no + * analytics events should be sent to a backend. + */ +@Singleton +class StubAnalyticsHelper @Inject constructor() : AnalyticsHelper { + override fun logEvent(event: AnalyticsEvent) { + Log.d(TAG, "Received analytics event: $event") + } +} diff --git a/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/UiHelpers.kt b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/UiHelpers.kt new file mode 100644 index 000000000..b0e5d29d8 --- /dev/null +++ b/core/analytics/src/main/java/com/google/samples/apps/nowinandroid/core/analytics/UiHelpers.kt @@ -0,0 +1,28 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.analytics + +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Global key used to obtain access to the AnalyticsHelper through a CompositionLocal. + */ +val LocalAnalyticsHelper = staticCompositionLocalOf { + // Provide a default AnalyticsHelper which does nothing. This is so that tests and previews + // do not have to provide one. For real app builds provide a different implementation. + NoOpAnalyticsHelper() +} diff --git a/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt b/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt new file mode 100644 index 000000000..e947d036b --- /dev/null +++ b/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.analytics + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class AnalyticsModule { + @Binds + abstract fun bindsAnalyticsHelper(analyticsHelperImpl: FirebaseAnalyticsHelper): AnalyticsHelper +} diff --git a/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/FirebaseAnalyticsHelper.kt b/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/FirebaseAnalyticsHelper.kt new file mode 100644 index 000000000..96b9ce67d --- /dev/null +++ b/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/FirebaseAnalyticsHelper.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.analytics + +import com.google.firebase.analytics.ktx.analytics +import com.google.firebase.analytics.ktx.logEvent +import com.google.firebase.ktx.Firebase +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Implementation of `AnalyticsHelper` which logs events to a Firebase backend. + */ +@Singleton +class FirebaseAnalyticsHelper @Inject constructor() : AnalyticsHelper { + + private val firebaseAnalytics = Firebase.analytics + + override fun logEvent(event: AnalyticsEvent) { + firebaseAnalytics.logEvent(event.type) { + for (extra in event.extras) { + // Truncate parameter keys and values according to firebase maximum length values. + param( + key = extra.key.take(40), + value = extra.value.take(100), + ) + } + } + } +} diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 717082bfe..5b468c43e 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { implementation(project(":core:database")) implementation(project(":core:datastore")) implementation(project(":core:network")) + implementation(project(":core:analytics")) testImplementation(project(":core:testing")) testImplementation(project(":core:datastore-test")) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/AnalyticsExtensions.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/AnalyticsExtensions.kt new file mode 100644 index 000000000..d36f509d9 --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/AnalyticsExtensions.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.repository + +import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent +import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.Param +import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper + +fun AnalyticsHelper.logNewsResourceBookmarkToggled(newsResourceId: String, isBookmarked: Boolean) { + val eventType = if (isBookmarked) "news_resource_saved" else "news_resource_unsaved" + val paramKey = if (isBookmarked) "saved_news_resource_id" else "unsaved_news_resource_id" + logEvent( + AnalyticsEvent( + type = eventType, + extras = listOf( + Param(key = paramKey, value = newsResourceId), + ), + ), + ) +} + +fun AnalyticsHelper.logTopicFollowToggled(followedTopicId: String, isFollowed: Boolean) { + val eventType = if (isFollowed) "topic_followed" else "topic_unfollowed" + val paramKey = if (isFollowed) "followed_topic_id" else "unfollowed_topic_id" + logEvent( + AnalyticsEvent( + type = eventType, + extras = listOf( + Param(key = paramKey, value = followedTopicId), + ), + ), + ) +} + +fun AnalyticsHelper.logThemeChanged(themeName: String) = + logEvent( + AnalyticsEvent( + type = "theme_changed", + extras = listOf( + Param(key = "theme_name", value = themeName), + ), + ), + ) + +fun AnalyticsHelper.logDarkThemeConfigChanged(darkThemeConfigName: String) = + logEvent( + AnalyticsEvent( + type = "dark_theme_config_changed", + extras = listOf( + Param(key = "dark_theme_config", value = darkThemeConfigName), + ), + ), + ) + +fun AnalyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor: Boolean) = + logEvent( + AnalyticsEvent( + type = "dynamic_color_preference_changed", + extras = listOf( + Param(key = "dynamic_color_preference", value = useDynamicColor.toString()), + ), + ), + ) + +fun AnalyticsHelper.logOnboardingStateChanged(shouldHideOnboarding: Boolean) { + val eventType = if (shouldHideOnboarding) "onboarding_complete" else "onboarding_reset" + logEvent( + AnalyticsEvent(type = eventType), + ) +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt index 200ca4a3d..334209538 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt @@ -16,6 +16,8 @@ package com.google.samples.apps.nowinandroid.core.data.repository +import androidx.annotation.VisibleForTesting +import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand @@ -25,29 +27,46 @@ import javax.inject.Inject class OfflineFirstUserDataRepository @Inject constructor( private val niaPreferencesDataSource: NiaPreferencesDataSource, + private val analyticsHelper: AnalyticsHelper, ) : UserDataRepository { override val userData: Flow = niaPreferencesDataSource.userData + @VisibleForTesting override suspend fun setFollowedTopicIds(followedTopicIds: Set) = niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds) - override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) = + override suspend fun toggleFollowedTopicId(followedTopicId: String, followed: Boolean) { niaPreferencesDataSource.toggleFollowedTopicId(followedTopicId, followed) + analyticsHelper.logTopicFollowToggled(followedTopicId, followed) + } - override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) = + override suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) { niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked) + analyticsHelper.logNewsResourceBookmarkToggled( + newsResourceId = newsResourceId, + isBookmarked = bookmarked, + ) + } - override suspend fun setThemeBrand(themeBrand: ThemeBrand) = + override suspend fun setThemeBrand(themeBrand: ThemeBrand) { niaPreferencesDataSource.setThemeBrand(themeBrand) + analyticsHelper.logThemeChanged(themeBrand.name) + } - override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) = + override suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) { niaPreferencesDataSource.setDarkThemeConfig(darkThemeConfig) + analyticsHelper.logDarkThemeConfigChanged(darkThemeConfig.name) + } - override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) = + override suspend fun setDynamicColorPreference(useDynamicColor: Boolean) { niaPreferencesDataSource.setDynamicColorPreference(useDynamicColor) + analyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor) + } - override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) = + override suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) { niaPreferencesDataSource.setShouldHideOnboarding(shouldHideOnboarding) + analyticsHelper.logOnboardingStateChanged(shouldHideOnboarding) + } } diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt index 055d8e074..61569d650 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt @@ -16,6 +16,7 @@ package com.google.samples.apps.nowinandroid.core.data.repository +import com.google.samples.apps.nowinandroid.core.analytics.NoOpAnalyticsHelper import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig @@ -37,6 +38,8 @@ class OfflineFirstUserDataRepositoryTest { private lateinit var niaPreferencesDataSource: NiaPreferencesDataSource + private val analyticsHelper = NoOpAnalyticsHelper() + @get:Rule val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() @@ -48,6 +51,7 @@ class OfflineFirstUserDataRepositoryTest { subject = OfflineFirstUserDataRepository( niaPreferencesDataSource = niaPreferencesDataSource, + analyticsHelper, ) } diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 39f9bcff1..0438b8f36 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { implementation(project(":core:designsystem")) implementation(project(":core:model")) implementation(project(":core:domain")) + implementation(project(":core:analytics")) implementation(libs.androidx.browser) implementation(libs.androidx.core.ktx) diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AnalyticsExtensions.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AnalyticsExtensions.kt new file mode 100644 index 000000000..38bce838a --- /dev/null +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AnalyticsExtensions.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent +import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.Param +import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.ParamKeys +import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.Types +import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper +import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper + +/** + * Classes and functions associated with analytics events for the UI. + */ +fun AnalyticsHelper.logScreenView(screenName: String) { + logEvent( + AnalyticsEvent( + type = Types.SCREEN_VIEW, + extras = listOf( + Param(ParamKeys.SCREEN_NAME, screenName), + ), + ), + ) +} + +fun AnalyticsHelper.logNewsResourceOpened(newsResourceId: String, newsResourceTitle: String) { + logEvent( + event = AnalyticsEvent( + type = "news_resource_opened", + extras = listOf( + Param("opened_news_resource", newsResourceId), + ), + ), + ) +} + +/** + * A side-effect which records a screen view event. + */ +@Composable +fun TrackScreenViewEvent( + screenName: String, + analyticsHelper: AnalyticsHelper = LocalAnalyticsHelper.current, +) = DisposableEffect(Unit) { + analyticsHelper.logScreenView(screenName) + onDispose {} +} diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt index 73c1a21e4..bad60961b 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource @@ -56,12 +57,19 @@ fun LazyGridScope.newsFeed( mutableStateOf(Uri.parse(userNewsResource.url)) } val context = LocalContext.current + val analyticsHelper = LocalAnalyticsHelper.current val backgroundColor = MaterialTheme.colorScheme.background.toArgb() NewsResourceCardExpanded( userNewsResource = userNewsResource, isBookmarked = userNewsResource.isSaved, - onClick = { launchCustomChromeTab(context, resourceUrl, backgroundColor) }, + onClick = { + analyticsHelper.logNewsResourceOpened( + newsResourceId = userNewsResource.id, + newsResourceTitle = userNewsResource.title, + ) + launchCustomChromeTab(context, resourceUrl, backgroundColor) + }, onToggleBookmark = { onNewsResourcesCheckedChanged( userNewsResource.id, diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt index a63bae657..87f110b36 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext +import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource /** @@ -45,12 +46,17 @@ fun LazyListScope.userNewsResourceCardItems( val resourceUrl = Uri.parse(userNewsResource.url) val backgroundColor = MaterialTheme.colorScheme.background.toArgb() val context = LocalContext.current + val analyticsHelper = LocalAnalyticsHelper.current NewsResourceCardExpanded( userNewsResource = userNewsResource, isBookmarked = userNewsResource.isSaved, onToggleBookmark = { onToggleBookmark(userNewsResource) }, onClick = { + analyticsHelper.logNewsResourceOpened( + newsResourceId = userNewsResource.id, + newsResourceTitle = userNewsResource.title, + ) when (onItemClick) { null -> launchCustomChromeTab(context, resourceUrl, backgroundColor) else -> onItemClick(userNewsResource) diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt index 1169f5777..a5d290897 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt @@ -59,6 +59,7 @@ import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success +import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.newsFeed @@ -95,6 +96,7 @@ internal fun BookmarksScreen( EmptyState(modifier) } } + TrackScreenViewEvent(screenName = "Saved") } @Composable diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index 492660142..52de0204b 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -85,6 +85,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState +import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.newsFeed @@ -207,6 +208,7 @@ internal fun ForYouScreen( ) } } + TrackScreenViewEvent(screenName = "ForYou") } /** diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt index fd45c7608..c8558cb2b 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt @@ -33,6 +33,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParameterProvider +import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent @OptIn(ExperimentalLifecycleComposeApi::class) @Composable @@ -78,6 +79,7 @@ internal fun InterestsScreen( is InterestsUiState.Empty -> InterestsEmptyScreen() } } + TrackScreenViewEvent(screenName = "Interests") } @Composable diff --git a/feature/settings/src/main/java/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt b/feature/settings/src/main/java/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt index bed230d0d..f6620d00e 100644 --- a/feature/settings/src/main/java/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt +++ b/feature/settings/src/main/java/com/google/samples/apps/nowinandroid/feature/settings/SettingsDialog.kt @@ -61,6 +61,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.LIGH import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.DEFAULT +import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.feature.settings.R.string import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Loading import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success @@ -134,6 +135,7 @@ fun SettingsDialog( Divider(Modifier.padding(top = 8.dp)) LinksPanel() } + TrackScreenViewEvent(screenName = "Settings") }, confirmButton = { Text( diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt index e7b218072..37e76daec 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt @@ -55,6 +55,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews +import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems @@ -71,6 +72,7 @@ internal fun TopicRoute( val topicUiState: TopicUiState by viewModel.topicUiState.collectAsStateWithLifecycle() val newsUiState: NewsUiState by viewModel.newUiState.collectAsStateWithLifecycle() + TrackScreenViewEvent(screenName = "Topic: ${viewModel.topicId}") TopicScreen( topicUiState = topicUiState, newsUiState = newsUiState, diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt index c0c6bbafd..c58d40b5e 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt @@ -50,6 +50,8 @@ class TopicViewModel @Inject constructor( private val topicArgs: TopicArgs = TopicArgs(savedStateHandle, stringDecoder) + val topicId = topicArgs.topicId + val topicUiState: StateFlow = topicUiState( topicId = topicArgs.topicId, userDataRepository = userDataRepository, diff --git a/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt b/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt index a8f1b0a88..dfed60385 100644 --- a/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt +++ b/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt @@ -70,6 +70,10 @@ class TopicViewModelTest { ) } + @Test + fun topicId_matchesTopicIdFromSavedStateHandle() = + assertEquals(testInputTopics[0].topic.id, viewModel.topicId) + @Test fun uiStateTopic_whenSuccess_matchesTopicFromRepository() = runTest { val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.topicUiState.collect() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 00fdd422f..2716a1f77 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -28,7 +28,7 @@ androidxUiAutomator = "2.2.0" androidxWindowManager = "1.0.0" androidxWork = "2.7.1" coil = "2.2.2" -firebaseBom = "31.0.3" +firebaseBom = "31.2.0" firebaseCrashlyticsPlugin = "2.9.2" firebasePerfPlugin = "1.4.2" gmsPlugin = "4.3.14" @@ -105,6 +105,7 @@ firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx"} firebase-crashlytics-gradle = { group = "com.google.firebase", name="firebase-crashlytics-gradle", version.ref = "firebaseCrashlyticsPlugin"} firebase-performance = { group = "com.google.firebase", name = "firebase-perf-ktx"} +firebase-performance-gradle = { group = "com.google.firebase", name = "perf-plugin", version.ref = "firebasePerfPlugin"} hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 809efd27d..857f9d56c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -46,6 +46,8 @@ include(":core:model") include(":core:network") include(":core:ui") include(":core:testing") +include(":core:analytics") + include(":feature:foryou") include(":feature:interests") include(":feature:bookmarks") diff --git a/sync/work/build.gradle.kts b/sync/work/build.gradle.kts index 70f6b2e89..a3b589db3 100644 --- a/sync/work/build.gradle.kts +++ b/sync/work/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(project(":core:model")) implementation(project(":core:data")) implementation(project(":core:datastore")) + implementation(project(":core:analytics")) implementation(libs.kotlinx.coroutines.android) diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/AnalyticsExtensions.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/AnalyticsExtensions.kt new file mode 100644 index 000000000..d5250b330 --- /dev/null +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/AnalyticsExtensions.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.sync.workers + +import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent +import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper + +fun AnalyticsHelper.logSyncStarted() = + logEvent( + AnalyticsEvent(type = "network_sync_started"), + ) + +fun AnalyticsHelper.logSyncFinished(syncedSuccessfully: Boolean) { + val eventType = if (syncedSuccessfully) "network_sync_successful" else "network_sync_failed" + logEvent( + AnalyticsEvent(type = eventType), + ) +} diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt index c6ac6fb65..211940ddb 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt @@ -24,6 +24,7 @@ import androidx.work.ForegroundInfo import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.WorkerParameters +import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper import com.google.samples.apps.nowinandroid.core.data.Synchronizer import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository @@ -52,6 +53,7 @@ class SyncWorker @AssistedInject constructor( private val topicRepository: TopicsRepository, private val newsRepository: NewsRepository, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, + private val analyticsHelper: AnalyticsHelper, ) : CoroutineWorker(appContext, workerParams), Synchronizer { override suspend fun getForegroundInfo(): ForegroundInfo = @@ -59,12 +61,16 @@ class SyncWorker @AssistedInject constructor( override suspend fun doWork(): Result = withContext(ioDispatcher) { traceAsync("Sync", 0) { + analyticsHelper.logSyncStarted() + // First sync the repositories in parallel val syncedSuccessfully = awaitAll( async { topicRepository.sync() }, async { newsRepository.sync() }, ).all { it } + analyticsHelper.logSyncFinished(syncedSuccessfully) + if (syncedSuccessfully) { Result.success() } else { From 50b88aa769928129f8aeb42b7a45c7ec906971c3 Mon Sep 17 00:00:00 2001 From: Don Turner Date: Thu, 9 Feb 2023 12:16:44 +0000 Subject: [PATCH 23/97] Update lint java version to 11 Change-Id: I7bba0615e3f1ca13523300950932b5f899b60567 --- lint/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lint/build.gradle.kts b/lint/build.gradle.kts index c87bb7877..bbf24126d 100644 --- a/lint/build.gradle.kts +++ b/lint/build.gradle.kts @@ -20,8 +20,8 @@ plugins { } java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } dependencies { From 11a4bbaa48431e4a5d1d14fcccc3ef380085552e Mon Sep 17 00:00:00 2001 From: Don Turner Date: Thu, 9 Feb 2023 18:03:48 +0000 Subject: [PATCH 24/97] Remove AD_ID permission Change-Id: Iaf691c8ff1e8fbbbcd39b068d8bfdc29c8f6a58c --- app/src/main/AndroidManifest.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 23b2e7862..99c233910 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,13 @@ + + + + + From 75f8fc7e0834dfb7efa630bea9fe70c2df350a29 Mon Sep 17 00:00:00 2001 From: Don Turner Date: Thu, 9 Feb 2023 18:11:07 +0000 Subject: [PATCH 25/97] Bump versionCode to 5 Change-Id: I4d6dc6b8d1a903172b7f11ab6166f6c631b1319b --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a8bb4365b..b7cb7d1d7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,8 +29,8 @@ plugins { android { defaultConfig { applicationId = "com.google.samples.apps.nowinandroid" - versionCode = 4 - versionName = "0.0.4" // X.Y.Z; X = Major, Y = minor, Z = Patch level + versionCode = 5 + versionName = "0.0.5" // X.Y.Z; X = Major, Y = minor, Z = Patch level // Custom test runner to set up Hilt dependency graph testInstrumentationRunner = "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner" From bdb05c00d9fe319afda098be291d36c7795db470 Mon Sep 17 00:00:00 2001 From: Don Turner Date: Thu, 9 Feb 2023 19:33:53 +0000 Subject: [PATCH 26/97] Revert "Revert "Revert "Revert "New build trigger"""" This reverts commit 71303b6f6126968e4a6a923d9ee9c93a08aa2454. Reason for revert: Stupid build is stuck again. Change-Id: I6cfb412e0ce46233b9e6ee5ed031019381ce5600 --- .google/BUILDME | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.google/BUILDME b/.google/BUILDME index d6b23eab3..5295ed188 100644 --- a/.google/BUILDME +++ b/.google/BUILDME @@ -1,2 +1,2 @@ # This file can be used to trigger an internal build by changing the number below -2 +3 From 9e399b69ead3c107acffc7f7693b8f9b915d11dd Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Fri, 10 Feb 2023 20:29:43 +0000 Subject: [PATCH 27/97] Improve network pooling and avoid startup image fetches. Change-Id: If747fb0833d31ce7c9bedffd400160e979bc945e --- app/build.gradle.kts | 1 - .../apps/nowinandroid/NiaApplication.kt | 21 +++------ core/network/build.gradle.kts | 3 ++ .../core/network/di/NetworkModule.kt | 47 +++++++++++++++++++ .../network/retrofit/RetrofitNiaNetwork.kt | 16 ++----- 5 files changed, 60 insertions(+), 28 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4163090ce..960a0f48b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -129,7 +129,6 @@ dependencies { implementation(libs.androidx.profileinstaller) implementation(libs.coil.kt) - implementation(libs.coil.kt.svg) } // androidx.test is forcing JUnit, 4.12. This forces it to use 4.13 diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/NiaApplication.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/NiaApplication.kt index 62629925e..699f52575 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/NiaApplication.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/NiaApplication.kt @@ -19,33 +19,24 @@ package com.google.samples.apps.nowinandroid import android.app.Application import coil.ImageLoader import coil.ImageLoaderFactory -import coil.decode.SvgDecoder import com.google.samples.apps.nowinandroid.sync.initializers.Sync import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject +import javax.inject.Provider /** * [Application] class for NiA */ @HiltAndroidApp class NiaApplication : Application(), ImageLoaderFactory { + @Inject + lateinit var imageLoader: Provider + override fun onCreate() { super.onCreate() // Initialize Sync; the system responsible for keeping data in the app up to date. Sync.initialize(context = this) } - /** - * Since we're displaying SVGs in the app, Coil needs an ImageLoader which supports this - * format. During Coil's initialization it will call `applicationContext.newImageLoader()` to - * obtain an ImageLoader. - * - * @see Coil - */ - override fun newImageLoader(): ImageLoader { - return ImageLoader.Builder(this) - .components { - add(SvgDecoder.Factory()) - } - .build() - } + override fun newImageLoader(): ImageLoader = imageLoader.get() } diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 57a75a8bf..97a68b3a5 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -51,4 +51,7 @@ dependencies { implementation(libs.okhttp.logging) implementation(libs.retrofit.core) implementation(libs.retrofit.kotlin.serialization) + + implementation(libs.coil.kt) + implementation(libs.coil.kt.svg) } diff --git a/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/NetworkModule.kt b/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/NetworkModule.kt index a272451e5..d52428f57 100644 --- a/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/NetworkModule.kt +++ b/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/NetworkModule.kt @@ -17,6 +17,10 @@ package com.google.samples.apps.nowinandroid.core.network.di import android.content.Context +import coil.ImageLoader +import coil.decode.SvgDecoder +import coil.util.DebugLogger +import com.google.samples.apps.nowinandroid.core.network.BuildConfig import com.google.samples.apps.nowinandroid.core.network.fake.FakeAssetManager import dagger.Module import dagger.Provides @@ -24,6 +28,9 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import kotlinx.serialization.json.Json +import okhttp3.Call +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor import javax.inject.Singleton @Module @@ -41,4 +48,44 @@ object NetworkModule { fun providesFakeAssetManager( @ApplicationContext context: Context, ): FakeAssetManager = FakeAssetManager(context.assets::open) + + @Provides + @Singleton + fun okHttpCallFactory(): Call.Factory = OkHttpClient.Builder() + .addInterceptor( + HttpLoggingInterceptor() + .apply { + if (BuildConfig.DEBUG) { + setLevel(HttpLoggingInterceptor.Level.BODY) + } + } + ) + .build() + + /** + * Since we're displaying SVGs in the app, Coil needs an ImageLoader which supports this + * format. During Coil's initialization it will call `applicationContext.newImageLoader()` to + * obtain an ImageLoader. + * + * @see Coil + */ + @Provides + @Singleton + fun imageLoader( + okHttpCallFactory: Call.Factory, + @ApplicationContext application: Context, + ): ImageLoader = ImageLoader.Builder(application) + .callFactory(okHttpCallFactory) + .components { + add(SvgDecoder.Factory()) + } + // Assume most content images are versioned urls + // but some problematic images are fetching each time + .respectCacheHeaders(false) + .apply { + if (BuildConfig.DEBUG) { + logger(DebugLogger()) + } + } + .build() } diff --git a/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiaNetwork.kt b/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiaNetwork.kt index 6b59f16e3..7f2f9d6a1 100644 --- a/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiaNetwork.kt +++ b/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiaNetwork.kt @@ -25,9 +25,8 @@ import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFact import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import okhttp3.Call import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.http.GET import retrofit2.http.Query @@ -69,26 +68,19 @@ private data class NetworkResponse( val data: T, ) + /** * [Retrofit] backed [NiaNetworkDataSource] */ @Singleton class RetrofitNiaNetwork @Inject constructor( networkJson: Json, + okhttpCallFactory: Call.Factory ) : NiaNetworkDataSource { private val networkApi = Retrofit.Builder() .baseUrl(NiaBaseUrl) - .client( - OkHttpClient.Builder() - .addInterceptor( - // TODO: Decide logging logic - HttpLoggingInterceptor().apply { - setLevel(HttpLoggingInterceptor.Level.BODY) - }, - ) - .build(), - ) + .callFactory(okhttpCallFactory) .addConverterFactory( @OptIn(ExperimentalSerializationApi::class) networkJson.asConverterFactory("application/json".toMediaType()), From 4fa730098df201b4154e038aca0afdc08660cf44 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sat, 11 Feb 2023 10:39:56 +0000 Subject: [PATCH 28/97] Fixes Change-Id: Iedcafbf0ad7b853544b7b5b3f37685a0a868c2e6 --- .../samples/apps/nowinandroid/core/network/di/NetworkModule.kt | 2 +- .../nowinandroid/core/network/retrofit/RetrofitNiaNetwork.kt | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/NetworkModule.kt b/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/NetworkModule.kt index d52428f57..98534ba93 100644 --- a/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/NetworkModule.kt +++ b/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/NetworkModule.kt @@ -58,7 +58,7 @@ object NetworkModule { if (BuildConfig.DEBUG) { setLevel(HttpLoggingInterceptor.Level.BODY) } - } + }, ) .build() diff --git a/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiaNetwork.kt b/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiaNetwork.kt index 7f2f9d6a1..9360d2cf1 100644 --- a/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiaNetwork.kt +++ b/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiaNetwork.kt @@ -68,14 +68,13 @@ private data class NetworkResponse( val data: T, ) - /** * [Retrofit] backed [NiaNetworkDataSource] */ @Singleton class RetrofitNiaNetwork @Inject constructor( networkJson: Json, - okhttpCallFactory: Call.Factory + okhttpCallFactory: Call.Factory, ) : NiaNetworkDataSource { private val networkApi = Retrofit.Builder() From 980a8402259fb649779634b6181dc00e9ef3af05 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 11 Feb 2023 12:20:53 +0000 Subject: [PATCH 29/97] ui-test-hilt-manifest uses `nowinandroid.android.hilt` plugin --- ui-test-hilt-manifest/build.gradle.kts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/ui-test-hilt-manifest/build.gradle.kts b/ui-test-hilt-manifest/build.gradle.kts index 346399d59..b55036591 100644 --- a/ui-test-hilt-manifest/build.gradle.kts +++ b/ui-test-hilt-manifest/build.gradle.kts @@ -15,15 +15,9 @@ */ plugins { id("nowinandroid.android.library") - kotlin("kapt") - id("dagger.hilt.android.plugin") + id("nowinandroid.android.hilt") } android { namespace = "com.google.samples.apps.nowinandroid.uitesthiltmanifest" } - -dependencies { - implementation(libs.hilt.android) - kapt(libs.hilt.compiler) -} From 8b8401e3ec0c6a8d3a2070bbb81e5fab2e67ff7f Mon Sep 17 00:00:00 2001 From: Keisuke Takagi Date: Mon, 13 Feb 2023 03:39:51 +0900 Subject: [PATCH 30/97] Add padding between the loading wheel and the top of the screen --- .../samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index cb0b0ecd6..f8771ac57 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -202,7 +202,8 @@ internal fun ForYouScreen( ) { val loadingContentDescription = stringResource(id = R.string.for_you_loading) Box( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() + .padding(top = 8.dp), ) { NiaOverlayLoadingWheel( modifier = Modifier From cc3f7905e585ae340cfd84e8070e65d53acda216 Mon Sep 17 00:00:00 2001 From: Keisuke Takagi Date: Tue, 14 Feb 2023 10:28:11 +0900 Subject: [PATCH 31/97] Resolved format violations error --- .../samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index f8771ac57..ef51da708 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -202,8 +202,9 @@ internal fun ForYouScreen( ) { val loadingContentDescription = stringResource(id = R.string.for_you_loading) Box( - modifier = Modifier.fillMaxWidth() - .padding(top = 8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp), ) { NiaOverlayLoadingWheel( modifier = Modifier From 9f1e1889cb4f8162614167239fdb36257b6b0c46 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 14 Feb 2023 08:24:13 -0500 Subject: [PATCH 32/97] Run build tests with the Demo variant --- .github/workflows/Build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml index 978279c11..342714245 100644 --- a/.github/workflows/Build.yaml +++ b/.github/workflows/Build.yaml @@ -90,7 +90,7 @@ jobs: disable-animations: true disk-size: 6000M heap-size: 600M - script: ./gradlew connectedProdDebugAndroidTest -x :benchmark:connectedProdBenchmarkAndroidTest + script: ./gradlew connectedDemoDebugAndroidTest -x :benchmark:connectedDemoBenchmarkAndroidTest - name: Upload test reports if: always() From 01b5f34eb3ca5c0352463dec335e78fedf6b1819 Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Mon, 13 Feb 2023 15:47:28 -0800 Subject: [PATCH 33/97] Update to Compose Material 3 1.1.0-alpha06 Change-Id: I6399358ad85254243b5aff547e31e3abe92fb6ed --- .../com/google/samples/apps/nowinandroid/ui/NiaApp.kt | 4 ++-- gradle/libs.versions.toml | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index f7abfa0c0..9565af2f8 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -21,7 +21,7 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.consumedWindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding @@ -141,7 +141,7 @@ fun NiaApp( Modifier .fillMaxSize() .padding(padding) - .consumedWindowInsets(padding) + .consumeWindowInsets(padding) .windowInsetsPadding( WindowInsets.safeDrawing.only( WindowInsetsSides.Horizontal, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2fbde3840..737ad43a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,8 +5,9 @@ androidGradlePlugin = "7.4.1" androidxActivity = "1.6.1" androidxAppCompat = "1.5.1" androidxBrowser = "1.4.0" -androidxComposeBom = "2022.12.00" -androidxComposeCompiler = "1.4.0" +androidxComposeBom = "2023.01.00" +androidxComposeCompiler = "1.4.1" +androidxComposeMaterial3 = "1.1.0-alpha06" androidxComposeRuntimeTracing = "1.0.0-alpha01" androidxCore = "1.9.0" androidxCoreSplashscreen = "1.0.0" @@ -64,8 +65,8 @@ androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", versi androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout" } androidx-compose-material-iconsExtended = { group = "androidx.compose.material", name = "material-icons-extended" } -androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } -androidx-compose-material3-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class" } +androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidxComposeMaterial3" } +androidx-compose-material3-windowSizeClass = { group = "androidx.compose.material3", name = "material3-window-size-class", version.ref = "androidxComposeMaterial3" } androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime" } androidx-compose-runtime-livedata = { group = "androidx.compose.runtime", name = "runtime-livedata" } androidx-compose-runtime-tracing = { group = "androidx.compose.runtime", name = "runtime-tracing", version.ref = "androidxComposeRuntimeTracing" } From 1489bf66e38130ced97187000ac473129e5e13bb Mon Sep 17 00:00:00 2001 From: Keisuke Takagi Date: Thu, 16 Feb 2023 01:47:29 +0900 Subject: [PATCH 34/97] remove unused updateTopics method --- .../apps/nowinandroid/core/database/dao/TopicDao.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt index 37724af69..210f55c28 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt @@ -20,7 +20,6 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import androidx.room.Update import androidx.room.Upsert import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import kotlinx.coroutines.flow.Flow @@ -55,12 +54,6 @@ interface TopicDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertOrIgnoreTopics(topicEntities: List): List - /** - * Updates [entities] in the db that match the primary key, and no-ops if they don't - */ - @Update - suspend fun updateTopics(entities: List) - /** * Inserts or updates [entities] in the db under the specified primary keys */ From 67412b272034d540453ffa59d69dd4235925a27e Mon Sep 17 00:00:00 2001 From: Keisuke Takagi Date: Thu, 16 Feb 2023 01:49:09 +0900 Subject: [PATCH 35/97] remove invalid overriding method --- .../apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt index 8ac0dc0b8..96402309f 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt @@ -58,10 +58,6 @@ class TestTopicDao : TopicDao { return topicEntities.map { it.id.toLong() } } - override suspend fun updateTopics(entities: List) { - throw NotImplementedError("Unused in tests") - } - override suspend fun upsertTopics(entities: List) { entitiesStateFlow.value = entities } From fb32580e0c0302268ccde7abde5ab6245f99d2bd Mon Sep 17 00:00:00 2001 From: Keisuke Takagi Date: Thu, 16 Feb 2023 01:49:37 +0900 Subject: [PATCH 36/97] remove unused updateNewsResources method --- .../apps/nowinandroid/core/database/dao/NewsResourceDao.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt index af0a59bce..4d89177ff 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt @@ -21,7 +21,6 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction -import androidx.room.Update import androidx.room.Upsert import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef @@ -65,12 +64,6 @@ interface NewsResourceDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertOrIgnoreNewsResources(entities: List): List - /** - * Updates [entities] in the db that match the primary key, and no-ops if they don't - */ - @Update - suspend fun updateNewsResources(entities: List) - /** * Inserts or updates [newsResourceEntities] in the db under the specified primary keys */ From 86c021a54a39c045061d373a240749cb8e0cc602 Mon Sep 17 00:00:00 2001 From: Keisuke Takagi Date: Thu, 16 Feb 2023 01:49:51 +0900 Subject: [PATCH 37/97] remove invalid overriding method --- .../nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt index f63014075..ba1d20581 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt @@ -75,10 +75,6 @@ class TestNewsResourceDao : NewsResourceDao { return entities.map { it.id.toLong() } } - override suspend fun updateNewsResources(entities: List) { - throw NotImplementedError("Unused in tests") - } - override suspend fun upsertNewsResources(newsResourceEntities: List) { entitiesStateFlow.value = newsResourceEntities } From 09f5c3bc619c0d3ee194a804df7dcab9fa35d306 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Mon, 13 Feb 2023 10:33:30 -0500 Subject: [PATCH 38/97] Add NewsResourceQuery to better query encapsulation Change-Id: I93f8c8ae2d1144975f6f9ff1ba93be9a4600768a --- .../core/data/repository/NewsRepository.kt | 24 +++-- .../repository/OfflineFirstNewsRepository.kt | 11 +- .../repository/fake/FakeNewsRepository.kt | 27 ++--- .../OfflineFirstNewsRepositoryTest.kt | 13 ++- .../data/testdoubles/TestNewsResourceDao.kt | 24 +++-- .../core/database/dao/NewsResourceDaoTest.kt | 101 ++++++++++++++++++ .../core/database/dao/NewsResourceDao.kt | 33 +++--- .../domain/GetUserNewsResourcesUseCase.kt | 14 ++- .../domain/GetUserNewsResourcesUseCaseTest.kt | 7 +- .../testing/repository/TestNewsRepository.kt | 20 ++-- .../feature/foryou/ForYouViewModel.kt | 7 +- .../feature/topic/TopicViewModel.kt | 9 +- 12 files changed, 224 insertions(+), 66 deletions(-) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/NewsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/NewsRepository.kt index e4f184c44..0e53f1239 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/NewsRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/NewsRepository.kt @@ -21,18 +21,30 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import kotlinx.coroutines.flow.Flow /** - * Data layer implementation for [NewsResource] + * Encapsulation class for query parameters for [NewsResource] */ -interface NewsRepository : Syncable { +data class NewsResourceQuery( + /** + * Topic ids to filter for. Null means any topic id will match. + */ + val filterTopicIds: Set? = null, /** - * Returns available news resources as a stream. + * News ids to filter for. Null means any news id will match. */ - fun getNewsResources(): Flow> + val filterNewsIds: Set? = null, +) +/** + * Data layer implementation for [NewsResource] + */ +interface NewsRepository : Syncable { /** - * Returns available news resources as a stream filtered by topics. + * Returns available news resources that match the specified [query]. */ fun getNewsResources( - filterTopicIds: Set = emptySet(), + query: NewsResourceQuery = NewsResourceQuery( + filterTopicIds = null, + filterNewsIds = null, + ), ): Flow> } diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt index 9e041b956..9e419b485 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt @@ -44,14 +44,13 @@ class OfflineFirstNewsRepository @Inject constructor( private val network: NiaNetworkDataSource, ) : NewsRepository { - override fun getNewsResources(): Flow> = - newsResourceDao.getNewsResources() - .map { it.map(PopulatedNewsResource::asExternalModel) } - override fun getNewsResources( - filterTopicIds: Set, + query: NewsResourceQuery, ): Flow> = newsResourceDao.getNewsResources( - filterTopicIds = filterTopicIds, + useFilterTopicIds = query.filterTopicIds != null, + filterTopicIds = query.filterTopicIds ?: emptySet(), + useFilterNewsIds = query.filterNewsIds != null, + filterNewsIds = query.filterNewsIds ?: emptySet(), ) .map { it.map(PopulatedNewsResource::asExternalModel) } diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeNewsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeNewsRepository.kt index d6a712538..39ad05d1e 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeNewsRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeNewsRepository.kt @@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.core.data.repository.fake import com.google.samples.apps.nowinandroid.core.data.Synchronizer import com.google.samples.apps.nowinandroid.core.data.model.asEntity import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.model.data.NewsResource @@ -43,26 +44,28 @@ class FakeNewsRepository @Inject constructor( private val datasource: FakeNiaNetworkDataSource, ) : NewsRepository { - override fun getNewsResources(): Flow> = - flow { - emit( - datasource.getNewsResources() - .map(NetworkNewsResource::asEntity) - .map(NewsResourceEntity::asExternalModel), - ) - }.flowOn(ioDispatcher) - override fun getNewsResources( - filterTopicIds: Set, + query: NewsResourceQuery, ): Flow> = flow { emit( datasource .getNewsResources() - .filter { it.topics.intersect(filterTopicIds).isNotEmpty() } + .filter { networkNewsResource -> + // Filter out any news resources which don't match the current query. + // If no query parameters (filterTopicIds or filterNewsIds) are specified + // then the news resource is returned. + listOfNotNull( + true, + query.filterNewsIds?.contains(networkNewsResource.id), + query.filterTopicIds?.let { filterTopicIds -> + networkNewsResource.topics.intersect(filterTopicIds).isNotEmpty() + }, + ) + .all(true::equals) + } .map(NetworkNewsResource::asEntity) .map(NewsResourceEntity::asExternalModel), - ) }.flowOn(ioDispatcher) diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt index 74848d655..9f43d3441 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt @@ -92,13 +92,16 @@ class OfflineFirstNewsRepositoryTest { fun offlineFirstNewsRepository_news_resources_for_topic_is_backed_by_news_resource_dao() = runTest { assertEquals( - newsResourceDao.getNewsResources( + expected = newsResourceDao.getNewsResources( filterTopicIds = filteredInterestsIds, + useFilterTopicIds = true, ) .first() .map(PopulatedNewsResource::asExternalModel), - subject.getNewsResources( - filterTopicIds = filteredInterestsIds, + actual = subject.getNewsResources( + query = NewsResourceQuery( + filterTopicIds = filteredInterestsIds, + ), ) .first(), ) @@ -106,7 +109,9 @@ class OfflineFirstNewsRepositoryTest { assertEquals( emptyList(), subject.getNewsResources( - filterTopicIds = nonPresentInterestsIds, + query = NewsResourceQuery( + filterTopicIds = nonPresentInterestsIds, + ), ) .first(), ) diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt index f63014075..baeff4a66 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt @@ -52,19 +52,27 @@ class TestNewsResourceDao : NewsResourceDao { internal var topicCrossReferences: List = listOf() - override fun getNewsResources(): Flow> = - entitiesStateFlow.map { - it.map(NewsResourceEntity::asPopulatedNewsResource) - } - override fun getNewsResources( + useFilterTopicIds: Boolean, filterTopicIds: Set, + useFilterNewsIds: Boolean, + filterNewsIds: Set, ): Flow> = - getNewsResources() + entitiesStateFlow + .map { it.map(NewsResourceEntity::asPopulatedNewsResource) } .map { resources -> - resources.filter { resource -> - resource.topics.any { it.id in filterTopicIds } + var result = resources + if (useFilterTopicIds) { + result = result.filter { resource -> + resource.topics.any { it.id in filterTopicIds } + } + } + if (useFilterNewsIds) { + result = result.filter { resource -> + resource.entity.id in filterNewsIds + } } + result } override suspend fun insertOrIgnoreNewsResources( diff --git a/core/database/src/androidTest/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt b/core/database/src/androidTest/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt index c1c1b39ba..83dc3c2e6 100644 --- a/core/database/src/androidTest/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt +++ b/core/database/src/androidTest/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDaoTest.kt @@ -84,6 +84,44 @@ class NewsResourceDaoTest { ) } + @Test + fun newsResourceDao_filters_items_by_news_ids_by_descending_publish_date() = runTest { + val newsResourceEntities = listOf( + testNewsResource( + id = "0", + millisSinceEpoch = 0, + ), + testNewsResource( + id = "1", + millisSinceEpoch = 3, + ), + testNewsResource( + id = "2", + millisSinceEpoch = 1, + ), + testNewsResource( + id = "3", + millisSinceEpoch = 2, + ), + ) + newsResourceDao.upsertNewsResources( + newsResourceEntities, + ) + + val savedNewsResourceEntities = newsResourceDao.getNewsResources( + useFilterNewsIds = true, + filterNewsIds = setOf("3", "0"), + ) + .first() + + assertEquals( + listOf("3", "0"), + savedNewsResourceEntities.map { + it.entity.id + }, + ) + } + @Test fun newsResourceDao_filters_items_by_topic_ids_by_descending_publish_date() = runTest { val topicEntities = listOf( @@ -132,6 +170,7 @@ class NewsResourceDaoTest { ) val filteredNewsResources = newsResourceDao.getNewsResources( + useFilterTopicIds = true, filterTopicIds = topicEntities .map(TopicEntity::id) .toSet(), @@ -143,6 +182,68 @@ class NewsResourceDaoTest { ) } + @Test + fun newsResourceDao_filters_items_by_news_and_topic_ids_by_descending_publish_date() = runTest { + val topicEntities = listOf( + testTopicEntity( + id = "1", + name = "1", + ), + testTopicEntity( + id = "2", + name = "2", + ), + ) + val newsResourceEntities = listOf( + testNewsResource( + id = "0", + millisSinceEpoch = 0, + ), + testNewsResource( + id = "1", + millisSinceEpoch = 3, + ), + testNewsResource( + id = "2", + millisSinceEpoch = 1, + ), + testNewsResource( + id = "3", + millisSinceEpoch = 2, + ), + ) + val newsResourceTopicCrossRefEntities = topicEntities.mapIndexed { index, topicEntity -> + NewsResourceTopicCrossRef( + newsResourceId = index.toString(), + topicId = topicEntity.id, + ) + } + + topicDao.insertOrIgnoreTopics( + topicEntities = topicEntities, + ) + newsResourceDao.upsertNewsResources( + newsResourceEntities, + ) + newsResourceDao.insertOrIgnoreTopicCrossRefEntities( + newsResourceTopicCrossRefEntities, + ) + + val filteredNewsResources = newsResourceDao.getNewsResources( + useFilterTopicIds = true, + filterTopicIds = topicEntities + .map(TopicEntity::id) + .toSet(), + useFilterNewsIds = true, + filterNewsIds = setOf("1"), + ).first() + + assertEquals( + listOf("1"), + filteredNewsResources.map { it.entity.id }, + ) + } + @Test fun newsResourceDao_deletes_items_by_ids() = runTest { diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt index af0a59bce..782e5c87a 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt @@ -34,29 +34,36 @@ import kotlinx.coroutines.flow.Flow */ @Dao interface NewsResourceDao { - @Transaction - @Query( - value = """ - SELECT * FROM news_resources - ORDER BY publish_date DESC - """, - ) - fun getNewsResources(): Flow> + /** + * Fetches news resources that match the query parameters + */ @Transaction @Query( value = """ SELECT * FROM news_resources - WHERE id in - ( - SELECT news_resource_id FROM news_resources_topics - WHERE topic_id IN (:filterTopicIds) - ) + WHERE + CASE WHEN :useFilterNewsIds + THEN id IN (:filterNewsIds) + ELSE 1 + END + AND + CASE WHEN :useFilterTopicIds + THEN id IN + ( + SELECT news_resource_id FROM news_resources_topics + WHERE topic_id IN (:filterTopicIds) + ) + ELSE 1 + END ORDER BY publish_date DESC """, ) fun getNewsResources( + useFilterTopicIds: Boolean = false, filterTopicIds: Set = emptySet(), + useFilterNewsIds: Boolean = false, + filterNewsIds: Set = emptySet(), ): Flow> /** diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCase.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCase.kt index db274bbbd..393b7b08b 100644 --- a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCase.kt +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCase.kt @@ -17,6 +17,7 @@ package com.google.samples.apps.nowinandroid.core.domain import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources @@ -38,17 +39,14 @@ class GetUserNewsResourcesUseCase @Inject constructor( /** * Returns a list of UserNewsResources which match the supplied set of topic ids. * - * @param filterTopicIds - A set of topic ids used to filter the list of news resources. If - * this is empty the list of news resources will not be filtered. + * @param query - Summary of query parameters for news resources. */ operator fun invoke( - filterTopicIds: Set = emptySet(), + query: NewsResourceQuery = NewsResourceQuery(), ): Flow> = - if (filterTopicIds.isEmpty()) { - newsRepository.getNewsResources() - } else { - newsRepository.getNewsResources(filterTopicIds = filterTopicIds) - }.mapToUserNewsResources(userDataRepository.userData) + newsRepository.getNewsResources( + query = query, + ).mapToUserNewsResources(userDataRepository.userData) } private fun Flow>.mapToUserNewsResources( diff --git a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCaseTest.kt b/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCaseTest.kt index 32ee8773c..0ff863d7c 100644 --- a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCaseTest.kt +++ b/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCaseTest.kt @@ -16,6 +16,7 @@ package com.google.samples.apps.nowinandroid.core.domain +import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video @@ -67,7 +68,11 @@ class GetUserNewsResourcesUseCaseTest { @Test fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest { // Obtain a stream of user news resources for the given topic id. - val userNewsResources = useCase(filterTopicIds = setOf(sampleTopic1.id)) + val userNewsResources = useCase( + NewsResourceQuery( + filterTopicIds = setOf(sampleTopic1.id), + ), + ) // Send test data into the repositories. newsRepository.sendNewsResources(sampleNewsResources) diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestNewsRepository.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestNewsRepository.kt index 87e2fe009..d0bfd21a1 100644 --- a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestNewsRepository.kt +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestNewsRepository.kt @@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.core.testing.repository import com.google.samples.apps.nowinandroid.core.data.Synchronizer import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.Topic import kotlinx.coroutines.channels.BufferOverflow @@ -33,13 +34,20 @@ class TestNewsRepository : NewsRepository { private val newsResourcesFlow: MutableSharedFlow> = MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - override fun getNewsResources(): Flow> = newsResourcesFlow - - override fun getNewsResources(filterTopicIds: Set): Flow> = - getNewsResources().map { newsResources -> - newsResources.filter { - it.topics.map(Topic::id).intersect(filterTopicIds).isNotEmpty() + override fun getNewsResources(query: NewsResourceQuery): Flow> = + newsResourcesFlow.map { newsResources -> + var result = newsResources + query.filterTopicIds?.let { filterTopicIds -> + result = newsResources.filter { + it.topics.map(Topic::id).intersect(filterTopicIds).isNotEmpty() + } + } + query.filterNewsIds?.let { filterNewsIds -> + result = newsResources.filter { + filterNewsIds.contains(it.id) + } } + result } /** diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt index cd029b4af..085593932 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt @@ -18,6 +18,7 @@ package com.google.samples.apps.nowinandroid.feature.foryou import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase @@ -127,7 +128,11 @@ private fun UserDataRepository.getFollowedUserNewsResources( if (followedTopics == null) { flowOf(emptyList()) } else { - getUserNewsResources(filterTopicIds = followedTopics) + getUserNewsResources( + NewsResourceQuery( + filterTopicIds = followedTopics, + ), + ) } } diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt index c58d40b5e..fcabff16b 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt @@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.feature.topic import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder @@ -120,9 +121,11 @@ private fun topicUiState( ), ) } + is Result.Loading -> { TopicUiState.Loading } + is Result.Error -> { TopicUiState.Error } @@ -137,7 +140,9 @@ private fun newsUiState( ): Flow { // Observe news val newsStream: Flow> = getSaveableNewsResources( - filterTopicIds = setOf(element = topicId), + NewsResourceQuery( + filterTopicIds = setOf(element = topicId), + ), ) // Observe bookmarks @@ -156,9 +161,11 @@ private fun newsUiState( val news = newsToBookmarksResult.data.first NewsUiState.Success(news) } + is Result.Loading -> { NewsUiState.Loading } + is Result.Error -> { NewsUiState.Error } From 098649d02fe2810fa8cbd3ed89278c1d44e59207 Mon Sep 17 00:00:00 2001 From: Caren Date: Thu, 16 Feb 2023 13:26:58 -0800 Subject: [PATCH 39/97] Remove unnecessary text description for buttons on Interest page Change-Id: I33968bf3a42de795eaa55e72cb86c2898f936dd1 --- feature/interests/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/feature/interests/src/main/res/values/strings.xml b/feature/interests/src/main/res/values/strings.xml index 5b9ab83e0..68deb933e 100644 --- a/feature/interests/src/main/res/values/strings.xml +++ b/feature/interests/src/main/res/values/strings.xml @@ -18,8 +18,8 @@ Interests Loading data "No available data" - Follow interest button - Unfollow interest button + Follow interest + Unfollow interest Interests Menu Search From 072364e7f16725db6e887957915ebf845e3447ba Mon Sep 17 00:00:00 2001 From: Keisuke Takagi Date: Sat, 18 Feb 2023 00:41:33 +0900 Subject: [PATCH 40/97] remove newsResourceTitle --- .../samples/apps/nowinandroid/core/ui/AnalyticsExtensions.kt | 2 +- .../com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt | 1 - .../samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AnalyticsExtensions.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AnalyticsExtensions.kt index 38bce838a..bebaa4711 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AnalyticsExtensions.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AnalyticsExtensions.kt @@ -39,7 +39,7 @@ fun AnalyticsHelper.logScreenView(screenName: String) { ) } -fun AnalyticsHelper.logNewsResourceOpened(newsResourceId: String, newsResourceTitle: String) { +fun AnalyticsHelper.logNewsResourceOpened(newsResourceId: String) { logEvent( event = AnalyticsEvent( type = "news_resource_opened", diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt index 3b0015bab..b085d27b7 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt @@ -67,7 +67,6 @@ fun LazyGridScope.newsFeed( onClick = { analyticsHelper.logNewsResourceOpened( newsResourceId = userNewsResource.id, - newsResourceTitle = userNewsResource.title, ) launchCustomChromeTab(context, resourceUrl, backgroundColor) }, diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt index 0f6861fbc..8429dbdda 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt @@ -56,7 +56,6 @@ fun LazyListScope.userNewsResourceCardItems( onClick = { analyticsHelper.logNewsResourceOpened( newsResourceId = userNewsResource.id, - newsResourceTitle = userNewsResource.title, ) when (onItemClick) { null -> launchCustomChromeTab(context, resourceUrl, backgroundColor) From bcd4a6de59b3b91578ce2b6560c0c62949c913cd Mon Sep 17 00:00:00 2001 From: AlfianHanantio Date: Sun, 19 Feb 2023 08:24:21 +0700 Subject: [PATCH 41/97] Remove redundant code block for managed virtual device creation --- app/build.gradle.kts | 12 ------------ core/database/build.gradle.kts | 18 +----------------- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 04918061a..81c128b91 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,6 @@ * limitations under the License. */ import com.google.samples.apps.nowinandroid.NiaBuildType -import com.android.build.api.dsl.ManagedVirtualDevice plugins { id("nowinandroid.android.application") @@ -75,17 +74,6 @@ android { unitTests { isIncludeAndroidResources = true } - // TODO: Convert it as a convention plugin once Flamingo goes out (https://github.com/android/nowinandroid/issues/523) - managedDevices { - devices { - maybeCreate("pixel4api30").apply { - device = "Pixel 4" - apiLevel = 30 - // ATDs currently support only API level 30. - systemImageSource = "aosp-atd" - } - } - } } namespace = "com.google.samples.apps.nowinandroid" } diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 79f980b6c..10eb4363c 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -14,8 +14,6 @@ * limitations under the License. */ -import com.android.build.api.dsl.ManagedVirtualDevice - // TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed @Suppress("DSL_SCOPE_VIOLATION") plugins { @@ -31,20 +29,6 @@ android { "com.google.samples.apps.nowinandroid.core.testing.NiaTestRunner" } namespace = "com.google.samples.apps.nowinandroid.core.database" - - testOptions { - // TODO: Convert it as a convention plugin once Flamingo goes out (https://github.com/android/nowinandroid/issues/523) - managedDevices { - devices { - maybeCreate("pixel4api30").apply { - device = "Pixel 4" - apiLevel = 30 - // ATDs currently support only API level 30. - systemImageSource = "aosp-atd" - } - } - } - } } dependencies { @@ -54,4 +38,4 @@ dependencies { implementation(libs.kotlinx.datetime) androidTestImplementation(project(":core:testing")) -} \ No newline at end of file +} From 2f3fc85d658cb37d3e64a404c596a5b33ac56685 Mon Sep 17 00:00:00 2001 From: Don Turner Date: Mon, 20 Feb 2023 21:07:43 +0000 Subject: [PATCH 42/97] Make Hilt provide Firebase.analytics Change-Id: I57bec82713ce7ce3a17cd4ad0f377c28c2aedb89 --- .../nowinandroid/core/analytics/AnalyticsModule.kt | 11 +++++++++++ .../core/analytics/FirebaseAnalyticsHelper.kt | 11 ++++------- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt b/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt index e947d036b..9f875ae6d 100644 --- a/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt +++ b/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt @@ -16,14 +16,25 @@ package com.google.samples.apps.nowinandroid.core.analytics +import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.analytics.ktx.analytics +import com.google.firebase.ktx.Firebase import dagger.Binds import dagger.Module +import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) abstract class AnalyticsModule { @Binds abstract fun bindsAnalyticsHelper(analyticsHelperImpl: FirebaseAnalyticsHelper): AnalyticsHelper + + companion object { + @Provides + @Singleton + fun provideFirebaseAnalytics(): FirebaseAnalytics { return Firebase.analytics } + } } diff --git a/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/FirebaseAnalyticsHelper.kt b/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/FirebaseAnalyticsHelper.kt index 96b9ce67d..75dfbc468 100644 --- a/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/FirebaseAnalyticsHelper.kt +++ b/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/FirebaseAnalyticsHelper.kt @@ -16,19 +16,16 @@ package com.google.samples.apps.nowinandroid.core.analytics -import com.google.firebase.analytics.ktx.analytics +import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.ktx.logEvent -import com.google.firebase.ktx.Firebase import javax.inject.Inject -import javax.inject.Singleton /** * Implementation of `AnalyticsHelper` which logs events to a Firebase backend. */ -@Singleton -class FirebaseAnalyticsHelper @Inject constructor() : AnalyticsHelper { - - private val firebaseAnalytics = Firebase.analytics +class FirebaseAnalyticsHelper @Inject constructor( + private val firebaseAnalytics: FirebaseAnalytics, +) : AnalyticsHelper { override fun logEvent(event: AnalyticsEvent) { firebaseAnalytics.logEvent(event.type) { From 91633af376474dff3ca56e9b47a9d6c693aec193 Mon Sep 17 00:00:00 2001 From: Manuel Vivo Date: Tue, 21 Feb 2023 19:07:19 +0100 Subject: [PATCH 43/97] Provide a CoroutineScope to fake DataStores --- .../OfflineFirstNewsRepositoryTest.kt | 20 ++++--- .../OfflineFirstTopicsRepositoryTest.kt | 14 +++-- .../OfflineFirstUserDataRepositoryTest.kt | 23 ++++---- core/datastore-test/build.gradle.kts | 1 + .../datastore/test/TestDataStoreModule.kt | 14 ++++- .../datastore/NiaPreferencesDataSourceTest.kt | 53 +++++++++++-------- 6 files changed, 79 insertions(+), 46 deletions(-) diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt index 9f43d3441..9f556438a 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt @@ -36,6 +36,8 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule @@ -45,6 +47,8 @@ import kotlin.test.assertEquals class OfflineFirstNewsRepositoryTest { + private val testScope = TestScope(UnconfinedTestDispatcher()) + private lateinit var subject: OfflineFirstNewsRepository private lateinit var newsResourceDao: TestNewsResourceDao @@ -65,7 +69,7 @@ class OfflineFirstNewsRepositoryTest { network = TestNiaNetworkDataSource() synchronizer = TestSynchronizer( NiaPreferencesDataSource( - tmpFolder.testUserPreferencesDataStore(), + tmpFolder.testUserPreferencesDataStore(testScope), ), ) @@ -78,7 +82,7 @@ class OfflineFirstNewsRepositoryTest { @Test fun offlineFirstNewsRepository_news_resources_stream_is_backed_by_news_resource_dao() = - runTest { + testScope.runTest { assertEquals( newsResourceDao.getNewsResources() .first() @@ -90,7 +94,7 @@ class OfflineFirstNewsRepositoryTest { @Test fun offlineFirstNewsRepository_news_resources_for_topic_is_backed_by_news_resource_dao() = - runTest { + testScope.runTest { assertEquals( expected = newsResourceDao.getNewsResources( filterTopicIds = filteredInterestsIds, @@ -119,7 +123,7 @@ class OfflineFirstNewsRepositoryTest { @Test fun offlineFirstNewsRepository_sync_pulls_from_network() = - runTest { + testScope.runTest { subject.syncWith(synchronizer) val newsResourcesFromNetwork = network.getNewsResources() @@ -144,7 +148,7 @@ class OfflineFirstNewsRepositoryTest { @Test fun offlineFirstNewsRepository_sync_deletes_items_marked_deleted_on_network() = - runTest { + testScope.runTest { val newsResourcesFromNetwork = network.getNewsResources() .map(NetworkNewsResource::asEntity) .map(NewsResourceEntity::asExternalModel) @@ -185,7 +189,7 @@ class OfflineFirstNewsRepositoryTest { @Test fun offlineFirstNewsRepository_incremental_sync_pulls_from_network() = - runTest { + testScope.runTest { // Set news version to 7 synchronizer.updateChangeListVersions { copy(newsResourceVersion = 7) @@ -224,7 +228,7 @@ class OfflineFirstNewsRepositoryTest { @Test fun offlineFirstNewsRepository_sync_saves_shell_topic_entities() = - runTest { + testScope.runTest { subject.syncWith(synchronizer) assertEquals( @@ -239,7 +243,7 @@ class OfflineFirstNewsRepositoryTest { @Test fun offlineFirstNewsRepository_sync_saves_topic_cross_references() = - runTest { + testScope.runTest { subject.syncWith(synchronizer) assertEquals( diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstTopicsRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstTopicsRepositoryTest.kt index ca9941b8a..3bd314eae 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstTopicsRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstTopicsRepositoryTest.kt @@ -29,6 +29,8 @@ import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferen import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule @@ -38,6 +40,8 @@ import kotlin.test.assertEquals class OfflineFirstTopicsRepositoryTest { + private val testScope = TestScope(UnconfinedTestDispatcher()) + private lateinit var subject: OfflineFirstTopicsRepository private lateinit var topicDao: TopicDao @@ -56,7 +60,7 @@ class OfflineFirstTopicsRepositoryTest { topicDao = TestTopicDao() network = TestNiaNetworkDataSource() niaPreferences = NiaPreferencesDataSource( - tmpFolder.testUserPreferencesDataStore(), + tmpFolder.testUserPreferencesDataStore(testScope), ) synchronizer = TestSynchronizer(niaPreferences) @@ -68,7 +72,7 @@ class OfflineFirstTopicsRepositoryTest { @Test fun offlineFirstTopicsRepository_topics_stream_is_backed_by_topics_dao() = - runTest { + testScope.runTest { assertEquals( topicDao.getTopicEntities() .first() @@ -80,7 +84,7 @@ class OfflineFirstTopicsRepositoryTest { @Test fun offlineFirstTopicsRepository_sync_pulls_from_network() = - runTest { + testScope.runTest { subject.syncWith(synchronizer) val networkTopics = network.getTopics() @@ -103,7 +107,7 @@ class OfflineFirstTopicsRepositoryTest { @Test fun offlineFirstTopicsRepository_incremental_sync_pulls_from_network() = - runTest { + testScope.runTest { // Set topics version to 10 synchronizer.updateChangeListVersions { copy(topicVersion = 10) @@ -133,7 +137,7 @@ class OfflineFirstTopicsRepositoryTest { @Test fun offlineFirstTopicsRepository_sync_deletes_items_marked_deleted_on_network() = - runTest { + testScope.runTest { val networkTopics = network.getTopics() .map(NetworkTopic::asEntity) .map(TopicEntity::asExternalModel) diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt index 61569d650..daf1a6564 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt @@ -24,6 +24,8 @@ import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.UserData import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule @@ -34,6 +36,9 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class OfflineFirstUserDataRepositoryTest { + + private val testScope = TestScope(UnconfinedTestDispatcher()) + private lateinit var subject: OfflineFirstUserDataRepository private lateinit var niaPreferencesDataSource: NiaPreferencesDataSource @@ -46,7 +51,7 @@ class OfflineFirstUserDataRepositoryTest { @Before fun setup() { niaPreferencesDataSource = NiaPreferencesDataSource( - tmpFolder.testUserPreferencesDataStore(), + tmpFolder.testUserPreferencesDataStore(testScope), ) subject = OfflineFirstUserDataRepository( @@ -57,7 +62,7 @@ class OfflineFirstUserDataRepositoryTest { @Test fun offlineFirstUserDataRepository_default_user_data_is_correct() = - runTest { + testScope.runTest { assertEquals( UserData( bookmarkedNewsResources = emptySet(), @@ -73,7 +78,7 @@ class OfflineFirstUserDataRepositoryTest { @Test fun offlineFirstUserDataRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() = - runTest { + testScope.runTest { subject.toggleFollowedTopicId(followedTopicId = "0", followed = true) assertEquals( @@ -104,7 +109,7 @@ class OfflineFirstUserDataRepositoryTest { @Test fun offlineFirstUserDataRepository_set_followed_topics_logic_delegates_to_nia_preferences() = - runTest { + testScope.runTest { subject.setFollowedTopicIds(followedTopicIds = setOf("1", "2")) assertEquals( @@ -126,7 +131,7 @@ class OfflineFirstUserDataRepositoryTest { @Test fun offlineFirstUserDataRepository_bookmark_news_resource_logic_delegates_to_nia_preferences() = - runTest { + testScope.runTest { subject.updateNewsResourceBookmark(newsResourceId = "0", bookmarked = true) assertEquals( @@ -157,7 +162,7 @@ class OfflineFirstUserDataRepositoryTest { @Test fun offlineFirstUserDataRepository_set_theme_brand_delegates_to_nia_preferences() = - runTest { + testScope.runTest { subject.setThemeBrand(ThemeBrand.ANDROID) assertEquals( @@ -177,7 +182,7 @@ class OfflineFirstUserDataRepositoryTest { @Test fun offlineFirstUserDataRepository_set_dynamic_color_delegates_to_nia_preferences() = - runTest { + testScope.runTest { subject.setDynamicColorPreference(true) assertEquals( @@ -197,7 +202,7 @@ class OfflineFirstUserDataRepositoryTest { @Test fun offlineFirstUserDataRepository_set_dark_theme_config_delegates_to_nia_preferences() = - runTest { + testScope.runTest { subject.setDarkThemeConfig(DarkThemeConfig.DARK) assertEquals( @@ -217,7 +222,7 @@ class OfflineFirstUserDataRepositoryTest { @Test fun whenUserCompletesOnboarding_thenRemovesAllInterests_shouldHideOnboardingIsFalse() = - runTest { + testScope.runTest { subject.setFollowedTopicIds(setOf("1")) subject.setShouldHideOnboarding(true) assertTrue(subject.userData.first().shouldHideOnboarding) diff --git a/core/datastore-test/build.gradle.kts b/core/datastore-test/build.gradle.kts index 40b287b7b..d8223c3f3 100644 --- a/core/datastore-test/build.gradle.kts +++ b/core/datastore-test/build.gradle.kts @@ -24,6 +24,7 @@ android { dependencies { api(project(":core:datastore")) + implementation(project(":core:common")) implementation(project(":core:testing")) api(libs.androidx.dataStore.core) diff --git a/core/datastore-test/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt b/core/datastore-test/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt index b29728cf1..0e83bb296 100644 --- a/core/datastore-test/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt +++ b/core/datastore-test/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt @@ -21,10 +21,15 @@ import androidx.datastore.core.DataStoreFactory import com.google.samples.apps.nowinandroid.core.datastore.UserPreferences import com.google.samples.apps.nowinandroid.core.datastore.UserPreferencesSerializer import com.google.samples.apps.nowinandroid.core.datastore.di.DataStoreModule +import com.google.samples.apps.nowinandroid.core.network.Dispatcher +import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import dagger.Module import dagger.Provides import dagger.hilt.components.SingletonComponent import dagger.hilt.testing.TestInstallIn +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import org.junit.rules.TemporaryFolder import javax.inject.Singleton @@ -38,16 +43,23 @@ object TestDataStoreModule { @Provides @Singleton fun providesUserPreferencesDataStore( + @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, userPreferencesSerializer: UserPreferencesSerializer, tmpFolder: TemporaryFolder, ): DataStore = - tmpFolder.testUserPreferencesDataStore(userPreferencesSerializer) + tmpFolder.testUserPreferencesDataStore( + // TODO: Provide an application-wide CoroutineScope in the DI graph + coroutineScope = CoroutineScope(SupervisorJob() + ioDispatcher), + userPreferencesSerializer = userPreferencesSerializer + ) } fun TemporaryFolder.testUserPreferencesDataStore( + coroutineScope: CoroutineScope, userPreferencesSerializer: UserPreferencesSerializer = UserPreferencesSerializer(), ) = DataStoreFactory.create( serializer = userPreferencesSerializer, + scope = coroutineScope ) { newFile("user_preferences_test.pb") } diff --git a/core/datastore/src/test/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSourceTest.kt b/core/datastore/src/test/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSourceTest.kt index 0d047d310..b865aa431 100644 --- a/core/datastore/src/test/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSourceTest.kt +++ b/core/datastore/src/test/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSourceTest.kt @@ -18,6 +18,8 @@ package com.google.samples.apps.nowinandroid.core.datastore import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Rule @@ -27,6 +29,9 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class NiaPreferencesDataSourceTest { + + private val testScope = TestScope(UnconfinedTestDispatcher()) + private lateinit var subject: NiaPreferencesDataSource @get:Rule @@ -35,54 +40,56 @@ class NiaPreferencesDataSourceTest { @Before fun setup() { subject = NiaPreferencesDataSource( - tmpFolder.testUserPreferencesDataStore(), + tmpFolder.testUserPreferencesDataStore(testScope), ) } @Test - fun shouldHideOnboardingIsFalseByDefault() = runTest { + fun shouldHideOnboardingIsFalseByDefault() = testScope.runTest { assertFalse(subject.userData.first().shouldHideOnboarding) } @Test - fun userShouldHideOnboardingIsTrueWhenSet() = runTest { + fun userShouldHideOnboardingIsTrueWhenSet() = testScope.runTest { subject.setShouldHideOnboarding(true) assertTrue(subject.userData.first().shouldHideOnboarding) } @Test - fun userShouldHideOnboarding_unfollowsLastTopic_shouldHideOnboardingIsFalse() = runTest { - // Given: user completes onboarding by selecting a single topic. - subject.toggleFollowedTopicId("1", true) - subject.setShouldHideOnboarding(true) + fun userShouldHideOnboarding_unfollowsLastTopic_shouldHideOnboardingIsFalse() = + testScope.runTest { + // Given: user completes onboarding by selecting a single topic. + subject.toggleFollowedTopicId("1", true) + subject.setShouldHideOnboarding(true) - // When: they unfollow that topic. - subject.toggleFollowedTopicId("1", false) + // When: they unfollow that topic. + subject.toggleFollowedTopicId("1", false) - // Then: onboarding should be shown again - assertFalse(subject.userData.first().shouldHideOnboarding) - } + // Then: onboarding should be shown again + assertFalse(subject.userData.first().shouldHideOnboarding) + } @Test - fun userShouldHideOnboarding_unfollowsAllTopics_shouldHideOnboardingIsFalse() = runTest { - // Given: user completes onboarding by selecting several topics. - subject.setFollowedTopicIds(setOf("1", "2")) - subject.setShouldHideOnboarding(true) + fun userShouldHideOnboarding_unfollowsAllTopics_shouldHideOnboardingIsFalse() = + testScope.runTest { + // Given: user completes onboarding by selecting several topics. + subject.setFollowedTopicIds(setOf("1", "2")) + subject.setShouldHideOnboarding(true) - // When: they unfollow those topics. - subject.setFollowedTopicIds(emptySet()) + // When: they unfollow those topics. + subject.setFollowedTopicIds(emptySet()) - // Then: onboarding should be shown again - assertFalse(subject.userData.first().shouldHideOnboarding) - } + // Then: onboarding should be shown again + assertFalse(subject.userData.first().shouldHideOnboarding) + } @Test - fun shouldUseDynamicColorFalseByDefault() = runTest { + fun shouldUseDynamicColorFalseByDefault() = testScope.runTest { assertFalse(subject.userData.first().useDynamicColor) } @Test - fun userShouldUseDynamicColorIsTrueWhenSet() = runTest { + fun userShouldUseDynamicColorIsTrueWhenSet() = testScope.runTest { subject.setDynamicColorPreference(true) assertTrue(subject.userData.first().useDynamicColor) } From 755413bfc5358b6a6aa4ec092cb116ba40914475 Mon Sep 17 00:00:00 2001 From: Manuel Vivo Date: Tue, 21 Feb 2023 19:38:32 +0100 Subject: [PATCH 44/97] Fix spotless --- .../nowinandroid/core/datastore/test/TestDataStoreModule.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/datastore-test/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt b/core/datastore-test/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt index 0e83bb296..fad7ac382 100644 --- a/core/datastore-test/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt +++ b/core/datastore-test/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt @@ -50,7 +50,7 @@ object TestDataStoreModule { tmpFolder.testUserPreferencesDataStore( // TODO: Provide an application-wide CoroutineScope in the DI graph coroutineScope = CoroutineScope(SupervisorJob() + ioDispatcher), - userPreferencesSerializer = userPreferencesSerializer + userPreferencesSerializer = userPreferencesSerializer, ) } @@ -59,7 +59,7 @@ fun TemporaryFolder.testUserPreferencesDataStore( userPreferencesSerializer: UserPreferencesSerializer = UserPreferencesSerializer(), ) = DataStoreFactory.create( serializer = userPreferencesSerializer, - scope = coroutineScope + scope = coroutineScope, ) { newFile("user_preferences_test.pb") } From 267adfd27fdc59ce10443b8c0481e0e6d2524e2f Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 21 Feb 2023 17:00:40 -0500 Subject: [PATCH 45/97] Batch sync news resources from remote Batch sync news resources from remote to reduce json payload size. --- .../repository/OfflineFirstNewsRepository.kt | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt index 9e419b485..ae3ad3b9c 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt @@ -34,6 +34,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Inject +private const val SYNC_BATCH_SIZE = 40 + /** * Disk storage backed implementation of the [NewsRepository]. * Reads are exclusively from local storage to support offline access. @@ -65,26 +67,29 @@ class OfflineFirstNewsRepository @Inject constructor( }, modelDeleter = newsResourceDao::deleteNewsResources, modelUpdater = { changedIds -> - val networkNewsResources = network.getNewsResources(ids = changedIds) + changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds + val networkNewsResources = network.getNewsResources(ids = chunkedIds) - // Order of invocation matters to satisfy id and foreign key constraints! + // Order of invocation matters to satisfy id and foreign key constraints! - topicDao.insertOrIgnoreTopics( - topicEntities = networkNewsResources - .map(NetworkNewsResource::topicEntityShells) - .flatten() - .distinctBy(TopicEntity::id), - ) - newsResourceDao.upsertNewsResources( - newsResourceEntities = networkNewsResources - .map(NetworkNewsResource::asEntity), - ) - newsResourceDao.insertOrIgnoreTopicCrossRefEntities( - newsResourceTopicCrossReferences = networkNewsResources - .map(NetworkNewsResource::topicCrossReferences) - .distinct() - .flatten(), - ) + topicDao.insertOrIgnoreTopics( + topicEntities = networkNewsResources + .map(NetworkNewsResource::topicEntityShells) + .flatten() + .distinctBy(TopicEntity::id), + ) + newsResourceDao.upsertNewsResources( + newsResourceEntities = networkNewsResources.map( + NetworkNewsResource::asEntity + ), + ) + newsResourceDao.insertOrIgnoreTopicCrossRefEntities( + newsResourceTopicCrossReferences = networkNewsResources + .map(NetworkNewsResource::topicCrossReferences) + .distinct() + .flatten(), + ) + } }, ) } From 1e8d1d98106cff3297efab767da5e378f0c0ea83 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 21 Feb 2023 17:11:47 -0500 Subject: [PATCH 46/97] Spotless fix Thought I was cool enough to edit code in the browser. Spotless said no. --- .../core/data/repository/OfflineFirstNewsRepository.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt index ae3ad3b9c..e98090cba 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt @@ -67,7 +67,7 @@ class OfflineFirstNewsRepository @Inject constructor( }, modelDeleter = newsResourceDao::deleteNewsResources, modelUpdater = { changedIds -> - changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds + changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds -> val networkNewsResources = network.getNewsResources(ids = chunkedIds) // Order of invocation matters to satisfy id and foreign key constraints! @@ -80,7 +80,7 @@ class OfflineFirstNewsRepository @Inject constructor( ) newsResourceDao.upsertNewsResources( newsResourceEntities = networkNewsResources.map( - NetworkNewsResource::asEntity + NetworkNewsResource::asEntity, ), ) newsResourceDao.insertOrIgnoreTopicCrossRefEntities( From 8765d3ac57a9e365446e6ec9cb71cc256be3672d Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Wed, 22 Feb 2023 03:19:16 -0500 Subject: [PATCH 47/97] Fix tests for batched news resource sync Change-Id: I37545eba8618edc936eb12fa38628d98f85f52ed --- .../repository/OfflineFirstNewsRepository.kt | 2 + .../OfflineFirstNewsRepositoryTest.kt | 47 ++++++++++--------- .../data/testdoubles/TestNewsResourceDao.kt | 36 +++++++------- .../core/data/testdoubles/TestTopicDao.kt | 22 ++++----- 4 files changed, 57 insertions(+), 50 deletions(-) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt index e98090cba..c16355d69 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt @@ -34,6 +34,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Inject +// Heuristic value to optimize for serialization and deserialization cost on client and server +// for each news resource batch. private const val SYNC_BATCH_SIZE = 40 /** diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt index 9f43d3441..b2c35056f 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt @@ -27,6 +27,7 @@ import com.google.samples.apps.nowinandroid.core.data.testdoubles.TestTopicDao import com.google.samples.apps.nowinandroid.core.data.testdoubles.filteredInterestsIds import com.google.samples.apps.nowinandroid.core.data.testdoubles.nonPresentInterestsIds import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity +import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel @@ -107,8 +108,8 @@ class OfflineFirstNewsRepositoryTest { ) assertEquals( - emptyList(), - subject.getNewsResources( + expected = emptyList(), + actual = subject.getNewsResources( query = NewsResourceQuery( filterTopicIds = nonPresentInterestsIds, ), @@ -131,14 +132,14 @@ class OfflineFirstNewsRepositoryTest { .map(PopulatedNewsResource::asExternalModel) assertEquals( - newsResourcesFromNetwork.map(NewsResource::id), - newsResourcesFromDb.map(NewsResource::id), + newsResourcesFromNetwork.map(NewsResource::id).sorted(), + newsResourcesFromDb.map(NewsResource::id).sorted(), ) // After sync version should be updated assertEquals( - network.latestChangeListVersion(CollectionType.NewsResources), - synchronizer.getChangeListVersions().newsResourceVersion, + expected = network.latestChangeListVersion(CollectionType.NewsResources), + actual = synchronizer.getChangeListVersions().newsResourceVersion, ) } @@ -172,14 +173,14 @@ class OfflineFirstNewsRepositoryTest { // Assert that items marked deleted on the network have been deleted locally assertEquals( - newsResourcesFromNetwork.map(NewsResource::id) - deletedItems, - newsResourcesFromDb.map(NewsResource::id), + expected = (newsResourcesFromNetwork.map(NewsResource::id) - deletedItems).sorted(), + actual = newsResourcesFromDb.map(NewsResource::id).sorted(), ) // After sync version should be updated assertEquals( - network.latestChangeListVersion(CollectionType.NewsResources), - synchronizer.getChangeListVersions().newsResourceVersion, + expected = network.latestChangeListVersion(CollectionType.NewsResources), + actual = synchronizer.getChangeListVersions().newsResourceVersion, ) } @@ -211,14 +212,14 @@ class OfflineFirstNewsRepositoryTest { .map(PopulatedNewsResource::asExternalModel) assertEquals( - newsResourcesFromNetwork.map(NewsResource::id), - newsResourcesFromDb.map(NewsResource::id), + expected = newsResourcesFromNetwork.map(NewsResource::id).sorted(), + actual = newsResourcesFromDb.map(NewsResource::id).sorted(), ) // After sync version should be updated assertEquals( - changeList.last().changeListVersion, - synchronizer.getChangeListVersions().newsResourceVersion, + expected = changeList.last().changeListVersion, + actual = synchronizer.getChangeListVersions().newsResourceVersion, ) } @@ -228,12 +229,14 @@ class OfflineFirstNewsRepositoryTest { subject.syncWith(synchronizer) assertEquals( - network.getNewsResources() + expected = network.getNewsResources() .map(NetworkNewsResource::topicEntityShells) .flatten() - .distinctBy(TopicEntity::id), - topicDao.getTopicEntities() - .first(), + .distinctBy(TopicEntity::id) + .sortedBy(TopicEntity::toString), + actual = topicDao.getTopicEntities() + .first() + .sortedBy(TopicEntity::toString), ) } @@ -243,11 +246,13 @@ class OfflineFirstNewsRepositoryTest { subject.syncWith(synchronizer) assertEquals( - network.getNewsResources() + expected = network.getNewsResources() .map(NetworkNewsResource::topicCrossReferences) + .flatten() .distinct() - .flatten(), - newsResourceDao.topicCrossReferences, + .sortedBy(NewsResourceTopicCrossRef::toString), + actual = newsResourceDao.topicCrossReferences + .sortedBy(NewsResourceTopicCrossRef::toString), ) } } diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt index baeff4a66..bb1ac20ab 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt @@ -21,12 +21,10 @@ import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEnti import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity -import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update -import kotlinx.datetime.Instant val filteredInterestsIds = setOf("1") val nonPresentInterestsIds = setOf("2") @@ -37,17 +35,7 @@ val nonPresentInterestsIds = setOf("2") class TestNewsResourceDao : NewsResourceDao { private var entitiesStateFlow = MutableStateFlow( - listOf( - NewsResourceEntity( - id = "1", - title = "news", - content = "Hilt", - url = "url", - headerImageUrl = "headerImageUrl", - type = Video, - publishDate = Instant.fromEpochMilliseconds(1), - ), - ), + emptyList(), ) internal var topicCrossReferences: List = listOf() @@ -78,7 +66,14 @@ class TestNewsResourceDao : NewsResourceDao { override suspend fun insertOrIgnoreNewsResources( entities: List, ): List { - entitiesStateFlow.value = entities + entitiesStateFlow.update { oldValues -> + // Old values come first so new values don't overwrite them + (oldValues + entities) + .distinctBy(NewsResourceEntity::id) + .sortedWith( + compareBy(NewsResourceEntity::publishDate).reversed(), + ) + } // Assume no conflicts on insert return entities.map { it.id.toLong() } } @@ -88,13 +83,22 @@ class TestNewsResourceDao : NewsResourceDao { } override suspend fun upsertNewsResources(newsResourceEntities: List) { - entitiesStateFlow.value = newsResourceEntities + entitiesStateFlow.update { oldValues -> + // New values come first so they overwrite old values + (newsResourceEntities + oldValues) + .distinctBy(NewsResourceEntity::id) + .sortedWith( + compareBy(NewsResourceEntity::publishDate).reversed(), + ) + } } override suspend fun insertOrIgnoreTopicCrossRefEntities( newsResourceTopicCrossReferences: List, ) { - topicCrossReferences = newsResourceTopicCrossReferences + // Keep old values over new ones + topicCrossReferences = (topicCrossReferences + newsResourceTopicCrossReferences) + .distinctBy { it.newsResourceId to it.topicId } } override suspend fun deleteNewsResources(ids: List) { diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt index 8ac0dc0b8..c0cef479f 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt @@ -29,16 +29,7 @@ import kotlinx.coroutines.flow.update class TestTopicDao : TopicDao { private var entitiesStateFlow = MutableStateFlow( - listOf( - TopicEntity( - id = "1", - name = "Topic", - shortDescription = "short description", - longDescription = "long description", - url = "URL", - imageUrl = "image URL", - ), - ), + emptyList(), ) override fun getTopicEntity(topicId: String): Flow { @@ -53,8 +44,10 @@ class TestTopicDao : TopicDao { .map { topics -> topics.filter { it.id in ids } } override suspend fun insertOrIgnoreTopics(topicEntities: List): List { - entitiesStateFlow.value = topicEntities - // Assume no conflicts on insert + // Keep old values over new values + entitiesStateFlow.update { oldValues -> + (oldValues + topicEntities).distinctBy(TopicEntity::id) + } return topicEntities.map { it.id.toLong() } } @@ -63,7 +56,10 @@ class TestTopicDao : TopicDao { } override suspend fun upsertTopics(entities: List) { - entitiesStateFlow.value = entities + // Overwrite old values with new values + entitiesStateFlow.update { oldValues -> + (entities + oldValues).distinctBy(TopicEntity::id) + } } override suspend fun deleteTopics(ids: List) { From 9ee2acf3276bbc412fc558662bcfd5eb1d8caa1f Mon Sep 17 00:00:00 2001 From: James Rose Date: Fri, 13 Jan 2023 15:35:11 -0800 Subject: [PATCH 48/97] Add viewed status for news resources to data layer --- .../OfflineFirstUserDataRepository.kt | 3 ++ .../data/repository/UserDataRepository.kt | 5 +++ .../repository/fake/FakeUserDataRepository.kt | 3 ++ .../OfflineFirstUserDataRepositoryTest.kt | 32 +++++++++++++++++++ .../datastore/NiaPreferencesDataSource.kt | 13 ++++++++ .../nowinandroid/data/user_preferences.proto | 3 ++ .../core/domain/model/UserNewsResource.kt | 2 ++ .../core/domain/UserNewsResourceTest.kt | 1 + .../nowinandroid/core/model/data/UserData.kt | 1 + .../testing/data/UserNewsResourcesTestData.kt | 1 + .../repository/TestUserDataRepository.kt | 16 ++++++++++ ...serNewsResourcePreviewParameterProvider.kt | 1 + 12 files changed, 81 insertions(+) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt index 334209538..f10046f73 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt @@ -50,6 +50,9 @@ class OfflineFirstUserDataRepository @Inject constructor( ) } + override suspend fun updateNewsResourceViewed(newsResourceId: String, viewed: Boolean) = + niaPreferencesDataSource.toggleNewsResourceViewed(newsResourceId, viewed) + override suspend fun setThemeBrand(themeBrand: ThemeBrand) { niaPreferencesDataSource.setThemeBrand(themeBrand) analyticsHelper.logThemeChanged(themeBrand.name) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt index ea093852f..2ce84a963 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt @@ -43,6 +43,11 @@ interface UserDataRepository { */ suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) + /** + * Updates the viewed status for a news resource + */ + suspend fun updateNewsResourceViewed(newsResourceId: String, viewed: Boolean) + /** * Sets the desired theme brand. */ diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt index af206e5c7..8b8a1f7f8 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt @@ -47,6 +47,9 @@ class FakeUserDataRepository @Inject constructor( niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked) } + override suspend fun updateNewsResourceViewed(newsResourceId: String, viewed: Boolean) = + niaPreferencesDataSource.toggleNewsResourceViewed(newsResourceId, viewed) + override suspend fun setThemeBrand(themeBrand: ThemeBrand) { niaPreferencesDataSource.setThemeBrand(themeBrand) } diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt index daf1a6564..994ae71b5 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt @@ -66,6 +66,7 @@ class OfflineFirstUserDataRepositoryTest { assertEquals( UserData( bookmarkedNewsResources = emptySet(), + viewedNewsResources = emptySet(), followedTopics = emptySet(), themeBrand = ThemeBrand.DEFAULT, darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, @@ -160,6 +161,37 @@ class OfflineFirstUserDataRepositoryTest { ) } + @Test + fun offlineFirstUserDataRepository_update_viewed_news_resources_delegates_to_nia_preferences() = + runTest { + subject.updateNewsResourceViewed(newsResourceId = "0", viewed = true) + + assertEquals( + setOf("0"), + subject.userData + .map { it.viewedNewsResources } + .first(), + ) + + subject.updateNewsResourceViewed(newsResourceId = "1", viewed = true) + + assertEquals( + setOf("0", "1"), + subject.userData + .map { it.viewedNewsResources } + .first(), + ) + + assertEquals( + niaPreferencesDataSource.userData + .map { it.viewedNewsResources } + .first(), + subject.userData + .map { it.viewedNewsResources } + .first(), + ) + } + @Test fun offlineFirstUserDataRepository_set_theme_brand_delegates_to_nia_preferences() = testScope.runTest { diff --git a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt index f5751193a..91f8a3df2 100644 --- a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt @@ -33,6 +33,7 @@ class NiaPreferencesDataSource @Inject constructor( .map { UserData( bookmarkedNewsResources = it.bookmarkedNewsResourceIdsMap.keys, + viewedNewsResources = it.viewedNewsResourceIdsMap.keys, followedTopics = it.followedTopicIdsMap.keys, themeBrand = when (it.themeBrand) { null, @@ -137,6 +138,18 @@ class NiaPreferencesDataSource @Inject constructor( } } + suspend fun toggleNewsResourceViewed(newsResourceId: String, viewed: Boolean) { + userPreferences.updateData { + it.copy { + if (viewed) { + viewedNewsResourceIds.put(newsResourceId, true) + } else { + viewedNewsResourceIds.remove(newsResourceId) + } + } + } + } + suspend fun getChangeListVersions() = userPreferences.data .map { ChangeListVersions( diff --git a/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto b/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto index 5288c04ea..11386613c 100644 --- a/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto +++ b/core/datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto @@ -40,6 +40,7 @@ message UserPreferences { map followed_topic_ids = 13; map followed_author_ids = 14; map bookmarked_news_resource_ids = 15; + map viewed_news_resource_ids = 20; ThemeBrandProto theme_brand = 16; DarkThemeConfigProto dark_theme_config = 17; @@ -47,4 +48,6 @@ message UserPreferences { bool should_hide_onboarding = 18; bool use_dynamic_color = 19; + + // NEXT AVAILABLE ID: 21 } diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/UserNewsResource.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/UserNewsResource.kt index 4e12ec95b..1d0051918 100644 --- a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/UserNewsResource.kt +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/UserNewsResource.kt @@ -35,6 +35,7 @@ data class UserNewsResource internal constructor( val type: NewsResourceType, val followableTopics: List, val isSaved: Boolean, + val isViewed: Boolean, ) { constructor(newsResource: NewsResource, userData: UserData) : this( id = newsResource.id, @@ -51,6 +52,7 @@ data class UserNewsResource internal constructor( ) }, isSaved = userData.bookmarkedNewsResources.contains(newsResource.id), + isViewed = userData.viewedNewsResources.contains(newsResource.id), ) } diff --git a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/UserNewsResourceTest.kt b/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/UserNewsResourceTest.kt index 8350c5178..7931d3f80 100644 --- a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/UserNewsResourceTest.kt +++ b/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/UserNewsResourceTest.kt @@ -68,6 +68,7 @@ class UserNewsResourceTest { val userData = UserData( bookmarkedNewsResources = setOf("N1"), + viewedNewsResources = setOf("N1"), followedTopics = setOf("T1"), themeBrand = DEFAULT, darkThemeConfig = FOLLOW_SYSTEM, diff --git a/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt index 638b90d36..6a22e4ff5 100644 --- a/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt +++ b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserData.kt @@ -21,6 +21,7 @@ package com.google.samples.apps.nowinandroid.core.model.data */ data class UserData( val bookmarkedNewsResources: Set, + val viewedNewsResources: Set, val followedTopics: Set, val themeBrand: ThemeBrand, val darkThemeConfig: DarkThemeConfig, diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt index 381160006..f4085d11e 100644 --- a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt @@ -30,6 +30,7 @@ import kotlinx.datetime.toInstant /* ktlint-disable max-line-length */ val userNewsResourcesTestData: List = UserData( bookmarkedNewsResources = setOf("1", "4"), + viewedNewsResources = setOf("1", "2", "4"), followedTopics = emptySet(), themeBrand = ThemeBrand.ANDROID, darkThemeConfig = DarkThemeConfig.DARK, diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt index e1b86cd63..1b8483d1a 100644 --- a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.filterNotNull val emptyUserData = UserData( bookmarkedNewsResources = emptySet(), + viewedNewsResources = emptySet(), followedTopics = emptySet(), themeBrand = ThemeBrand.DEFAULT, darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, @@ -72,6 +73,21 @@ class TestUserDataRepository : UserDataRepository { } } + override suspend fun updateNewsResourceViewed(newsResourceId: String, viewed: Boolean) { + currentUserData.let { current -> + _userData.tryEmit( + current.copy( + viewedNewsResources = + if (viewed) { + current.viewedNewsResources + newsResourceId + } else { + current.viewedNewsResources - newsResourceId + }, + ), + ) + } + } + override suspend fun setThemeBrand(themeBrand: ThemeBrand) { currentUserData.let { current -> _userData.tryEmit(current.copy(themeBrand = themeBrand)) diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt index e32aa1a57..8a1c108c6 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt @@ -40,6 +40,7 @@ class UserNewsResourcePreviewParameterProvider : PreviewParameterProvider Date: Fri, 10 Feb 2023 11:02:06 -0800 Subject: [PATCH 49/97] Replace GetUserNewsResourcesUseCase with UserNewsResourceRepository This moves the responsibility for joining the UserData and the NewsResources to UserNewsResourceRepository. This way, the work can be done once and shared with all consumers in a SharedFlow, rather than having each consumer perform the join itself by invoking the UseCase. --- .../core/network/NiaDispatchers.kt | 1 + .../core/network/di/DispatchersModule.kt | 5 ++ .../domain/GetUserNewsResourcesUseCase.kt | 58 --------------- .../core/domain/di/CoroutineScopesModule.kt | 42 +++++++++++ .../di/UserNewsResourceRepositoryModule.kt | 33 +++++++++ .../CompositeUserNewsResourceRepository.kt | 74 +++++++++++++++++++ .../repository/UserNewsResourceRepository.kt | 41 ++++++++++ ...ompositeUserNewsResourceRepositoryTest.kt} | 51 +++++++++---- .../feature/bookmarks/BookmarksViewModel.kt | 6 +- .../bookmarks/BookmarksViewModelTest.kt | 8 +- .../feature/foryou/ForYouViewModel.kt | 14 ++-- .../feature/foryou/ForYouViewModelTest.kt | 8 +- .../feature/topic/TopicViewModel.kt | 14 ++-- .../feature/topic/TopicViewModelTest.kt | 8 +- 14 files changed, 262 insertions(+), 101 deletions(-) delete mode 100644 core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCase.kt create mode 100644 core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/CoroutineScopesModule.kt create mode 100644 core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/UserNewsResourceRepositoryModule.kt create mode 100644 core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/CompositeUserNewsResourceRepository.kt create mode 100644 core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/UserNewsResourceRepository.kt rename core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/{GetUserNewsResourcesUseCaseTest.kt => CompositeUserNewsResourceRepositoryTest.kt} (75%) diff --git a/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/NiaDispatchers.kt b/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/NiaDispatchers.kt index 277b68717..9c21dd69a 100644 --- a/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/NiaDispatchers.kt +++ b/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/NiaDispatchers.kt @@ -24,5 +24,6 @@ import kotlin.annotation.AnnotationRetention.RUNTIME annotation class Dispatcher(val niaDispatcher: NiaDispatchers) enum class NiaDispatchers { + Default, IO, } diff --git a/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/DispatchersModule.kt b/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/DispatchersModule.kt index 1b8409eff..95ec07049 100644 --- a/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/DispatchersModule.kt +++ b/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/DispatchersModule.kt @@ -17,6 +17,7 @@ package com.google.samples.apps.nowinandroid.core.network.di import com.google.samples.apps.nowinandroid.core.network.Dispatcher +import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.Default import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import dagger.Module import dagger.Provides @@ -31,4 +32,8 @@ object DispatchersModule { @Provides @Dispatcher(IO) fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO + + @Provides + @Dispatcher(Default) + fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default } diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCase.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCase.kt deleted file mode 100644 index 393b7b08b..000000000 --- a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCase.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2022 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.samples.apps.nowinandroid.core.domain - -import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository -import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery -import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource -import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources -import com.google.samples.apps.nowinandroid.core.model.data.NewsResource -import com.google.samples.apps.nowinandroid.core.model.data.UserData -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNot -import javax.inject.Inject - -/** - * A use case responsible for obtaining news resources with their associated bookmarked (also known - * as "saved") state. - */ -class GetUserNewsResourcesUseCase @Inject constructor( - private val newsRepository: NewsRepository, - private val userDataRepository: UserDataRepository, -) { - /** - * Returns a list of UserNewsResources which match the supplied set of topic ids. - * - * @param query - Summary of query parameters for news resources. - */ - operator fun invoke( - query: NewsResourceQuery = NewsResourceQuery(), - ): Flow> = - newsRepository.getNewsResources( - query = query, - ).mapToUserNewsResources(userDataRepository.userData) -} - -private fun Flow>.mapToUserNewsResources( - userDataStream: Flow, -): Flow> = - filterNot { it.isEmpty() } - .combine(userDataStream) { newsResources, userData -> - newsResources.mapToUserNewsResources(userData) - } diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/CoroutineScopesModule.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/CoroutineScopesModule.kt new file mode 100644 index 000000000..cfd07e565 --- /dev/null +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/CoroutineScopesModule.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import javax.inject.Qualifier +import javax.inject.Singleton + +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class ApplicationScope + +@InstallIn(SingletonComponent::class) +@Module +object CoroutinesScopesModule { + + @Singleton + @ApplicationScope + @Provides + fun providesCoroutineScope(): CoroutineScope = + CoroutineScope(SupervisorJob() + Dispatchers.Default) +} diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/UserNewsResourceRepositoryModule.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/UserNewsResourceRepositoryModule.kt new file mode 100644 index 000000000..0dd83a852 --- /dev/null +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/UserNewsResourceRepositoryModule.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain.di + +import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository +import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface UserNewsResourceRepositoryModule { + @Binds + fun bindsUserNewsResourceRepository( + userDataRepository: CompositeUserNewsResourceRepository, + ): UserNewsResourceRepository +} diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/CompositeUserNewsResourceRepository.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/CompositeUserNewsResourceRepository.kt new file mode 100644 index 000000000..43c2ddf8f --- /dev/null +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/CompositeUserNewsResourceRepository.kt @@ -0,0 +1,74 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain.repository + +import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery +import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository +import com.google.samples.apps.nowinandroid.core.domain.di.ApplicationScope +import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import com.google.samples.apps.nowinandroid.core.model.data.UserData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.shareIn +import javax.inject.Inject + +/** + * Implements a [UserNewsResourceRepository] by combining a [NewsRepository] with a + * [UserDataRepository]. + */ +class CompositeUserNewsResourceRepository @Inject constructor( + @ApplicationScope private val coroutineScope: CoroutineScope, + val newsRepository: NewsRepository, + val userDataRepository: UserDataRepository, +) : UserNewsResourceRepository { + + private val userNewsResources = + newsRepository.getNewsResources().mapToUserNewsResources(userDataRepository.userData) + .shareIn(coroutineScope, started = WhileSubscribed(5000), replay = 1) + + override fun getUserNewsResources( + query: NewsResourceQuery, + ): Flow> = + userNewsResources.map { resources -> + resources.filter { resource -> + query.filterTopicIds?.let { topics -> resource.hasTopic(topics) } ?: true && + query.filterNewsIds?.contains(resource.id) ?: true + } + } + + override fun getUserNewsResourcesForFollowedTopics(): Flow> = + userDataRepository.userData.flatMapLatest { getUserNewsResources(NewsResourceQuery(filterTopicIds = it.followedTopics)) } + + private fun UserNewsResource.hasTopic(filterTopicIds: Set) = + followableTopics.any { filterTopicIds.contains(it.topic.id) } +} + +private fun Flow>.mapToUserNewsResources( + userDataStream: Flow, +): Flow> = + filterNot { it.isEmpty() } + .combine(userDataStream) { newsResources, userData -> + newsResources.mapToUserNewsResources(userData) + } diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/UserNewsResourceRepository.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/UserNewsResourceRepository.kt new file mode 100644 index 000000000..d81a3d1e0 --- /dev/null +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/UserNewsResourceRepository.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain.repository + +import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery +import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import kotlinx.coroutines.flow.Flow + +/** + * Data layer implementation for [UserNewsResource] + */ +interface UserNewsResourceRepository { + /** + * Returns available news resources as a stream. + */ + fun getUserNewsResources( + query: NewsResourceQuery = NewsResourceQuery( + filterTopicIds = null, + filterNewsIds = null, + ), + ): Flow> + + /** + * Returns available news resources for the user's followed topics as a stream. + */ + fun getUserNewsResourcesForFollowedTopics(): Flow> +} diff --git a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCaseTest.kt b/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/CompositeUserNewsResourceRepositoryTest.kt similarity index 75% rename from core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCaseTest.kt rename to core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/CompositeUserNewsResourceRepositoryTest.kt index 0ff863d7c..9462cf89e 100644 --- a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCaseTest.kt +++ b/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/CompositeUserNewsResourceRepositoryTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Android Open Source Project + * Copyright 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,34 +18,36 @@ package com.google.samples.apps.nowinandroid.core.domain import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources +import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository 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.Topic 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.emptyUserData -import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import kotlinx.datetime.Instant -import org.junit.Rule import org.junit.Test import kotlin.test.assertEquals -class GetUserNewsResourcesUseCaseTest { - - @get:Rule - val mainDispatcherRule = MainDispatcherRule() +class CompositeUserNewsResourceRepositoryTest { private val newsRepository = TestNewsRepository() private val userDataRepository = TestUserDataRepository() - val useCase = GetUserNewsResourcesUseCase(newsRepository, userDataRepository) + private val userNewsResourceRepository = CompositeUserNewsResourceRepository( + coroutineScope = TestScope(UnconfinedTestDispatcher()), + newsRepository = newsRepository, + userDataRepository = userDataRepository, + ) @Test fun whenNoFilters_allNewsResourcesAreReturned() = runTest { - // Obtain the user news resources stream. - val userNewsResources = useCase() + // Obtain the user news resources flow. + val userNewsResources = userNewsResourceRepository.getUserNewsResources() // Send some news resources and user data into the data repositories. newsRepository.sendNewsResources(sampleNewsResources) @@ -68,11 +70,8 @@ class GetUserNewsResourcesUseCaseTest { @Test fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest { // Obtain a stream of user news resources for the given topic id. - val userNewsResources = useCase( - NewsResourceQuery( - filterTopicIds = setOf(sampleTopic1.id), - ), - ) + val userNewsResources = + userNewsResourceRepository.getUserNewsResources(NewsResourceQuery(filterTopicIds = setOf(sampleTopic1.id))) // Send test data into the repositories. newsRepository.sendNewsResources(sampleNewsResources) @@ -86,6 +85,28 @@ class GetUserNewsResourcesUseCaseTest { userNewsResources.first(), ) } + + @Test + fun whenFilteredByFollowedTopics_matchingNewsResourcesAreReturned() = runTest { + // Obtain a stream of user news resources for the given topic id. + val userNewsResources = + userNewsResourceRepository.getUserNewsResourcesForFollowedTopics() + + // Send test data into the repositories. + val userData = emptyUserData.copy( + followedTopics = setOf(sampleTopic1.id), + ) + newsRepository.sendNewsResources(sampleNewsResources) + userDataRepository.setUserData(userData) + + // Check that only news resources with the given topic id are returned. + assertEquals( + sampleNewsResources + .filter { it.topics.contains(sampleTopic1) } + .mapToUserNewsResources(userData), + userNewsResources.first(), + ) + } } private val sampleTopic1 = Topic( diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt index fe631c287..91d9355ae 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt @@ -19,8 +19,8 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository -import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading import dagger.hilt.android.lifecycle.HiltViewModel @@ -36,10 +36,10 @@ import javax.inject.Inject @HiltViewModel class BookmarksViewModel @Inject constructor( private val userDataRepository: UserDataRepository, - getSaveableNewsResources: GetUserNewsResourcesUseCase, + userNewsResourceRepository: UserNewsResourceRepository, ) : ViewModel() { - val feedUiState: StateFlow = getSaveableNewsResources() + val feedUiState: StateFlow = userNewsResourceRepository.getUserNewsResources() .filterNot { it.isEmpty() } .map { newsResources -> newsResources.filter(UserNewsResource::isSaved) } // Only show bookmarked news resources. .map, NewsFeedUiState>(NewsFeedUiState::Success) diff --git a/feature/bookmarks/src/test/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt b/feature/bookmarks/src/test/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt index ae4445197..d97f71095 100644 --- a/feature/bookmarks/src/test/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt +++ b/feature/bookmarks/src/test/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt @@ -16,7 +16,7 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks -import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase +import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository @@ -25,6 +25,7 @@ import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Before @@ -43,7 +44,8 @@ class BookmarksViewModelTest { private val userDataRepository = TestUserDataRepository() private val newsRepository = TestNewsRepository() - private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase( + private val userNewsResourceRepository = CompositeUserNewsResourceRepository( + coroutineScope = TestScope(UnconfinedTestDispatcher()), newsRepository = newsRepository, userDataRepository = userDataRepository, ) @@ -53,7 +55,7 @@ class BookmarksViewModelTest { fun setup() { viewModel = BookmarksViewModel( userDataRepository = userDataRepository, - getSaveableNewsResources = getUserNewsResourcesUseCase, + userNewsResourceRepository = userNewsResourceRepository, ) } diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt index 085593932..cece3a6c3 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt @@ -22,8 +22,8 @@ import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQue import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase -import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import dagger.hilt.android.lifecycle.HiltViewModel @@ -43,7 +43,7 @@ import javax.inject.Inject class ForYouViewModel @Inject constructor( syncStatusMonitor: SyncStatusMonitor, private val userDataRepository: UserDataRepository, - getUserNewsResources: GetUserNewsResourcesUseCase, + userNewsResourceRepository: UserNewsResourceRepository, getFollowableTopics: GetFollowableTopicsUseCase, ) : ViewModel() { @@ -58,7 +58,7 @@ class ForYouViewModel @Inject constructor( ) val feedState: StateFlow = - userDataRepository.getFollowedUserNewsResources(getUserNewsResources) + userDataRepository.getFollowedUserNewsResources(userNewsResourceRepository) .map(NewsFeedUiState::Success) .stateIn( scope = viewModelScope, @@ -108,7 +108,7 @@ class ForYouViewModel @Inject constructor( * getUserNewsResources: The `UseCase` used to obtain the flow of user news resources. */ private fun UserDataRepository.getFollowedUserNewsResources( - getUserNewsResources: GetUserNewsResourcesUseCase, + userNewsResourceRepository: UserNewsResourceRepository, ): Flow> = userData // Map the user data into a set of followed topic IDs or null if we should return an empty list. .map { userData -> @@ -128,10 +128,8 @@ private fun UserDataRepository.getFollowedUserNewsResources( if (followedTopics == null) { flowOf(emptyList()) } else { - getUserNewsResources( - NewsResourceQuery( - filterTopicIds = followedTopics, - ), + userNewsResourceRepository.getUserNewsResources( + NewsResourceQuery(filterTopicIds = followedTopics), ) } } diff --git a/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt index 9e51758f0..9bac2549c 100644 --- a/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt +++ b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt @@ -17,10 +17,10 @@ package com.google.samples.apps.nowinandroid.feature.foryou import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase -import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources +import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository 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.Topic @@ -34,6 +34,7 @@ import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncStatusMoni import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -56,7 +57,8 @@ class ForYouViewModelTest { private val userDataRepository = TestUserDataRepository() private val topicsRepository = TestTopicsRepository() private val newsRepository = TestNewsRepository() - private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase( + private val userNewsResourceRepository = CompositeUserNewsResourceRepository( + coroutineScope = TestScope(UnconfinedTestDispatcher()), newsRepository = newsRepository, userDataRepository = userDataRepository, ) @@ -72,7 +74,7 @@ class ForYouViewModelTest { viewModel = ForYouViewModel( syncStatusMonitor = syncStatusMonitor, userDataRepository = userDataRepository, - getUserNewsResources = getUserNewsResourcesUseCase, + userNewsResourceRepository = userNewsResourceRepository, getFollowableTopics = getFollowableTopicsUseCase, ) } diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt index fcabff16b..bb03f9ae6 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt @@ -23,9 +23,9 @@ import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQue import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder -import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.result.Result import com.google.samples.apps.nowinandroid.core.result.asResult @@ -46,7 +46,7 @@ class TopicViewModel @Inject constructor( stringDecoder: StringDecoder, private val userDataRepository: UserDataRepository, topicsRepository: TopicsRepository, - getSaveableNewsResources: GetUserNewsResourcesUseCase, + userNewsResourceRepository: UserNewsResourceRepository, ) : ViewModel() { private val topicArgs: TopicArgs = TopicArgs(savedStateHandle, stringDecoder) @@ -67,7 +67,7 @@ class TopicViewModel @Inject constructor( val newUiState: StateFlow = newsUiState( topicId = topicArgs.topicId, userDataRepository = userDataRepository, - getSaveableNewsResources = getSaveableNewsResources, + userNewsResourceRepository = userNewsResourceRepository, ) .stateIn( scope = viewModelScope, @@ -135,14 +135,12 @@ private fun topicUiState( private fun newsUiState( topicId: String, - getSaveableNewsResources: GetUserNewsResourcesUseCase, + userNewsResourceRepository: UserNewsResourceRepository, userDataRepository: UserDataRepository, ): Flow { // Observe news - val newsStream: Flow> = getSaveableNewsResources( - NewsResourceQuery( - filterTopicIds = setOf(element = topicId), - ), + val newsStream: Flow> = userNewsResourceRepository.getUserNewsResources( + NewsResourceQuery(filterTopicIds = setOf(element = topicId)), ) // Observe bookmarks diff --git a/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt b/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt index dfed60385..3580a960b 100644 --- a/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt +++ b/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt @@ -17,8 +17,8 @@ package com.google.samples.apps.nowinandroid.feature.topic import androidx.lifecycle.SavedStateHandle -import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository 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.Topic @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import kotlinx.datetime.Instant @@ -53,7 +54,8 @@ class TopicViewModelTest { private val userDataRepository = TestUserDataRepository() private val topicsRepository = TestTopicsRepository() private val newsRepository = TestNewsRepository() - private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase( + private val userNewsResourceRepository = CompositeUserNewsResourceRepository( + coroutineScope = TestScope(UnconfinedTestDispatcher()), newsRepository = newsRepository, userDataRepository = userDataRepository, ) @@ -66,7 +68,7 @@ class TopicViewModelTest { stringDecoder = FakeStringDecoder(), userDataRepository = userDataRepository, topicsRepository = topicsRepository, - getSaveableNewsResources = getUserNewsResourcesUseCase, + userNewsResourceRepository = userNewsResourceRepository, ) } From ebfbb5bafd1d13119fa4cdf75f0176ad84cb33eb Mon Sep 17 00:00:00 2001 From: James Rose Date: Fri, 20 Jan 2023 13:57:39 -0800 Subject: [PATCH 50/97] Display unread state on the news feed and bottom nav bar When a news resource is unread, display a dot on its card in the news feed. When the For You section has unread resources, display a dot on its icon in the navigation bar. Update the read status when a resource is opened. --- app/build.gradle.kts | 2 + .../apps/nowinandroid/ui/NavigationUiTest.kt | 20 ++++++++ .../samples/apps/nowinandroid/MainActivity.kt | 5 ++ .../samples/apps/nowinandroid/ui/NiaApp.kt | 37 ++++++++++++++ .../core/ui/NewsResourceCardTest.kt | 51 +++++++++++++++++++ .../apps/nowinandroid/core/ui/NewsFeed.kt | 5 ++ .../nowinandroid/core/ui/NewsResourceCard.kt | 35 ++++++++++++- .../core/ui/NewsResourceCardList.kt | 7 ++- core/ui/src/main/res/values/strings.xml | 2 + .../feature/bookmarks/BookmarksScreenTest.kt | 4 ++ .../feature/bookmarks/BookmarksScreen.kt | 7 ++- .../feature/bookmarks/BookmarksViewModel.kt | 6 +++ .../feature/foryou/ForYouScreenTest.kt | 7 +++ .../feature/foryou/ForYouScreen.kt | 8 +++ .../feature/foryou/ForYouViewModel.kt | 6 +++ .../feature/topic/TopicScreenTest.kt | 4 ++ .../nowinandroid/feature/topic/TopicScreen.kt | 11 +++- .../feature/topic/TopicViewModel.kt | 6 +++ 18 files changed, 219 insertions(+), 4 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 81c128b91..8197ad57b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -86,9 +86,11 @@ dependencies { implementation(project(":feature:settings")) implementation(project(":core:common")) + implementation(project(":core:domain")) implementation(project(":core:ui")) implementation(project(":core:designsystem")) implementation(project(":core:data")) + implementation(project(":core:domain")) implementation(project(":core:model")) implementation(project(":core:analytics")) diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt index c498c03dd..a0a737237 100644 --- a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt @@ -26,10 +26,15 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.google.accompanist.testharness.TestHarness import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor +import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository +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.uitesthiltmanifest.HiltComponentActivity import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Before import org.junit.Rule import org.junit.Test @@ -63,6 +68,12 @@ class NavigationUiTest { @get:Rule(order = 2) val composeTestRule = createAndroidComposeRule() + val userNewsResourceRepository = CompositeUserNewsResourceRepository( + coroutineScope = TestScope(UnconfinedTestDispatcher()), + newsRepository = TestNewsRepository(), + userDataRepository = TestUserDataRepository(), + ) + @Inject lateinit var networkMonitor: NetworkMonitor @@ -81,6 +92,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -100,6 +112,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -119,6 +132,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -138,6 +152,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -157,6 +172,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -176,6 +192,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -195,6 +212,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -214,6 +232,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -233,6 +252,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt index 5fc9d0525..200c963b7 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt @@ -42,6 +42,7 @@ import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme +import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.ui.NiaApp @@ -67,6 +68,9 @@ class MainActivity : ComponentActivity() { @Inject lateinit var analyticsHelper: AnalyticsHelper + @Inject + lateinit var userNewsResourceRepository: UserNewsResourceRepository + val viewModel: MainActivityViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -119,6 +123,7 @@ class MainActivity : ComponentActivity() { NiaApp( networkMonitor = networkMonitor, windowSizeClass = calculateWindowSizeClass(this), + userNewsResourceRepository = userNewsResourceRepository, ) } } diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index 9565af2f8..c8648b666 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -44,12 +44,15 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag 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.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy @@ -67,6 +70,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVec import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors +import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog import com.google.samples.apps.nowinandroid.navigation.NiaNavHost import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination @@ -85,6 +89,7 @@ fun NiaApp( networkMonitor = networkMonitor, windowSizeClass = windowSizeClass, ), + userNewsResourceRepository: UserNewsResourceRepository, ) { val shouldShowGradientBackground = appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU @@ -128,8 +133,17 @@ fun NiaApp( snackbarHost = { SnackbarHost(snackbarHostState) }, bottomBar = { if (appState.shouldShowBottomBar) { + val forYouNewsResources by userNewsResourceRepository.getUserNewsResourcesForFollowedTopics() + .collectAsStateWithLifecycle(emptyList()) + val unreadDestinations = + when { + forYouNewsResources.all { it.isViewed } -> emptySet() + else -> setOf(TopLevelDestination.FOR_YOU) + } + NiaBottomBar( destinations = appState.topLevelDestinations, + destinationsWithUnreadResources = unreadDestinations, onNavigateToDestination = appState::navigateToTopLevelDestination, currentDestination = appState.currentDestination, modifier = Modifier.testTag("NiaBottomBar"), @@ -211,6 +225,7 @@ private fun NiaNavRail( imageVector = icon.imageVector, contentDescription = null, ) + is DrawableResourceIcon -> Icon( painter = painterResource(id = icon.id), contentDescription = null, @@ -218,6 +233,7 @@ private fun NiaNavRail( } }, label = { Text(stringResource(destination.iconTextId)) }, + ) } } @@ -226,6 +242,7 @@ private fun NiaNavRail( @Composable private fun NiaBottomBar( destinations: List, + destinationsWithUnreadResources: Set, onNavigateToDestination: (TopLevelDestination) -> Unit, currentDestination: NavDestination?, modifier: Modifier = Modifier, @@ -234,6 +251,7 @@ private fun NiaBottomBar( modifier = modifier, ) { destinations.forEach { destination -> + val hasUnread = destinationsWithUnreadResources.contains(destination) val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) NiaNavigationBarItem( selected = selected, @@ -257,6 +275,25 @@ private fun NiaBottomBar( } }, label = { Text(stringResource(destination.iconTextId)) }, + modifier = if (hasUnread) { + val tertiaryColor = MaterialTheme.colorScheme.tertiary + Modifier.drawWithContent { + drawContent() + drawCircle( + tertiaryColor, + radius = 5.dp.toPx(), + // This is based on the dimensions of the NavigationBar's "indicator pill"; + // however, its parameters are private, so we must depend on them implicitly + // (NavigationBarTokens.ActiveIndicatorWidth = 64.dp) + center = center + Offset( + 64.dp.toPx() * .45f, + 32.dp.toPx() * -.45f - 6.dp.toPx(), + ), + ) + } + } else { + Modifier + }, ) } } diff --git a/core/ui/src/androidTest/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt b/core/ui/src/androidTest/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt index 712771422..8e2e8fb4a 100644 --- a/core/ui/src/androidTest/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt +++ b/core/ui/src/androidTest/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt @@ -20,6 +20,7 @@ import androidx.activity.ComponentActivity import androidx.compose.ui.test.assertContentDescriptionEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData @@ -39,6 +40,7 @@ class NewsResourceCardTest { NewsResourceCardExpanded( userNewsResource = newsWithKnownResourceType, isBookmarked = false, + isViewed = false, onToggleBookmark = {}, onClick = {}, onTopicClick = {}, @@ -67,6 +69,7 @@ class NewsResourceCardTest { NewsResourceCardExpanded( userNewsResource = newsWithUnknownResourceType, isBookmarked = false, + isViewed = false, onToggleBookmark = {}, onClick = {}, onTopicClick = {}, @@ -101,4 +104,52 @@ class NewsResourceCardTest { .assertContentDescriptionEquals(expectedContentDescription) } } + + @Test + fun testUnreadDot_displayedWhenUnread() { + val unreadNews = userNewsResourcesTestData[2] + + composeTestRule.setContent { + NewsResourceCardExpanded( + userNewsResource = unreadNews, + isBookmarked = false, + isViewed = false, + onToggleBookmark = {}, + onClick = {}, + onTopicClick = {}, + ) + } + + composeTestRule + .onNodeWithContentDescription( + composeTestRule.activity.getString( + R.string.unread_resource_dot_content_description, + ), + ) + .assertIsDisplayed() + } + + @Test + fun testUnreadDot_notDisplayedWhenRead() { + val readNews = userNewsResourcesTestData[0] + + composeTestRule.setContent { + NewsResourceCardExpanded( + userNewsResource = readNews, + isBookmarked = false, + isViewed = true, + onToggleBookmark = {}, + onClick = {}, + onTopicClick = {}, + ) + } + + composeTestRule + .onNodeWithContentDescription( + composeTestRule.activity.getString( + R.string.unread_resource_dot_content_description, + ), + ) + .assertDoesNotExist() + } } diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt index 3b0015bab..fb1fb56b7 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt @@ -48,6 +48,7 @@ import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource fun LazyGridScope.newsFeed( feedState: NewsFeedUiState, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, + onNewsResourcesViewedChanged: (String, Boolean) -> Unit, onTopicClick: (String) -> Unit, ) { when (feedState) { @@ -70,7 +71,9 @@ fun LazyGridScope.newsFeed( newsResourceTitle = userNewsResource.title, ) launchCustomChromeTab(context, resourceUrl, backgroundColor) + onNewsResourcesViewedChanged(userNewsResource.id, true) }, + isViewed = userNewsResource.isViewed, onToggleBookmark = { onNewsResourcesCheckedChanged( userNewsResource.id, @@ -122,6 +125,7 @@ private fun NewsFeedLoadingPreview() { newsFeed( feedState = NewsFeedUiState.Loading, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, onTopicClick = {}, ) } @@ -140,6 +144,7 @@ private fun NewsFeedContentPreview( newsFeed( feedState = NewsFeedUiState.Success(userNewsResources), onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, onTopicClick = {}, ) } diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt index cffa59436..67a41fece 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt @@ -16,6 +16,7 @@ package com.google.samples.apps.nowinandroid.core.ui +import androidx.compose.foundation.Canvas import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card @@ -40,7 +42,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode @@ -77,6 +81,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.R as DesignsystemR fun NewsResourceCardExpanded( userNewsResource: UserNewsResource, isBookmarked: Boolean, + isViewed: Boolean, onToggleBookmark: () -> Unit, onClick: () -> Unit, onTopicClick: (String) -> Unit, @@ -113,7 +118,16 @@ fun NewsResourceCardExpanded( BookmarkButton(isBookmarked, onToggleBookmark) } Spacer(modifier = Modifier.height(12.dp)) - NewsResourceMetaData(userNewsResource.publishDate, userNewsResource.type) + Row(verticalAlignment = Alignment.CenterVertically) { + if (!isViewed) { + Dot( + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.size(8.dp), + ) + Spacer(modifier = Modifier.size(6.dp)) + } + NewsResourceMetaData(userNewsResource.publishDate, userNewsResource.type) + } Spacer(modifier = Modifier.height(12.dp)) NewsResourceShortDescription(userNewsResource.content) Spacer(modifier = Modifier.height(12.dp)) @@ -181,6 +195,24 @@ fun BookmarkButton( ) } +@Composable +fun Dot( + color: Color, + modifier: Modifier = Modifier, +) { + val description = stringResource(R.string.unread_resource_dot_content_description) + Canvas( + modifier = modifier + .semantics { contentDescription = description }, + onDraw = { + drawCircle( + color, + radius = size.minDimension / 2, + ) + }, + ) +} + @Composable fun dateFormatted(publishDate: Instant): String { var zoneId by remember { mutableStateOf(ZoneId.systemDefault()) } @@ -301,6 +333,7 @@ private fun ExpandedNewsResourcePreview( NewsResourceCardExpanded( userNewsResource = userNewsResources[0], isBookmarked = true, + isViewed = false, onToggleBookmark = {}, onClick = {}, onTopicClick = {}, diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt index 0f6861fbc..6c971e7a2 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt @@ -37,6 +37,7 @@ import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource fun LazyListScope.userNewsResourceCardItems( items: List, onToggleBookmark: (item: UserNewsResource) -> Unit, + onNewsResourcesViewedChanged: (String, Boolean) -> Unit, onItemClick: ((item: UserNewsResource) -> Unit)? = null, onTopicClick: (String) -> Unit, itemModifier: Modifier = Modifier, @@ -52,6 +53,7 @@ fun LazyListScope.userNewsResourceCardItems( NewsResourceCardExpanded( userNewsResource = userNewsResource, isBookmarked = userNewsResource.isSaved, + isViewed = userNewsResource.isViewed, onToggleBookmark = { onToggleBookmark(userNewsResource) }, onClick = { analyticsHelper.logNewsResourceOpened( @@ -59,7 +61,10 @@ fun LazyListScope.userNewsResourceCardItems( newsResourceTitle = userNewsResource.title, ) when (onItemClick) { - null -> launchCustomChromeTab(context, resourceUrl, backgroundColor) + null -> { + launchCustomChromeTab(context, resourceUrl, backgroundColor) + onNewsResourcesViewedChanged(userNewsResource.id, true) + } else -> onItemClick(userNewsResource) } }, diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index bfb1d38de..d21a5ea36 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -19,6 +19,8 @@ Unbookmark Back + Unread + Open Resource Link %1$s • %2$s diff --git a/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt b/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt index 3662bd47f..c5ddd5c10 100644 --- a/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt +++ b/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt @@ -52,6 +52,7 @@ class BookmarksScreenTest { feedState = NewsFeedUiState.Loading, removeFromBookmarks = {}, onTopicClick = {}, + onNewsResourcesViewedChanged = { _, _ -> }, ) } @@ -71,6 +72,7 @@ class BookmarksScreenTest { ), removeFromBookmarks = {}, onTopicClick = {}, + onNewsResourcesViewedChanged = { _, _ -> }, ) } @@ -113,6 +115,7 @@ class BookmarksScreenTest { removeFromBookmarksCalled = true }, onTopicClick = {}, + onNewsResourcesViewedChanged = { _, _ -> }, ) } @@ -143,6 +146,7 @@ class BookmarksScreenTest { feedState = NewsFeedUiState.Success(emptyList()), removeFromBookmarks = {}, onTopicClick = {}, + onNewsResourcesViewedChanged = { _, _ -> }, ) } diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt index 3e0bb5784..b39f189d1 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt @@ -73,6 +73,7 @@ internal fun BookmarksRoute( BookmarksScreen( feedState = feedState, removeFromBookmarks = viewModel::removeFromSavedResources, + onNewsResourcesViewedChanged = viewModel::updateNewsResourceViewed, onTopicClick = onTopicClick, modifier = modifier, ) @@ -86,13 +87,14 @@ internal fun BookmarksRoute( internal fun BookmarksScreen( feedState: NewsFeedUiState, removeFromBookmarks: (String) -> Unit, + onNewsResourcesViewedChanged: (String, Boolean) -> Unit, onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, ) { when (feedState) { Loading -> LoadingState(modifier) is Success -> if (feedState.feed.isNotEmpty()) { - BookmarksGrid(feedState, removeFromBookmarks, onTopicClick, modifier) + BookmarksGrid(feedState, removeFromBookmarks, onNewsResourcesViewedChanged, onTopicClick, modifier) } else { EmptyState(modifier) } @@ -115,6 +117,7 @@ private fun LoadingState(modifier: Modifier = Modifier) { private fun BookmarksGrid( feedState: NewsFeedUiState, removeFromBookmarks: (String) -> Unit, + onNewsResourcesViewedChanged: (String, Boolean) -> Unit, onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, ) { @@ -133,6 +136,7 @@ private fun BookmarksGrid( newsFeed( feedState = feedState, onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) }, + onNewsResourcesViewedChanged = onNewsResourcesViewedChanged, onTopicClick = onTopicClick, ) item(span = { GridItemSpan(maxLineSpan) }) { @@ -198,6 +202,7 @@ private fun BookmarksGridPreview( BookmarksGrid( feedState = Success(userNewsResources), removeFromBookmarks = {}, + onNewsResourcesViewedChanged = { _, _ -> }, onTopicClick = {}, ) } diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt index 91d9355ae..7d0003aed 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt @@ -55,4 +55,10 @@ class BookmarksViewModel @Inject constructor( userDataRepository.updateNewsResourceBookmark(newsResourceId, false) } } + + fun updateNewsResourceViewed(newsResourceId: String, isViewed: Boolean) { + viewModelScope.launch { + userDataRepository.updateNewsResourceViewed(newsResourceId, isViewed) + } + } } diff --git a/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt b/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt index ab712cbb5..a3566fc31 100644 --- a/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt +++ b/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt @@ -56,6 +56,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, ) } } @@ -79,6 +80,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, ) } } @@ -108,6 +110,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, ) } } @@ -152,6 +155,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, ) } } @@ -189,6 +193,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, ) } } @@ -212,6 +217,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, ) } } @@ -236,6 +242,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, ) } diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index aa4dc5f26..44a323868 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -107,6 +107,7 @@ internal fun ForYouRoute( onTopicClick = onTopicClick, saveFollowedTopics = viewModel::dismissOnboarding, onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved, + onNewsResourcesViewedChanged = viewModel::updateNewsResourceViewed, modifier = modifier, ) } @@ -120,6 +121,7 @@ internal fun ForYouScreen( onTopicClick: (String) -> Unit, saveFollowedTopics: () -> Unit, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, + onNewsResourcesViewedChanged: (String, Boolean) -> Unit, modifier: Modifier = Modifier, ) { val isOnboardingLoading = onboardingUiState is OnboardingUiState.Loading @@ -177,6 +179,7 @@ internal fun ForYouScreen( newsFeed( feedState = feedState, onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, + onNewsResourcesViewedChanged = onNewsResourcesViewedChanged, onTopicClick = onTopicClick, ) @@ -413,6 +416,7 @@ fun ForYouScreenPopulatedFeed( onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, onTopicClick = {}, ) } @@ -436,6 +440,7 @@ fun ForYouScreenOfflinePopulatedFeed( onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, onTopicClick = {}, ) } @@ -461,6 +466,7 @@ fun ForYouScreenTopicSelection( onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, onTopicClick = {}, ) } @@ -479,6 +485,7 @@ fun ForYouScreenLoading() { onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, onTopicClick = {}, ) } @@ -502,6 +509,7 @@ fun ForYouScreenPopulatedAndLoading( onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, onTopicClick = {}, ) } diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt index cece3a6c3..363356aef 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt @@ -95,6 +95,12 @@ class ForYouViewModel @Inject constructor( } } + fun updateNewsResourceViewed(newsResourceId: String, isViewed: Boolean) { + viewModelScope.launch { + userDataRepository.updateNewsResourceViewed(newsResourceId, isViewed) + } + } + fun dismissOnboarding() { viewModelScope.launch { userDataRepository.setShouldHideOnboarding(true) diff --git a/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt b/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt index 3a267d7e7..65d923442 100644 --- a/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt +++ b/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt @@ -59,6 +59,7 @@ class TopicScreenTest { onFollowClick = {}, onTopicClick = {}, onBookmarkChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, ) } @@ -78,6 +79,7 @@ class TopicScreenTest { onFollowClick = {}, onTopicClick = {}, onBookmarkChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, ) } @@ -102,6 +104,7 @@ class TopicScreenTest { onFollowClick = {}, onTopicClick = {}, onBookmarkChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, ) } @@ -124,6 +127,7 @@ class TopicScreenTest { onFollowClick = {}, onTopicClick = {}, onBookmarkChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, ) } diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt index da6981010..4fc9faaca 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt @@ -79,6 +79,7 @@ internal fun TopicRoute( onBackClick = onBackClick, onFollowClick = viewModel::followTopicToggle, onBookmarkChanged = viewModel::bookmarkNews, + onNewsResourcesViewedChanged = viewModel::updateNewsResourceViewed, onTopicClick = onTopicClick, ) } @@ -92,6 +93,7 @@ internal fun TopicScreen( onFollowClick: (Boolean) -> Unit, onTopicClick: (String) -> Unit, onBookmarkChanged: (String, Boolean) -> Unit, + onNewsResourcesViewedChanged: (String, Boolean) -> Unit, modifier: Modifier = Modifier, ) { val state = rememberLazyListState() @@ -127,6 +129,7 @@ internal fun TopicScreen( news = newsUiState, imageUrl = topicUiState.followableTopic.topic.imageUrl, onBookmarkChanged = onBookmarkChanged, + onNewsResourcesViewedChanged = onNewsResourcesViewedChanged, onTopicClick = onTopicClick, ) } @@ -143,6 +146,7 @@ private fun LazyListScope.TopicBody( news: NewsUiState, imageUrl: String, onBookmarkChanged: (String, Boolean) -> Unit, + onNewsResourcesViewedChanged: (String, Boolean) -> Unit, onTopicClick: (String) -> Unit, ) { // TODO: Show icon if available @@ -150,7 +154,7 @@ private fun LazyListScope.TopicBody( TopicHeader(name, description, imageUrl) } - userNewsResourceCards(news, onBookmarkChanged, onTopicClick) + userNewsResourceCards(news, onBookmarkChanged, onNewsResourcesViewedChanged, onTopicClick) } @Composable @@ -181,6 +185,7 @@ private fun TopicHeader(name: String, description: String, imageUrl: String) { private fun LazyListScope.userNewsResourceCards( news: NewsUiState, onBookmarkChanged: (String, Boolean) -> Unit, + onNewsResourcesViewedChanged: (String, Boolean) -> Unit, onTopicClick: (String) -> Unit, ) { when (news) { @@ -188,6 +193,7 @@ private fun LazyListScope.userNewsResourceCards( userNewsResourceCardItems( items = news.news, onToggleBookmark = { onBookmarkChanged(it.id, !it.isSaved) }, + onNewsResourcesViewedChanged = onNewsResourcesViewedChanged, onTopicClick = onTopicClick, itemModifier = Modifier.padding(24.dp), ) @@ -214,6 +220,7 @@ private fun TopicBodyPreview() { news = NewsUiState.Success(emptyList()), imageUrl = "", onBookmarkChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, onTopicClick = {}, ) } @@ -271,6 +278,7 @@ fun TopicScreenPopulated( onBackClick = {}, onFollowClick = {}, onBookmarkChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, onTopicClick = {}, ) } @@ -288,6 +296,7 @@ fun TopicScreenLoading() { onBackClick = {}, onFollowClick = {}, onBookmarkChanged = { _, _ -> }, + onNewsResourcesViewedChanged = { _, _ -> }, onTopicClick = {}, ) } diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt index bb03f9ae6..4dac25983 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt @@ -86,6 +86,12 @@ class TopicViewModel @Inject constructor( userDataRepository.updateNewsResourceBookmark(newsResourceId, bookmarked) } } + + fun updateNewsResourceViewed(newsResourceId: String, isViewed: Boolean) { + viewModelScope.launch { + userDataRepository.updateNewsResourceViewed(newsResourceId, isViewed) + } + } } private fun topicUiState( From 8ec54d19d8fe55ce4133516f476a8ebc8086873f Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sun, 12 Mar 2023 11:47:22 +0100 Subject: [PATCH 51/97] Sort and group Gradle dependencies thanks to square/gradle-dependencies-sorter --- app-nia-catalog/build.gradle.kts | 5 ++--- benchmarks/build.gradle.kts | 4 ++-- build-logic/convention/build.gradle.kts | 4 ++-- core/analytics/build.gradle.kts | 5 ++--- core/data/build.gradle.kts | 14 ++++++------- core/datastore-test/build.gradle.kts | 4 ++-- core/datastore/build.gradle.kts | 10 ++++----- core/designsystem/build.gradle.kts | 14 ++++++++----- core/domain/build.gradle.kts | 8 +++---- core/network/build.gradle.kts | 11 ++++------ core/testing/build.gradle.kts | 24 ++++++++++----------- core/ui/build.gradle.kts | 28 ++++++++++++------------- feature/foryou/build.gradle.kts | 3 +-- sync/work/build.gradle.kts | 16 +++++++------- 14 files changed, 69 insertions(+), 81 deletions(-) diff --git a/app-nia-catalog/build.gradle.kts b/app-nia-catalog/build.gradle.kts index 8232350d9..bf0695fd3 100644 --- a/app-nia-catalog/build.gradle.kts +++ b/app-nia-catalog/build.gradle.kts @@ -65,9 +65,8 @@ android { } dependencies { - implementation(project(":core:ui")) implementation(project(":core:designsystem")) - - implementation(libs.androidx.activity.compose) + implementation(project(":core:ui")) implementation(libs.accompanist.flowlayout) + implementation(libs.androidx.activity.compose) } diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index 9af89d98d..fa8aeefb0 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -68,13 +68,13 @@ android { } dependencies { + implementation(libs.androidx.benchmark.macro) implementation(libs.androidx.test.core) implementation(libs.androidx.test.espresso.core) implementation(libs.androidx.test.ext) - implementation(libs.androidx.test.runner) implementation(libs.androidx.test.rules) + implementation(libs.androidx.test.runner) implementation(libs.androidx.test.uiautomator) - implementation(libs.androidx.benchmark.macro) } androidComponents { diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 0b929d4f7..281434b87 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -27,9 +27,9 @@ java { dependencies { compileOnly(libs.android.gradlePlugin) - compileOnly(libs.kotlin.gradlePlugin) - compileOnly(libs.firebase.performance.gradle) compileOnly(libs.firebase.crashlytics.gradle) + compileOnly(libs.firebase.performance.gradle) + compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.ksp.gradlePlugin) } diff --git a/core/analytics/build.gradle.kts b/core/analytics/build.gradle.kts index e42499769..8c573b854 100644 --- a/core/analytics/build.gradle.kts +++ b/core/analytics/build.gradle.kts @@ -24,10 +24,9 @@ android { } dependencies { - implementation(libs.kotlinx.coroutines.android) + implementation(platform(libs.firebase.bom)) implementation(libs.androidx.compose.runtime) implementation(libs.androidx.core.ktx) - - implementation(platform(libs.firebase.bom)) implementation(libs.firebase.analytics) + implementation(libs.kotlinx.coroutines.android) } diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 5b468c43e..e4b265649 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -30,19 +30,17 @@ android { } dependencies { + implementation(project(":core:analytics")) implementation(project(":core:common")) - implementation(project(":core:model")) implementation(project(":core:database")) implementation(project(":core:datastore")) + implementation(project(":core:model")) implementation(project(":core:network")) - implementation(project(":core:analytics")) - - testImplementation(project(":core:testing")) - testImplementation(project(":core:datastore-test")) - implementation(libs.androidx.core.ktx) - - implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) + + testImplementation(project(":core:datastore-test")) + testImplementation(project(":core:testing")) } \ No newline at end of file diff --git a/core/datastore-test/build.gradle.kts b/core/datastore-test/build.gradle.kts index d8223c3f3..c7c423c25 100644 --- a/core/datastore-test/build.gradle.kts +++ b/core/datastore-test/build.gradle.kts @@ -24,8 +24,8 @@ android { dependencies { api(project(":core:datastore")) + api(libs.androidx.dataStore.core) + implementation(project(":core:common")) implementation(project(":core:testing")) - - api(libs.androidx.dataStore.core) } diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 8f3d7ece6..f2c6c1892 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -57,12 +57,10 @@ protobuf { dependencies { implementation(project(":core:common")) implementation(project(":core:model")) - - testImplementation(project(":core:testing")) - testImplementation(project(":core:datastore-test")) - - implementation(libs.kotlinx.coroutines.android) - implementation(libs.androidx.dataStore.core) + implementation(libs.kotlinx.coroutines.android) implementation(libs.protobuf.kotlin.lite) + + testImplementation(project(":core:datastore-test")) + testImplementation(project(":core:testing")) } diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts index 1bcc9d65c..a40926383 100644 --- a/core/designsystem/build.gradle.kts +++ b/core/designsystem/build.gradle.kts @@ -30,16 +30,20 @@ android { } dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.coil.kt.compose) + lintPublish(project(":lint")) + api(libs.androidx.compose.foundation) api(libs.androidx.compose.foundation.layout) api(libs.androidx.compose.material.iconsExtended) api(libs.androidx.compose.material3) - debugApi(libs.androidx.compose.ui.tooling) + api(libs.androidx.compose.runtime) api(libs.androidx.compose.ui.tooling.preview) api(libs.androidx.compose.ui.util) - api(libs.androidx.compose.runtime) - lintPublish(project(":lint")) + + debugApi(libs.androidx.compose.ui.tooling) + + implementation(libs.androidx.core.ktx) + implementation(libs.coil.kt.compose) + androidTestImplementation(project(":core:testing")) } diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index 8483d890c..0e3949aa3 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -24,15 +24,13 @@ android { } dependencies { - implementation(project(":core:data")) implementation(project(":core:model")) - - testImplementation(project(":core:testing")) - + implementation(libs.hilt.android) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.datetime) - implementation(libs.hilt.android) kapt(libs.hilt.compiler) + + testImplementation(project(":core:testing")) } \ No newline at end of file diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 97a68b3a5..633e2573d 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -41,17 +41,14 @@ secrets { dependencies { implementation(project(":core:common")) implementation(project(":core:model")) - - testImplementation(project(":core:testing")) - + implementation(libs.coil.kt) + implementation(libs.coil.kt.svg) implementation(libs.kotlinx.coroutines.android) - implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.datetime) - + implementation(libs.kotlinx.serialization.json) implementation(libs.okhttp.logging) implementation(libs.retrofit.core) implementation(libs.retrofit.kotlin.serialization) - implementation(libs.coil.kt) - implementation(libs.coil.kt.svg) + testImplementation(project(":core:testing")) } diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index 5e0c3e409..4e87bb039 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -24,23 +24,21 @@ android { } dependencies { - implementation(project(":core:common")) - implementation(project(":core:data")) - implementation(project(":core:domain")) - implementation(project(":core:model")) - - implementation(libs.kotlinx.datetime) - - api(libs.junit4) + api(libs.androidx.compose.ui.test) api(libs.androidx.test.core) - api(libs.kotlinx.coroutines.test) - api(libs.turbine) - api(libs.androidx.test.espresso.core) - api(libs.androidx.test.runner) api(libs.androidx.test.rules) - api(libs.androidx.compose.ui.test) + api(libs.androidx.test.runner) api(libs.hilt.android.testing) + api(libs.junit4) + api(libs.kotlinx.coroutines.test) + api(libs.turbine) debugApi(libs.androidx.compose.ui.testManifest) + + implementation(project(":core:common")) + implementation(project(":core:data")) + implementation(project(":core:domain")) + implementation(project(":core:model")) + implementation(libs.kotlinx.datetime) } diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 0438b8f36..b7280e757 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -27,28 +27,28 @@ android { } dependencies { - implementation(project(":core:designsystem")) - implementation(project(":core:model")) - implementation(project(":core:domain")) - implementation(project(":core:analytics")) - - implementation(libs.androidx.browser) - implementation(libs.androidx.core.ktx) - implementation(libs.coil.kt) - implementation(libs.coil.kt.compose) - implementation(libs.kotlinx.datetime) - api(libs.androidx.compose.foundation) api(libs.androidx.compose.foundation.layout) api(libs.androidx.compose.material.iconsExtended) api(libs.androidx.compose.material3) - debugApi(libs.androidx.compose.ui.tooling) - api(libs.androidx.compose.ui.tooling.preview) - api(libs.androidx.compose.ui.util) api(libs.androidx.compose.runtime) api(libs.androidx.compose.runtime.livedata) + api(libs.androidx.compose.ui.tooling.preview) + api(libs.androidx.compose.ui.util) api(libs.androidx.metrics) api(libs.androidx.tracing.ktx) + debugApi(libs.androidx.compose.ui.tooling) + + implementation(project(":core:analytics")) + implementation(project(":core:designsystem")) + implementation(project(":core:domain")) + implementation(project(":core:model")) + implementation(libs.androidx.browser) + implementation(libs.androidx.core.ktx) + implementation(libs.coil.kt) + implementation(libs.coil.kt.compose) + implementation(libs.kotlinx.datetime) + androidTestImplementation(project(":core:testing")) } \ No newline at end of file diff --git a/feature/foryou/build.gradle.kts b/feature/foryou/build.gradle.kts index ed7be27dc..4fa032a88 100644 --- a/feature/foryou/build.gradle.kts +++ b/feature/foryou/build.gradle.kts @@ -27,7 +27,6 @@ android { } dependencies { - implementation(libs.kotlinx.datetime) - implementation(libs.accompanist.flowlayout) + implementation(libs.kotlinx.datetime) } diff --git a/sync/work/build.gradle.kts b/sync/work/build.gradle.kts index a3b589db3..b5b3bdb68 100644 --- a/sync/work/build.gradle.kts +++ b/sync/work/build.gradle.kts @@ -27,24 +27,22 @@ android { } dependencies { + implementation(project(":core:analytics")) implementation(project(":core:common")) - implementation(project(":core:model")) implementation(project(":core:data")) implementation(project(":core:datastore")) - implementation(project(":core:analytics")) - - implementation(libs.kotlinx.coroutines.android) - + implementation(project(":core:model")) implementation(libs.androidx.lifecycle.livedata.ktx) - implementation(libs.androidx.tracing.ktx) implementation(libs.androidx.startup) + implementation(libs.androidx.tracing.ktx) implementation(libs.androidx.work.ktx) implementation(libs.hilt.ext.work) - - testImplementation(project(":core:testing")) - androidTestImplementation(project(":core:testing")) + implementation(libs.kotlinx.coroutines.android) kapt(libs.hilt.ext.compiler) + testImplementation(project(":core:testing")) + + androidTestImplementation(project(":core:testing")) androidTestImplementation(libs.androidx.work.testing) } From 57c13d84bdffd5a983ba67b01497417ca2de946d Mon Sep 17 00:00:00 2001 From: James Rose Date: Fri, 3 Mar 2023 10:50:34 -0800 Subject: [PATCH 52/97] Incorporate code review changes: Move UserNewsResourceRepository to data module; move UserNewsResource to model module. Implement unread dot for bookmarked articles. Keep the flows cold in UserNewsResourceRepository. --- app/build.gradle.kts | 2 - .../apps/nowinandroid/ui/NavigationUiTest.kt | 5 +- .../apps/nowinandroid/ui/NiaAppStateTest.kt | 32 +++++--- .../samples/apps/nowinandroid/MainActivity.kt | 2 +- .../samples/apps/nowinandroid/ui/NiaApp.kt | 53 ++++++------- .../apps/nowinandroid/ui/NiaAppState.kt | 37 +++++++++- .../di/UserNewsResourceRepositoryModule.kt | 6 +- .../CompositeUserNewsResourceRepository.kt | 69 +++++++++++++++++ .../OfflineFirstUserDataRepository.kt | 4 +- .../data/repository/UserDataRepository.kt | 2 +- .../repository/UserNewsResourceRepository.kt | 10 ++- .../repository/fake/FakeUserDataRepository.kt | 4 +- ...CompositeUserNewsResourceRepositoryTest.kt | 40 ++++++++-- .../core/data}/UserNewsResourceTest.kt | 6 +- .../OfflineFirstUserDataRepositoryTest.kt | 4 +- .../datastore/NiaPreferencesDataSource.kt | 2 +- .../core/domain/GetFollowableTopicsUseCase.kt | 2 +- .../core/domain/di/CoroutineScopesModule.kt | 42 ----------- .../CompositeUserNewsResourceRepository.kt | 74 ------------------- .../domain/GetFollowableTopicsUseCaseTest.kt | 2 +- .../core/model/data}/FollowableTopic.kt | 6 +- .../core/model/data}/UserNewsResource.kt | 11 +-- .../testing/data/FollowableTopicTestData.kt | 2 +- .../testing/data/UserNewsResourcesTestData.kt | 14 ++-- .../repository/TestUserDataRepository.kt | 2 +- .../core/ui/NewsResourceCardTest.kt | 8 +- ...FollowableTopicPreviewParameterProvider.kt | 2 +- .../apps/nowinandroid/core/ui/NewsFeed.kt | 12 +-- .../nowinandroid/core/ui/NewsResourceCard.kt | 14 ++-- .../core/ui/NewsResourceCardList.kt | 12 ++- ...serNewsResourcePreviewParameterProvider.kt | 2 +- .../feature/bookmarks/BookmarksScreenTest.kt | 8 +- .../feature/bookmarks/BookmarksScreen.kt | 14 ++-- .../feature/bookmarks/BookmarksViewModel.kt | 28 ++++--- .../bookmarks/BookmarksViewModelTest.kt | 4 +- .../feature/foryou/ForYouScreenTest.kt | 14 ++-- .../feature/foryou/ForYouScreen.kt | 18 ++--- .../feature/foryou/ForYouViewModel.kt | 58 +-------------- .../feature/foryou/OnboardingUiState.kt | 2 +- .../feature/foryou/ForYouViewModelTest.kt | 10 +-- .../feature/interests/InterestsScreen.kt | 2 +- .../feature/interests/InterestsViewModel.kt | 2 +- .../feature/interests/TabContent.kt | 2 +- .../interests/InterestsViewModelTest.kt | 2 +- .../feature/topic/TopicScreenTest.kt | 8 +- .../nowinandroid/feature/topic/TopicScreen.kt | 24 +++--- .../feature/topic/TopicViewModel.kt | 10 +-- .../feature/topic/TopicViewModelTest.kt | 6 +- 48 files changed, 325 insertions(+), 370 deletions(-) rename core/{domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain => data/src/main/java/com/google/samples/apps/nowinandroid/core/data}/di/UserNewsResourceRepositoryModule.kt (79%) create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/CompositeUserNewsResourceRepository.kt rename core/{domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain => data/src/main/java/com/google/samples/apps/nowinandroid/core/data}/repository/UserNewsResourceRepository.kt (83%) rename core/{domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain => data/src/test/java/com/google/samples/apps/nowinandroid/core/data}/CompositeUserNewsResourceRepositoryTest.kt (81%) rename core/{domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain => data/src/test/java/com/google/samples/apps/nowinandroid/core/data}/UserNewsResourceTest.kt (95%) delete mode 100644 core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/CoroutineScopesModule.kt delete mode 100644 core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/CompositeUserNewsResourceRepository.kt rename core/{domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model => model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data}/FollowableTopic.kt (81%) rename core/{domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model => model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data}/UserNewsResource.kt (81%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8197ad57b..81c128b91 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -86,11 +86,9 @@ dependencies { implementation(project(":feature:settings")) implementation(project(":core:common")) - implementation(project(":core:domain")) implementation(project(":core:ui")) implementation(project(":core:designsystem")) implementation(project(":core:data")) - implementation(project(":core:domain")) implementation(project(":core:model")) implementation(project(":core:analytics")) diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt index a0a737237..cd4b40a50 100644 --- a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationUiTest.kt @@ -25,16 +25,14 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import com.google.accompanist.testharness.TestHarness +import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor -import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository 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.uitesthiltmanifest.HiltComponentActivity import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher import org.junit.Before import org.junit.Rule import org.junit.Test @@ -69,7 +67,6 @@ class NavigationUiTest { val composeTestRule = createAndroidComposeRule() val userNewsResourceRepository = CompositeUserNewsResourceRepository( - coroutineScope = TestScope(UnconfinedTestDispatcher()), newsRepository = TestNewsRepository(), userDataRepository = TestUserDataRepository(), ) diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt index 64896a544..2457af900 100644 --- a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NiaAppStateTest.kt @@ -30,6 +30,9 @@ import androidx.navigation.compose.ComposeNavigator import androidx.navigation.compose.composable import androidx.navigation.createGraph import androidx.navigation.testing.TestNavHostController +import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository +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.TestNetworkMonitor import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch @@ -56,6 +59,9 @@ class NiaAppStateTest { // Create the test dependencies. private val networkMonitor = TestNetworkMonitor() + private val userNewsResourceRepository = + CompositeUserNewsResourceRepository(TestNewsRepository(), TestUserDataRepository()) + // Subject under test. private lateinit var state: NiaAppState @@ -67,10 +73,11 @@ class NiaAppStateTest { val navController = rememberTestNavController() state = remember(navController) { NiaAppState( - windowSizeClass = getCompactWindowClass(), navController = navController, - networkMonitor = networkMonitor, coroutineScope = backgroundScope, + windowSizeClass = getCompactWindowClass(), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } @@ -92,6 +99,7 @@ class NiaAppStateTest { state = rememberNiaAppState( windowSizeClass = getCompactWindowClass(), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } @@ -105,10 +113,11 @@ class NiaAppStateTest { fun niaAppState_showBottomBar_compact() = runTest { composeTestRule.setContent { state = NiaAppState( - windowSizeClass = getCompactWindowClass(), navController = NavHostController(LocalContext.current), - networkMonitor = networkMonitor, coroutineScope = backgroundScope, + windowSizeClass = getCompactWindowClass(), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } @@ -120,10 +129,11 @@ class NiaAppStateTest { fun niaAppState_showNavRail_medium() = runTest { composeTestRule.setContent { state = NiaAppState( - windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)), navController = NavHostController(LocalContext.current), - networkMonitor = networkMonitor, coroutineScope = backgroundScope, + windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(800.dp, 800.dp)), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } @@ -135,10 +145,11 @@ class NiaAppStateTest { fun niaAppState_showNavRail_large() = runTest { composeTestRule.setContent { state = NiaAppState( - windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)), navController = NavHostController(LocalContext.current), - networkMonitor = networkMonitor, coroutineScope = backgroundScope, + windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } @@ -150,10 +161,11 @@ class NiaAppStateTest { fun stateIsOfflineWhenNetworkMonitorIsOffline() = runTest(UnconfinedTestDispatcher()) { composeTestRule.setContent { state = NiaAppState( - windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)), navController = NavHostController(LocalContext.current), - networkMonitor = networkMonitor, coroutineScope = backgroundScope, + windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(900.dp, 1200.dp)), + networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt index 200c963b7..79d556f73 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/MainActivity.kt @@ -40,9 +40,9 @@ import com.google.samples.apps.nowinandroid.MainActivityUiState.Loading import com.google.samples.apps.nowinandroid.MainActivityUiState.Success import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper +import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme -import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.ui.NiaApp diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index c8648b666..780849cf2 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -57,6 +57,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy import com.google.samples.apps.nowinandroid.R +import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaGradientBackground @@ -70,7 +71,6 @@ import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVec import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors -import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.feature.settings.SettingsDialog import com.google.samples.apps.nowinandroid.navigation.NiaNavHost import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination @@ -85,11 +85,12 @@ import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR fun NiaApp( windowSizeClass: WindowSizeClass, networkMonitor: NetworkMonitor, + userNewsResourceRepository: UserNewsResourceRepository, appState: NiaAppState = rememberNiaAppState( networkMonitor = networkMonitor, windowSizeClass = windowSizeClass, + userNewsResourceRepository = userNewsResourceRepository, ), - userNewsResourceRepository: UserNewsResourceRepository, ) { val shouldShowGradientBackground = appState.currentTopLevelDestination == TopLevelDestination.FOR_YOU @@ -133,14 +134,7 @@ fun NiaApp( snackbarHost = { SnackbarHost(snackbarHostState) }, bottomBar = { if (appState.shouldShowBottomBar) { - val forYouNewsResources by userNewsResourceRepository.getUserNewsResourcesForFollowedTopics() - .collectAsStateWithLifecycle(emptyList()) - val unreadDestinations = - when { - forYouNewsResources.all { it.isViewed } -> emptySet() - else -> setOf(TopLevelDestination.FOR_YOU) - } - + val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle() NiaBottomBar( destinations = appState.topLevelDestinations, destinationsWithUnreadResources = unreadDestinations, @@ -275,30 +269,31 @@ private fun NiaBottomBar( } }, label = { Text(stringResource(destination.iconTextId)) }, - modifier = if (hasUnread) { - val tertiaryColor = MaterialTheme.colorScheme.tertiary - Modifier.drawWithContent { - drawContent() - drawCircle( - tertiaryColor, - radius = 5.dp.toPx(), - // This is based on the dimensions of the NavigationBar's "indicator pill"; - // however, its parameters are private, so we must depend on them implicitly - // (NavigationBarTokens.ActiveIndicatorWidth = 64.dp) - center = center + Offset( - 64.dp.toPx() * .45f, - 32.dp.toPx() * -.45f - 6.dp.toPx(), - ), - ) - } - } else { - Modifier - }, + modifier = if (hasUnread) notificationDot() else Modifier, ) } } } +@Composable +private fun notificationDot(): Modifier { + val tertiaryColor = MaterialTheme.colorScheme.tertiary + return Modifier.drawWithContent { + drawContent() + drawCircle( + tertiaryColor, + radius = 5.dp.toPx(), + // This is based on the dimensions of the NavigationBar's "indicator pill"; + // however, its parameters are private, so we must depend on them implicitly + // (NavigationBarTokens.ActiveIndicatorWidth = 64.dp) + center = center + Offset( + 64.dp.toPx() * .45f, + 32.dp.toPx() * -.45f - 6.dp.toPx(), + ), + ) + } +} + private fun NavDestination?.isTopLevelDestinationInHierarchy(destination: TopLevelDestination) = this?.hierarchy?.any { it.route?.contains(destination.name, true) ?: false diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt index 7f655af21..e472ee2af 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -33,6 +33,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions import androidx.tracing.trace +import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.ui.TrackDisposableJank import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksRoute @@ -47,6 +48,8 @@ import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_Y import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -54,12 +57,25 @@ import kotlinx.coroutines.flow.stateIn fun rememberNiaAppState( windowSizeClass: WindowSizeClass, networkMonitor: NetworkMonitor, + userNewsResourceRepository: UserNewsResourceRepository, coroutineScope: CoroutineScope = rememberCoroutineScope(), navController: NavHostController = rememberNavController(), ): NiaAppState { NavigationTrackingSideEffect(navController) - return remember(navController, coroutineScope, windowSizeClass, networkMonitor) { - NiaAppState(navController, coroutineScope, windowSizeClass, networkMonitor) + return remember( + navController, + coroutineScope, + windowSizeClass, + networkMonitor, + userNewsResourceRepository, + ) { + NiaAppState( + navController, + coroutineScope, + windowSizeClass, + networkMonitor, + userNewsResourceRepository, + ) } } @@ -69,6 +85,7 @@ class NiaAppState( val coroutineScope: CoroutineScope, val windowSizeClass: WindowSizeClass, networkMonitor: NetworkMonitor, + userNewsResourceRepository: UserNewsResourceRepository, ) { val currentDestination: NavDestination? @Composable get() = navController @@ -105,6 +122,22 @@ class NiaAppState( */ val topLevelDestinations: List = TopLevelDestination.values().asList() + /** + * The top level destinations that have unread news resources. + */ + val topLevelDestinationsWithUnreadResources: StateFlow> = + userNewsResourceRepository.getUserNewsResourcesForFollowedTopics() + .combine(userNewsResourceRepository.getBookmarkedUserNewsResources()) { forYouNewsResources, bookmarkedNewsResources -> + setOfNotNull( + FOR_YOU.takeIf { forYouNewsResources.any { !it.hasBeenViewed } }, + BOOKMARKS.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } }, + ) + }.stateIn( + coroutineScope, + SharingStarted.WhileSubscribed(5_000), + initialValue = emptySet(), + ) + /** * UI logic for navigating to a top level destination in the app. Top level destinations have * only one copy of the destination of the back stack, and save and restore state whenever you diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/UserNewsResourceRepositoryModule.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/UserNewsResourceRepositoryModule.kt similarity index 79% rename from core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/UserNewsResourceRepositoryModule.kt rename to core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/UserNewsResourceRepositoryModule.kt index 0dd83a852..1a7a80fff 100644 --- a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/UserNewsResourceRepositoryModule.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/UserNewsResourceRepositoryModule.kt @@ -14,10 +14,10 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.domain.di +package com.google.samples.apps.nowinandroid.core.data.di -import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository -import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository +import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository +import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/CompositeUserNewsResourceRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/CompositeUserNewsResourceRepository.kt new file mode 100644 index 000000000..dc9ad299f --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/CompositeUserNewsResourceRepository.kt @@ -0,0 +1,69 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.repository + +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +/** + * Implements a [UserNewsResourceRepository] by combining a [NewsRepository] with a + * [UserDataRepository]. + */ +class CompositeUserNewsResourceRepository @Inject constructor( + val newsRepository: NewsRepository, + val userDataRepository: UserDataRepository, +) : UserNewsResourceRepository { + + /** + * Returns available news resources (joined with user data) matching the given query. + */ + override fun getUserNewsResources( + query: NewsResourceQuery, + ): Flow> = + newsRepository.getNewsResources(query) + .combine(userDataRepository.userData) { newsResources, userData -> + newsResources.mapToUserNewsResources(userData) + } + + /** + * Returns available news resources (joined with user data) for the followed topics. + */ + override fun getUserNewsResourcesForFollowedTopics(): Flow> = + userDataRepository.userData.map { it.followedTopics }.distinctUntilChanged() + .flatMapLatest { followedTopics -> + when { + followedTopics.isEmpty() -> flowOf(emptyList()) + else -> getUserNewsResources(NewsResourceQuery(filterTopicIds = followedTopics)) + } + } + + override fun getBookmarkedUserNewsResources(): Flow> = + userDataRepository.userData.map { it.bookmarkedNewsResources }.distinctUntilChanged() + .flatMapLatest { bookmarkedNewsResources -> + when { + bookmarkedNewsResources.isEmpty() -> flowOf(emptyList()) + else -> getUserNewsResources(NewsResourceQuery(filterNewsIds = bookmarkedNewsResources)) + } + } +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt index f10046f73..2559362ba 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepository.kt @@ -50,8 +50,8 @@ class OfflineFirstUserDataRepository @Inject constructor( ) } - override suspend fun updateNewsResourceViewed(newsResourceId: String, viewed: Boolean) = - niaPreferencesDataSource.toggleNewsResourceViewed(newsResourceId, viewed) + override suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) = + niaPreferencesDataSource.setNewsResourceViewed(newsResourceId, viewed) override suspend fun setThemeBrand(themeBrand: ThemeBrand) { niaPreferencesDataSource.setThemeBrand(themeBrand) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt index 2ce84a963..5e0e7ebfc 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt @@ -46,7 +46,7 @@ interface UserDataRepository { /** * Updates the viewed status for a news resource */ - suspend fun updateNewsResourceViewed(newsResourceId: String, viewed: Boolean) + suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) /** * Sets the desired theme brand. diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/UserNewsResourceRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserNewsResourceRepository.kt similarity index 83% rename from core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/UserNewsResourceRepository.kt rename to core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserNewsResourceRepository.kt index d81a3d1e0..9f7540da2 100644 --- a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/UserNewsResourceRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserNewsResourceRepository.kt @@ -14,10 +14,9 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.domain.repository +package com.google.samples.apps.nowinandroid.core.data.repository -import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import kotlinx.coroutines.flow.Flow /** @@ -38,4 +37,9 @@ interface UserNewsResourceRepository { * Returns available news resources for the user's followed topics as a stream. */ fun getUserNewsResourcesForFollowedTopics(): Flow> + + /** + * + */ + fun getBookmarkedUserNewsResources(): Flow> } diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt index 8b8a1f7f8..74813389e 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeUserDataRepository.kt @@ -47,8 +47,8 @@ class FakeUserDataRepository @Inject constructor( niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked) } - override suspend fun updateNewsResourceViewed(newsResourceId: String, viewed: Boolean) = - niaPreferencesDataSource.toggleNewsResourceViewed(newsResourceId, viewed) + override suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) = + niaPreferencesDataSource.setNewsResourceViewed(newsResourceId, viewed) override suspend fun setThemeBrand(themeBrand: ThemeBrand) { niaPreferencesDataSource.setThemeBrand(themeBrand) diff --git a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/CompositeUserNewsResourceRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/CompositeUserNewsResourceRepositoryTest.kt similarity index 81% rename from core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/CompositeUserNewsResourceRepositoryTest.kt rename to core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/CompositeUserNewsResourceRepositoryTest.kt index 9462cf89e..78271b809 100644 --- a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/CompositeUserNewsResourceRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/CompositeUserNewsResourceRepositoryTest.kt @@ -14,20 +14,18 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.domain +package com.google.samples.apps.nowinandroid.core.data +import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery -import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources -import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository 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.Topic +import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources 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.emptyUserData import kotlinx.coroutines.flow.first -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import kotlinx.datetime.Instant import org.junit.Test @@ -39,7 +37,6 @@ class CompositeUserNewsResourceRepositoryTest { private val userDataRepository = TestUserDataRepository() private val userNewsResourceRepository = CompositeUserNewsResourceRepository( - coroutineScope = TestScope(UnconfinedTestDispatcher()), newsRepository = newsRepository, userDataRepository = userDataRepository, ) @@ -71,7 +68,13 @@ class CompositeUserNewsResourceRepositoryTest { fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest { // Obtain a stream of user news resources for the given topic id. val userNewsResources = - userNewsResourceRepository.getUserNewsResources(NewsResourceQuery(filterTopicIds = setOf(sampleTopic1.id))) + userNewsResourceRepository.getUserNewsResources( + NewsResourceQuery( + filterTopicIds = setOf( + sampleTopic1.id, + ), + ), + ) // Send test data into the repositories. newsRepository.sendNewsResources(sampleNewsResources) @@ -107,6 +110,29 @@ class CompositeUserNewsResourceRepositoryTest { userNewsResources.first(), ) } + + @Test + fun whenFilteredByBookmarkedResources_matchingNewsResourcesAreReturned() = runTest { + // Obtain the bookmarked user news resources flow. + val userNewsResources = userNewsResourceRepository.getBookmarkedUserNewsResources() + + // Send some news resources and user data into the data repositories. + newsRepository.sendNewsResources(sampleNewsResources) + + // Construct the test user data with bookmarks and followed topics. + val userData = emptyUserData.copy( + bookmarkedNewsResources = setOf(sampleNewsResources[0].id, sampleNewsResources[2].id), + followedTopics = setOf(sampleTopic1.id), + ) + + userDataRepository.setUserData(userData) + + // Check that the correct news resources are returned with their bookmarked state. + assertEquals( + listOf(sampleNewsResources[0], sampleNewsResources[2]).mapToUserNewsResources(userData), + userNewsResources.first(), + ) + } } private val sampleTopic1 = Topic( diff --git a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/UserNewsResourceTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/UserNewsResourceTest.kt similarity index 95% rename from core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/UserNewsResourceTest.kt rename to core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/UserNewsResourceTest.kt index 7931d3f80..004966ec9 100644 --- a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/UserNewsResourceTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/UserNewsResourceTest.kt @@ -14,16 +14,16 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.domain +package com.google.samples.apps.nowinandroid.core.data -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.FOLLOW_SYSTEM +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.Article import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.DEFAULT import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.UserData +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import kotlinx.datetime.Clock import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt index 994ae71b5..952f667f7 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstUserDataRepositoryTest.kt @@ -164,7 +164,7 @@ class OfflineFirstUserDataRepositoryTest { @Test fun offlineFirstUserDataRepository_update_viewed_news_resources_delegates_to_nia_preferences() = runTest { - subject.updateNewsResourceViewed(newsResourceId = "0", viewed = true) + subject.setNewsResourceViewed(newsResourceId = "0", viewed = true) assertEquals( setOf("0"), @@ -173,7 +173,7 @@ class OfflineFirstUserDataRepositoryTest { .first(), ) - subject.updateNewsResourceViewed(newsResourceId = "1", viewed = true) + subject.setNewsResourceViewed(newsResourceId = "1", viewed = true) assertEquals( setOf("0", "1"), diff --git a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt index 91f8a3df2..33c04b70d 100644 --- a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferencesDataSource.kt @@ -138,7 +138,7 @@ class NiaPreferencesDataSource @Inject constructor( } } - suspend fun toggleNewsResourceViewed(newsResourceId: String, viewed: Boolean) { + suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) { userPreferences.updateData { it.copy { if (viewed) { diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCase.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCase.kt index ccc7e4ee1..c3c045d44 100644 --- a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCase.kt +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCase.kt @@ -20,7 +20,7 @@ import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepositor import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NAME import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NONE -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import javax.inject.Inject diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/CoroutineScopesModule.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/CoroutineScopesModule.kt deleted file mode 100644 index cfd07e565..000000000 --- a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/CoroutineScopesModule.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.samples.apps.nowinandroid.core.domain.di - -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import javax.inject.Qualifier -import javax.inject.Singleton - -@Retention(AnnotationRetention.RUNTIME) -@Qualifier -annotation class ApplicationScope - -@InstallIn(SingletonComponent::class) -@Module -object CoroutinesScopesModule { - - @Singleton - @ApplicationScope - @Provides - fun providesCoroutineScope(): CoroutineScope = - CoroutineScope(SupervisorJob() + Dispatchers.Default) -} diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/CompositeUserNewsResourceRepository.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/CompositeUserNewsResourceRepository.kt deleted file mode 100644 index 43c2ddf8f..000000000 --- a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/CompositeUserNewsResourceRepository.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.google.samples.apps.nowinandroid.core.domain.repository - -import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository -import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery -import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository -import com.google.samples.apps.nowinandroid.core.domain.di.ApplicationScope -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource -import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources -import com.google.samples.apps.nowinandroid.core.model.data.NewsResource -import com.google.samples.apps.nowinandroid.core.model.data.UserData -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.shareIn -import javax.inject.Inject - -/** - * Implements a [UserNewsResourceRepository] by combining a [NewsRepository] with a - * [UserDataRepository]. - */ -class CompositeUserNewsResourceRepository @Inject constructor( - @ApplicationScope private val coroutineScope: CoroutineScope, - val newsRepository: NewsRepository, - val userDataRepository: UserDataRepository, -) : UserNewsResourceRepository { - - private val userNewsResources = - newsRepository.getNewsResources().mapToUserNewsResources(userDataRepository.userData) - .shareIn(coroutineScope, started = WhileSubscribed(5000), replay = 1) - - override fun getUserNewsResources( - query: NewsResourceQuery, - ): Flow> = - userNewsResources.map { resources -> - resources.filter { resource -> - query.filterTopicIds?.let { topics -> resource.hasTopic(topics) } ?: true && - query.filterNewsIds?.contains(resource.id) ?: true - } - } - - override fun getUserNewsResourcesForFollowedTopics(): Flow> = - userDataRepository.userData.flatMapLatest { getUserNewsResources(NewsResourceQuery(filterTopicIds = it.followedTopics)) } - - private fun UserNewsResource.hasTopic(filterTopicIds: Set) = - followableTopics.any { filterTopicIds.contains(it.topic.id) } -} - -private fun Flow>.mapToUserNewsResources( - userDataStream: Flow, -): Flow> = - filterNot { it.isEmpty() } - .combine(userDataStream) { newsResources, userData -> - newsResources.mapToUserNewsResources(userData) - } diff --git a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCaseTest.kt b/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCaseTest.kt index 8bf63aea4..42a31f858 100644 --- a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCaseTest.kt +++ b/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetFollowableTopicsUseCaseTest.kt @@ -17,7 +17,7 @@ package com.google.samples.apps.nowinandroid.core.domain import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NAME -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/FollowableTopic.kt b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/FollowableTopic.kt similarity index 81% rename from core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/FollowableTopic.kt rename to core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/FollowableTopic.kt index 7b59df412..cef319c5f 100644 --- a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/FollowableTopic.kt +++ b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/FollowableTopic.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Android Open Source Project + * Copyright 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,9 +14,7 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.domain.model - -import com.google.samples.apps.nowinandroid.core.model.data.Topic +package com.google.samples.apps.nowinandroid.core.model.data /** * A [topic] with the additional information for whether or not it is followed. diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/UserNewsResource.kt b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserNewsResource.kt similarity index 81% rename from core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/UserNewsResource.kt rename to core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserNewsResource.kt index 1d0051918..251911930 100644 --- a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/UserNewsResource.kt +++ b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserNewsResource.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022 The Android Open Source Project + * Copyright 2023 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,11 +14,8 @@ * limitations under the License. */ -package com.google.samples.apps.nowinandroid.core.domain.model +package com.google.samples.apps.nowinandroid.core.model.data -import com.google.samples.apps.nowinandroid.core.model.data.NewsResource -import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType -import com.google.samples.apps.nowinandroid.core.model.data.UserData import kotlinx.datetime.Instant /** @@ -35,7 +32,7 @@ data class UserNewsResource internal constructor( val type: NewsResourceType, val followableTopics: List, val isSaved: Boolean, - val isViewed: Boolean, + val hasBeenViewed: Boolean, ) { constructor(newsResource: NewsResource, userData: UserData) : this( id = newsResource.id, @@ -52,7 +49,7 @@ data class UserNewsResource internal constructor( ) }, isSaved = userData.bookmarkedNewsResources.contains(newsResource.id), - isViewed = userData.viewedNewsResources.contains(newsResource.id), + hasBeenViewed = userData.viewedNewsResources.contains(newsResource.id), ) } diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/FollowableTopicTestData.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/FollowableTopicTestData.kt index 40e9327d3..32a0cd127 100644 --- a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/FollowableTopicTestData.kt +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/FollowableTopicTestData.kt @@ -16,7 +16,7 @@ package com.google.samples.apps.nowinandroid.core.testing.data -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.Topic /* ktlint-disable max-line-length */ diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt index f4085d11e..987b48b57 100644 --- a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/data/UserNewsResourcesTestData.kt @@ -16,12 +16,14 @@ package com.google.samples.apps.nowinandroid.core.testing.data -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.NewsResource -import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType +import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Codelab +import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Unknown +import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.UserData +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone @@ -54,7 +56,7 @@ val userNewsResourcesTestData: List = UserData( second = 0, nanosecond = 0, ).toInstant(TimeZone.UTC), - type = NewsResourceType.Codelab, + type = Codelab, topics = listOf(topicsTestData[2]), ), userData = userData, @@ -70,7 +72,7 @@ val userNewsResourcesTestData: List = UserData( url = "https://youtu.be/-fJ6poHQrjM", headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), - type = NewsResourceType.Video, + type = Video, topics = topicsTestData.take(2), ), userData = userData, @@ -86,7 +88,7 @@ val userNewsResourcesTestData: List = UserData( url = "https://youtu.be/ZARz0pjm5YM", headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), - type = NewsResourceType.Video, + type = Video, topics = listOf(topicsTestData[2]), ), userData = userData, @@ -100,7 +102,7 @@ val userNewsResourcesTestData: List = UserData( url = "https://developer.android.com/jetpack/androidx/versions/all-channel", headerImageUrl = "", publishDate = Instant.parse("2022-10-01T00:00:00.000Z"), - type = NewsResourceType.Unknown, + type = Unknown, topics = listOf(topicsTestData[2]), ), userData = userData, diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt index 1b8483d1a..66ac80868 100644 --- a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestUserDataRepository.kt @@ -73,7 +73,7 @@ class TestUserDataRepository : UserDataRepository { } } - override suspend fun updateNewsResourceViewed(newsResourceId: String, viewed: Boolean) { + override suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) { currentUserData.let { current -> _userData.tryEmit( current.copy( diff --git a/core/ui/src/androidTest/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt b/core/ui/src/androidTest/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt index 8e2e8fb4a..a495a6266 100644 --- a/core/ui/src/androidTest/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt +++ b/core/ui/src/androidTest/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardTest.kt @@ -40,7 +40,7 @@ class NewsResourceCardTest { NewsResourceCardExpanded( userNewsResource = newsWithKnownResourceType, isBookmarked = false, - isViewed = false, + hasBeenViewed = false, onToggleBookmark = {}, onClick = {}, onTopicClick = {}, @@ -69,7 +69,7 @@ class NewsResourceCardTest { NewsResourceCardExpanded( userNewsResource = newsWithUnknownResourceType, isBookmarked = false, - isViewed = false, + hasBeenViewed = false, onToggleBookmark = {}, onClick = {}, onTopicClick = {}, @@ -113,7 +113,7 @@ class NewsResourceCardTest { NewsResourceCardExpanded( userNewsResource = unreadNews, isBookmarked = false, - isViewed = false, + hasBeenViewed = false, onToggleBookmark = {}, onClick = {}, onTopicClick = {}, @@ -137,7 +137,7 @@ class NewsResourceCardTest { NewsResourceCardExpanded( userNewsResource = readNews, isBookmarked = false, - isViewed = true, + hasBeenViewed = true, onToggleBookmark = {}, onClick = {}, onTopicClick = {}, diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/FollowableTopicPreviewParameterProvider.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/FollowableTopicPreviewParameterProvider.kt index 3c83b973c..0dd9501b4 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/FollowableTopicPreviewParameterProvider.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/FollowableTopicPreviewParameterProvider.kt @@ -17,7 +17,7 @@ package com.google.samples.apps.nowinandroid.core.ui import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.Topic /* ktlint-disable max-line-length */ diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt index fb1fb56b7..412266034 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt @@ -39,7 +39,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource /** * An extension on [LazyListScope] defining a feed with news resources. @@ -48,7 +48,7 @@ import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource fun LazyGridScope.newsFeed( feedState: NewsFeedUiState, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, - onNewsResourcesViewedChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, ) { when (feedState) { @@ -71,9 +71,9 @@ fun LazyGridScope.newsFeed( newsResourceTitle = userNewsResource.title, ) launchCustomChromeTab(context, resourceUrl, backgroundColor) - onNewsResourcesViewedChanged(userNewsResource.id, true) + onNewsResourceViewed(userNewsResource.id) }, - isViewed = userNewsResource.isViewed, + hasBeenViewed = userNewsResource.hasBeenViewed, onToggleBookmark = { onNewsResourcesCheckedChanged( userNewsResource.id, @@ -125,7 +125,7 @@ private fun NewsFeedLoadingPreview() { newsFeed( feedState = NewsFeedUiState.Loading, onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -144,7 +144,7 @@ private fun NewsFeedContentPreview( newsFeed( feedState = NewsFeedUiState.Success(userNewsResources), onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt index 67a41fece..f74fb48ca 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt @@ -61,10 +61,10 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconT import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopicTag 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.domain.model.FollowableTopic -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +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 +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import kotlinx.datetime.Instant import kotlinx.datetime.toJavaInstant import java.time.ZoneId @@ -81,7 +81,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.R as DesignsystemR fun NewsResourceCardExpanded( userNewsResource: UserNewsResource, isBookmarked: Boolean, - isViewed: Boolean, + hasBeenViewed: Boolean, onToggleBookmark: () -> Unit, onClick: () -> Unit, onTopicClick: (String) -> Unit, @@ -119,8 +119,8 @@ fun NewsResourceCardExpanded( } Spacer(modifier = Modifier.height(12.dp)) Row(verticalAlignment = Alignment.CenterVertically) { - if (!isViewed) { - Dot( + if (!hasBeenViewed) { + NotificationDot( color = MaterialTheme.colorScheme.tertiary, modifier = Modifier.size(8.dp), ) @@ -196,7 +196,7 @@ fun BookmarkButton( } @Composable -fun Dot( +fun NotificationDot( color: Color, modifier: Modifier = Modifier, ) { @@ -333,7 +333,7 @@ private fun ExpandedNewsResourcePreview( NewsResourceCardExpanded( userNewsResource = userNewsResources[0], isBookmarked = true, - isViewed = false, + hasBeenViewed = false, onToggleBookmark = {}, onClick = {}, onTopicClick = {}, diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt index 6c971e7a2..884da93b5 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt @@ -24,7 +24,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource /** * Extension function for displaying a [List] of [NewsResourceCardExpanded] backed by a list of @@ -37,7 +37,7 @@ import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource fun LazyListScope.userNewsResourceCardItems( items: List, onToggleBookmark: (item: UserNewsResource) -> Unit, - onNewsResourcesViewedChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, onItemClick: ((item: UserNewsResource) -> Unit)? = null, onTopicClick: (String) -> Unit, itemModifier: Modifier = Modifier, @@ -53,7 +53,7 @@ fun LazyListScope.userNewsResourceCardItems( NewsResourceCardExpanded( userNewsResource = userNewsResource, isBookmarked = userNewsResource.isSaved, - isViewed = userNewsResource.isViewed, + hasBeenViewed = userNewsResource.hasBeenViewed, onToggleBookmark = { onToggleBookmark(userNewsResource) }, onClick = { analyticsHelper.logNewsResourceOpened( @@ -61,12 +61,10 @@ fun LazyListScope.userNewsResourceCardItems( newsResourceTitle = userNewsResource.title, ) when (onItemClick) { - null -> { - launchCustomChromeTab(context, resourceUrl, backgroundColor) - onNewsResourcesViewedChanged(userNewsResource.id, true) - } + null -> launchCustomChromeTab(context, resourceUrl, backgroundColor) else -> onItemClick(userNewsResource) } + onNewsResourceViewed(userNewsResource.id) }, onTopicClick = onTopicClick, modifier = itemModifier, diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt index 8a1c108c6..3f3f9bddd 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt @@ -17,7 +17,6 @@ package com.google.samples.apps.nowinandroid.core.ui import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType @@ -25,6 +24,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Vid import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.UserData +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone diff --git a/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt b/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt index c5ddd5c10..680c6dcf7 100644 --- a/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt +++ b/feature/bookmarks/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreenTest.kt @@ -52,7 +52,7 @@ class BookmarksScreenTest { feedState = NewsFeedUiState.Loading, removeFromBookmarks = {}, onTopicClick = {}, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } @@ -72,7 +72,7 @@ class BookmarksScreenTest { ), removeFromBookmarks = {}, onTopicClick = {}, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } @@ -115,7 +115,7 @@ class BookmarksScreenTest { removeFromBookmarksCalled = true }, onTopicClick = {}, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } @@ -146,7 +146,7 @@ class BookmarksScreenTest { feedState = NewsFeedUiState.Success(emptyList()), removeFromBookmarks = {}, onTopicClick = {}, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt index b39f189d1..a9ef26f64 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt @@ -54,7 +54,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success @@ -73,7 +73,7 @@ internal fun BookmarksRoute( BookmarksScreen( feedState = feedState, removeFromBookmarks = viewModel::removeFromSavedResources, - onNewsResourcesViewedChanged = viewModel::updateNewsResourceViewed, + onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) }, onTopicClick = onTopicClick, modifier = modifier, ) @@ -87,14 +87,14 @@ internal fun BookmarksRoute( internal fun BookmarksScreen( feedState: NewsFeedUiState, removeFromBookmarks: (String) -> Unit, - onNewsResourcesViewedChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, ) { when (feedState) { Loading -> LoadingState(modifier) is Success -> if (feedState.feed.isNotEmpty()) { - BookmarksGrid(feedState, removeFromBookmarks, onNewsResourcesViewedChanged, onTopicClick, modifier) + BookmarksGrid(feedState, removeFromBookmarks, onNewsResourceViewed, onTopicClick, modifier) } else { EmptyState(modifier) } @@ -117,7 +117,7 @@ private fun LoadingState(modifier: Modifier = Modifier) { private fun BookmarksGrid( feedState: NewsFeedUiState, removeFromBookmarks: (String) -> Unit, - onNewsResourcesViewedChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, ) { @@ -136,7 +136,7 @@ private fun BookmarksGrid( newsFeed( feedState = feedState, onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) }, - onNewsResourcesViewedChanged = onNewsResourcesViewedChanged, + onNewsResourceViewed = onNewsResourceViewed, onTopicClick = onTopicClick, ) item(span = { GridItemSpan(maxLineSpan) }) { @@ -202,7 +202,7 @@ private fun BookmarksGridPreview( BookmarksGrid( feedState = Success(userNewsResources), removeFromBookmarks = {}, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt index 7d0003aed..82d2c0e19 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt @@ -19,14 +19,13 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource -import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository +import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn @@ -39,16 +38,15 @@ class BookmarksViewModel @Inject constructor( userNewsResourceRepository: UserNewsResourceRepository, ) : ViewModel() { - val feedUiState: StateFlow = userNewsResourceRepository.getUserNewsResources() - .filterNot { it.isEmpty() } - .map { newsResources -> newsResources.filter(UserNewsResource::isSaved) } // Only show bookmarked news resources. - .map, NewsFeedUiState>(NewsFeedUiState::Success) - .onStart { emit(Loading) } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = Loading, - ) + val feedUiState: StateFlow = + userNewsResourceRepository.getBookmarkedUserNewsResources() + .map, NewsFeedUiState>(NewsFeedUiState::Success) + .onStart { emit(Loading) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = Loading, + ) fun removeFromSavedResources(newsResourceId: String) { viewModelScope.launch { @@ -56,9 +54,9 @@ class BookmarksViewModel @Inject constructor( } } - fun updateNewsResourceViewed(newsResourceId: String, isViewed: Boolean) { + fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) { viewModelScope.launch { - userDataRepository.updateNewsResourceViewed(newsResourceId, isViewed) + userDataRepository.setNewsResourceViewed(newsResourceId, viewed) } } } diff --git a/feature/bookmarks/src/test/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt b/feature/bookmarks/src/test/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt index d97f71095..6469a684b 100644 --- a/feature/bookmarks/src/test/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt +++ b/feature/bookmarks/src/test/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModelTest.kt @@ -16,7 +16,7 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks -import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository +import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository @@ -25,7 +25,6 @@ import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.Before @@ -45,7 +44,6 @@ class BookmarksViewModelTest { private val userDataRepository = TestUserDataRepository() private val newsRepository = TestNewsRepository() private val userNewsResourceRepository = CompositeUserNewsResourceRepository( - coroutineScope = TestScope(UnconfinedTestDispatcher()), newsRepository = newsRepository, userDataRepository = userDataRepository, ) diff --git a/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt b/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt index a3566fc31..fde215aa1 100644 --- a/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt +++ b/feature/foryou/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreenTest.kt @@ -56,7 +56,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } } @@ -80,7 +80,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } } @@ -110,7 +110,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } } @@ -155,7 +155,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } } @@ -193,7 +193,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } } @@ -217,7 +217,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } } @@ -242,7 +242,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index 44a323868..961046538 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -81,7 +81,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconT import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaOverlayLoadingWheel 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.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent @@ -107,7 +107,7 @@ internal fun ForYouRoute( onTopicClick = onTopicClick, saveFollowedTopics = viewModel::dismissOnboarding, onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved, - onNewsResourcesViewedChanged = viewModel::updateNewsResourceViewed, + onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) }, modifier = modifier, ) } @@ -121,7 +121,7 @@ internal fun ForYouScreen( onTopicClick: (String) -> Unit, saveFollowedTopics: () -> Unit, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, - onNewsResourcesViewedChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, modifier: Modifier = Modifier, ) { val isOnboardingLoading = onboardingUiState is OnboardingUiState.Loading @@ -179,7 +179,7 @@ internal fun ForYouScreen( newsFeed( feedState = feedState, onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, - onNewsResourcesViewedChanged = onNewsResourcesViewedChanged, + onNewsResourceViewed = onNewsResourceViewed, onTopicClick = onTopicClick, ) @@ -416,7 +416,7 @@ fun ForYouScreenPopulatedFeed( onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -440,7 +440,7 @@ fun ForYouScreenOfflinePopulatedFeed( onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -466,7 +466,7 @@ fun ForYouScreenTopicSelection( onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -485,7 +485,7 @@ fun ForYouScreenLoading() { onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -509,7 +509,7 @@ fun ForYouScreenPopulatedAndLoading( onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt index 363356aef..84638c55e 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt @@ -18,22 +18,16 @@ package com.google.samples.apps.nowinandroid.feature.foryou import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository +import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource -import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository -import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -58,7 +52,7 @@ class ForYouViewModel @Inject constructor( ) val feedState: StateFlow = - userDataRepository.getFollowedUserNewsResources(userNewsResourceRepository) + userNewsResourceRepository.getUserNewsResourcesForFollowedTopics() .map(NewsFeedUiState::Success) .stateIn( scope = viewModelScope, @@ -95,9 +89,9 @@ class ForYouViewModel @Inject constructor( } } - fun updateNewsResourceViewed(newsResourceId: String, isViewed: Boolean) { + fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) { viewModelScope.launch { - userDataRepository.updateNewsResourceViewed(newsResourceId, isViewed) + userDataRepository.setNewsResourceViewed(newsResourceId, viewed) } } @@ -107,47 +101,3 @@ class ForYouViewModel @Inject constructor( } } } - -/** - * Obtain a flow of user news resources whose topics match those the user is following. - * - * getUserNewsResources: The `UseCase` used to obtain the flow of user news resources. - */ -private fun UserDataRepository.getFollowedUserNewsResources( - userNewsResourceRepository: UserNewsResourceRepository, -): Flow> = userData - // Map the user data into a set of followed topic IDs or null if we should return an empty list. - .map { userData -> - if (userData.shouldShowEmptyFeed()) { - null - } else { - userData.followedTopics - } - } - // Only emit a set of followed topic IDs if it's changed. This avoids calling potentially - // expensive operations (like setting up a new flow) when nothing has changed. - .distinctUntilChanged() - // getUserNewsResources returns a flow, so we have a flow inside a flow. flatMapLatest moves - // the inner flow (the one we want to return) to the outer flow and cancels any previous flows - // created by getUserNewsResources. - .flatMapLatest { followedTopics -> - if (followedTopics == null) { - flowOf(emptyList()) - } else { - userNewsResourceRepository.getUserNewsResources( - NewsResourceQuery(filterTopicIds = followedTopics), - ) - } - } - -/** - * If the user hasn't completed the onboarding and hasn't selected any interests - * show an empty news list to clearly demonstrate that their selections affect the - * news articles they will see. - * - * Note: It should not be possible for the user to get into a state where the onboarding - * is not displayed AND they haven't followed any topics, however, this method is to safeguard - * against that scenario in future. - */ -private fun UserData.shouldShowEmptyFeed() = - !shouldHideOnboarding && followedTopics.isEmpty() diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/OnboardingUiState.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/OnboardingUiState.kt index faf368b1e..58f4f1683 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/OnboardingUiState.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/OnboardingUiState.kt @@ -16,7 +16,7 @@ package com.google.samples.apps.nowinandroid.feature.foryou -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic /** * A sealed hierarchy describing the onboarding state for the for you screen. diff --git a/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt index 9bac2549c..16c593aa0 100644 --- a/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt +++ b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt @@ -16,14 +16,14 @@ package com.google.samples.apps.nowinandroid.feature.foryou +import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource -import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources -import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository +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 import com.google.samples.apps.nowinandroid.core.model.data.Topic +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources 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 @@ -34,7 +34,6 @@ import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncStatusMoni import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -58,7 +57,6 @@ class ForYouViewModelTest { private val topicsRepository = TestTopicsRepository() private val newsRepository = TestNewsRepository() private val userNewsResourceRepository = CompositeUserNewsResourceRepository( - coroutineScope = TestScope(UnconfinedTestDispatcher()), newsRepository = newsRepository, userDataRepository = userDataRepository, ) diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt index 8f863ba5a..e618c1c9f 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt @@ -29,7 +29,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaBackground import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.FollowableTopicPreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt index d6ef94521..debc49bcd 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsViewModel.kt @@ -21,7 +21,7 @@ import androidx.lifecycle.viewModelScope import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.TopicSortField -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt index dcca35795..457014cc2 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt @@ -27,7 +27,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic @Composable fun TopicsTabContent( diff --git a/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt b/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt index e47b25021..c46cb7780 100644 --- a/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt +++ b/feature/interests/src/test/java/com/google/samples/apps/nowinandroid/interests/InterestsViewModelTest.kt @@ -17,7 +17,7 @@ package com.google.samples.apps.nowinandroid.interests import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository diff --git a/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt b/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt index 65d923442..94f86a8e4 100644 --- a/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt +++ b/feature/topic/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreenTest.kt @@ -59,7 +59,7 @@ class TopicScreenTest { onFollowClick = {}, onTopicClick = {}, onBookmarkChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } @@ -79,7 +79,7 @@ class TopicScreenTest { onFollowClick = {}, onTopicClick = {}, onBookmarkChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } @@ -104,7 +104,7 @@ class TopicScreenTest { onFollowClick = {}, onTopicClick = {}, onBookmarkChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } @@ -127,7 +127,7 @@ class TopicScreenTest { onFollowClick = {}, onTopicClick = {}, onBookmarkChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt index 4fc9faaca..fd408f9cf 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicScreen.kt @@ -51,8 +51,8 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilte import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel 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.domain.model.FollowableTopic -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank @@ -79,7 +79,7 @@ internal fun TopicRoute( onBackClick = onBackClick, onFollowClick = viewModel::followTopicToggle, onBookmarkChanged = viewModel::bookmarkNews, - onNewsResourcesViewedChanged = viewModel::updateNewsResourceViewed, + onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) }, onTopicClick = onTopicClick, ) } @@ -93,7 +93,7 @@ internal fun TopicScreen( onFollowClick: (Boolean) -> Unit, onTopicClick: (String) -> Unit, onBookmarkChanged: (String, Boolean) -> Unit, - onNewsResourcesViewedChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, modifier: Modifier = Modifier, ) { val state = rememberLazyListState() @@ -129,7 +129,7 @@ internal fun TopicScreen( news = newsUiState, imageUrl = topicUiState.followableTopic.topic.imageUrl, onBookmarkChanged = onBookmarkChanged, - onNewsResourcesViewedChanged = onNewsResourcesViewedChanged, + onNewsResourceViewed = onNewsResourceViewed, onTopicClick = onTopicClick, ) } @@ -146,7 +146,7 @@ private fun LazyListScope.TopicBody( news: NewsUiState, imageUrl: String, onBookmarkChanged: (String, Boolean) -> Unit, - onNewsResourcesViewedChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, ) { // TODO: Show icon if available @@ -154,7 +154,7 @@ private fun LazyListScope.TopicBody( TopicHeader(name, description, imageUrl) } - userNewsResourceCards(news, onBookmarkChanged, onNewsResourcesViewedChanged, onTopicClick) + userNewsResourceCards(news, onBookmarkChanged, onNewsResourceViewed, onTopicClick) } @Composable @@ -185,7 +185,7 @@ private fun TopicHeader(name: String, description: String, imageUrl: String) { private fun LazyListScope.userNewsResourceCards( news: NewsUiState, onBookmarkChanged: (String, Boolean) -> Unit, - onNewsResourcesViewedChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, ) { when (news) { @@ -193,7 +193,7 @@ private fun LazyListScope.userNewsResourceCards( userNewsResourceCardItems( items = news.news, onToggleBookmark = { onBookmarkChanged(it.id, !it.isSaved) }, - onNewsResourcesViewedChanged = onNewsResourcesViewedChanged, + onNewsResourceViewed = onNewsResourceViewed, onTopicClick = onTopicClick, itemModifier = Modifier.padding(24.dp), ) @@ -220,7 +220,7 @@ private fun TopicBodyPreview() { news = NewsUiState.Success(emptyList()), imageUrl = "", onBookmarkChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -278,7 +278,7 @@ fun TopicScreenPopulated( onBackClick = {}, onFollowClick = {}, onBookmarkChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -296,7 +296,7 @@ fun TopicScreenLoading() { onBackClick = {}, onFollowClick = {}, onBookmarkChanged = { _, _ -> }, - onNewsResourcesViewedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt index 4dac25983..425d66c73 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt @@ -22,11 +22,11 @@ import androidx.lifecycle.viewModelScope import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository +import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository import com.google.samples.apps.nowinandroid.core.decoder.StringDecoder -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic -import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource -import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.Topic +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.result.Result import com.google.samples.apps.nowinandroid.core.result.asResult import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicArgs @@ -87,9 +87,9 @@ class TopicViewModel @Inject constructor( } } - fun updateNewsResourceViewed(newsResourceId: String, isViewed: Boolean) { + fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) { viewModelScope.launch { - userDataRepository.updateNewsResourceViewed(newsResourceId, isViewed) + userDataRepository.setNewsResourceViewed(newsResourceId, viewed) } } } diff --git a/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt b/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt index 3580a960b..ff7a88160 100644 --- a/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt +++ b/feature/topic/src/test/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModelTest.kt @@ -17,8 +17,8 @@ package com.google.samples.apps.nowinandroid.feature.topic import androidx.lifecycle.SavedStateHandle -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic -import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository +import com.google.samples.apps.nowinandroid.core.data.repository.CompositeUserNewsResourceRepository +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 import com.google.samples.apps.nowinandroid.core.model.data.Topic @@ -32,7 +32,6 @@ import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import kotlinx.datetime.Instant @@ -55,7 +54,6 @@ class TopicViewModelTest { private val topicsRepository = TestTopicsRepository() private val newsRepository = TestNewsRepository() private val userNewsResourceRepository = CompositeUserNewsResourceRepository( - coroutineScope = TestScope(UnconfinedTestDispatcher()), newsRepository = newsRepository, userDataRepository = userDataRepository, ) From e5d29547a1d278ca80171ed44bd88b77b0c5484a Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Mon, 13 Mar 2023 23:27:27 +0000 Subject: [PATCH 53/97] Remove debug logs from CI workflow Closes #584 --- .github/workflows/AndroidCIWithGmd.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/AndroidCIWithGmd.yaml b/.github/workflows/AndroidCIWithGmd.yaml index bae399f47..44f8141f6 100644 --- a/.github/workflows/AndroidCIWithGmd.yaml +++ b/.github/workflows/AndroidCIWithGmd.yaml @@ -27,7 +27,7 @@ jobs: - name: Run instrumented tests with GMD run: ./gradlew cleanManagedDevices --unused-only && ./gradlew ${{ matrix.device-config }}DemoDebugAndroidTest -Dorg.gradle.workers.max=1 - -Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true --info + -Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true - name: Upload test reports if: success() || failure() From 6b605a779c40e1237c9eb30efa96fd2c84abacf6 Mon Sep 17 00:00:00 2001 From: Amaury Medeiros Date: Tue, 14 Mar 2023 11:45:18 +0000 Subject: [PATCH 54/97] Fix ForYouScreenTopicSelection Compose Preview --- .../samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index aa4dc5f26..25ae1910b 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -453,7 +453,8 @@ fun ForYouScreenTopicSelection( ForYouScreen( isSyncing = false, onboardingUiState = OnboardingUiState.Shown( - topics = userNewsResources.flatMap { news -> news.followableTopics }, + topics = userNewsResources.flatMap { news -> news.followableTopics } + .distinctBy { it.topic.id }, ), feedState = NewsFeedUiState.Success( feed = userNewsResources, From 462b6a1b3a8c0ec0f37dac0a935a37c087044e9a Mon Sep 17 00:00:00 2001 From: vketteni Date: Fri, 17 Mar 2023 19:28:58 +0100 Subject: [PATCH 55/97] Correct a table entry of a code reference from ArchitectureLearningJourney.md --- docs/ArchitectureLearningJourney.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ArchitectureLearningJourney.md b/docs/ArchitectureLearningJourney.md index 9d7c77e1c..925858111 100644 --- a/docs/ArchitectureLearningJourney.md +++ b/docs/ArchitectureLearningJourney.md @@ -64,7 +64,7 @@ Here's what's happening in each step. The easiest way to find the associated cod On app startup, a WorkManager job to sync all repositories is enqueued. - SyncInitializer.create + Sync.initialize From 973eb04d81b8f9925a3c62c4f7b42edaad63217f Mon Sep 17 00:00:00 2001 From: David Rawson Date: Sat, 18 Mar 2023 17:30:50 +1300 Subject: [PATCH 56/97] Remove unnecessary Gradle wrapper --- build-logic/gradle/wrapper/gradle-wrapper.jar | Bin 59821 -> 0 bytes .../gradle/wrapper/gradle-wrapper.properties | 5 ----- 2 files changed, 5 deletions(-) delete mode 100644 build-logic/gradle/wrapper/gradle-wrapper.jar delete mode 100644 build-logic/gradle/wrapper/gradle-wrapper.properties diff --git a/build-logic/gradle/wrapper/gradle-wrapper.jar b/build-logic/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 41d9927a4d4fb3f96a785543079b8df6723c946b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 59821 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 diff --git a/build-logic/gradle/wrapper/gradle-wrapper.properties b/build-logic/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index ae04661ee..000000000 --- a/build-logic/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists From 93c01ad18481902be558a25a511f0a0ee249b77b Mon Sep 17 00:00:00 2001 From: Chris Sinco Date: Fri, 24 Mar 2023 09:38:19 -0700 Subject: [PATCH 57/97] Fix trailing commas based on spotless check --- .../com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt | 2 +- .../samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt | 2 +- .../samples/apps/nowinandroid/feature/interests/TabContent.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt index 52bd1f6cf..df9b5dab8 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt @@ -80,7 +80,7 @@ fun LazyGridScope.newsFeed( ) }, onTopicClick = onTopicClick, - modifier = Modifier.padding(horizontal = 8.dp) + modifier = Modifier.padding(horizontal = 8.dp), ) } } diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index d791dfc5c..fe549b79e 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -268,7 +268,7 @@ private fun LazyGridScope.onboarding( modifier = Modifier .padding(horizontal = 24.dp) .widthIn(364.dp) - .fillMaxWidth() + .fillMaxWidth(), ) { Text( text = stringResource(R.string.done), diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt index 11d0aff2f..71667e4dc 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt @@ -40,7 +40,7 @@ fun TopicsTabContent( modifier = modifier .padding(horizontal = 24.dp) .testTag("interests:topics"), - contentPadding = PaddingValues(vertical = 16.dp) + contentPadding = PaddingValues(vertical = 16.dp), ) { topics.forEach { followableTopic -> val topicId = followableTopic.topic.id From 08956492c84ea9b4c57ffdd69c9a4eef9de47b4a Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 21 Mar 2023 09:37:24 -0400 Subject: [PATCH 58/97] Backend triggered sync Change-Id: I53c43b136ebb755f6258b1e815301dddb3b536a3 --- core/data/build.gradle.kts | 1 + .../repository/OfflineFirstNewsRepository.kt | 27 +++++++++++++ .../{SyncStatusMonitor.kt => SyncManager.kt} | 3 +- .../OfflineFirstNewsRepositoryTest.kt | 24 ++++++++++++ core/notifications/.gitignore | 1 + core/notifications/build.gradle.kts | 35 +++++++++++++++++ .../core/notifications/NotificationsModule.kt | 31 +++++++++++++++ .../src/main/AndroidManifest.xml | 17 +++++++++ .../notifications/AndroidSystemNotifier.kt | 32 ++++++++++++++++ .../core/notifications/NoOpNotifier.kt | 27 +++++++++++++ .../core/notifications/Notifier.kt | 26 +++++++++++++ .../src/main/res/values/strings.xml | 22 +++++++++++ .../core/notifications/NotificationsModule.kt | 31 +++++++++++++++ core/testing/build.gradle.kts | 1 + .../testing/notifications/TestNotifier.kt | 34 +++++++++++++++++ ...yncStatusMonitor.kt => TestSyncManager.kt} | 8 +++- .../feature/foryou/ForYouViewModel.kt | 6 +-- .../feature/foryou/ForYouViewModelTest.kt | 8 ++-- gradle/libs.versions.toml | 21 +++++----- settings.gradle.kts | 1 + ...sMonitor.kt => NeverSyncingSyncManager.kt} | 5 ++- .../core/sync/test/TestSyncModule.kt | 6 +-- sync/work/build.gradle.kts | 1 + sync/work/src/demo/AndroidManifest.xml | 38 +++++++++++++++++++ sync/work/src/main/AndroidManifest.xml | 8 +++- .../apps/nowinandroid/sync/di/SyncModule.kt | 8 ++-- .../sync/services/SyncNotificationsService.kt | 38 +++++++++++++++++++ ...usMonitor.kt => WorkManagerSyncManager.kt} | 22 ++++++++--- 28 files changed, 447 insertions(+), 35 deletions(-) rename core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/{SyncStatusMonitor.kt => SyncManager.kt} (94%) create mode 100644 core/notifications/.gitignore create mode 100644 core/notifications/build.gradle.kts create mode 100644 core/notifications/src/demo/java/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt create mode 100644 core/notifications/src/main/AndroidManifest.xml create mode 100644 core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/AndroidSystemNotifier.kt create mode 100644 core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/NoOpNotifier.kt create mode 100644 core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/Notifier.kt create mode 100644 core/notifications/src/main/res/values/strings.xml create mode 100644 core/notifications/src/prod/java/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt create mode 100644 core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/notifications/TestNotifier.kt rename core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/{TestSyncStatusMonitor.kt => TestSyncManager.kt} (85%) rename sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/{NeverSyncingSyncStatusMonitor.kt => NeverSyncingSyncManager.kt} (82%) create mode 100644 sync/work/src/demo/AndroidManifest.xml create mode 100644 sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt rename sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/{WorkManagerSyncStatusMonitor.kt => WorkManagerSyncManager.kt} (67%) diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index e4b265649..5d34aac2c 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(project(":core:datastore")) implementation(project(":core:model")) implementation(project(":core:network")) + implementation(project(":core:notifications")) implementation(libs.androidx.core.ktx) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.datetime) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt index c16355d69..02c58d855 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt @@ -30,7 +30,9 @@ import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource +import com.google.samples.apps.nowinandroid.core.notifications.Notifier import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -46,6 +48,7 @@ class OfflineFirstNewsRepository @Inject constructor( private val newsResourceDao: NewsResourceDao, private val topicDao: TopicDao, private val network: NiaNetworkDataSource, + private val notifier: Notifier, ) : NewsRepository { override fun getNewsResources( @@ -69,6 +72,16 @@ class OfflineFirstNewsRepository @Inject constructor( }, modelDeleter = newsResourceDao::deleteNewsResources, modelUpdater = { changedIds -> + // TODO: Make this more efficient, there is no need to retrieve populated + // news resources when all that's needed are the ids + val existingNewsResourceIds = newsResourceDao.getNewsResources( + useFilterNewsIds = true, + filterNewsIds = changedIds.toSet(), + ) + .first() + .map { it.entity.id } + .toSet() + changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds -> val networkNewsResources = network.getNewsResources(ids = chunkedIds) @@ -92,6 +105,20 @@ class OfflineFirstNewsRepository @Inject constructor( .flatten(), ) } + + val addedNewsResources = newsResourceDao.getNewsResources( + useFilterNewsIds = true, + filterNewsIds = changedIds.toSet(), + ) + .first() + .filter { !existingNewsResourceIds.contains(it.entity.id) } + .map(PopulatedNewsResource::asExternalModel) + + // TODO: Define business logic for notifications on first time sync. + // we probably do not want to send notifications on first install. + // We can easily check if the change list version is 0 and not send notifications + // if it is. + if (addedNewsResources.isNotEmpty()) notifier.onNewsAdded(addedNewsResources) }, ) } diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/SyncStatusMonitor.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/SyncManager.kt similarity index 94% rename from core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/SyncStatusMonitor.kt rename to core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/SyncManager.kt index 14823ed0e..d72fa27a6 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/SyncStatusMonitor.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/SyncManager.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.Flow /** * Reports on if synchronization is in progress */ -interface SyncStatusMonitor { +interface SyncManager { val isSyncing: Flow + fun requestSync() } diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt index 6b424e69e..6cdbf67d0 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt @@ -36,6 +36,7 @@ import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferen import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource +import com.google.samples.apps.nowinandroid.core.testing.notifications.TestNotifier import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -58,6 +59,8 @@ class OfflineFirstNewsRepositoryTest { private lateinit var network: TestNiaNetworkDataSource + private lateinit var notifier: TestNotifier + private lateinit var synchronizer: Synchronizer @get:Rule @@ -68,6 +71,7 @@ class OfflineFirstNewsRepositoryTest { newsResourceDao = TestNewsResourceDao() topicDao = TestTopicDao() network = TestNiaNetworkDataSource() + notifier = TestNotifier() synchronizer = TestSynchronizer( NiaPreferencesDataSource( tmpFolder.testUserPreferencesDataStore(testScope), @@ -78,6 +82,7 @@ class OfflineFirstNewsRepositoryTest { newsResourceDao = newsResourceDao, topicDao = topicDao, network = network, + notifier = notifier, ) } @@ -145,6 +150,12 @@ class OfflineFirstNewsRepositoryTest { expected = network.latestChangeListVersion(CollectionType.NewsResources), actual = synchronizer.getChangeListVersions().newsResourceVersion, ) + + // Notifier should have been called with new news resources + assertEquals( + expected = newsResourcesFromDb.map(NewsResource::id).sorted(), + actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(), + ) } @Test @@ -186,6 +197,13 @@ class OfflineFirstNewsRepositoryTest { expected = network.latestChangeListVersion(CollectionType.NewsResources), actual = synchronizer.getChangeListVersions().newsResourceVersion, ) + + // Notifier should have been called with news resources from network that are not + // deleted + assertEquals( + expected = (newsResourcesFromNetwork.map(NewsResource::id) - deletedItems).sorted(), + actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(), + ) } @Test @@ -225,6 +243,12 @@ class OfflineFirstNewsRepositoryTest { expected = changeList.last().changeListVersion, actual = synchronizer.getChangeListVersions().newsResourceVersion, ) + + // Notifier should have been called with only added news resources from network + assertEquals( + expected = newsResourcesFromNetwork.map(NewsResource::id).sorted(), + actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(), + ) } @Test diff --git a/core/notifications/.gitignore b/core/notifications/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/core/notifications/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/notifications/build.gradle.kts b/core/notifications/build.gradle.kts new file mode 100644 index 000000000..608e59a38 --- /dev/null +++ b/core/notifications/build.gradle.kts @@ -0,0 +1,35 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + id("nowinandroid.android.library") + id("nowinandroid.android.library.compose") + id("nowinandroid.android.hilt") +} + +android { + namespace = "com.google.samples.apps.nowinandroid.core.notifications" +} + +dependencies { + implementation(project(":core:model")) + + implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.core.ktx) + + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.cloud.messaging) +} diff --git a/core/notifications/src/demo/java/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt b/core/notifications/src/demo/java/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt new file mode 100644 index 000000000..9bb2b3fb9 --- /dev/null +++ b/core/notifications/src/demo/java/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.notifications + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class NotificationsModule { + @Binds + abstract fun bindNotifier( + notifier: NoOpNotifier, + ): Notifier +} diff --git a/core/notifications/src/main/AndroidManifest.xml b/core/notifications/src/main/AndroidManifest.xml new file mode 100644 index 000000000..31c889874 --- /dev/null +++ b/core/notifications/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + diff --git a/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/AndroidSystemNotifier.kt b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/AndroidSystemNotifier.kt new file mode 100644 index 000000000..00d97fcb3 --- /dev/null +++ b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/AndroidSystemNotifier.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.notifications + +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Implementation of [Notifier] that displays notifications in the system tray. + */ +@Singleton +class AndroidSystemNotifier @Inject constructor() : Notifier { + + override fun onNewsAdded(newsResources: List) { + // TODO, create notification and display to the user + } +} diff --git a/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/NoOpNotifier.kt b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/NoOpNotifier.kt new file mode 100644 index 000000000..5a8141e91 --- /dev/null +++ b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/NoOpNotifier.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.notifications + +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import javax.inject.Inject + +/** + * Implementation of [Notifier] which does nothing. Useful for tests and previews. + */ +class NoOpNotifier @Inject constructor() : Notifier { + override fun onNewsAdded(newsResources: List) = Unit +} diff --git a/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/Notifier.kt b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/Notifier.kt new file mode 100644 index 000000000..3084dcb75 --- /dev/null +++ b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/Notifier.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.notifications + +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource + +/** + * Interface for creating notifications in the app + */ +interface Notifier { + fun onNewsAdded(newsResources: List) +} diff --git a/core/notifications/src/main/res/values/strings.xml b/core/notifications/src/main/res/values/strings.xml new file mode 100644 index 000000000..e3fd73ff8 --- /dev/null +++ b/core/notifications/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + + Now in Android + Sync + Background tasks for Now in Android + + diff --git a/core/notifications/src/prod/java/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt b/core/notifications/src/prod/java/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt new file mode 100644 index 000000000..0b4bd6bae --- /dev/null +++ b/core/notifications/src/prod/java/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.notifications + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class NotificationsModule { + @Binds + abstract fun bindNotifier( + notifier: AndroidSystemNotifier, + ): Notifier +} diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index 4e87bb039..2fea5beb3 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -40,5 +40,6 @@ dependencies { implementation(project(":core:data")) implementation(project(":core:domain")) implementation(project(":core:model")) + implementation(project(":core:notifications")) implementation(libs.kotlinx.datetime) } diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/notifications/TestNotifier.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/notifications/TestNotifier.kt new file mode 100644 index 000000000..669d2e6c4 --- /dev/null +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/notifications/TestNotifier.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.testing.notifications + +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import com.google.samples.apps.nowinandroid.core.notifications.Notifier + +/** + * Aggregates news resources that have been notified for addition + */ +class TestNotifier : Notifier { + + private val mutableAddedNewResources = mutableListOf>() + + val addedNewsResources: List> = mutableAddedNewResources + + override fun onNewsAdded(newsResources: List) { + mutableAddedNewResources.add(newsResources) + } +} diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncStatusMonitor.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncManager.kt similarity index 85% rename from core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncStatusMonitor.kt rename to core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncManager.kt index a2edc89ff..999b67195 100644 --- a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncStatusMonitor.kt +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncManager.kt @@ -16,16 +16,20 @@ package com.google.samples.apps.nowinandroid.core.testing.util -import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor +import com.google.samples.apps.nowinandroid.core.data.util.SyncManager import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -class TestSyncStatusMonitor : SyncStatusMonitor { +class TestSyncManager : SyncManager { private val syncStatusFlow = MutableStateFlow(false) override val isSyncing: Flow = syncStatusFlow + override fun requestSync() { + TODO("Not yet implemented") + } + /** * A test-only API to set the sync status from tests. */ diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt index 085593932..376624376 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt @@ -20,7 +20,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository -import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor +import com.google.samples.apps.nowinandroid.core.data.util.SyncManager import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource @@ -41,7 +41,7 @@ import javax.inject.Inject @HiltViewModel class ForYouViewModel @Inject constructor( - syncStatusMonitor: SyncStatusMonitor, + syncManager: SyncManager, private val userDataRepository: UserDataRepository, getUserNewsResources: GetUserNewsResourcesUseCase, getFollowableTopics: GetFollowableTopicsUseCase, @@ -50,7 +50,7 @@ class ForYouViewModel @Inject constructor( private val shouldShowOnboarding: Flow = userDataRepository.userData.map { !it.shouldHideOnboarding } - val isSyncing = syncStatusMonitor.isSyncing + val isSyncing = syncManager.isSyncing .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), diff --git a/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt index 9e51758f0..db8178b89 100644 --- a/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt +++ b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt @@ -30,7 +30,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserData import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor -import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncStatusMonitor +import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncManager import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch @@ -52,7 +52,7 @@ class ForYouViewModelTest { val mainDispatcherRule = MainDispatcherRule() private val networkMonitor = TestNetworkMonitor() - private val syncStatusMonitor = TestSyncStatusMonitor() + private val syncManager = TestSyncManager() private val userDataRepository = TestUserDataRepository() private val topicsRepository = TestTopicsRepository() private val newsRepository = TestNewsRepository() @@ -70,7 +70,7 @@ class ForYouViewModelTest { @Before fun setup() { viewModel = ForYouViewModel( - syncStatusMonitor = syncStatusMonitor, + syncManager = syncManager, userDataRepository = userDataRepository, getUserNewsResources = getUserNewsResourcesUseCase, getFollowableTopics = getFollowableTopicsUseCase, @@ -106,7 +106,7 @@ class ForYouViewModelTest { @Test fun stateIsLoadingWhenAppIsSyncingWithNoInterests() = runTest { - syncStatusMonitor.setSyncing(true) + syncManager.setSyncing(true) val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.isSyncing.collect() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 737ad43a0..8826089a8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -94,19 +94,20 @@ androidx-test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.r androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } 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-tracing-ktx = { group = "androidx.tracing", name = "tracing-ktx", version.ref = "androidxTracing" } androidx-window-manager = { module = "androidx.window:window", version.ref = "androidxWindowManager" } 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" } coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } -firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref="firebaseBom"} -firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx"} -firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx"} -firebase-crashlytics-gradle = { group = "com.google.firebase", name="firebase-crashlytics-gradle", version.ref = "firebaseCrashlyticsPlugin"} -firebase-performance = { group = "com.google.firebase", name = "firebase-perf-ktx"} -firebase-performance-gradle = { group = "com.google.firebase", name = "perf-plugin", version.ref = "firebasePerfPlugin"} +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } +firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" } +firebase-cloud-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" } +firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx" } +firebase-crashlytics-gradle = { group = "com.google.firebase", name = "firebase-crashlytics-gradle", version.ref = "firebaseCrashlyticsPlugin" } +firebase-performance = { group = "com.google.firebase", name = "firebase-perf-ktx" } +firebase-performance-gradle = { group = "com.google.firebase", name = "perf-plugin", version.ref = "firebasePerfPlugin" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } @@ -138,9 +139,9 @@ ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devto android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" } -firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin"} -firebase-perf = { id = "com.google.firebase.firebase-perf", version.ref = "firebasePerfPlugin"} -gms = { id = "com.google.gms.google-services", version.ref = "gmsPlugin"} +firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin" } +firebase-perf = { id = "com.google.firebase.firebase-perf", version.ref = "firebasePerfPlugin" } +gms = { id = "com.google.gms.google-services", version.ref = "gmsPlugin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 857f9d56c..2af582a7b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -47,6 +47,7 @@ include(":core:network") include(":core:ui") include(":core:testing") include(":core:analytics") +include(":core:notifications") include(":feature:foryou") include(":feature:interests") diff --git a/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncStatusMonitor.kt b/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncManager.kt similarity index 82% rename from sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncStatusMonitor.kt rename to sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncManager.kt index 647dd864e..2b0b4fb6a 100644 --- a/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncStatusMonitor.kt +++ b/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncManager.kt @@ -16,11 +16,12 @@ package com.google.samples.apps.nowinandroid.core.sync.test -import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor +import com.google.samples.apps.nowinandroid.core.data.util.SyncManager import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import javax.inject.Inject -class NeverSyncingSyncStatusMonitor @Inject constructor() : SyncStatusMonitor { +class NeverSyncingSyncManager @Inject constructor() : SyncManager { override val isSyncing: Flow = flowOf(false) + override fun requestSync() = Unit } diff --git a/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/TestSyncModule.kt b/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/TestSyncModule.kt index 323704b5a..0089450b5 100644 --- a/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/TestSyncModule.kt +++ b/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/TestSyncModule.kt @@ -16,7 +16,7 @@ package com.google.samples.apps.nowinandroid.core.sync.test -import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor +import com.google.samples.apps.nowinandroid.core.data.util.SyncManager import com.google.samples.apps.nowinandroid.sync.di.SyncModule import dagger.Binds import dagger.Module @@ -31,6 +31,6 @@ import dagger.hilt.testing.TestInstallIn interface TestSyncModule { @Binds fun bindsSyncStatusMonitor( - syncStatusMonitor: NeverSyncingSyncStatusMonitor, - ): SyncStatusMonitor + syncStatusMonitor: NeverSyncingSyncManager, + ): SyncManager } diff --git a/sync/work/build.gradle.kts b/sync/work/build.gradle.kts index b5b3bdb68..fa7835323 100644 --- a/sync/work/build.gradle.kts +++ b/sync/work/build.gradle.kts @@ -36,6 +36,7 @@ dependencies { implementation(libs.androidx.startup) implementation(libs.androidx.tracing.ktx) implementation(libs.androidx.work.ktx) + implementation(libs.firebase.cloud.messaging) implementation(libs.hilt.ext.work) implementation(libs.kotlinx.coroutines.android) diff --git a/sync/work/src/demo/AndroidManifest.xml b/sync/work/src/demo/AndroidManifest.xml new file mode 100644 index 000000000..dac61a5bc --- /dev/null +++ b/sync/work/src/demo/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + diff --git a/sync/work/src/main/AndroidManifest.xml b/sync/work/src/main/AndroidManifest.xml index 2487eb105..0d0b720bb 100644 --- a/sync/work/src/main/AndroidManifest.xml +++ b/sync/work/src/main/AndroidManifest.xml @@ -29,7 +29,13 @@ android:value="androidx.startup" tools:node="remove" /> - + + + + + diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt index 68f9eee93..bbc45dc42 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt @@ -16,8 +16,8 @@ package com.google.samples.apps.nowinandroid.sync.di -import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor -import com.google.samples.apps.nowinandroid.sync.status.WorkManagerSyncStatusMonitor +import com.google.samples.apps.nowinandroid.core.data.util.SyncManager +import com.google.samples.apps.nowinandroid.sync.status.WorkManagerSyncManager import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -28,6 +28,6 @@ import dagger.hilt.components.SingletonComponent interface SyncModule { @Binds fun bindsSyncStatusMonitor( - syncStatusMonitor: WorkManagerSyncStatusMonitor, - ): SyncStatusMonitor + syncStatusMonitor: WorkManagerSyncManager, + ): SyncManager } diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt new file mode 100644 index 000000000..ab318776a --- /dev/null +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.sync.services + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.google.samples.apps.nowinandroid.core.data.util.SyncManager +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +private const val SYNC_TOPIC = "sync" + +@AndroidEntryPoint +class SyncNotificationsService : FirebaseMessagingService() { + + @Inject + lateinit var syncManager: SyncManager + + override fun onMessageReceived(message: RemoteMessage) { + if (SYNC_TOPIC == message.from) { + syncManager.requestSync() + } + } +} diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncStatusMonitor.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncManager.kt similarity index 67% rename from sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncStatusMonitor.kt rename to sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncManager.kt index f4f9d02cb..9bb57ccf0 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncStatusMonitor.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncManager.kt @@ -19,27 +19,39 @@ package com.google.samples.apps.nowinandroid.sync.status import android.content.Context import androidx.lifecycle.asFlow import androidx.lifecycle.map +import androidx.work.ExistingWorkPolicy import androidx.work.WorkInfo import androidx.work.WorkInfo.State import androidx.work.WorkManager -import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor +import com.google.samples.apps.nowinandroid.core.data.util.SyncManager import com.google.samples.apps.nowinandroid.sync.initializers.SyncWorkName +import com.google.samples.apps.nowinandroid.sync.workers.SyncWorker import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.conflate import javax.inject.Inject /** - * [SyncStatusMonitor] backed by [WorkInfo] from [WorkManager] + * [SyncManager] backed by [WorkInfo] from [WorkManager] */ -class WorkManagerSyncStatusMonitor @Inject constructor( - @ApplicationContext context: Context, -) : SyncStatusMonitor { +class WorkManagerSyncManager @Inject constructor( + @ApplicationContext private val context: Context, +) : SyncManager { override val isSyncing: Flow = WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(SyncWorkName) .map(MutableList::anyRunning) .asFlow() .conflate() + + override fun requestSync() { + val workManager = WorkManager.getInstance(context) + // Run sync on app startup and ensure only one sync worker runs at any time + workManager.enqueueUniqueWork( + SyncWorkName, + ExistingWorkPolicy.KEEP, + SyncWorker.startUpSyncWork(), + ) + } } private val List.anyRunning get() = any { it.state == State.RUNNING } From 3b7e7e3c028e7a5815da600a3fe4260c8dcec115 Mon Sep 17 00:00:00 2001 From: mlykotom Date: Fri, 31 Mar 2023 15:38:02 +0200 Subject: [PATCH 59/97] Use ReportDrawnWhen Change-Id: I77b4db63f72f43c6c826e66358f7672792ef137d --- feature/foryou/build.gradle.kts | 1 + .../feature/foryou/ForYouScreen.kt | 24 +++---------------- gradle/libs.versions.toml | 2 +- 3 files changed, 5 insertions(+), 22 deletions(-) diff --git a/feature/foryou/build.gradle.kts b/feature/foryou/build.gradle.kts index 4fa032a88..ad50e531b 100644 --- a/feature/foryou/build.gradle.kts +++ b/feature/foryou/build.gradle.kts @@ -29,4 +29,5 @@ android { dependencies { implementation(libs.accompanist.flowlayout) implementation(libs.kotlinx.datetime) + implementation(libs.androidx.activity.compose) } diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index fe549b79e..767bede9a 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -16,7 +16,7 @@ package com.google.samples.apps.nowinandroid.feature.foryou -import android.app.Activity +import androidx.activity.compose.ReportDrawnWhen import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -57,13 +57,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.layout 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 @@ -73,7 +71,6 @@ 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.view.doOnPreDraw import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage @@ -126,23 +123,8 @@ internal fun ForYouScreen( val isOnboardingLoading = onboardingUiState is OnboardingUiState.Loading val isFeedLoading = feedState is NewsFeedUiState.Loading - // 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. - // TODO replace with ReportDrawnWhen { } once androidx.activity-compose 1.7.0 is used (currently alpha) - if (!isSyncing && !isOnboardingLoading && !isFeedLoading) { - 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() } - } - } + // This code should be called when the UI is ready for use and relates to Time To Full Display. + ReportDrawnWhen { !isSyncing && !isOnboardingLoading && !isFeedLoading } val state = rememberLazyGridState() TrackScrollJank(scrollableState = state, stateName = "forYou:feed") diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8826089a8..c3648e5b6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ accompanist = "0.28.0" androidDesugarJdkLibs = "1.2.2" androidGradlePlugin = "7.4.1" -androidxActivity = "1.6.1" +androidxActivity = "1.7.0" androidxAppCompat = "1.5.1" androidxBrowser = "1.4.0" androidxComposeBom = "2023.01.00" From 489502ba134481c300f1d8ac8ddd4f93a339a7d7 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Thu, 6 Apr 2023 09:46:11 -0400 Subject: [PATCH 60/97] Initialize sync manually Change-Id: Ia26b1179270a87d3b87a915185b8d5fc7532c464 --- gradle/libs.versions.toml | 1 - sync/work/build.gradle.kts | 1 - sync/work/src/demo/AndroidManifest.xml | 11 ------- sync/work/src/main/AndroidManifest.xml | 11 ------- .../sync/initializers/SyncInitializer.kt | 29 ++++--------------- 5 files changed, 5 insertions(+), 48 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c3648e5b6..677438412 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -87,7 +87,6 @@ androidx-metrics = { group = "androidx.metrics", name = "metrics-performance", v androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" } androidx-navigation-testing = { group = "androidx.navigation", name = "navigation-testing", version.ref = "androidxNavigation" } androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "androidxProfileinstaller" } -androidx-startup = { group = "androidx.startup", name = "startup-runtime", version.ref = "androidxStartup" } androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspresso" } androidx-test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxTestExt" } diff --git a/sync/work/build.gradle.kts b/sync/work/build.gradle.kts index fa7835323..79902e486 100644 --- a/sync/work/build.gradle.kts +++ b/sync/work/build.gradle.kts @@ -33,7 +33,6 @@ dependencies { implementation(project(":core:datastore")) implementation(project(":core:model")) implementation(libs.androidx.lifecycle.livedata.ktx) - implementation(libs.androidx.startup) implementation(libs.androidx.tracing.ktx) implementation(libs.androidx.work.ktx) implementation(libs.firebase.cloud.messaging) diff --git a/sync/work/src/demo/AndroidManifest.xml b/sync/work/src/demo/AndroidManifest.xml index dac61a5bc..8dc32c86f 100644 --- a/sync/work/src/demo/AndroidManifest.xml +++ b/sync/work/src/demo/AndroidManifest.xml @@ -18,17 +18,6 @@ xmlns:tools="http://schemas.android.com/tools"> - - - - - - - - diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt index 837eb9a20..00f61f17d 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt @@ -17,31 +17,14 @@ package com.google.samples.apps.nowinandroid.sync.initializers import android.content.Context -import androidx.startup.AppInitializer -import androidx.startup.Initializer import androidx.work.ExistingWorkPolicy import androidx.work.WorkManager -import androidx.work.WorkManagerInitializer import com.google.samples.apps.nowinandroid.sync.workers.SyncWorker object Sync { - // This method is a workaround to manually initialize the sync process instead of relying on - // automatic initialization with Androidx Startup. It is called from the app module's - // Application.onCreate() and should be only done once. + // This method is initializes sync, the process that keeps the app's data current. + // It is called from the app module's Application.onCreate() and should be only done once. fun initialize(context: Context) { - AppInitializer.getInstance(context) - .initializeComponent(SyncInitializer::class.java) - } -} - -// This name should not be changed otherwise the app may have concurrent sync requests running -internal const val SyncWorkName = "SyncWorkName" - -/** - * Registers work to sync the data layer periodically on app startup. - */ -class SyncInitializer : Initializer { - override fun create(context: Context): Sync { WorkManager.getInstance(context).apply { // Run sync on app startup and ensure only one sync worker runs at any time enqueueUniqueWork( @@ -50,10 +33,8 @@ class SyncInitializer : Initializer { SyncWorker.startUpSyncWork(), ) } - - return Sync } - - override fun dependencies(): List>> = - listOf(WorkManagerInitializer::class.java) } + +// This name should not be changed otherwise the app may have concurrent sync requests running +internal const val SyncWorkName = "SyncWorkName" From 086dc957c067c54f4aafcd254ce4e08bb0c0880b Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Fri, 14 Apr 2023 08:58:16 -0700 Subject: [PATCH 61/97] Update to AGP 8.0 Change-Id: I27eb106e484e2003d732782a39612dc72c845282 --- .github/workflows/AndroidCIWithGmd.yaml | 2 +- .github/workflows/Build.yaml | 8 ++++---- .github/workflows/Release.yml | 4 ++-- build-logic/convention/build.gradle.kts | 8 ++++++++ .../apps/nowinandroid/AndroidCompose.kt | 14 ++++++++++---- .../apps/nowinandroid/KotlinAndroid.kt | 15 +++++++-------- core/datastore/build.gradle.kts | 5 ----- gradle/libs.versions.toml | 4 ++-- gradle/wrapper/gradle-wrapper.jar | Bin 60756 -> 61574 bytes gradle/wrapper/gradle-wrapper.properties | 3 ++- gradlew | 12 ++++++++---- gradlew.bat | 1 + kokoro/build.sh | 2 +- lint/build.gradle.kts | 9 +++++++++ 14 files changed, 55 insertions(+), 32 deletions(-) diff --git a/.github/workflows/AndroidCIWithGmd.yaml b/.github/workflows/AndroidCIWithGmd.yaml index 44f8141f6..1c1206ed4 100644 --- a/.github/workflows/AndroidCIWithGmd.yaml +++ b/.github/workflows/AndroidCIWithGmd.yaml @@ -18,7 +18,7 @@ jobs: - uses: actions/setup-java@v3 with: distribution: 'zulu' - java-version: '11' + java-version: 17 - uses: actions/checkout@v3 - name: Setup Android SDK diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml index 342714245..c15daea80 100644 --- a/.github/workflows/Build.yaml +++ b/.github/workflows/Build.yaml @@ -24,11 +24,11 @@ jobs: - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: distribution: 'zulu' - java-version: 11 + java-version: 17 - name: Setup Gradle uses: gradle/gradle-build-action@v2 @@ -73,11 +73,11 @@ jobs: - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: distribution: 'zulu' - java-version: 11 + java-version: 17 - name: Setup Gradle uses: gradle/gradle-build-action@v2 diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index 86f88a920..534e9d893 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -20,11 +20,11 @@ jobs: - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: distribution: 'zulu' - java-version: 11 + java-version: 17 - name: Build app run: ./gradlew :app:assembleDemoRelease diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 281434b87..5408b218a 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -14,6 +14,8 @@ * limitations under the License. */ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { `kotlin-dsl` } @@ -25,6 +27,12 @@ java { targetCompatibility = JavaVersion.VERSION_11 } +tasks.withType().configureEach { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } +} + dependencies { compileOnly(libs.android.gradlePlugin) compileOnly(libs.firebase.crashlytics.gradle) diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt index 4da997fc3..cdcc65a7f 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt @@ -17,10 +17,14 @@ package com.google.samples.apps.nowinandroid import com.android.build.api.dsl.CommonExtension +import org.gradle.api.JavaVersion import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.provideDelegate +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.io.File /** @@ -40,16 +44,18 @@ internal fun Project.configureAndroidCompose( kotlinCompilerExtensionVersion = libs.findVersion("androidxComposeCompiler").get().toString() } - kotlinOptions { - freeCompilerArgs = freeCompilerArgs + buildComposeMetricsParameters() - } - dependencies { val bom = libs.findLibrary("androidx-compose-bom").get() add("implementation", platform(bom)) add("androidTestImplementation", platform(bom)) } } + + tasks.withType().configureEach { + kotlinOptions { + freeCompilerArgs = freeCompilerArgs + buildComposeMetricsParameters() + } + } } private fun Project.buildComposeMetricsParameters(): List { diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index da0cab531..bf510a2c4 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -24,7 +24,9 @@ import org.gradle.api.plugins.ExtensionAware import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.provideDelegate +import org.gradle.kotlin.dsl.withType import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile /** * Configure base Kotlin with Android options @@ -44,13 +46,17 @@ internal fun Project.configureKotlinAndroid( targetCompatibility = JavaVersion.VERSION_11 isCoreLibraryDesugaringEnabled = true } + } + // Use withType to workaround https://youtrack.jetbrains.com/issue/KT-55947 + tasks.withType().configureEach { kotlinOptions { + // Set JVM target to 11 + jvmTarget = JavaVersion.VERSION_11.toString() // Treat all Kotlin warnings as errors (disabled by default) // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties val warningsAsErrors: String? by project allWarningsAsErrors = warningsAsErrors.toBoolean() - freeCompilerArgs = freeCompilerArgs + listOf( "-opt-in=kotlin.RequiresOptIn", // Enable experimental coroutines APIs, including Flow @@ -58,9 +64,6 @@ internal fun Project.configureKotlinAndroid( "-opt-in=kotlinx.coroutines.FlowPreview", "-opt-in=kotlin.Experimental", ) - - // Set JVM target to 11 - jvmTarget = JavaVersion.VERSION_11.toString() } } @@ -70,7 +73,3 @@ internal fun Project.configureKotlinAndroid( add("coreLibraryDesugaring", libs.findLibrary("android.desugarJdkLibs").get()) } } - -fun CommonExtension<*, *, *, *>.kotlinOptions(block: KotlinJvmOptions.() -> Unit) { - (this as ExtensionAware).extensions.configure("kotlinOptions", block) -} diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 1787a5b8f..59a3a8e32 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -14,11 +14,6 @@ * limitations under the License. */ -import com.google.protobuf.gradle.builtins -import com.google.protobuf.gradle.generateProtoTasks -import com.google.protobuf.gradle.protobuf -import com.google.protobuf.gradle.protoc - // TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed @Suppress("DSL_SCOPE_VIOLATION") plugins { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 677438412..08af52b65 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] accompanist = "0.28.0" androidDesugarJdkLibs = "1.2.2" -androidGradlePlugin = "7.4.1" +androidGradlePlugin = "8.0.0" androidxActivity = "1.7.0" androidxAppCompat = "1.5.1" androidxBrowser = "1.4.0" @@ -45,7 +45,7 @@ ksp = "1.8.0-1.0.9" lint = "30.3.1" okhttp = "4.10.0" protobuf = "3.21.12" -protobufPlugin = "0.8.19" +protobufPlugin = "0.9.1" retrofit = "2.9.0" retrofitKotlinxSerializationJson = "0.8.0" room = "2.5.0" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e5832f090a2944b7473328c07c9755baa3196..943f0cbfa754578e88a3dae77fce6e3dea56edbf 100644 GIT binary patch delta 36524 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%0DGK?7GHTj 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 VwlMN}`%er%aGR6olj~j${vQ;P=LY}) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 070cb702f..0c85a1f75 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index a69d9cb6c..65dcd68d6 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,10 +80,10 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' @@ -143,12 +143,16 @@ fi if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac diff --git a/gradlew.bat b/gradlew.bat index 53a6b238d..6689b85be 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,6 +26,7 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% diff --git a/kokoro/build.sh b/kokoro/build.sh index 5c50a814f..2036d1e07 100755 --- a/kokoro/build.sh +++ b/kokoro/build.sh @@ -36,7 +36,7 @@ echo y | ${ANDROID_HOME}/tools/bin/sdkmanager --licenses cd $KOKORO_ARTIFACTS_DIR/git/nowinandroid # The build needs Java 11, set it as the default Java version. -sudo update-java-alternatives --set java-1.11.0-openjdk-amd64 +sudo update-java-alternatives --set java-1.17.0-openjdk-amd64 # Also clear JAVA_HOME variable so java -version is used instead export JAVA_HOME= diff --git a/lint/build.gradle.kts b/lint/build.gradle.kts index bbf24126d..08ab9bec4 100644 --- a/lint/build.gradle.kts +++ b/lint/build.gradle.kts @@ -13,6 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { `java-library` kotlin("jvm") @@ -24,6 +27,12 @@ java { targetCompatibility = JavaVersion.VERSION_11 } +tasks.withType().configureEach { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } +} + dependencies { compileOnly(libs.kotlin.stdlib) compileOnly(libs.lint.api) From 96a0449ba963da5ecfd660c8e4390784e4e7bc1b Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Fri, 14 Apr 2023 12:15:51 -0700 Subject: [PATCH 62/97] Add dontwarn for proguard rules Change-Id: I316513bd13b30cfcdbde0caba1d9c669815601f2 --- app/proguard-rules.pro | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 1ea4feef3..41012b47a 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -24,3 +24,13 @@ # @Serializable and @Polymorphic are used at runtime for polymorphic serialization. -keepattributes RuntimeVisibleAnnotations,AnnotationDefault + +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE From 1713f14f53f22b861b181e0ef6f8eff00e82b4a1 Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Fri, 14 Apr 2023 12:16:20 -0700 Subject: [PATCH 63/97] Update Java version name Change-Id: Ife3d1c2defe8e8e7eac67e53fe5813cd3fd267a9 --- kokoro/build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kokoro/build.sh b/kokoro/build.sh index 2036d1e07..c217e995c 100755 --- a/kokoro/build.sh +++ b/kokoro/build.sh @@ -35,7 +35,7 @@ echo y | ${ANDROID_HOME}/tools/bin/sdkmanager --licenses cd $KOKORO_ARTIFACTS_DIR/git/nowinandroid -# The build needs Java 11, set it as the default Java version. +# The build needs Java 17, set it as the default Java version. sudo update-java-alternatives --set java-1.17.0-openjdk-amd64 # Also clear JAVA_HOME variable so java -version is used instead From 38260e5a86d7bbe79d8c438053db5a06aabcedbf Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Fri, 14 Apr 2023 12:17:31 -0700 Subject: [PATCH 64/97] Update to VERSION_17 everywhere Change-Id: I3e75e8a833f45365657a24c4af652c2449af804c --- build-logic/convention/build.gradle.kts | 6 +++--- .../com/google/samples/apps/nowinandroid/KotlinAndroid.kt | 6 +++--- lint/build.gradle.kts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 5408b218a..4da2b86dd 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -23,13 +23,13 @@ plugins { group = "com.google.samples.apps.nowinandroid.buildlogic" java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } tasks.withType().configureEach { kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + jvmTarget = JavaVersion.VERSION_17.toString() } } diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index bf510a2c4..719fbd399 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -42,8 +42,8 @@ internal fun Project.configureKotlinAndroid( } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true } } @@ -52,7 +52,7 @@ internal fun Project.configureKotlinAndroid( tasks.withType().configureEach { kotlinOptions { // Set JVM target to 11 - jvmTarget = JavaVersion.VERSION_11.toString() + jvmTarget = JavaVersion.VERSION_17.toString() // Treat all Kotlin warnings as errors (disabled by default) // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties val warningsAsErrors: String? by project diff --git a/lint/build.gradle.kts b/lint/build.gradle.kts index 08ab9bec4..b665ba8a7 100644 --- a/lint/build.gradle.kts +++ b/lint/build.gradle.kts @@ -23,13 +23,13 @@ plugins { } java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } tasks.withType().configureEach { kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + jvmTarget = JavaVersion.VERSION_17.toString() } } From e323c2155fd6fdf4a383af37f7a4343ea837789b Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Fri, 14 Apr 2023 12:18:15 -0700 Subject: [PATCH 65/97] Remove unused imports in build-config Change-Id: I9fa6da0490fd9dc43cf5ed21aaa770c76b461a55 --- .../com/google/samples/apps/nowinandroid/KotlinAndroid.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index 719fbd399..701ef4e5c 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -20,12 +20,10 @@ import com.android.build.api.dsl.CommonExtension import org.gradle.api.JavaVersion import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension -import org.gradle.api.plugins.ExtensionAware import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.provideDelegate import org.gradle.kotlin.dsl.withType -import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions import org.jetbrains.kotlin.gradle.tasks.KotlinCompile /** From 4cdfaa93b2e6a8a421afc19a5b71517b55b05d08 Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Fri, 14 Apr 2023 14:23:09 -0700 Subject: [PATCH 66/97] Fix comment referring to 11 Change-Id: Ibef7bf42f60dd132535e70a7619958b242b7afe9 --- .../com/google/samples/apps/nowinandroid/KotlinAndroid.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index 701ef4e5c..d4cb010a2 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -49,7 +49,7 @@ internal fun Project.configureKotlinAndroid( // Use withType to workaround https://youtrack.jetbrains.com/issue/KT-55947 tasks.withType().configureEach { kotlinOptions { - // Set JVM target to 11 + // Set JVM target to 17 jvmTarget = JavaVersion.VERSION_17.toString() // Treat all Kotlin warnings as errors (disabled by default) // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties From 33a3e360a4cc422fa534f9092ea1ebd3c08f0a21 Mon Sep 17 00:00:00 2001 From: AnirudhPudari Date: Sun, 16 Apr 2023 13:34:35 +0530 Subject: [PATCH 67/97] Deprecated FlowLayouts from accompanist --- app-nia-catalog/build.gradle.kts | 1 - .../samples/apps/niacatalog/ui/Catalog.kt | 20 ++++++++++--------- feature/foryou/build.gradle.kts | 1 - gradle/libs.versions.toml | 1 - 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/app-nia-catalog/build.gradle.kts b/app-nia-catalog/build.gradle.kts index bf0695fd3..42ffd7039 100644 --- a/app-nia-catalog/build.gradle.kts +++ b/app-nia-catalog/build.gradle.kts @@ -67,6 +67,5 @@ android { dependencies { implementation(project(":core:designsystem")) implementation(project(":core:ui")) - implementation(libs.accompanist.flowlayout) implementation(libs.androidx.activity.compose) } diff --git a/app-nia-catalog/src/main/java/com/google/samples/apps/niacatalog/ui/Catalog.kt b/app-nia-catalog/src/main/java/com/google/samples/apps/niacatalog/ui/Catalog.kt index a18600f33..54e4264fa 100644 --- a/app-nia-catalog/src/main/java/com/google/samples/apps/niacatalog/ui/Catalog.kt +++ b/app-nia-catalog/src/main/java/com/google/samples/apps/niacatalog/ui/Catalog.kt @@ -17,6 +17,8 @@ package com.google.samples.apps.niacatalog.ui import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.add import androidx.compose.foundation.layout.asPaddingValues @@ -36,7 +38,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import com.google.accompanist.flowlayout.FlowRow import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton @@ -54,6 +55,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme /** * Now in Android component catalog. */ +@OptIn(ExperimentalLayoutApi::class) @Composable fun NiaCatalog() { NiaTheme { @@ -75,7 +77,7 @@ fun NiaCatalog() { } item { Text("Buttons", Modifier.padding(top = 16.dp)) } item { - FlowRow(mainAxisSpacing = 16.dp) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { NiaButton(onClick = {}) { Text(text = "Enabled") } @@ -89,7 +91,7 @@ fun NiaCatalog() { } item { Text("Disabled buttons", Modifier.padding(top = 16.dp)) } item { - FlowRow(mainAxisSpacing = 16.dp) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { NiaButton( onClick = {}, enabled = false, @@ -112,7 +114,7 @@ fun NiaCatalog() { } item { Text("Buttons with leading icons", Modifier.padding(top = 16.dp)) } item { - FlowRow(mainAxisSpacing = 16.dp) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { NiaButton( onClick = {}, text = { Text(text = "Enabled") }, @@ -138,7 +140,7 @@ fun NiaCatalog() { } item { Text("Disabled buttons with leading icons", Modifier.padding(top = 16.dp)) } item { - FlowRow(mainAxisSpacing = 16.dp) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { NiaButton( onClick = {}, enabled = false, @@ -168,7 +170,7 @@ fun NiaCatalog() { item { Text("Dropdown menus", Modifier.padding(top = 16.dp)) } item { Text("Chips", Modifier.padding(top = 16.dp)) } item { - FlowRow(mainAxisSpacing = 16.dp) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { var firstChecked by remember { mutableStateOf(false) } NiaFilterChip( selected = firstChecked, @@ -197,7 +199,7 @@ fun NiaCatalog() { } item { Text("Icon buttons", Modifier.padding(top = 16.dp)) } item { - FlowRow(mainAxisSpacing = 16.dp) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { var firstChecked by remember { mutableStateOf(false) } NiaIconToggleButton( checked = firstChecked, @@ -270,7 +272,7 @@ fun NiaCatalog() { } item { Text("View toggle", Modifier.padding(top = 16.dp)) } item { - FlowRow(mainAxisSpacing = 16.dp) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { var firstExpanded by remember { mutableStateOf(false) } NiaViewToggleButton( expanded = firstExpanded, @@ -296,7 +298,7 @@ fun NiaCatalog() { } item { Text("Tags", Modifier.padding(top = 16.dp)) } item { - FlowRow(mainAxisSpacing = 16.dp) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) { NiaTopicTag( followed = true, onClick = {}, diff --git a/feature/foryou/build.gradle.kts b/feature/foryou/build.gradle.kts index ad50e531b..8c6747dd1 100644 --- a/feature/foryou/build.gradle.kts +++ b/feature/foryou/build.gradle.kts @@ -27,7 +27,6 @@ android { } dependencies { - implementation(libs.accompanist.flowlayout) implementation(libs.kotlinx.datetime) implementation(libs.androidx.activity.compose) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c3648e5b6..3b0c61b74 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -53,7 +53,6 @@ secrets = "2.0.1" turbine = "0.12.1" [libraries] -accompanist-flowlayout = { group = "com.google.accompanist", name = "accompanist-flowlayout", version.ref = "accompanist" } accompanist-systemuicontroller = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "accompanist" } accompanist-testharness = { group = "com.google.accompanist", name = "accompanist-testharness", version.ref = "accompanist" } android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" } From b49767a88cce244d7ccd4a8c218de92787b52549 Mon Sep 17 00:00:00 2001 From: James Rose Date: Mon, 17 Apr 2023 13:42:26 -0700 Subject: [PATCH 68/97] Rename getUserNewsResources to observeAll --- .../google/samples/apps/nowinandroid/ui/NiaAppState.kt | 4 ++-- .../repository/CompositeUserNewsResourceRepository.kt | 10 +++++----- .../core/data/repository/UserNewsResourceRepository.kt | 6 +++--- .../data/CompositeUserNewsResourceRepositoryTest.kt | 8 ++++---- .../feature/bookmarks/BookmarksViewModel.kt | 2 +- .../apps/nowinandroid/feature/topic/TopicViewModel.kt | 2 +- 6 files changed, 16 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt index e472ee2af..df6fe1da2 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -126,8 +126,8 @@ class NiaAppState( * The top level destinations that have unread news resources. */ val topLevelDestinationsWithUnreadResources: StateFlow> = - userNewsResourceRepository.getUserNewsResourcesForFollowedTopics() - .combine(userNewsResourceRepository.getBookmarkedUserNewsResources()) { forYouNewsResources, bookmarkedNewsResources -> + userNewsResourceRepository.observeAllForFollowedTopics() + .combine(userNewsResourceRepository.observeAllBookmarked()) { forYouNewsResources, bookmarkedNewsResources -> setOfNotNull( FOR_YOU.takeIf { forYouNewsResources.any { !it.hasBeenViewed } }, BOOKMARKS.takeIf { bookmarkedNewsResources.any { !it.hasBeenViewed } }, diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/CompositeUserNewsResourceRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/CompositeUserNewsResourceRepository.kt index dc9ad299f..64e02e7d9 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/CompositeUserNewsResourceRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/CompositeUserNewsResourceRepository.kt @@ -38,7 +38,7 @@ class CompositeUserNewsResourceRepository @Inject constructor( /** * Returns available news resources (joined with user data) matching the given query. */ - override fun getUserNewsResources( + override fun observeAll( query: NewsResourceQuery, ): Flow> = newsRepository.getNewsResources(query) @@ -49,21 +49,21 @@ class CompositeUserNewsResourceRepository @Inject constructor( /** * Returns available news resources (joined with user data) for the followed topics. */ - override fun getUserNewsResourcesForFollowedTopics(): Flow> = + override fun observeAllForFollowedTopics(): Flow> = userDataRepository.userData.map { it.followedTopics }.distinctUntilChanged() .flatMapLatest { followedTopics -> when { followedTopics.isEmpty() -> flowOf(emptyList()) - else -> getUserNewsResources(NewsResourceQuery(filterTopicIds = followedTopics)) + else -> observeAll(NewsResourceQuery(filterTopicIds = followedTopics)) } } - override fun getBookmarkedUserNewsResources(): Flow> = + override fun observeAllBookmarked(): Flow> = userDataRepository.userData.map { it.bookmarkedNewsResources }.distinctUntilChanged() .flatMapLatest { bookmarkedNewsResources -> when { bookmarkedNewsResources.isEmpty() -> flowOf(emptyList()) - else -> getUserNewsResources(NewsResourceQuery(filterNewsIds = bookmarkedNewsResources)) + else -> observeAll(NewsResourceQuery(filterNewsIds = bookmarkedNewsResources)) } } } diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserNewsResourceRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserNewsResourceRepository.kt index 9f7540da2..4e3e214bc 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserNewsResourceRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserNewsResourceRepository.kt @@ -26,7 +26,7 @@ interface UserNewsResourceRepository { /** * Returns available news resources as a stream. */ - fun getUserNewsResources( + fun observeAll( query: NewsResourceQuery = NewsResourceQuery( filterTopicIds = null, filterNewsIds = null, @@ -36,10 +36,10 @@ interface UserNewsResourceRepository { /** * Returns available news resources for the user's followed topics as a stream. */ - fun getUserNewsResourcesForFollowedTopics(): Flow> + fun observeAllForFollowedTopics(): Flow> /** * */ - fun getBookmarkedUserNewsResources(): Flow> + fun observeAllBookmarked(): Flow> } diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/CompositeUserNewsResourceRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/CompositeUserNewsResourceRepositoryTest.kt index 78271b809..eb4241295 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/CompositeUserNewsResourceRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/CompositeUserNewsResourceRepositoryTest.kt @@ -44,7 +44,7 @@ class CompositeUserNewsResourceRepositoryTest { @Test fun whenNoFilters_allNewsResourcesAreReturned() = runTest { // Obtain the user news resources flow. - val userNewsResources = userNewsResourceRepository.getUserNewsResources() + val userNewsResources = userNewsResourceRepository.observeAll() // Send some news resources and user data into the data repositories. newsRepository.sendNewsResources(sampleNewsResources) @@ -68,7 +68,7 @@ class CompositeUserNewsResourceRepositoryTest { fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest { // Obtain a stream of user news resources for the given topic id. val userNewsResources = - userNewsResourceRepository.getUserNewsResources( + userNewsResourceRepository.observeAll( NewsResourceQuery( filterTopicIds = setOf( sampleTopic1.id, @@ -93,7 +93,7 @@ class CompositeUserNewsResourceRepositoryTest { fun whenFilteredByFollowedTopics_matchingNewsResourcesAreReturned() = runTest { // Obtain a stream of user news resources for the given topic id. val userNewsResources = - userNewsResourceRepository.getUserNewsResourcesForFollowedTopics() + userNewsResourceRepository.observeAllForFollowedTopics() // Send test data into the repositories. val userData = emptyUserData.copy( @@ -114,7 +114,7 @@ class CompositeUserNewsResourceRepositoryTest { @Test fun whenFilteredByBookmarkedResources_matchingNewsResourcesAreReturned() = runTest { // Obtain the bookmarked user news resources flow. - val userNewsResources = userNewsResourceRepository.getBookmarkedUserNewsResources() + val userNewsResources = userNewsResourceRepository.observeAllBookmarked() // Send some news resources and user data into the data repositories. newsRepository.sendNewsResources(sampleNewsResources) diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt index 82d2c0e19..8a1869322 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt @@ -39,7 +39,7 @@ class BookmarksViewModel @Inject constructor( ) : ViewModel() { val feedUiState: StateFlow = - userNewsResourceRepository.getBookmarkedUserNewsResources() + userNewsResourceRepository.observeAllBookmarked() .map, NewsFeedUiState>(NewsFeedUiState::Success) .onStart { emit(Loading) } .stateIn( diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt index 425d66c73..2b2565f9e 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/TopicViewModel.kt @@ -145,7 +145,7 @@ private fun newsUiState( userDataRepository: UserDataRepository, ): Flow { // Observe news - val newsStream: Flow> = userNewsResourceRepository.getUserNewsResources( + val newsStream: Flow> = userNewsResourceRepository.observeAll( NewsResourceQuery(filterTopicIds = setOf(element = topicId)), ) From 93953c2206798e768e29e36ea4205251f9f3b59e Mon Sep 17 00:00:00 2001 From: James Rose Date: Mon, 17 Apr 2023 13:52:58 -0700 Subject: [PATCH 69/97] Add missing method doc --- .../core/data/repository/UserNewsResourceRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserNewsResourceRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserNewsResourceRepository.kt index 4e3e214bc..c0f4c013a 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserNewsResourceRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserNewsResourceRepository.kt @@ -39,7 +39,7 @@ interface UserNewsResourceRepository { fun observeAllForFollowedTopics(): Flow> /** - * + * Returns the user's bookmarked news resources as a stream. */ fun observeAllBookmarked(): Flow> } From 05be2855d86f3070aceb1c67be67b424ddec7050 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 4 Apr 2023 09:24:32 -0400 Subject: [PATCH 70/97] Wire up backend requested sync Change-Id: I1d4485b589c7e94527a2a02f371cd3f030231622 --- .../repository/OfflineFirstNewsRepository.kt | 49 ++++---- .../OfflineFirstNewsRepositoryTest.kt | 106 ++++++++++++++---- .../data/testdoubles/TestNewsResourceDao.kt | 36 +++--- .../core/database/dao/NewsResourceDao.kt | 7 -- .../apps/nowinandroid/sync/di/SyncModule.kt | 7 ++ .../sync/initializers/SyncWorkHelpers.kt | 1 + .../sync/services/SyncNotificationsService.kt | 3 +- .../sync/status/StubSyncSubscriber.kt | 31 +++++ .../sync/status/SyncSubscriber.kt | 24 ++++ .../nowinandroid/sync/workers/SyncWorker.kt | 4 + .../apps/nowinandroid/sync/di/SyncModule.kt | 51 +++++++++ .../sync/status/FirebaseSyncSubscriber.kt | 35 ++++++ 12 files changed, 291 insertions(+), 63 deletions(-) rename sync/work/src/{main => demo}/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt (81%) create mode 100644 sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/StubSyncSubscriber.kt create mode 100644 sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/SyncSubscriber.kt create mode 100644 sync/work/src/prod/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt create mode 100644 sync/work/src/prod/java/com/google/samples/apps/nowinandroid/sync/status/FirebaseSyncSubscriber.kt diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt index 02c58d855..27590b0b7 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt @@ -27,6 +27,7 @@ import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsRes import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions +import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource @@ -45,6 +46,7 @@ private const val SYNC_BATCH_SIZE = 40 * Reads are exclusively from local storage to support offline access. */ class OfflineFirstNewsRepository @Inject constructor( + private val niaPreferencesDataSource: NiaPreferencesDataSource, private val newsResourceDao: NewsResourceDao, private val topicDao: TopicDao, private val network: NiaNetworkDataSource, @@ -72,15 +74,25 @@ class OfflineFirstNewsRepository @Inject constructor( }, modelDeleter = newsResourceDao::deleteNewsResources, modelUpdater = { changedIds -> + val userData = niaPreferencesDataSource.userData.first() + val hasOnBoarded = userData.shouldHideOnboarding + val followedTopicIds = userData.followedTopics + // TODO: Make this more efficient, there is no need to retrieve populated // news resources when all that's needed are the ids - val existingNewsResourceIds = newsResourceDao.getNewsResources( - useFilterNewsIds = true, - filterNewsIds = changedIds.toSet(), - ) - .first() - .map { it.entity.id } - .toSet() + val existingFollowedChangedNewsResourceIds = when { + hasOnBoarded -> newsResourceDao.getNewsResources( + useFilterTopicIds = true, + filterTopicIds = followedTopicIds, + useFilterNewsIds = true, + filterNewsIds = changedIds.toSet(), + ) + .first() + .map { it.entity.id } + .toSet() + // No need to retrieve anything if notifications won't be sent + else -> emptySet() + } changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds -> val networkNewsResources = network.getNewsResources(ids = chunkedIds) @@ -106,19 +118,18 @@ class OfflineFirstNewsRepository @Inject constructor( ) } - val addedNewsResources = newsResourceDao.getNewsResources( - useFilterNewsIds = true, - filterNewsIds = changedIds.toSet(), - ) - .first() - .filter { !existingNewsResourceIds.contains(it.entity.id) } - .map(PopulatedNewsResource::asExternalModel) + if (hasOnBoarded) { + val addedNewsResources = newsResourceDao.getNewsResources( + useFilterTopicIds = true, + filterTopicIds = followedTopicIds, + useFilterNewsIds = true, + filterNewsIds = changedIds.toSet() - existingFollowedChangedNewsResourceIds, + ) + .first() + .map(PopulatedNewsResource::asExternalModel) - // TODO: Define business logic for notifications on first time sync. - // we probably do not want to send notifications on first install. - // We can easily check if the change list version is 0 and not send notifications - // if it is. - if (addedNewsResources.isNotEmpty()) notifier.onNewsAdded(addedNewsResources) + if (addedNewsResources.isNotEmpty()) notifier.onNewsAdded(addedNewsResources) + } }, ) } diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt index 6cdbf67d0..9e6834f8f 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt @@ -34,6 +34,7 @@ import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.testing.notifications.TestNotifier @@ -46,6 +47,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import kotlin.test.assertEquals +import kotlin.test.assertTrue class OfflineFirstNewsRepositoryTest { @@ -53,6 +55,8 @@ class OfflineFirstNewsRepositoryTest { private lateinit var subject: OfflineFirstNewsRepository + private lateinit var niaPreferencesDataSource: NiaPreferencesDataSource + private lateinit var newsResourceDao: TestNewsResourceDao private lateinit var topicDao: TestTopicDao @@ -68,17 +72,19 @@ class OfflineFirstNewsRepositoryTest { @Before fun setup() { + niaPreferencesDataSource = NiaPreferencesDataSource( + tmpFolder.testUserPreferencesDataStore(testScope), + ) newsResourceDao = TestNewsResourceDao() topicDao = TestTopicDao() network = TestNiaNetworkDataSource() notifier = TestNotifier() synchronizer = TestSynchronizer( - NiaPreferencesDataSource( - tmpFolder.testUserPreferencesDataStore(testScope), - ), + niaPreferencesDataSource, ) subject = OfflineFirstNewsRepository( + niaPreferencesDataSource = niaPreferencesDataSource, newsResourceDao = newsResourceDao, topicDao = topicDao, network = network, @@ -130,6 +136,8 @@ class OfflineFirstNewsRepositoryTest { @Test fun offlineFirstNewsRepository_sync_pulls_from_network() = testScope.runTest { + // User has not onboarded + niaPreferencesDataSource.setShouldHideOnboarding(false) subject.syncWith(synchronizer) val newsResourcesFromNetwork = network.getNewsResources() @@ -151,16 +159,16 @@ class OfflineFirstNewsRepositoryTest { actual = synchronizer.getChangeListVersions().newsResourceVersion, ) - // Notifier should have been called with new news resources - assertEquals( - expected = newsResourcesFromDb.map(NewsResource::id).sorted(), - actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(), - ) + // Notifier should not have been called + assertTrue(notifier.addedNewsResources.isEmpty()) } @Test fun offlineFirstNewsRepository_sync_deletes_items_marked_deleted_on_network() = testScope.runTest { + // User has not onboarded + niaPreferencesDataSource.setShouldHideOnboarding(false) + val newsResourcesFromNetwork = network.getNewsResources() .map(NetworkNewsResource::asEntity) .map(NewsResourceEntity::asExternalModel) @@ -198,17 +206,16 @@ class OfflineFirstNewsRepositoryTest { actual = synchronizer.getChangeListVersions().newsResourceVersion, ) - // Notifier should have been called with news resources from network that are not - // deleted - assertEquals( - expected = (newsResourcesFromNetwork.map(NewsResource::id) - deletedItems).sorted(), - actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(), - ) + // Notifier should not have been called + assertTrue(notifier.addedNewsResources.isEmpty()) } @Test fun offlineFirstNewsRepository_incremental_sync_pulls_from_network() = testScope.runTest { + // User has not onboarded + niaPreferencesDataSource.setShouldHideOnboarding(false) + // Set news version to 7 synchronizer.updateChangeListVersions { copy(newsResourceVersion = 7) @@ -244,11 +251,8 @@ class OfflineFirstNewsRepositoryTest { actual = synchronizer.getChangeListVersions().newsResourceVersion, ) - // Notifier should have been called with only added news resources from network - assertEquals( - expected = newsResourcesFromNetwork.map(NewsResource::id).sorted(), - actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(), - ) + // Notifier should not have been called + assertTrue(notifier.addedNewsResources.isEmpty()) } @Test @@ -283,4 +287,68 @@ class OfflineFirstNewsRepositoryTest { .sortedBy(NewsResourceTopicCrossRef::toString), ) } + + @Test + fun offlineFirstNewsRepository_sends_notifications_for_newly_synced_news_that_is_followed() = + testScope.runTest { + // User has onboarded + niaPreferencesDataSource.setShouldHideOnboarding(true) + + val networkNewsResources = network.getNewsResources() + + // Follow roughly half the topics + val followedTopicIds = networkNewsResources + .flatMap(NetworkNewsResource::topicEntityShells) + .mapNotNull { topic -> + when (topic.id.chars().sum() % 2) { + 0 -> topic.id + else -> null + } + } + .toSet() + + // Set followed topics + niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds) + + subject.syncWith(synchronizer) + + // Notifier should have been called with only news resources that have topics + // that the user follows + assertEquals( + expected = networkNewsResources + .filter { (it.topics intersect followedTopicIds).isNotEmpty() } + .map(NetworkNewsResource::id) + .sorted(), + actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(), + ) + } + + @Test + fun offlineFirstNewsRepository_does_not_send_notifications_for_existing_news_resources() = + testScope.runTest { + // User has onboarded + niaPreferencesDataSource.setShouldHideOnboarding(true) + + val networkNewsResources = network.getNewsResources() + .map(NetworkNewsResource::asEntity) + + val newsResources = networkNewsResources + .map(NewsResourceEntity::asExternalModel) + + // Prepopulate dao with news resources + newsResourceDao.upsertNewsResources(networkNewsResources) + + val followedTopicIds = newsResources + .flatMap(NewsResource::topics) + .map(Topic::id) + .toSet() + + // Follow all topics + niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds) + + subject.syncWith(synchronizer) + + // Notifier should not have been called bc all news resources existed previously + assertTrue(notifier.addedNewsResources.isEmpty()) + } } diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt index bb1ac20ab..d5d8932e7 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt @@ -47,7 +47,11 @@ class TestNewsResourceDao : NewsResourceDao { filterNewsIds: Set, ): Flow> = entitiesStateFlow - .map { it.map(NewsResourceEntity::asPopulatedNewsResource) } + .map { newsResourceEntities -> + newsResourceEntities.map { entity -> + entity.asPopulatedNewsResource(topicCrossReferences) + } + } .map { resources -> var result = resources if (useFilterTopicIds) { @@ -78,10 +82,6 @@ class TestNewsResourceDao : NewsResourceDao { return entities.map { it.id.toLong() } } - override suspend fun updateNewsResources(entities: List) { - throw NotImplementedError("Unused in tests") - } - override suspend fun upsertNewsResources(newsResourceEntities: List) { entitiesStateFlow.update { oldValues -> // New values come first so they overwrite old values @@ -109,16 +109,20 @@ class TestNewsResourceDao : NewsResourceDao { } } -private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource( +private fun NewsResourceEntity.asPopulatedNewsResource( + topicCrossReferences: List, +) = PopulatedNewsResource( entity = this, - topics = listOf( - TopicEntity( - id = filteredInterestsIds.random(), - name = "name", - shortDescription = "short description", - longDescription = "long description", - url = "URL", - imageUrl = "image URL", - ), - ), + topics = topicCrossReferences + .filter { it.newsResourceId == id } + .map { newsResourceTopicCrossRef -> + TopicEntity( + id = newsResourceTopicCrossRef.topicId, + name = "name", + shortDescription = "short description", + longDescription = "long description", + url = "URL", + imageUrl = "image URL", + ) + }, ) diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt index 782e5c87a..a05507a8b 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt @@ -21,7 +21,6 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction -import androidx.room.Update import androidx.room.Upsert import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef @@ -72,12 +71,6 @@ interface NewsResourceDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertOrIgnoreNewsResources(entities: List): List - /** - * Updates [entities] in the db that match the primary key, and no-ops if they don't - */ - @Update - suspend fun updateNewsResources(entities: List) - /** * Inserts or updates [newsResourceEntities] in the db under the specified primary keys */ diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt b/sync/work/src/demo/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt similarity index 81% rename from sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt rename to sync/work/src/demo/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt index bbc45dc42..40d094cd2 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt +++ b/sync/work/src/demo/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt @@ -17,6 +17,8 @@ package com.google.samples.apps.nowinandroid.sync.di import com.google.samples.apps.nowinandroid.core.data.util.SyncManager +import com.google.samples.apps.nowinandroid.sync.status.StubSyncSubscriber +import com.google.samples.apps.nowinandroid.sync.status.SyncSubscriber import com.google.samples.apps.nowinandroid.sync.status.WorkManagerSyncManager import dagger.Binds import dagger.Module @@ -30,4 +32,9 @@ interface SyncModule { fun bindsSyncStatusMonitor( syncStatusMonitor: WorkManagerSyncManager, ): SyncManager + + @Binds + fun bindsSyncSubscriber( + syncSubscriber: StubSyncSubscriber, + ): SyncSubscriber } diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt index 334b3f0c7..a3cff5fb9 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt @@ -27,6 +27,7 @@ import androidx.work.ForegroundInfo import androidx.work.NetworkType import com.google.samples.apps.nowinandroid.sync.R +const val SYNC_TOPIC = "sync" private const val SyncNotificationId = 0 private const val SyncNotificationChannelID = "SyncNotificationChannel" diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt index ab318776a..1d182dda1 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt @@ -19,11 +19,10 @@ package com.google.samples.apps.nowinandroid.sync.services import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.google.samples.apps.nowinandroid.core.data.util.SyncManager +import com.google.samples.apps.nowinandroid.sync.initializers.SYNC_TOPIC import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -private const val SYNC_TOPIC = "sync" - @AndroidEntryPoint class SyncNotificationsService : FirebaseMessagingService() { diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/StubSyncSubscriber.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/StubSyncSubscriber.kt new file mode 100644 index 000000000..0ef90fb29 --- /dev/null +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/StubSyncSubscriber.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.sync.status + +import android.util.Log +import javax.inject.Inject + +private const val TAG = "StubSyncSubscriber" + +/** + * Stub implementation of [SyncSubscriber] + */ +class StubSyncSubscriber @Inject constructor() : SyncSubscriber { + override suspend fun subscribe() { + Log.d(TAG, "Subscribing to sync") + } +} diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/SyncSubscriber.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/SyncSubscriber.kt new file mode 100644 index 000000000..b1845b070 --- /dev/null +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/SyncSubscriber.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.sync.status + +/** + * Subscribes to backend requested synchronization + */ +interface SyncSubscriber { + suspend fun subscribe() +} diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt index 211940ddb..d8f1ef91c 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt @@ -34,6 +34,7 @@ import com.google.samples.apps.nowinandroid.core.network.Dispatcher import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.sync.initializers.SyncConstraints import com.google.samples.apps.nowinandroid.sync.initializers.syncForegroundInfo +import com.google.samples.apps.nowinandroid.sync.status.SyncSubscriber import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineDispatcher @@ -54,6 +55,7 @@ class SyncWorker @AssistedInject constructor( private val newsRepository: NewsRepository, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, private val analyticsHelper: AnalyticsHelper, + private val syncSubscriber: SyncSubscriber, ) : CoroutineWorker(appContext, workerParams), Synchronizer { override suspend fun getForegroundInfo(): ForegroundInfo = @@ -63,6 +65,8 @@ class SyncWorker @AssistedInject constructor( traceAsync("Sync", 0) { analyticsHelper.logSyncStarted() + syncSubscriber.subscribe() + // First sync the repositories in parallel val syncedSuccessfully = awaitAll( async { topicRepository.sync() }, diff --git a/sync/work/src/prod/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt b/sync/work/src/prod/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt new file mode 100644 index 000000000..af4508406 --- /dev/null +++ b/sync/work/src/prod/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt @@ -0,0 +1,51 @@ +/* + * 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.sync.di + +import com.google.firebase.ktx.Firebase +import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.messaging.ktx.messaging +import com.google.samples.apps.nowinandroid.core.data.util.SyncManager +import com.google.samples.apps.nowinandroid.sync.status.FirebaseSyncSubscriber +import com.google.samples.apps.nowinandroid.sync.status.SyncSubscriber +import com.google.samples.apps.nowinandroid.sync.status.WorkManagerSyncManager +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface SyncModule { + @Binds + fun bindsSyncStatusMonitor( + syncStatusMonitor: WorkManagerSyncManager, + ): SyncManager + + @Binds + fun bindsSyncSubscriber( + syncSubscriber: FirebaseSyncSubscriber, + ): SyncSubscriber + + companion object { + @Provides + @Singleton + fun provideFirebaseMessaging(): FirebaseMessaging = Firebase.messaging + } +} diff --git a/sync/work/src/prod/java/com/google/samples/apps/nowinandroid/sync/status/FirebaseSyncSubscriber.kt b/sync/work/src/prod/java/com/google/samples/apps/nowinandroid/sync/status/FirebaseSyncSubscriber.kt new file mode 100644 index 000000000..c2405bccc --- /dev/null +++ b/sync/work/src/prod/java/com/google/samples/apps/nowinandroid/sync/status/FirebaseSyncSubscriber.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.sync.status + +import com.google.firebase.messaging.FirebaseMessaging +import com.google.samples.apps.nowinandroid.sync.initializers.SYNC_TOPIC +import kotlinx.coroutines.tasks.await +import javax.inject.Inject + +/** + * Implementation of [SyncSubscriber] that subscribes to the FCM [SYNC_TOPIC] + */ +class FirebaseSyncSubscriber @Inject constructor( + private val firebaseMessaging: FirebaseMessaging, +) : SyncSubscriber { + override suspend fun subscribe() { + firebaseMessaging + .subscribeToTopic(SYNC_TOPIC) + .await() + } +} From bea2eecd903ab44abc54c4abed67da910d71c025 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rton=20Braun?= Date: Wed, 19 Apr 2023 10:15:08 +0200 Subject: [PATCH 71/97] Update Kotlin, Compose compiler, serialization versions Change-Id: I2aa0d07769e79e6f85824a391418566aef6b721e --- .../core/network/retrofit/RetrofitNiaNetwork.kt | 2 -- gradle/libs.versions.toml | 10 +++++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiaNetwork.kt b/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiaNetwork.kt index 9360d2cf1..7e9122ca8 100644 --- a/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiaNetwork.kt +++ b/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiaNetwork.kt @@ -22,7 +22,6 @@ import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory -import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import okhttp3.Call @@ -81,7 +80,6 @@ class RetrofitNiaNetwork @Inject constructor( .baseUrl(NiaBaseUrl) .callFactory(okhttpCallFactory) .addConverterFactory( - @OptIn(ExperimentalSerializationApi::class) networkJson.asConverterFactory("application/json".toMediaType()), ) .build() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 677438412..4adf1e69b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ androidxActivity = "1.7.0" androidxAppCompat = "1.5.1" androidxBrowser = "1.4.0" androidxComposeBom = "2023.01.00" -androidxComposeCompiler = "1.4.1" +androidxComposeCompiler = "1.4.4" androidxComposeMaterial3 = "1.1.0-alpha06" androidxComposeRuntimeTracing = "1.0.0-alpha01" androidxCore = "1.9.0" @@ -37,17 +37,17 @@ hilt = "2.44.2" hiltExt = "1.0.0" jacoco = "0.8.7" junit4 = "4.13.2" -kotlin = "1.8.0" +kotlin = "1.8.10" kotlinxCoroutines = "1.6.4" kotlinxDatetime = "0.4.0" -kotlinxSerializationJson = "1.4.1" -ksp = "1.8.0-1.0.9" +kotlinxSerializationJson = "1.5.0" +ksp = "1.8.10-1.0.9" lint = "30.3.1" okhttp = "4.10.0" protobuf = "3.21.12" protobufPlugin = "0.8.19" retrofit = "2.9.0" -retrofitKotlinxSerializationJson = "0.8.0" +retrofitKotlinxSerializationJson = "1.0.0" room = "2.5.0" secrets = "2.0.1" turbine = "0.12.1" From bf747434cdc8e1ec64b55499fed0a2116e551acc Mon Sep 17 00:00:00 2001 From: Neelansh Sahai Date: Wed, 29 Mar 2023 01:31:06 +0530 Subject: [PATCH 72/97] Add Undo snackbar on Bookmark removal Change-Id: I1fefd6e72378e26ae35b66e032529a116cff9a79 --- feature/bookmarks/build.gradle.kts | 4 +- .../feature/bookmarks/BookmarksScreen.kt | 67 +++++++++++++++++-- .../feature/bookmarks/BookmarksViewModel.kt | 22 ++++++ .../bookmarks/src/main/res/values/strings.xml | 4 +- 4 files changed, 87 insertions(+), 10 deletions(-) diff --git a/feature/bookmarks/build.gradle.kts b/feature/bookmarks/build.gradle.kts index 5dfd7e014..667e674ec 100644 --- a/feature/bookmarks/build.gradle.kts +++ b/feature/bookmarks/build.gradle.kts @@ -14,8 +14,6 @@ * limitations under the License. */ -import com.android.build.api.dsl.ManagedVirtualDevice - plugins { id("nowinandroid.android.feature") id("nowinandroid.android.library.compose") @@ -28,4 +26,4 @@ android { dependencies { implementation(libs.androidx.compose.material3.windowSizeClass) -} \ No newline at end of file +} diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt index 2ed6a76b3..e2eb4524b 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt @@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks import androidx.annotation.VisibleForTesting import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -34,13 +35,23 @@ import androidx.compose.foundation.lazy.grid.GridCells.Adaptive import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration.Short +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult.ActionPerformed import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -50,6 +61,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme @@ -76,12 +89,16 @@ internal fun BookmarksRoute( onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) }, onTopicClick = onTopicClick, modifier = modifier, + shouldDisplayUndoBookmark = viewModel.shouldDisplayUndoBookmark, + undoBookmarkRemoval = viewModel::undoBookmarkRemoval, + clearUndoState = viewModel::clearUndoState, ) } /** * Displays the user's bookmarked articles. Includes support for loading and empty states. */ +@OptIn(ExperimentalMaterial3Api::class) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @Composable internal fun BookmarksScreen( @@ -90,13 +107,51 @@ internal fun BookmarksScreen( onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, + shouldDisplayUndoBookmark: Boolean = false, + undoBookmarkRemoval: () -> Unit = {}, + clearUndoState: () -> Unit = {}, ) { - when (feedState) { - Loading -> LoadingState(modifier) - is Success -> if (feedState.feed.isNotEmpty()) { - BookmarksGrid(feedState, removeFromBookmarks, onNewsResourceViewed, onTopicClick, modifier) - } else { - EmptyState(modifier) + val bookmarkRemovedMessage = stringResource(id = R.string.bookmark_removed) + val undoText = stringResource(id = R.string.undo) + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(shouldDisplayUndoBookmark) { + if (shouldDisplayUndoBookmark) { + val snackBarResult = snackbarHostState.showSnackbar( + message = bookmarkRemovedMessage, + actionLabel = undoText, + duration = Short, + ) + when (snackBarResult) { + ActionPerformed -> { undoBookmarkRemoval() } + else -> { clearUndoState() } + } + } + } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_STOP) { + clearUndoState() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + Scaffold(snackbarHost = { SnackbarHost(hostState = snackbarHostState) }) { + Box( + modifier = Modifier.padding(it).fillMaxSize(), + ) { + when (feedState) { + Loading -> LoadingState(modifier) + is Success -> if (feedState.feed.isNotEmpty()) { + BookmarksGrid(feedState, removeFromBookmarks, onNewsResourceViewed, onTopicClick, modifier) + } else { + EmptyState(modifier) + } + } } } TrackScreenViewEvent(screenName = "Saved") diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt index 8a1869322..7b6cac76a 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt @@ -16,6 +16,9 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository @@ -38,6 +41,9 @@ class BookmarksViewModel @Inject constructor( userNewsResourceRepository: UserNewsResourceRepository, ) : ViewModel() { + var shouldDisplayUndoBookmark by mutableStateOf(false) + private var lastRemovedBookmarkId: String? = null + val feedUiState: StateFlow = userNewsResourceRepository.observeAllBookmarked() .map, NewsFeedUiState>(NewsFeedUiState::Success) @@ -50,6 +56,8 @@ class BookmarksViewModel @Inject constructor( fun removeFromSavedResources(newsResourceId: String) { viewModelScope.launch { + shouldDisplayUndoBookmark = true + lastRemovedBookmarkId = newsResourceId userDataRepository.updateNewsResourceBookmark(newsResourceId, false) } } @@ -59,4 +67,18 @@ class BookmarksViewModel @Inject constructor( userDataRepository.setNewsResourceViewed(newsResourceId, viewed) } } + + fun undoBookmarkRemoval() { + viewModelScope.launch { + lastRemovedBookmarkId?.let { + userDataRepository.updateNewsResourceBookmark(it, true) + } + } + clearUndoState() + } + + fun clearUndoState() { + shouldDisplayUndoBookmark = false + lastRemovedBookmarkId = null + } } diff --git a/feature/bookmarks/src/main/res/values/strings.xml b/feature/bookmarks/src/main/res/values/strings.xml index 61781ad6e..2dd36659e 100644 --- a/feature/bookmarks/src/main/res/values/strings.xml +++ b/feature/bookmarks/src/main/res/values/strings.xml @@ -22,4 +22,6 @@ Menu No saved updates Updates you save will be stored here\nto read later - \ No newline at end of file + Bookmark removed + UNDO + From 80b8ae7a98b616031673d6036a93510840026261 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 22 Apr 2023 13:53:10 +0100 Subject: [PATCH 73/97] Don't rely on `finalizeDsl` API to configure Crashlytics --- ...droidApplicationFirebaseConventionPlugin.kt | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt index 598da727d..7b3a0059f 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import com.android.build.api.dsl.ApplicationExtension import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension import org.gradle.api.Plugin import org.gradle.api.Project @@ -41,15 +41,13 @@ class AndroidApplicationFirebaseConventionPlugin : Plugin { "implementation"(libs.findLibrary("firebase.crashlytics").get()) } - extensions.configure { - finalizeDsl { - it.buildTypes.forEach { buildType -> - // Disable the Crashlytics mapping file upload. This feature should only be - // enabled if a Firebase backend is available and configured in - // google-services.json. - buildType.configure { - mappingFileUploadEnabled = false - } + extensions.configure { + buildTypes.configureEach { + // Disable the Crashlytics mapping file upload. This feature should only be + // enabled if a Firebase backend is available and configured in + // google-services.json. + configure { + mappingFileUploadEnabled = false } } } From 091abad348bc5ff748ec662cf5dbf0990c10446e Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 22 Apr 2023 14:14:05 +0100 Subject: [PATCH 74/97] Simplify String resource loading in NavigationTest.kt --- .../apps/nowinandroid/ui/NavigationTest.kt | 46 +++++++------------ 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt index a37eb7a77..382bd9a46 100644 --- a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt @@ -16,12 +16,14 @@ package com.google.samples.apps.nowinandroid.ui +import androidx.annotation.StringRes import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription @@ -34,10 +36,10 @@ import com.google.samples.apps.nowinandroid.R import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest -import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder +import kotlin.properties.ReadOnlyProperty import com.google.samples.apps.nowinandroid.feature.bookmarks.R as BookmarksR import com.google.samples.apps.nowinandroid.feature.foryou.R as FeatureForyouR import com.google.samples.apps.nowinandroid.feature.interests.R as FeatureInterestsR @@ -69,35 +71,21 @@ class NavigationTest { @get:Rule(order = 2) val composeTestRule = createAndroidComposeRule() - // The strings used for matching in these tests - private lateinit var done: String - private lateinit var navigateUp: String - private lateinit var forYouLoading: String - private lateinit var forYou: String - private lateinit var interests: String - private lateinit var sampleTopic: String - private lateinit var appName: String - private lateinit var saved: String - private lateinit var settings: String - private lateinit var brand: String - private lateinit var ok: String + private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) = + ReadOnlyProperty { _, _ -> activity.getString(resId) } - @Before - fun setup() { - composeTestRule.activity.apply { - done = getString(FeatureForyouR.string.done) - navigateUp = getString(FeatureForyouR.string.navigate_up) - forYouLoading = getString(FeatureForyouR.string.for_you_loading) - forYou = getString(FeatureForyouR.string.for_you) - interests = getString(FeatureInterestsR.string.interests) - sampleTopic = "Headlines" - appName = getString(R.string.app_name) - saved = getString(BookmarksR.string.saved) - settings = getString(SettingsR.string.top_app_bar_action_icon_description) - brand = getString(SettingsR.string.brand_android) - ok = getString(SettingsR.string.dismiss_dialog_button_text) - } - } + // The strings used for matching in these tests + private val done by composeTestRule.stringResource(FeatureForyouR.string.done) + private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.navigate_up) + private val forYouLoading by composeTestRule.stringResource(FeatureForyouR.string.for_you_loading) + private val forYou by composeTestRule.stringResource(FeatureForyouR.string.for_you) + private val interests by composeTestRule.stringResource(FeatureInterestsR.string.interests) + private val sampleTopic = "Headlines" + private val appName by composeTestRule.stringResource(R.string.app_name) + private val saved by composeTestRule.stringResource(BookmarksR.string.saved) + private val settings by composeTestRule.stringResource(SettingsR.string.top_app_bar_action_icon_description) + private val brand by composeTestRule.stringResource(SettingsR.string.brand_android) + private val ok by composeTestRule.stringResource(SettingsR.string.dismiss_dialog_button_text) @Test fun firstScreen_isForYou() { From 9499e559e70e9a579b0cf3051ecc5ef1cf3e92ed Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 22 Apr 2023 14:35:15 +0100 Subject: [PATCH 75/97] Delete no longer used `AUTHORS_ASSET` reference --- .../nowinandroid/core/network/fake/FakeNiaNetworkDataSource.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiaNetworkDataSource.kt b/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiaNetworkDataSource.kt index 4ffddb20a..a3bf8ac0c 100644 --- a/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiaNetworkDataSource.kt +++ b/core/network/src/main/java/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiaNetworkDataSource.kt @@ -40,7 +40,6 @@ class FakeNiaNetworkDataSource @Inject constructor( ) : NiaNetworkDataSource { companion object { - private const val AUTHORS_ASSET = "authors.json" private const val NEWS_ASSET = "news.json" private const val TOPICS_ASSET = "topics.json" } From 3a810f015bafc37bbc059c039b5e6a9643849a93 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Mon, 24 Apr 2023 20:04:11 +0200 Subject: [PATCH 76/97] Remove unused variables --- .../com/google/samples/apps/nowinandroid/ui/NavigationTest.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt index 382bd9a46..5aa3ab02e 100644 --- a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt @@ -75,9 +75,7 @@ class NavigationTest { ReadOnlyProperty { _, _ -> activity.getString(resId) } // The strings used for matching in these tests - private val done by composeTestRule.stringResource(FeatureForyouR.string.done) private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.navigate_up) - private val forYouLoading by composeTestRule.stringResource(FeatureForyouR.string.for_you_loading) private val forYou by composeTestRule.stringResource(FeatureForyouR.string.for_you) private val interests by composeTestRule.stringResource(FeatureInterestsR.string.interests) private val sampleTopic = "Headlines" From 022cd92f4c23a884dbad517965d17b3dd09f2f1d Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 25 Apr 2023 05:38:24 -0400 Subject: [PATCH 77/97] Update core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt Co-authored-by: Don Turner --- .../core/data/repository/OfflineFirstNewsRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt index 27590b0b7..5467deb73 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt @@ -75,7 +75,7 @@ class OfflineFirstNewsRepository @Inject constructor( modelDeleter = newsResourceDao::deleteNewsResources, modelUpdater = { changedIds -> val userData = niaPreferencesDataSource.userData.first() - val hasOnBoarded = userData.shouldHideOnboarding + val hasOnboarded = userData.shouldHideOnboarding val followedTopicIds = userData.followedTopics // TODO: Make this more efficient, there is no need to retrieve populated From 337c940d66b18823807679bb40bba01316ab5d09 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 25 Apr 2023 05:41:30 -0400 Subject: [PATCH 78/97] Add comments explaining backend sync and made variable names easier to grok Change-Id: I90cd7444de95efa20bf243a922a772f7849a23ec --- .../core/data/repository/OfflineFirstNewsRepository.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt index 5467deb73..3e22103b9 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt @@ -80,8 +80,8 @@ class OfflineFirstNewsRepository @Inject constructor( // TODO: Make this more efficient, there is no need to retrieve populated // news resources when all that's needed are the ids - val existingFollowedChangedNewsResourceIds = when { - hasOnBoarded -> newsResourceDao.getNewsResources( + val existingNewsResourceIdsThatHaveChanged = when { + hasOnboarded -> newsResourceDao.getNewsResources( useFilterTopicIds = true, filterTopicIds = followedTopicIds, useFilterNewsIds = true, @@ -94,6 +94,7 @@ class OfflineFirstNewsRepository @Inject constructor( else -> emptySet() } + // Obtain the news resources which have changed from the network and upsert them locally changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds -> val networkNewsResources = network.getNewsResources(ids = chunkedIds) @@ -118,12 +119,12 @@ class OfflineFirstNewsRepository @Inject constructor( ) } - if (hasOnBoarded) { + if (hasOnboarded) { val addedNewsResources = newsResourceDao.getNewsResources( useFilterTopicIds = true, filterTopicIds = followedTopicIds, useFilterNewsIds = true, - filterNewsIds = changedIds.toSet() - existingFollowedChangedNewsResourceIds, + filterNewsIds = changedIds.toSet() - existingNewsResourceIdsThatHaveChanged, ) .first() .map(PopulatedNewsResource::asExternalModel) From f86d174c1bb235bc4bfff9f424013545e4d4b23d Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 25 Apr 2023 05:44:59 -0400 Subject: [PATCH 79/97] Used more explicit variable names in tests Change-Id: I2e0ebead70441eced05eb786aaa7683bfefa8976 --- .../data/repository/OfflineFirstNewsRepositoryTest.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt index 9e6834f8f..d3c1851f9 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt @@ -312,13 +312,15 @@ class OfflineFirstNewsRepositoryTest { subject.syncWith(synchronizer) + val followedNewsResourcesFromNetwork = networkNewsResources + .filter { (it.topics intersect followedTopicIds).isNotEmpty() } + .map(NetworkNewsResource::id) + .sorted() + // Notifier should have been called with only news resources that have topics // that the user follows assertEquals( - expected = networkNewsResources - .filter { (it.topics intersect followedTopicIds).isNotEmpty() } - .map(NetworkNewsResource::id) - .sorted(), + expected = followedNewsResourcesFromNetwork, actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(), ) } From b3f2502ec2846f3c27840c06e20c2062faf836d8 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 25 Apr 2023 06:58:50 -0400 Subject: [PATCH 80/97] Update core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt Co-authored-by: Don Turner --- .../core/data/repository/OfflineFirstNewsRepositoryTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt index d3c1851f9..c24918475 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt @@ -312,7 +312,7 @@ class OfflineFirstNewsRepositoryTest { subject.syncWith(synchronizer) - val followedNewsResourcesFromNetwork = networkNewsResources + val followedNewsResourceIdsFromNetwork = networkNewsResources .filter { (it.topics intersect followedTopicIds).isNotEmpty() } .map(NetworkNewsResource::id) .sorted() From 6b834b6f4c93c8b92e457ba96bd3808bdea0e05e Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 25 Apr 2023 06:59:34 -0400 Subject: [PATCH 81/97] Applied code review suggestions Change-Id: I12fab8e0d27a8a1805e0063054ec3382bebd78f1 --- .../core/data/repository/OfflineFirstNewsRepositoryTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt index c24918475..a38d9c621 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt @@ -320,7 +320,7 @@ class OfflineFirstNewsRepositoryTest { // Notifier should have been called with only news resources that have topics // that the user follows assertEquals( - expected = followedNewsResourcesFromNetwork, + expected = followedNewsResourceIdsFromNetwork, actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(), ) } From b3cdc172cd7596b98b2696e9fc5d1cbeeff30f3f Mon Sep 17 00:00:00 2001 From: Takeshi Hagikura Date: Wed, 26 Apr 2023 13:13:54 +0900 Subject: [PATCH 82/97] Implement search feature (#685) Implement search feature - Add a feature module named "search" - Add a SearchScreen that is navigated by tapping the search icon at the top left corner - Add a data layer that takes care of populating the *Fts tables and querying them by a search query - Add a SearchViewModel that wires up the data layer of the Fts tables with the SearchScreen The SearchScreen has following features: - The user is able to type the search query in the TextField - The search result is displayed as the user types - When the search result is clicked, it navigates to: - The InterestsScreen when a topic is clicked - Chrome custom tab with the URL of the clicked news resource - When the search result is clicked or the IME is explicitly closed by the user, the current search query in the TextField is saved as recent searches - Latest recent searches are displayed in the SearchScreen --- app/build.gradle.kts | 1 + .../nowinandroid/navigation/NiaNavHost.kt | 12 +- .../samples/apps/nowinandroid/ui/NiaApp.kt | 7 +- .../apps/nowinandroid/ui/NiaAppState.kt | 5 + .../core/data/test/TestDataModule.kt | 14 + .../nowinandroid/core/data/di/DataModule.kt | 14 + .../core/data/model/RecentSearchQuery.kt | 31 + .../DefaultRecentSearchRepository.kt | 55 ++ .../DefaultSearchContentsRepository.kt | 85 +++ .../data/repository/RecentSearchRepository.kt | 41 ++ .../repository/SearchContentsRepository.kt | 38 ++ .../fake/FakeRecentSearchRepository.kt | 35 ++ .../fake/FakeSearchContentsRepository.kt | 33 + .../data/testdoubles/TestNewsResourceDao.kt | 2 + .../core/data/testdoubles/TestTopicDao.kt | 2 + .../13.json | 282 +++++++++ .../14.json | 308 ++++++++++ .../nowinandroid/core/database/DaosModule.kt | 18 + .../nowinandroid/core/database/NiaDatabase.kt | 16 +- .../core/database/dao/NewsResourceDao.kt | 4 + .../core/database/dao/NewsResourceFtsDao.kt | 39 ++ .../core/database/dao/RecentSearchQueryDao.kt | 38 ++ .../core/database/dao/TopicDao.kt | 3 + .../core/database/dao/TopicFtsDao.kt | 39 ++ .../database/model/NewsResourceFtsEntity.kt | 44 ++ .../database/model/PopulatedNewsResource.kt | 6 + .../database/model/RecentSearchQueryEntity.kt | 35 ++ .../core/database/model/TopicFtsEntity.kt | 48 ++ .../domain/GetRecentSearchQueriesUseCase.kt | 32 + .../domain/GetSearchContentsCountUseCase.kt | 31 + .../core/domain/GetSearchContentsUseCase.kt | 61 ++ .../core/model/data/SearchResult.kt.kt | 23 + .../core/model/data/UserSearchResult.kt | 26 + .../repository/TestRecentSearchRepository.kt | 38 ++ .../TestSearchContentsRepository.kt | 65 ++ .../apps/nowinandroid/core/ui/NewsFeed.kt | 2 + ...serNewsResourcePreviewParameterProvider.kt | 192 +++--- .../feature/interests/TabContent.kt | 7 +- feature/search/.gitignore | 1 + feature/search/build.gradle.kts | 33 + .../feature/search/SearchScreenTest.kt | 218 +++++++ feature/search/src/main/AndroidManifest.xml | 17 + .../search/RecentSearchQueriesUiState.kt | 27 + .../feature/search/SearchResultUiState.kt | 46 ++ .../feature/search/SearchScreen.kt | 565 ++++++++++++++++++ .../SearchUiStatePreviewParameterProvider.kt | 38 ++ .../feature/search/SearchViewModel.kt | 122 ++++ .../search/navigation/SearchNavigation.kt | 45 ++ .../search/src/main/res/values/strings.xml | 29 + .../feature/search/SearchViewModelTest.kt | 128 ++++ .../settings/src/main/res/values/strings.xml | 1 + settings.gradle.kts | 1 + .../nowinandroid/sync/workers/SyncWorker.kt | 3 + 53 files changed, 2905 insertions(+), 101 deletions(-) create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/model/RecentSearchQuery.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultRecentSearchRepository.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/RecentSearchRepository.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/SearchContentsRepository.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeRecentSearchRepository.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeSearchContentsRepository.kt create mode 100644 core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/13.json create mode 100644 core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/14.json create mode 100644 core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceFtsDao.kt create mode 100644 core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/RecentSearchQueryDao.kt create mode 100644 core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicFtsDao.kt create mode 100644 core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceFtsEntity.kt create mode 100644 core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/RecentSearchQueryEntity.kt create mode 100644 core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/TopicFtsEntity.kt create mode 100644 core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetRecentSearchQueriesUseCase.kt create mode 100644 core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsCountUseCase.kt create mode 100644 core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsUseCase.kt create mode 100644 core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/SearchResult.kt.kt create mode 100644 core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserSearchResult.kt create mode 100644 core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestRecentSearchRepository.kt create mode 100644 core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestSearchContentsRepository.kt create mode 100644 feature/search/.gitignore create mode 100644 feature/search/build.gradle.kts create mode 100644 feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt create mode 100644 feature/search/src/main/AndroidManifest.xml create mode 100644 feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/RecentSearchQueriesUiState.kt create mode 100644 feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt create mode 100644 feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt create mode 100644 feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt create mode 100644 feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt create mode 100644 feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt create mode 100644 feature/search/src/main/res/values/strings.xml create mode 100644 feature/search/src/test/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 81c128b91..42dee2602 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,6 +83,7 @@ dependencies { implementation(project(":feature:foryou")) implementation(project(":feature:bookmarks")) implementation(project(":feature:topic")) + implementation(project(":feature:search")) implementation(project(":feature:settings")) implementation(project(":core:common")) diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt index bc950ee92..e43dfaba7 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt @@ -18,14 +18,16 @@ package com.google.samples.apps.nowinandroid.navigation import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph +import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen +import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS +import com.google.samples.apps.nowinandroid.ui.NiaAppState /** * Top-level navigation graph. Navigation is organized as explained at @@ -36,10 +38,11 @@ import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen */ @Composable fun NiaNavHost( - navController: NavHostController, + appState: NiaAppState, modifier: Modifier = Modifier, startDestination: String = forYouNavigationRoute, ) { + val navController = appState.navController NavHost( navController = navController, startDestination = startDestination, @@ -48,6 +51,11 @@ fun NiaNavHost( // TODO: handle topic clicks from each top level destination forYouScreen(onTopicClick = {}) bookmarksScreen(onTopicClick = {}) + searchScreen( + onBackClick = navController::popBackStack, + onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) }, + onTopicClick = navController::navigateToTopic, + ) interestsGraph( onTopicClick = { topicId -> navController.navigateToTopic(topicId) diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index 780849cf2..83fa4d45b 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -173,6 +173,10 @@ fun NiaApp( if (destination != null) { NiaTopAppBar( titleRes = destination.titleTextId, + navigationIcon = NiaIcons.Search, + navigationIconContentDescription = stringResource( + id = settingsR.string.top_app_bar_navigation_icon_description, + ), actionIcon = NiaIcons.Settings, actionIconContentDescription = stringResource( id = settingsR.string.top_app_bar_action_icon_description, @@ -181,10 +185,11 @@ fun NiaApp( containerColor = Color.Transparent, ), onActionClick = { appState.setShowSettingsDialog(true) }, + onNavigationClick = { appState.navigateToSearch() }, ) } - NiaNavHost(appState.navController) + NiaNavHost(appState) } // TODO: We may want to add padding or spacer when the snackbar is shown so that diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt index df6fe1da2..fb6ae1bc6 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -42,6 +42,7 @@ import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavi import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsRoute import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph +import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU @@ -172,6 +173,10 @@ class NiaAppState( fun setShowSettingsDialog(shouldShow: Boolean) { shouldShowSettingsDialog = shouldShow } + + fun navigateToSearch() { + navController.navigateToSearch() + } } /** diff --git a/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt b/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt index f4fc9c7b0..2ec2bcf9c 100644 --- a/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt +++ b/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt @@ -18,9 +18,13 @@ package com.google.samples.apps.nowinandroid.core.data.test import com.google.samples.apps.nowinandroid.core.data.di.DataModule import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository +import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeNewsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeRecentSearchRepository +import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeSearchContentsRepository import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeUserDataRepository import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor @@ -50,6 +54,16 @@ interface TestDataModule { userDataRepository: FakeUserDataRepository, ): UserDataRepository + @Binds + fun bindsRecentSearchRepository( + recentSearchRepository: FakeRecentSearchRepository, + ): RecentSearchRepository + + @Binds + fun bindsSearchContentsRepository( + searchContentsRepository: FakeSearchContentsRepository, + ): SearchContentsRepository + @Binds fun bindsNetworkMonitor( networkMonitor: AlwaysOnlineNetworkMonitor, diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt index b4dda701e..26f0bbc51 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt @@ -16,10 +16,14 @@ package com.google.samples.apps.nowinandroid.core.data.di +import com.google.samples.apps.nowinandroid.core.data.repository.DefaultRecentSearchRepository +import com.google.samples.apps.nowinandroid.core.data.repository.DefaultSearchContentsRepository import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstNewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstTopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstUserDataRepository +import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository +import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.util.ConnectivityManagerNetworkMonitor @@ -48,6 +52,16 @@ interface DataModule { userDataRepository: OfflineFirstUserDataRepository, ): UserDataRepository + @Binds + fun bindsRecentSearchRepository( + recentSearchRepository: DefaultRecentSearchRepository, + ): RecentSearchRepository + + @Binds + fun bindsSearchContentsRepository( + searchContentsRepository: DefaultSearchContentsRepository, + ): SearchContentsRepository + @Binds fun bindsNetworkMonitor( networkMonitor: ConnectivityManagerNetworkMonitor, diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/model/RecentSearchQuery.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/model/RecentSearchQuery.kt new file mode 100644 index 000000000..76dd08811 --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/model/RecentSearchQuery.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.model + +import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +data class RecentSearchQuery( + val query: String, + val queriedDate: Instant = Clock.System.now(), +) + +fun RecentSearchQueryEntity.asExternalModel() = RecentSearchQuery( + query = query, + queriedDate = queriedDate, +) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultRecentSearchRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultRecentSearchRepository.kt new file mode 100644 index 000000000..983c6af3e --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultRecentSearchRepository.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.repository + +import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery +import com.google.samples.apps.nowinandroid.core.data.model.asExternalModel +import com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao +import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity +import com.google.samples.apps.nowinandroid.core.network.Dispatcher +import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import javax.inject.Inject + +class DefaultRecentSearchRepository @Inject constructor( + private val recentSearchQueryDao: RecentSearchQueryDao, + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, +) : RecentSearchRepository { + override suspend fun insertOrReplaceRecentSearch(searchQuery: String) { + withContext(ioDispatcher) { + recentSearchQueryDao.insertOrReplaceRecentSearchQuery( + RecentSearchQueryEntity( + query = searchQuery, + queriedDate = Clock.System.now(), + ), + ) + } + } + + override fun getRecentSearchQueries(limit: Int): Flow> = + recentSearchQueryDao.getRecentSearchQueryEntities(limit).map { searchQueries -> + searchQueries.map { + it.asExternalModel() + } + } + + override suspend fun clearRecentSearches() = recentSearchQueryDao.clearRecentSearchQueries() +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt new file mode 100644 index 000000000..40b170cbe --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.repository + +import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao +import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao +import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao +import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao +import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel +import com.google.samples.apps.nowinandroid.core.database.model.asFtsEntity +import com.google.samples.apps.nowinandroid.core.model.data.SearchResult +import com.google.samples.apps.nowinandroid.core.network.Dispatcher +import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class DefaultSearchContentsRepository @Inject constructor( + private val newsResourceDao: NewsResourceDao, + private val newsResourceFtsDao: NewsResourceFtsDao, + private val topicDao: TopicDao, + private val topicFtsDao: TopicFtsDao, + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, +) : SearchContentsRepository { + + override suspend fun populateFtsData() { + withContext(ioDispatcher) { + newsResourceFtsDao.insertAll( + newsResourceDao.getOneOffNewsResources().map { it.asFtsEntity() }, + ) + topicFtsDao.insertAll(topicDao.getOneOffTopicEntities().map { it.asFtsEntity() }) + } + } + + override fun searchContents(searchQuery: String): Flow { + // Surround the query by asterisks to match the query when it's in the middle of + // a word + val newsResourceIds = newsResourceFtsDao.searchAllNewsResources("*$searchQuery*") + val topicIds = topicFtsDao.searchAllTopics("*$searchQuery*") + + val newsResourcesFlow = newsResourceIds + .mapLatest { it.toSet() } + .distinctUntilChanged() + .flatMapLatest { + newsResourceDao.getNewsResources(useFilterNewsIds = true, filterNewsIds = it) + } + val topicsFlow = topicIds + .mapLatest { it.toSet() } + .distinctUntilChanged() + .flatMapLatest(topicDao::getTopicEntities) + return combine(newsResourcesFlow, topicsFlow) { newsResources, topics -> + SearchResult( + topics = topics.map { it.asExternalModel() }, + newsResources = newsResources.map { it.asExternalModel() }, + ) + } + } + + override fun getSearchContentsCount(): Flow = + combine( + newsResourceFtsDao.getCount(), + topicFtsDao.getCount(), + ) { newsResourceCount, topicsCount -> + newsResourceCount + topicsCount + } +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/RecentSearchRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/RecentSearchRepository.kt new file mode 100644 index 000000000..87a2ce9dc --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/RecentSearchRepository.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.repository + +import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery +import kotlinx.coroutines.flow.Flow + +/** + * Data layer interface for the recent searches. + */ +interface RecentSearchRepository { + + /** + * Get the recent search queries up to the number of queries specified as [limit]. + */ + fun getRecentSearchQueries(limit: Int): Flow> + + /** + * Insert or replace the [searchQuery] as part of the recent searches. + */ + suspend fun insertOrReplaceRecentSearch(searchQuery: String) + + /** + * Clear the recent searches. + */ + suspend fun clearRecentSearches() +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/SearchContentsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/SearchContentsRepository.kt new file mode 100644 index 000000000..2fe6bd820 --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/SearchContentsRepository.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.repository + +import com.google.samples.apps.nowinandroid.core.model.data.SearchResult +import kotlinx.coroutines.flow.Flow + +/** + * Data layer interface for the search feature. + */ +interface SearchContentsRepository { + + /** + * Populate the fts tables for the search contents. + */ + suspend fun populateFtsData() + + /** + * Query the contents matched with the [searchQuery] and returns it as a [Flow] of [SearchResult] + */ + fun searchContents(searchQuery: String): Flow + + fun getSearchContentsCount(): Flow +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeRecentSearchRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeRecentSearchRepository.kt new file mode 100644 index 000000000..fc649f3ec --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeRecentSearchRepository.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.repository.fake + +import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery +import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +/** + * Fake implementation of the [RecentSearchRepository] + */ +class FakeRecentSearchRepository @Inject constructor() : RecentSearchRepository { + override suspend fun insertOrReplaceRecentSearch(searchQuery: String) { /* no-op */ } + + override fun getRecentSearchQueries(limit: Int): Flow> = + flowOf(emptyList()) + + override suspend fun clearRecentSearches() { /* no-op */ } +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeSearchContentsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeSearchContentsRepository.kt new file mode 100644 index 000000000..d15890a10 --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeSearchContentsRepository.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.repository.fake + +import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository +import com.google.samples.apps.nowinandroid.core.model.data.SearchResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +/** + * Fake implementation of the [SearchContentsRepository] + */ +class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository { + + override suspend fun populateFtsData() { /* no-op */ } + override fun searchContents(searchQuery: String): Flow = flowOf() + override fun getSearchContentsCount(): Flow = flowOf(1) +} diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt index d5d8932e7..09af77213 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt @@ -67,6 +67,8 @@ class TestNewsResourceDao : NewsResourceDao { result } + override suspend fun getOneOffNewsResources(): List = emptyList() + override suspend fun insertOrIgnoreNewsResources( entities: List, ): List { diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt index c0cef479f..e891dcfdc 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt @@ -43,6 +43,8 @@ class TestTopicDao : TopicDao { getTopicEntities() .map { topics -> topics.filter { it.id in ids } } + override suspend fun getOneOffTopicEntities(): List = emptyList() + override suspend fun insertOrIgnoreTopics(topicEntities: List): List { // Keep old values over new values entitiesStateFlow.update { oldValues -> diff --git a/core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/13.json b/core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/13.json new file mode 100644 index 000000000..387049dea --- /dev/null +++ b/core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/13.json @@ -0,0 +1,282 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "b6b299e53da623b16360975581ebfcfe", + "entities": [ + { + "tableName": "news_resources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "headerImageUrl", + "columnName": "header_image_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishDate", + "columnName": "publish_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "news_resources_topics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `topic_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "newsResourceId", + "columnName": "news_resource_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "topicId", + "columnName": "topic_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "news_resource_id", + "topic_id" + ] + }, + "indices": [ + { + "name": "index_news_resources_topics_news_resource_id", + "unique": false, + "columnNames": [ + "news_resource_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_topics_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)" + }, + { + "name": "index_news_resources_topics_topic_id", + "unique": false, + "columnNames": [ + "topic_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_topics_topic_id` ON `${TABLE_NAME}` (`topic_id`)" + } + ], + "foreignKeys": [ + { + "table": "news_resources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "news_resource_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "topics", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "topic_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [], + "tableName": "newsResourcesFts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`newsResourceId` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "newsResourceId", + "columnName": "newsResourceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "topics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "longDescription", + "columnName": "longDescription", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [], + "tableName": "topicsFts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`topicId` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "topicId", + "columnName": "topicId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "longDescription", + "columnName": "longDescription", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b6b299e53da623b16360975581ebfcfe')" + ] + } +} \ No newline at end of file diff --git a/core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/14.json b/core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/14.json new file mode 100644 index 000000000..aa90a9723 --- /dev/null +++ b/core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/14.json @@ -0,0 +1,308 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "51271b81bde7c7997d67fb23c8f31780", + "entities": [ + { + "tableName": "news_resources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "headerImageUrl", + "columnName": "header_image_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishDate", + "columnName": "publish_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "news_resources_topics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `topic_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "newsResourceId", + "columnName": "news_resource_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "topicId", + "columnName": "topic_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "news_resource_id", + "topic_id" + ] + }, + "indices": [ + { + "name": "index_news_resources_topics_news_resource_id", + "unique": false, + "columnNames": [ + "news_resource_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_topics_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)" + }, + { + "name": "index_news_resources_topics_topic_id", + "unique": false, + "columnNames": [ + "topic_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_topics_topic_id` ON `${TABLE_NAME}` (`topic_id`)" + } + ], + "foreignKeys": [ + { + "table": "news_resources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "news_resource_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "topics", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "topic_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [], + "tableName": "newsResourcesFts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`newsResourceId` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "newsResourceId", + "columnName": "newsResourceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "topics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "longDescription", + "columnName": "longDescription", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [], + "tableName": "topicsFts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`topicId` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "topicId", + "columnName": "topicId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "longDescription", + "columnName": "longDescription", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "recentSearchQueries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `queriedDate` INTEGER NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "queriedDate", + "columnName": "queriedDate", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '51271b81bde7c7997d67fb23c8f31780')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/DaosModule.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/DaosModule.kt index 1cb17f110..34840a733 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/DaosModule.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/DaosModule.kt @@ -17,7 +17,10 @@ package com.google.samples.apps.nowinandroid.core.database import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao +import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao +import com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao +import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -35,4 +38,19 @@ object DaosModule { fun providesNewsResourceDao( database: NiaDatabase, ): NewsResourceDao = database.newsResourceDao() + + @Provides + fun providesTopicFtsDao( + database: NiaDatabase, + ): TopicFtsDao = database.topicFtsDao() + + @Provides + fun providesNewsResourceFtsDao( + database: NiaDatabase, + ): NewsResourceFtsDao = database.newsResourceFtsDao() + + @Provides + fun providesRecentSearchQueryDao( + database: NiaDatabase, + ): RecentSearchQueryDao = database.recentSearchQueryDao() } diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt index 83bd46967..96714f9a9 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt @@ -21,10 +21,16 @@ import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao +import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao +import com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao +import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity +import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceFtsEntity import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef +import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity +import com.google.samples.apps.nowinandroid.core.database.model.TopicFtsEntity import com.google.samples.apps.nowinandroid.core.database.util.InstantConverter import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeConverter @@ -32,9 +38,12 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC entities = [ NewsResourceEntity::class, NewsResourceTopicCrossRef::class, + NewsResourceFtsEntity::class, TopicEntity::class, + TopicFtsEntity::class, + RecentSearchQueryEntity::class, ], - version = 12, + version = 14, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3, spec = DatabaseMigrations.Schema2to3::class), @@ -47,6 +56,8 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC AutoMigration(from = 9, to = 10), AutoMigration(from = 10, to = 11, spec = DatabaseMigrations.Schema10to11::class), AutoMigration(from = 11, to = 12, spec = DatabaseMigrations.Schema11to12::class), + AutoMigration(from = 12, to = 13), + AutoMigration(from = 13, to = 14), ], exportSchema = true, ) @@ -57,4 +68,7 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC abstract class NiaDatabase : RoomDatabase() { abstract fun topicDao(): TopicDao abstract fun newsResourceDao(): NewsResourceDao + abstract fun topicFtsDao(): TopicFtsDao + abstract fun newsResourceFtsDao(): NewsResourceFtsDao + abstract fun recentSearchQueryDao(): RecentSearchQueryDao } diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt index a05507a8b..b5949c6d2 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt @@ -65,6 +65,10 @@ interface NewsResourceDao { filterNewsIds: Set = emptySet(), ): Flow> + @Transaction + @Query(value = "SELECT * FROM news_resources ORDER BY publish_date DESC") + suspend fun getOneOffNewsResources(): List + /** * Inserts [entities] into the db if they don't exist, and ignores those that do */ diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceFtsDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceFtsDao.kt new file mode 100644 index 000000000..86cc5529e --- /dev/null +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceFtsDao.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceFtsEntity +import kotlinx.coroutines.flow.Flow + +/** + * DAO for [NewsResourceFtsEntity] access. + */ +@Dao +interface NewsResourceFtsDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(newsResources: List) + + @Query("SELECT newsResourceId FROM newsResourcesFts WHERE newsResourcesFts MATCH :query") + fun searchAllNewsResources(query: String): Flow> + + @Query("SELECT count(*) FROM newsResourcesFts") + fun getCount(): Flow +} diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/RecentSearchQueryDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/RecentSearchQueryDao.kt new file mode 100644 index 000000000..826575828 --- /dev/null +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/RecentSearchQueryDao.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity +import kotlinx.coroutines.flow.Flow + +/** + * DAO for [RecentSearchQueryEntity] access + */ +@Dao +interface RecentSearchQueryDao { + @Query(value = "SELECT * FROM recentSearchQueries ORDER BY queriedDate DESC LIMIT :limit") + fun getRecentSearchQueryEntities(limit: Int): Flow> + + @Upsert + suspend fun insertOrReplaceRecentSearchQuery(recentSearchQuery: RecentSearchQueryEntity) + + @Query(value = "DELETE FROM recentSearchQueries") + suspend fun clearRecentSearchQueries() +} diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt index 37724af69..693a85b77 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt @@ -41,6 +41,9 @@ interface TopicDao { @Query(value = "SELECT * FROM topics") fun getTopicEntities(): Flow> + @Query(value = "SELECT * FROM topics") + suspend fun getOneOffTopicEntities(): List + @Query( value = """ SELECT * FROM topics diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicFtsDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicFtsDao.kt new file mode 100644 index 000000000..25dea5b83 --- /dev/null +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicFtsDao.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.google.samples.apps.nowinandroid.core.database.model.TopicFtsEntity +import kotlinx.coroutines.flow.Flow + +/** + * DAO for [TopicFtsEntity] access. + */ +@Dao +interface TopicFtsDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(topics: List) + + @Query("SELECT topicId FROM topicsFts WHERE topicsFts MATCH :query") + fun searchAllTopics(query: String): Flow> + + @Query("SELECT count(*) FROM topicsFts") + fun getCount(): Flow +} diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceFtsEntity.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceFtsEntity.kt new file mode 100644 index 000000000..0ef9333c1 --- /dev/null +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceFtsEntity.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.database.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Fts4 + +/** + * Fts entity for the news resources. See https://developer.android.com/reference/androidx/room/Fts4. + */ +@Entity(tableName = "newsResourcesFts") +@Fts4 +data class NewsResourceFtsEntity( + + @ColumnInfo(name = "newsResourceId") + val newsResourceId: String, + + @ColumnInfo(name = "title") + val title: String, + + @ColumnInfo(name = "content") + val content: String, +) + +fun NewsResourceEntity.asFtsEntity() = NewsResourceFtsEntity( + newsResourceId = id, + title = title, + content = content, +) diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/PopulatedNewsResource.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/PopulatedNewsResource.kt index ec8acfb3f..a70342401 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/PopulatedNewsResource.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/PopulatedNewsResource.kt @@ -49,3 +49,9 @@ fun PopulatedNewsResource.asExternalModel() = NewsResource( type = entity.type, topics = topics.map(TopicEntity::asExternalModel), ) + +fun PopulatedNewsResource.asFtsEntity() = NewsResourceFtsEntity( + newsResourceId = entity.id, + title = entity.title, + content = entity.content, +) diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/RecentSearchQueryEntity.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/RecentSearchQueryEntity.kt new file mode 100644 index 000000000..9c7439233 --- /dev/null +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/RecentSearchQueryEntity.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.database.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.datetime.Instant + +/** + * Defines an database entity that stored recent search queries. + */ +@Entity( + tableName = "recentSearchQueries", +) +data class RecentSearchQueryEntity( + @PrimaryKey + val query: String, + @ColumnInfo + val queriedDate: Instant, +) diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/TopicFtsEntity.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/TopicFtsEntity.kt new file mode 100644 index 000000000..23d56f2df --- /dev/null +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/TopicFtsEntity.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.database.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Fts4 + +/** + * Fts entity for the topic. See https://developer.android.com/reference/androidx/room/Fts4. + */ +@Entity(tableName = "topicsFts") +@Fts4 +data class TopicFtsEntity( + + @ColumnInfo(name = "topicId") + val topicId: String, + + @ColumnInfo(name = "name") + val name: String, + + @ColumnInfo(name = "shortDescription") + val shortDescription: String, + + @ColumnInfo(name = "longDescription") + val longDescription: String, +) + +fun TopicEntity.asFtsEntity() = TopicFtsEntity( + topicId = id, + name = name, + shortDescription = shortDescription, + longDescription = longDescription, +) diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetRecentSearchQueriesUseCase.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetRecentSearchQueriesUseCase.kt new file mode 100644 index 000000000..51f87d6fd --- /dev/null +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetRecentSearchQueriesUseCase.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain + +import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery +import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +/** + * A use case which returns the recent search queries. + */ +class GetRecentSearchQueriesUseCase @Inject constructor( + private val recentSearchRepository: RecentSearchRepository, +) { + operator fun invoke(limit: Int = 10): Flow> = + recentSearchRepository.getRecentSearchQueries(limit) +} diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsCountUseCase.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsCountUseCase.kt new file mode 100644 index 000000000..3e3e1952e --- /dev/null +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsCountUseCase.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain + +import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +/** + * A use case which returns total count of *Fts tables + */ +class GetSearchContentsCountUseCase @Inject constructor( + private val searchContentsRepository: SearchContentsRepository, +) { + operator fun invoke(): Flow = + searchContentsRepository.getSearchContentsCount() +} diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsUseCase.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsUseCase.kt new file mode 100644 index 000000000..d1065e87c --- /dev/null +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsUseCase.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain + +import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.SearchResult +import com.google.samples.apps.nowinandroid.core.model.data.UserData +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.UserSearchResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +/** + * A use case which returns the searched contents matched with the search query. + */ +class GetSearchContentsUseCase @Inject constructor( + private val searchContentsRepository: SearchContentsRepository, + private val userDataRepository: UserDataRepository, +) { + + operator fun invoke( + searchQuery: String, + ): Flow = + searchContentsRepository.searchContents(searchQuery) + .mapToUserSearchResult(userDataRepository.userData) +} + +private fun Flow.mapToUserSearchResult(userDataStream: Flow): Flow = + combine(userDataStream) { searchResult, userData -> + UserSearchResult( + topics = searchResult.topics.map { topic -> + FollowableTopic( + topic = topic, + isFollowed = topic.id in userData.followedTopics, + ) + }, + newsResources = searchResult.newsResources.map { news -> + UserNewsResource( + newsResource = news, + userData = userData, + ) + }, + ) + } diff --git a/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/SearchResult.kt.kt b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/SearchResult.kt.kt new file mode 100644 index 000000000..060347035 --- /dev/null +++ b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/SearchResult.kt.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.model.data + +/** An entity that holds the search result */ +data class SearchResult( + val topics: List = emptyList(), + val newsResources: List = emptyList(), +) diff --git a/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserSearchResult.kt b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserSearchResult.kt new file mode 100644 index 000000000..acc2cdc69 --- /dev/null +++ b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserSearchResult.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.model.data + +/** + * An entity of [SearchResult] with additional user information such as whether the user is + * following a topic. + */ +data class UserSearchResult( + val topics: List = emptyList(), + val newsResources: List = emptyList(), +) diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestRecentSearchRepository.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestRecentSearchRepository.kt new file mode 100644 index 000000000..961473401 --- /dev/null +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestRecentSearchRepository.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.testing.repository + +import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery +import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class TestRecentSearchRepository : RecentSearchRepository { + + private val cachedRecentSearches: MutableList = mutableListOf() + + override fun getRecentSearchQueries(limit: Int): Flow> = + flowOf(cachedRecentSearches.sortedByDescending { it.queriedDate }.take(limit)) + + override suspend fun insertOrReplaceRecentSearch(searchQuery: String) { + cachedRecentSearches.add(RecentSearchQuery(searchQuery)) + } + + override suspend fun clearRecentSearches() { + cachedRecentSearches.clear() + } +} diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestSearchContentsRepository.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestSearchContentsRepository.kt new file mode 100644 index 000000000..2aa54e463 --- /dev/null +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestSearchContentsRepository.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.testing.repository + +import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import com.google.samples.apps.nowinandroid.core.model.data.SearchResult +import com.google.samples.apps.nowinandroid.core.model.data.Topic +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf + +class TestSearchContentsRepository : SearchContentsRepository { + + private val cachedTopics: MutableList = mutableListOf() + private val cachedNewsResources: MutableList = mutableListOf() + + override suspend fun populateFtsData() { /* no-op */ } + + override fun searchContents(searchQuery: String): Flow = flowOf( + SearchResult( + topics = cachedTopics.filter { + it.name.contains(searchQuery) || + it.shortDescription.contains(searchQuery) || + it.longDescription.contains(searchQuery) + }, + newsResources = cachedNewsResources.filter { + it.content.contains(searchQuery) || + it.title.contains(searchQuery) + }, + ), + ) + + override fun getSearchContentsCount(): Flow = flow { + emit(cachedTopics.size + cachedNewsResources.size) + } + + /** + * Test only method to add the topics to the stored list in memory + */ + fun addTopics(topics: List) { + cachedTopics.addAll(topics) + } + + /** + * Test only method to add the news resources to the stored list in memory + */ + fun addNewsResources(newsResources: List) { + cachedNewsResources.addAll(newsResources) + } +} diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt index 981de26f9..58ec216fd 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt @@ -52,6 +52,7 @@ fun LazyGridScope.newsFeed( onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, + onExpandedCardClick: () -> Unit = {}, ) { when (feedState) { NewsFeedUiState.Loading -> Unit @@ -68,6 +69,7 @@ fun LazyGridScope.newsFeed( userNewsResource = userNewsResource, isBookmarked = userNewsResource.isSaved, onClick = { + onExpandedCardClick() analyticsHelper.logNewsResourceOpened( newsResourceId = userNewsResource.id, ) diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt index 3f3f9bddd..84d3ce165 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt @@ -25,6 +25,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource +import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.newsResources import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone @@ -36,101 +37,102 @@ import kotlinx.datetime.toInstant * provides list of [UserNewsResource] for Composable previews. */ class UserNewsResourcePreviewParameterProvider : PreviewParameterProvider> { - override val values: Sequence> - get() { - val userData: UserData = UserData( - bookmarkedNewsResources = setOf("1", "3"), - viewedNewsResources = setOf("1", "2", "4"), - followedTopics = emptySet(), - themeBrand = ThemeBrand.ANDROID, - darkThemeConfig = DarkThemeConfig.DARK, - shouldHideOnboarding = true, - useDynamicColor = false, - ) - val topics = listOf( - Topic( - id = "2", - name = "Headlines", - shortDescription = "News we want everyone to see", - longDescription = "Stay up to date with the latest events and announcements from Android!", - imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f", - url = "", - ), - Topic( - id = "3", - name = "UI", - shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets", - longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on tocpis such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!", - imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594", - url = "", - ), - Topic( - id = "4", - name = "Testing", - shortDescription = "CI, Espresso, TestLab, etc", - longDescription = "Testing is an integral part of the app development process. By running tests against your app consistently, you can verify your app's correctness, functional behavior, and usability before you release it publicly. Stay up to date on the latest tricks in CI, Espresso, and Firebase TestLab.", - imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Testing.svg?alt=media&token=a11533c4-7cc8-4b11-91a3-806158ebf428", - url = "", - ), - ) + override val values: Sequence> = sequenceOf(newsResources) +} + +object PreviewParameterData { + + private val userData: UserData = UserData( + bookmarkedNewsResources = setOf("1", "3"), + viewedNewsResources = setOf("1", "2", "4"), + followedTopics = emptySet(), + themeBrand = ThemeBrand.ANDROID, + darkThemeConfig = DarkThemeConfig.DARK, + shouldHideOnboarding = true, + useDynamicColor = false, + ) + + val topics = listOf( + Topic( + id = "2", + name = "Headlines", + shortDescription = "News we want everyone to see", + longDescription = "Stay up to date with the latest events and announcements from Android!", + imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f", + url = "", + ), + Topic( + id = "3", + name = "UI", + shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets", + longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on tocpis such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!", + imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594", + url = "", + ), + Topic( + id = "4", + name = "Testing", + shortDescription = "CI, Espresso, TestLab, etc", + longDescription = "Testing is an integral part of the app development process. By running tests against your app consistently, you can verify your app's correctness, functional behavior, and usability before you release it publicly. Stay up to date on the latest tricks in CI, Espresso, and Firebase TestLab.", + imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Testing.svg?alt=media&token=a11533c4-7cc8-4b11-91a3-806158ebf428", + url = "", + ), + ) - return sequenceOf( - listOf( - UserNewsResource( - newsResource = NewsResource( - id = "1", - title = "Android Basics with Compose", - content = "We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. You’ll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Android’s modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey", - url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html", - headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg", - publishDate = LocalDateTime( - year = 2022, - monthNumber = 5, - dayOfMonth = 4, - hour = 23, - minute = 0, - second = 0, - nanosecond = 0, - ).toInstant(TimeZone.UTC), - type = NewsResourceType.Codelab, - topics = listOf(topics[2]), - ), - userData = userData, - ), - UserNewsResource( - newsResource = NewsResource( - id = "2", - title = "Thanks for helping us reach 1M YouTube Subscribers", - content = "Thank you everyone for following the Now in Android series and everything the " + - "Android Developers YouTube channel has to offer. During the Android Developer " + - "Summit, our YouTube channel reached 1 million subscribers! Here’s a small video to " + - "thank you all.", - url = "https://youtu.be/-fJ6poHQrjM", - headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", - publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), - type = Video, - topics = topics.take(2), - ), - userData = userData, - ), - UserNewsResource( - newsResource = NewsResource( - id = "3", - title = "Transformations and customisations in the Paging Library", - content = "A demonstration of different operations that can be performed " + - "with Paging. Transformations like inserting separators, when to " + - "create a new pager, and customisation options for consuming " + - "PagingData.", - url = "https://youtu.be/ZARz0pjm5YM", - headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", - publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), - type = Video, - topics = listOf(topics[2]), - ), - userData = userData, - ), - ), - ) - } + val newsResources = listOf( + UserNewsResource( + newsResource = NewsResource( + id = "1", + title = "Android Basics with Compose", + content = "We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. You’ll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Android’s modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey", + url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html", + headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg", + publishDate = LocalDateTime( + year = 2022, + monthNumber = 5, + dayOfMonth = 4, + hour = 23, + minute = 0, + second = 0, + nanosecond = 0, + ).toInstant(TimeZone.UTC), + type = NewsResourceType.Codelab, + topics = listOf(topics[2]), + ), + userData = userData, + ), + UserNewsResource( + newsResource = NewsResource( + id = "2", + title = "Thanks for helping us reach 1M YouTube Subscribers", + content = "Thank you everyone for following the Now in Android series and everything the " + + "Android Developers YouTube channel has to offer. During the Android Developer " + + "Summit, our YouTube channel reached 1 million subscribers! Here’s a small video to " + + "thank you all.", + url = "https://youtu.be/-fJ6poHQrjM", + headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", + publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), + type = Video, + topics = topics.take(2), + ), + userData = userData, + ), + UserNewsResource( + newsResource = NewsResource( + id = "3", + title = "Transformations and customisations in the Paging Library", + content = "A demonstration of different operations that can be performed " + + "with Paging. Transformations like inserting separators, when to " + + "create a new pager, and customisation options for consuming " + + "PagingData.", + url = "https://youtu.be/ZARz0pjm5YM", + headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", + publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), + type = Video, + topics = listOf(topics[2]), + ), + userData = userData, + ), + ) } diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt index 289776164..d55cd9a38 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt @@ -35,6 +35,7 @@ fun TopicsTabContent( onTopicClick: (String) -> Unit, onFollowButtonClick: (String, Boolean) -> Unit, modifier: Modifier = Modifier, + withBottomSpacer: Boolean = true, ) { LazyColumn( modifier = modifier @@ -56,8 +57,10 @@ fun TopicsTabContent( } } - item { - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + if (withBottomSpacer) { + item { + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } } } } diff --git a/feature/search/.gitignore b/feature/search/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/search/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/search/build.gradle.kts b/feature/search/build.gradle.kts new file mode 100644 index 000000000..cbaa767bc --- /dev/null +++ b/feature/search/build.gradle.kts @@ -0,0 +1,33 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("nowinandroid.android.feature") + id("nowinandroid.android.library.compose") + id("nowinandroid.android.library.jacoco") +} + +android { + namespace = "com.google.samples.apps.nowinandroid.feature.search" +} + +dependencies { + implementation(project(":feature:bookmarks")) + implementation(project(":feature:foryou")) + implementation(project(":feature:interests")) + implementation(libs.kotlinx.datetime) +} + diff --git a/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt b/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt new file mode 100644 index 000000000..53f00c0dc --- /dev/null +++ b/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt @@ -0,0 +1,218 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery +import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK +import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID +import com.google.samples.apps.nowinandroid.core.model.data.UserData +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource +import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData +import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import com.google.samples.apps.nowinandroid.feature.interests.R as interestsR + +/** + * UI test for checking the correct behaviour of the Search screen. + */ +class SearchScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private lateinit var clearSearchContentDesc: String + private lateinit var followButtonContentDesc: String + private lateinit var unfollowButtonContentDesc: String + private lateinit var clearRecentSearchesContentDesc: String + private lateinit var topicsString: String + private lateinit var updatesString: String + private lateinit var tryAnotherSearchString: String + private lateinit var searchNotReadyString: String + + private val userData: UserData = UserData( + bookmarkedNewsResources = setOf("1", "3"), + viewedNewsResources = setOf("1", "2", "4"), + followedTopics = emptySet(), + themeBrand = ANDROID, + darkThemeConfig = DARK, + shouldHideOnboarding = true, + useDynamicColor = false, + ) + + @Before + fun setup() { + composeTestRule.activity.apply { + clearSearchContentDesc = getString(R.string.clear_search_text_content_desc) + clearRecentSearchesContentDesc = getString(R.string.clear_recent_searches_content_desc) + followButtonContentDesc = + getString(interestsR.string.card_follow_button_content_desc) + unfollowButtonContentDesc = + getString(interestsR.string.card_unfollow_button_content_desc) + topicsString = getString(R.string.topics) + updatesString = getString(R.string.updates) + tryAnotherSearchString = getString(R.string.try_another_search) + + " " + getString(R.string.interests) + " " + getString(R.string.to_browse_topics) + searchNotReadyString = getString(R.string.search_not_ready) + } + } + + @Test + fun searchTextField_isFocused() { + composeTestRule.setContent { + SearchScreen() + } + + composeTestRule + .onNodeWithTag("searchTextField") + .assertIsFocused() + } + + @Test + fun emptySearchResult_emptyScreenIsDisplayed() { + composeTestRule.setContent { + SearchScreen( + searchResultUiState = SearchResultUiState.Success(), + ) + } + + composeTestRule + .onNodeWithText(tryAnotherSearchString) + .assertIsDisplayed() + } + + @Test + fun emptySearchResult_nonEmptyRecentSearches_emptySearchScreenAndRecentSearchesAreDisplayed() { + val recentSearches = listOf("kotlin") + composeTestRule.setContent { + SearchScreen( + searchResultUiState = SearchResultUiState.Success(), + recentSearchesUiState = RecentSearchQueriesUiState.Success( + recentQueries = recentSearches.map(::RecentSearchQuery), + ), + ) + } + + composeTestRule + .onNodeWithText(tryAnotherSearchString) + .assertIsDisplayed() + composeTestRule + .onNodeWithContentDescription(clearRecentSearchesContentDesc) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("kotlin") + .assertIsDisplayed() + } + + @Test + fun searchResultWithTopics_allTopicsAreVisible_followButtonsVisibleForTheNumOfFollowedTopics() { + composeTestRule.setContent { + SearchScreen( + searchResultUiState = SearchResultUiState.Success(topics = followableTopicTestData), + ) + } + + composeTestRule + .onNodeWithText(topicsString) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(followableTopicTestData[0].topic.name) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(followableTopicTestData[1].topic.name) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(followableTopicTestData[2].topic.name) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithContentDescription(followButtonContentDesc) + .assertCountEquals(2) + composeTestRule + .onAllNodesWithContentDescription(unfollowButtonContentDesc) + .assertCountEquals(1) + } + + @Test + fun searchResultWithNewsResources_firstNewsResourcesIsVisible() { + composeTestRule.setContent { + SearchScreen( + searchResultUiState = SearchResultUiState.Success( + newsResources = newsResourcesTestData.map { + UserNewsResource( + newsResource = it, + userData = userData, + ) + }, + ), + ) + } + + composeTestRule + .onNodeWithText(updatesString) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(newsResourcesTestData[0].title) + .assertIsDisplayed() + } + + @Test + fun emptyQuery_notEmptyRecentSearches_verifyClearSearchesButton_displayed() { + val recentSearches = listOf("kotlin", "testing") + composeTestRule.setContent { + SearchScreen( + searchResultUiState = SearchResultUiState.EmptyQuery, + recentSearchesUiState = RecentSearchQueriesUiState.Success( + recentQueries = recentSearches.map(::RecentSearchQuery), + ), + ) + } + + composeTestRule + .onNodeWithContentDescription(clearRecentSearchesContentDesc) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("kotlin") + .assertIsDisplayed() + composeTestRule + .onNodeWithText("testing") + .assertIsDisplayed() + } + + @Test + fun searchNotReady_verifySearchNotReadyMessageIsVisible() { + composeTestRule.setContent { + SearchScreen( + searchResultUiState = SearchResultUiState.SearchNotReady, + ) + } + + composeTestRule + .onNodeWithText(searchNotReadyString) + .assertIsDisplayed() + } +} diff --git a/feature/search/src/main/AndroidManifest.xml b/feature/search/src/main/AndroidManifest.xml new file mode 100644 index 000000000..70c188dd8 --- /dev/null +++ b/feature/search/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/RecentSearchQueriesUiState.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/RecentSearchQueriesUiState.kt new file mode 100644 index 000000000..8628d2e54 --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/RecentSearchQueriesUiState.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search + +import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery + +sealed interface RecentSearchQueriesUiState { + object Loading : RecentSearchQueriesUiState + + data class Success( + val recentQueries: List = emptyList(), + ) : RecentSearchQueriesUiState +} diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt new file mode 100644 index 000000000..68ea623e8 --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search + +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource + +sealed interface SearchResultUiState { + object Loading : SearchResultUiState + + /** + * The state query is empty or too short. To distinguish the state between the + * (initial state or when the search query is cleared) vs the state where no search + * result is returned, explicitly define the empty query state. + */ + object EmptyQuery : SearchResultUiState + + object LoadFailed : SearchResultUiState + + data class Success( + val topics: List = emptyList(), + val newsResources: List = emptyList(), + ) : SearchResultUiState { + fun isEmpty(): Boolean = topics.isEmpty() && newsResources.isEmpty() + } + + /** + * A state where the search contents are not ready. This happens when the *Fts tables are not + * populated yet. + */ + object SearchNotReady : SearchResultUiState +} diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt new file mode 100644 index 000000000..e3a9be8dc --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt @@ -0,0 +1,565 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells.Adaptive +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +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.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource +import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews +import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState +import com.google.samples.apps.nowinandroid.core.ui.R.string +import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent +import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank +import com.google.samples.apps.nowinandroid.core.ui.newsFeed +import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksViewModel +import com.google.samples.apps.nowinandroid.feature.foryou.ForYouViewModel +import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel +import com.google.samples.apps.nowinandroid.feature.interests.TopicsTabContent +import com.google.samples.apps.nowinandroid.feature.search.R as searchR + +@Composable +internal fun SearchRoute( + modifier: Modifier = Modifier, + onBackClick: () -> Unit, + onInterestsClick: () -> Unit, + onTopicClick: (String) -> Unit, + bookmarksViewModel: BookmarksViewModel = hiltViewModel(), + interestsViewModel: InterestsViewModel = hiltViewModel(), + searchViewModel: SearchViewModel = hiltViewModel(), + forYouViewModel: ForYouViewModel = hiltViewModel(), +) { + val recentSearchQueriesUiState by searchViewModel.recentSearchQueriesUiState.collectAsStateWithLifecycle() + val searchResultUiState by searchViewModel.searchResultUiState.collectAsStateWithLifecycle() + val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle() + SearchScreen( + modifier = modifier, + onBackClick = onBackClick, + onClearRecentSearches = searchViewModel::clearRecentSearches, + onFollowButtonClick = interestsViewModel::followTopic, + onInterestsClick = onInterestsClick, + onSearchQueryChanged = searchViewModel::onSearchQueryChanged, + onSearchTriggered = searchViewModel::onSearchTriggered, + onTopicClick = onTopicClick, + onNewsResourcesCheckedChanged = forYouViewModel::updateNewsResourceSaved, + onNewsResourceViewed = { bookmarksViewModel.setNewsResourceViewed(it, true) }, + recentSearchesUiState = recentSearchQueriesUiState, + searchQuery = searchQuery, + searchResultUiState = searchResultUiState, + ) +} + +@Composable +internal fun SearchScreen( + modifier: Modifier = Modifier, + onBackClick: () -> Unit = {}, + onClearRecentSearches: () -> Unit = {}, + onFollowButtonClick: (String, Boolean) -> Unit = { _, _ -> }, + onInterestsClick: () -> Unit = {}, + onNewsResourcesCheckedChanged: (String, Boolean) -> Unit = { _, _ -> }, + onNewsResourceViewed: (String) -> Unit = {}, + onSearchQueryChanged: (String) -> Unit = {}, + onSearchTriggered: (String) -> Unit = {}, + onTopicClick: (String) -> Unit = {}, + searchQuery: String = "", + recentSearchesUiState: RecentSearchQueriesUiState = RecentSearchQueriesUiState.Loading, + searchResultUiState: SearchResultUiState = SearchResultUiState.Loading, +) { + TrackScreenViewEvent(screenName = "Search") + Column(modifier = modifier) { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + SearchToolbar( + onBackClick = onBackClick, + onSearchQueryChanged = onSearchQueryChanged, + onSearchTriggered = onSearchTriggered, + searchQuery = searchQuery, + ) + when (searchResultUiState) { + SearchResultUiState.Loading, + SearchResultUiState.LoadFailed, + -> Unit + + SearchResultUiState.SearchNotReady -> SearchNotReadyBody() + SearchResultUiState.EmptyQuery, + -> { + if (recentSearchesUiState is RecentSearchQueriesUiState.Success) { + RecentSearchesBody( + onClearRecentSearches = onClearRecentSearches, + onRecentSearchClicked = { + onSearchQueryChanged(it) + onSearchTriggered(it) + }, + recentSearchQueries = recentSearchesUiState.recentQueries.map { it.query }, + ) + } + } + + is SearchResultUiState.Success -> { + if (searchResultUiState.isEmpty()) { + EmptySearchResultBody( + onInterestsClick = onInterestsClick, + searchQuery = searchQuery, + ) + if (recentSearchesUiState is RecentSearchQueriesUiState.Success) { + RecentSearchesBody( + onClearRecentSearches = onClearRecentSearches, + onRecentSearchClicked = { + onSearchQueryChanged(it) + onSearchTriggered(it) + }, + recentSearchQueries = recentSearchesUiState.recentQueries.map { it.query }, + ) + } + } else { + SearchResultBody( + topics = searchResultUiState.topics, + onFollowButtonClick = onFollowButtonClick, + onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, + onNewsResourceViewed = onNewsResourceViewed, + onSearchTriggered = onSearchTriggered, + onTopicClick = onTopicClick, + newsResources = searchResultUiState.newsResources, + searchQuery = searchQuery, + ) + } + } + } + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } +} + +@Composable +fun EmptySearchResultBody( + onInterestsClick: () -> Unit, + searchQuery: String, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 48.dp), + ) { + val message = stringResource(id = searchR.string.search_result_not_found, searchQuery) + val start = message.indexOf(searchQuery) + Text( + text = AnnotatedString( + text = message, + spanStyles = listOf( + AnnotatedString.Range( + SpanStyle(fontWeight = FontWeight.Bold), + start = start, + end = start + searchQuery.length, + ), + ), + ), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 24.dp), + ) + val interests = stringResource(id = searchR.string.interests) + val tryAnotherSearchString = buildAnnotatedString { + append(stringResource(id = searchR.string.try_another_search)) + append(" ") + withStyle( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + fontWeight = FontWeight.Bold, + ), + ) { + pushStringAnnotation(tag = interests, annotation = interests) + append(interests) + } + append(" ") + append(stringResource(id = searchR.string.to_browse_topics)) + } + ClickableText( + text = tryAnotherSearchString, + style = MaterialTheme.typography.bodyLarge.merge( + TextStyle( + textAlign = TextAlign.Center, + ), + ), + modifier = Modifier + .padding(start = 36.dp, end = 36.dp, bottom = 24.dp) + .clickable {}, + ) { offset -> + tryAnotherSearchString.getStringAnnotations(start = offset, end = offset) + .firstOrNull() + ?.let { + onInterestsClick() + } + } + } +} + +@Composable +private fun SearchNotReadyBody() { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 48.dp), + ) { + Text( + text = stringResource(id = searchR.string.search_not_ready), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 24.dp), + ) + } +} + +@Composable +private fun SearchResultBody( + topics: List, + newsResources: List, + onFollowButtonClick: (String, Boolean) -> Unit, + onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, + onSearchTriggered: (String) -> Unit, + onTopicClick: (String) -> Unit, + searchQuery: String = "", +) { + if (topics.isNotEmpty()) { + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(stringResource(id = searchR.string.topics)) + } + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + TopicsTabContent( + topics = topics, + onTopicClick = { + // Pass the current search query to ViewModel to save it as recent searches + onSearchTriggered(searchQuery) + onTopicClick(it) + }, + onFollowButtonClick = onFollowButtonClick, + withBottomSpacer = false, + ) + } + + if (newsResources.isNotEmpty()) { + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(stringResource(id = searchR.string.updates)) + } + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + + val state = rememberLazyGridState() + TrackScrollJank(scrollableState = state, stateName = "search:newsResource") + LazyVerticalGrid( + columns = Adaptive(300.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .fillMaxSize() + .testTag("search:newsResources"), + state = state, + ) { + newsFeed( + feedState = NewsFeedUiState.Success(feed = newsResources), + onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, + onNewsResourceViewed = onNewsResourceViewed, + onTopicClick = onTopicClick, + onExpandedCardClick = { + onSearchTriggered(searchQuery) + }, + ) + } + } +} + +@Composable +private fun RecentSearchesBody( + onClearRecentSearches: () -> Unit, + onRecentSearchClicked: (String) -> Unit, + recentSearchQueries: List, +) { + Column { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(stringResource(id = searchR.string.recent_searches)) + } + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + if (recentSearchQueries.isNotEmpty()) { + IconButton( + onClick = { + onClearRecentSearches() + }, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Icon( + imageVector = NiaIcons.Close, + contentDescription = stringResource( + id = searchR.string.clear_recent_searches_content_desc, + ), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + LazyColumn(modifier = Modifier.padding(horizontal = 16.dp)) { + items(recentSearchQueries) { recentSearch -> + Text( + text = recentSearch, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .padding(vertical = 16.dp) + .clickable { + onRecentSearchClicked(recentSearch) + } + .fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun SearchToolbar( + modifier: Modifier = Modifier, + onBackClick: () -> Unit, + onSearchQueryChanged: (String) -> Unit, + searchQuery: String = "", + onSearchTriggered: (String) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth(), + ) { + IconButton(onClick = { onBackClick() }) { + Icon( + imageVector = NiaIcons.ArrowBack, + contentDescription = stringResource( + id = string.back, + ), + ) + } + SearchTextField( + onSearchQueryChanged = onSearchQueryChanged, + onSearchTriggered = onSearchTriggered, + searchQuery = searchQuery, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun SearchTextField( + onSearchQueryChanged: (String) -> Unit, + searchQuery: String, + onSearchTriggered: (String) -> Unit, +) { + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + val onSearchExplicitlyTriggered = { + keyboardController?.hide() + onSearchTriggered(searchQuery) + } + + TextField( + colors = TextFieldDefaults.textFieldColors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + leadingIcon = { + Icon( + imageVector = NiaIcons.Search, + contentDescription = stringResource( + id = searchR.string.search, + ), + tint = MaterialTheme.colorScheme.onSurface, + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton( + onClick = { + onSearchQueryChanged("") + }, + ) { + Icon( + imageVector = NiaIcons.Close, + contentDescription = stringResource( + id = searchR.string.clear_search_text_content_desc, + ), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + }, + onValueChange = { + if (!it.contains("\n")) { + onSearchQueryChanged(it) + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .focusRequester(focusRequester) + .onKeyEvent { + if (it.key == Key.Enter) { + onSearchExplicitlyTriggered() + true + } else { + false + } + } + .testTag("searchTextField"), + shape = RoundedCornerShape(32.dp), + value = searchQuery, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Search, + ), + keyboardActions = KeyboardActions( + onSearch = { + onSearchExplicitlyTriggered() + }, + ), + maxLines = 1, + singleLine = true, + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@Preview +@Composable +private fun SearchToolbarPreview() { + NiaTheme { + SearchToolbar( + onBackClick = {}, + onSearchQueryChanged = {}, + onSearchTriggered = {}, + ) + } +} + +@Preview +@Composable +private fun EmptySearchResultColumnPreview() { + NiaTheme { + EmptySearchResultBody( + onInterestsClick = {}, + searchQuery = "C++", + ) + } +} + +@Preview +@Composable +private fun RecentSearchesBodyPreview() { + NiaTheme { + RecentSearchesBody( + onClearRecentSearches = {}, + onRecentSearchClicked = {}, + recentSearchQueries = listOf("kotlin", "jetpack compose", "testing"), + ) + } +} + +@Preview +@Composable +private fun SearchNotReadyBodyPreview() { + NiaTheme { + SearchNotReadyBody() + } +} + +@DevicePreviews +@Composable +private fun SearchScreenPreview( + @PreviewParameter(SearchUiStatePreviewParameterProvider::class) + searchResultUiState: SearchResultUiState, +) { + NiaTheme { + SearchScreen(searchResultUiState = searchResultUiState) + } +} diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt new file mode 100644 index 000000000..4268893da --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic +import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.newsResources +import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.topics + +/* ktlint-disable max-line-length */ +/** + * This [PreviewParameterProvider](https://developer.android.com/reference/kotlin/androidx/compose/ui/tooling/preview/PreviewParameterProvider) + * provides list of [SearchResultUiState] for Composable previews. + */ +class SearchUiStatePreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + SearchResultUiState.Success( + topics = topics.mapIndexed { i, topic -> + FollowableTopic(topic = topic, isFollowed = i % 2 == 0) + }, + newsResources = newsResources, + ), + ) +} diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt new file mode 100644 index 000000000..f4b4485bc --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository +import com.google.samples.apps.nowinandroid.core.domain.GetRecentSearchQueriesUseCase +import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsCountUseCase +import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsUseCase +import com.google.samples.apps.nowinandroid.core.result.Result +import com.google.samples.apps.nowinandroid.core.result.asResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel @Inject constructor( + getSearchContentsUseCase: GetSearchContentsUseCase, + getSearchContentsCountUseCase: GetSearchContentsCountUseCase, + recentSearchQueriesUseCase: GetRecentSearchQueriesUseCase, + private val recentSearchRepository: RecentSearchRepository, + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + + val searchQuery = savedStateHandle.getStateFlow(SEARCH_QUERY, "") + + val searchResultUiState: StateFlow = + getSearchContentsCountUseCase().flatMapLatest { totalCount -> + if (totalCount < SEARCH_MIN_FTS_ENTITY_COUNT) { + flowOf(SearchResultUiState.SearchNotReady) + } else { + searchQuery.flatMapLatest { query -> + if (query.length < SEARCH_QUERY_MIN_LENGTH) { + flowOf(SearchResultUiState.EmptyQuery) + } else { + getSearchContentsUseCase(query).asResult().map { + when (it) { + is Result.Success -> { + SearchResultUiState.Success( + topics = it.data.topics, + newsResources = it.data.newsResources, + ) + } + + is Result.Loading -> { + SearchResultUiState.Loading + } + + is Result.Error -> { + SearchResultUiState.LoadFailed + } + } + } + } + } + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = SearchResultUiState.Loading, + ) + + val recentSearchQueriesUiState: StateFlow = + recentSearchQueriesUseCase().map(RecentSearchQueriesUiState::Success) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = RecentSearchQueriesUiState.Loading, + ) + + fun onSearchQueryChanged(query: String) { + savedStateHandle[SEARCH_QUERY] = query + } + + /** + * Called when the search action is explicitly triggered by the user. For example, when the + * search icon is tapped in the IME or when the enter key is pressed in the search text field. + * + * The search results are displayed on the fly as the user types, but to explicitly save the + * search query in the search text field, defining this method. + */ + fun onSearchTriggered(query: String) { + viewModelScope.launch { + recentSearchRepository.insertOrReplaceRecentSearch(query) + } + } + + fun clearRecentSearches() { + viewModelScope.launch { + recentSearchRepository.clearRecentSearches() + } + } +} + +/** Minimum length where search query is considered as [SearchResultUiState.EmptyQuery] */ +private const val SEARCH_QUERY_MIN_LENGTH = 2 + +/** Minimum number of the fts table's entity count where it's considered as search is not ready */ +private const val SEARCH_MIN_FTS_ENTITY_COUNT = 1 +private const val SEARCH_QUERY = "searchQuery" diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt new file mode 100644 index 000000000..42bf3f475 --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.google.samples.apps.nowinandroid.feature.search.SearchRoute + +const val searchRoute = "search_route" + +fun NavController.navigateToSearch(navOptions: NavOptions? = null) { + this.navigate(searchRoute, navOptions) +} + +fun NavGraphBuilder.searchScreen( + onBackClick: () -> Unit, + onInterestsClick: () -> Unit, + onTopicClick: (String) -> Unit, +) { + // TODO: Handle back stack for each top-level destination. At the moment each top-level + // destination may have own search screen's back stack. + composable(route = searchRoute) { + SearchRoute( + onBackClick = onBackClick, + onInterestsClick = onInterestsClick, + onTopicClick = onTopicClick, + ) + } +} diff --git a/feature/search/src/main/res/values/strings.xml b/feature/search/src/main/res/values/strings.xml new file mode 100644 index 000000000..62db1da1d --- /dev/null +++ b/feature/search/src/main/res/values/strings.xml @@ -0,0 +1,29 @@ + + + + Search + Clear search text + Sorry, there is no content found for your search \"%1$s\" + Sorry, we are still processing the search index. Please come back later + Try another search or explorer + Interests + to browse topics + Topics + Updates + Recent searches + Clear searches + \ No newline at end of file diff --git a/feature/search/src/test/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt b/feature/search/src/test/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt new file mode 100644 index 000000000..1d50b75fd --- /dev/null +++ b/feature/search/src/test/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search + +import androidx.lifecycle.SavedStateHandle +import com.google.samples.apps.nowinandroid.core.domain.GetRecentSearchQueriesUseCase +import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsCountUseCase +import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsUseCase +import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData +import com.google.samples.apps.nowinandroid.core.testing.data.topicsTestData +import com.google.samples.apps.nowinandroid.core.testing.repository.TestRecentSearchRepository +import com.google.samples.apps.nowinandroid.core.testing.repository.TestSearchContentsRepository +import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository +import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule +import com.google.samples.apps.nowinandroid.feature.search.RecentSearchQueriesUiState.Success +import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.EmptyQuery +import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.Loading +import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.SearchNotReady +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** + * To learn more about how this test handles Flows created with stateIn, see + * https://developer.android.com/kotlin/flow/test#statein + */ +class SearchViewModelTest { + + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val userDataRepository = TestUserDataRepository() + private val searchContentsRepository = TestSearchContentsRepository() + private val getSearchContentsUseCase = GetSearchContentsUseCase( + searchContentsRepository = searchContentsRepository, + userDataRepository = userDataRepository, + ) + private val recentSearchRepository = TestRecentSearchRepository() + private val getRecentQueryUseCase = GetRecentSearchQueriesUseCase(recentSearchRepository) + private val getSearchContentsCountUseCase = GetSearchContentsCountUseCase(searchContentsRepository) + private lateinit var viewModel: SearchViewModel + + @Before + fun setup() { + viewModel = SearchViewModel( + getSearchContentsUseCase = getSearchContentsUseCase, + getSearchContentsCountUseCase = getSearchContentsCountUseCase, + recentSearchQueriesUseCase = getRecentQueryUseCase, + savedStateHandle = SavedStateHandle(), + recentSearchRepository = recentSearchRepository, + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertEquals(Loading, viewModel.searchResultUiState.value) + } + + @Test + fun stateIsEmptyQuery_withEmptySearchQuery() = runTest { + searchContentsRepository.addNewsResources(newsResourcesTestData) + searchContentsRepository.addTopics(topicsTestData) + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() } + + viewModel.onSearchQueryChanged("") + + assertEquals(EmptyQuery, viewModel.searchResultUiState.value) + + collectJob.cancel() + } + + @Test + fun emptyResultIsReturned_withNotMatchingQuery() = runTest { + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() } + + viewModel.onSearchQueryChanged("XXX") + searchContentsRepository.addNewsResources(newsResourcesTestData) + searchContentsRepository.addTopics(topicsTestData) + + val result = viewModel.searchResultUiState.value + // TODO: Figure out to get the latest emitted ui State? The result is emitted as EmptyQuery + // assertIs(result) + + collectJob.cancel() + } + + @Test + fun recentSearches_verifyUiStateIsSuccess() = runTest { + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.recentSearchQueriesUiState.collect() } + viewModel.onSearchTriggered("kotlin") + + val result = viewModel.recentSearchQueriesUiState.value + assertIs(result) + + collectJob.cancel() + } + + @Test + fun searchNotReady_withNoFtsTableEntity() = runTest { + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() } + + viewModel.onSearchQueryChanged("") + + assertEquals(SearchNotReady, viewModel.searchResultUiState.value) + + collectJob.cancel() + } +} diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 5efaeb577..cbd4df8ed 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ --> Settings + Search Settings Loading... Privacy policy diff --git a/settings.gradle.kts b/settings.gradle.kts index 2af582a7b..d0c477b3d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -53,6 +53,7 @@ include(":feature:foryou") include(":feature:interests") include(":feature:bookmarks") include(":feature:topic") +include(":feature:search") include(":feature:settings") include(":lint") include(":sync:work") diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt index d8f1ef91c..1948b49a3 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt @@ -27,6 +27,7 @@ import androidx.work.WorkerParameters import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper import com.google.samples.apps.nowinandroid.core.data.Synchronizer import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource @@ -53,6 +54,7 @@ class SyncWorker @AssistedInject constructor( private val niaPreferences: NiaPreferencesDataSource, private val topicRepository: TopicsRepository, private val newsRepository: NewsRepository, + private val searchContentsRepository: SearchContentsRepository, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, private val analyticsHelper: AnalyticsHelper, private val syncSubscriber: SyncSubscriber, @@ -76,6 +78,7 @@ class SyncWorker @AssistedInject constructor( analyticsHelper.logSyncFinished(syncedSuccessfully) if (syncedSuccessfully) { + searchContentsRepository.populateFtsData() Result.success() } else { Result.retry() From e1bb03b33d1f0eb5b24b40ffd33212d88c732bbc Mon Sep 17 00:00:00 2001 From: Takeshi Hagikura Date: Wed, 26 Apr 2023 18:50:02 +0900 Subject: [PATCH 83/97] Set the source/target compatibility to 11 This is because up to Java 11 features are supported without requiring minimum API level nor AGP versions at the moment as in https://developer.android.com/studio/write/java11-minimal-support-table --- build-logic/convention/build.gradle.kts | 8 +++++--- .../google/samples/apps/nowinandroid/AndroidCompose.kt | 2 -- .../com/google/samples/apps/nowinandroid/KotlinAndroid.kt | 8 +++++--- lint/build.gradle.kts | 8 +++++--- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 4da2b86dd..571ba8c2f 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -23,13 +23,15 @@ plugins { group = "com.google.samples.apps.nowinandroid.buildlogic" java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + // Up to Java 11 APIs are available through desugaring + // https://developer.android.com/studio/write/java11-minimal-support-table + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } tasks.withType().configureEach { kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() + jvmTarget = JavaVersion.VERSION_11.toString() } } diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt index cdcc65a7f..5997f7d4e 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt @@ -17,12 +17,10 @@ package com.google.samples.apps.nowinandroid import com.android.build.api.dsl.CommonExtension -import org.gradle.api.JavaVersion import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType -import org.gradle.kotlin.dsl.provideDelegate import org.gradle.kotlin.dsl.withType import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.io.File diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index d4cb010a2..e800d0570 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -40,8 +40,10 @@ internal fun Project.configureKotlinAndroid( } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + // Up to Java 11 APIs are available through desugaring + // https://developer.android.com/studio/write/java11-minimal-support-table + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 isCoreLibraryDesugaringEnabled = true } } @@ -50,7 +52,7 @@ internal fun Project.configureKotlinAndroid( tasks.withType().configureEach { kotlinOptions { // Set JVM target to 17 - jvmTarget = JavaVersion.VERSION_17.toString() + jvmTarget = JavaVersion.VERSION_11.toString() // Treat all Kotlin warnings as errors (disabled by default) // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties val warningsAsErrors: String? by project diff --git a/lint/build.gradle.kts b/lint/build.gradle.kts index b665ba8a7..4ae719aa6 100644 --- a/lint/build.gradle.kts +++ b/lint/build.gradle.kts @@ -23,13 +23,15 @@ plugins { } java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + // Up to Java 11 APIs are available through desugaring + // https://developer.android.com/studio/write/java11-minimal-support-table + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } tasks.withType().configureEach { kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() + jvmTarget = JavaVersion.VERSION_11.toString() } } From 1921c6544d2a50558dc30e5e076d3ed85daaa9be Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sun, 16 Apr 2023 22:48:57 +0200 Subject: [PATCH 84/97] Replace deprecated methods --- app-nia-catalog/build.gradle.kts | 2 +- app/build.gradle.kts | 2 +- .../google/samples/apps/nowinandroid/GradleManagedDevices.kt | 3 +-- .../kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt | 5 +++++ 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app-nia-catalog/build.gradle.kts b/app-nia-catalog/build.gradle.kts index 42ffd7039..7a8d61339 100644 --- a/app-nia-catalog/build.gradle.kts +++ b/app-nia-catalog/build.gradle.kts @@ -47,7 +47,7 @@ android { missingDimensionStrategy(FlavorDimension.contentType.name, NiaFlavor.demo.name) } - packagingOptions { + packaging { resources { excludes.add("/META-INF/{AL2.0,LGPL2.1}") } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 42dee2602..70160f3e1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -65,7 +65,7 @@ android { } } - packagingOptions { + packaging { resources { excludes.add("/META-INF/{AL2.0,LGPL2.1}") } diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt index 624afeea9..8129defeb 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt @@ -20,7 +20,6 @@ import com.android.build.api.dsl.CommonExtension import com.android.build.api.dsl.ManagedVirtualDevice import org.gradle.api.Project import org.gradle.kotlin.dsl.invoke -import java.util.Locale /** * Configure project for Gradle managed devices @@ -55,7 +54,7 @@ private data class DeviceConfig( val systemImageSource: String, ) { val taskName = buildString { - append(device.toLowerCase(Locale.ROOT).replace(" ", "")) + append(device.lowercase().replace(" ", "")) append("api") append(apiLevel.toString()) append(systemImageSource.replace("-", "")) diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt index a34cd7c1c..d801d7b69 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt @@ -27,6 +27,7 @@ import org.gradle.kotlin.dsl.withType import org.gradle.testing.jacoco.plugins.JacocoPluginExtension import org.gradle.testing.jacoco.plugins.JacocoTaskExtension import org.gradle.testing.jacoco.tasks.JacocoReport +import java.util.Locale private val coverageExclusions = listOf( // Android @@ -36,6 +37,10 @@ private val coverageExclusions = listOf( "**/Manifest*.*" ) +private fun String.capitalize() = replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() +} + internal fun Project.configureJacoco( androidComponentsExtension: AndroidComponentsExtension<*, *, *>, ) { From 459717f8baca2baa0b851c5df4753350d02991a3 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sun, 16 Apr 2023 22:50:09 +0200 Subject: [PATCH 85/97] Remove `DSL_SCOPE_VIOLATION` suppression as it is now fixed in Gradle 8.1 --- core/database/build.gradle.kts | 2 -- core/datastore/build.gradle.kts | 2 -- core/model/build.gradle.kts | 3 +-- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 10eb4363c..a9c711ae3 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -14,8 +14,6 @@ * limitations under the License. */ -// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed -@Suppress("DSL_SCOPE_VIOLATION") plugins { id("nowinandroid.android.library") id("nowinandroid.android.library.jacoco") diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 59a3a8e32..6fecb37d4 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -14,8 +14,6 @@ * limitations under the License. */ -// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed -@Suppress("DSL_SCOPE_VIOLATION") plugins { id("nowinandroid.android.library") id("nowinandroid.android.library.jacoco") diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index edfcc4596..d49c5b3ee 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -13,8 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed -@Suppress("DSL_SCOPE_VIOLATION") + plugins { id("kotlin") } From 1341f7c2c59162b852ab7e70f096eac8f36e46c3 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sun, 16 Apr 2023 22:50:57 +0200 Subject: [PATCH 86/97] Replace unused variable with simpler method calls --- app-nia-catalog/build.gradle.kts | 2 +- app/build.gradle.kts | 4 ++-- benchmarks/build.gradle.kts | 2 +- core/datastore/build.gradle.kts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app-nia-catalog/build.gradle.kts b/app-nia-catalog/build.gradle.kts index 7a8d61339..1f9ac1e2a 100644 --- a/app-nia-catalog/build.gradle.kts +++ b/app-nia-catalog/build.gradle.kts @@ -55,7 +55,7 @@ android { namespace = "com.google.samples.apps.niacatalog" buildTypes { - val release by getting { + release { // To publish on the Play store a private signing key is required, but to allow anyone // who clones the code to sign and run the release variant, use the debug signing key. // TODO: Abstract the signing configuration to a separate file to avoid hardcoding this. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70160f3e1..e172ed8bb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -39,7 +39,7 @@ android { } buildTypes { - val debug by getting { + debug { applicationIdSuffix = NiaBuildType.DEBUG.applicationIdSuffix } val release by getting { @@ -52,7 +52,7 @@ android { // TODO: Abstract the signing configuration to a separate file to avoid hardcoding this. signingConfig = signingConfigs.getByName("debug") } - val benchmark by creating { + create("benchmark") { // Enable all the optimizations from release build through initWith(release). initWith(release) matchingFallbacks.add("release") diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index fa8aeefb0..fb46ae63f 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -39,7 +39,7 @@ android { // This benchmark buildType is used for benchmarking, and should function like your // release build (for example, with minification on). It's signed with a debug key // for easy local/CI testing. - val benchmark by creating { + create("benchmark") { // Keep the build type debuggable so we can attach a debugger if needed. isDebuggable = true signingConfig = signingConfigs.getByName("debug") diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 6fecb37d4..a9ec7a78f 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -41,10 +41,10 @@ protobuf { generateProtoTasks { all().forEach { task -> task.builtins { - val java by registering { + register("java") { option("lite") } - val kotlin by registering { + register("kotlin") { option("lite") } } From f19dd06200808ea2f6e49df3db5983bbb22a35ac Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sun, 16 Apr 2023 22:54:45 +0200 Subject: [PATCH 87/97] Remove deprecated `kotlin.Experimental` opt-in This would produce the following warning messages: ``` w: Opt-in requirement marker kotlin.Experimental is unresolved. Please make sure it's present in the module dependencies ``` --- .../kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index e800d0570..65a734895 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -62,7 +62,6 @@ internal fun Project.configureKotlinAndroid( // Enable experimental coroutines APIs, including Flow "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-opt-in=kotlinx.coroutines.FlowPreview", - "-opt-in=kotlin.Experimental", ) } } From 661dd2bc2e581690b468cd7ccdcfba218b55e131 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Wed, 26 Apr 2023 20:26:22 +0200 Subject: [PATCH 88/97] Update Kotlin 1.8.20 stack - KSP 1.8.20-1.0.10 - Compose compiler 1.4.5 --- gradle/libs.versions.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 95b084fa7..289f6a363 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ androidxActivity = "1.7.0" androidxAppCompat = "1.5.1" androidxBrowser = "1.4.0" androidxComposeBom = "2023.01.00" -androidxComposeCompiler = "1.4.4" +androidxComposeCompiler = "1.4.5" androidxComposeMaterial3 = "1.1.0-alpha06" androidxComposeRuntimeTracing = "1.0.0-alpha01" androidxCore = "1.9.0" @@ -37,11 +37,11 @@ hilt = "2.44.2" hiltExt = "1.0.0" jacoco = "0.8.7" junit4 = "4.13.2" -kotlin = "1.8.10" +kotlin = "1.8.20" kotlinxCoroutines = "1.6.4" kotlinxDatetime = "0.4.0" kotlinxSerializationJson = "1.5.0" -ksp = "1.8.10-1.0.9" +ksp = "1.8.20-1.0.10" lint = "30.3.1" okhttp = "4.10.0" protobuf = "3.21.12" From 5500edc37bc27a502ebe3371495fac23ede3a3d1 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Tue, 18 Apr 2023 07:54:21 +0200 Subject: [PATCH 89/97] Update KSP 1.8.20-1.0.11 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 289f6a363..ccfdeca99 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,7 @@ kotlin = "1.8.20" kotlinxCoroutines = "1.6.4" kotlinxDatetime = "0.4.0" kotlinxSerializationJson = "1.5.0" -ksp = "1.8.20-1.0.10" +ksp = "1.8.20-1.0.11" lint = "30.3.1" okhttp = "4.10.0" protobuf = "3.21.12" From 246d6c652865caabf20678b9b033c506ab4d06a0 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 4 Feb 2023 17:36:56 +0100 Subject: [PATCH 90/97] Use dedicated build step to package test apps before spawning the emulator --- .github/workflows/Build.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml index c15daea80..e7a23f358 100644 --- a/.github/workflows/Build.yaml +++ b/.github/workflows/Build.yaml @@ -82,6 +82,9 @@ jobs: - name: Setup Gradle uses: gradle/gradle-build-action@v2 + - name: Build AndroidTest apps + run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest --daemon + - name: Run instrumentation tests uses: reactivecircus/android-emulator-runner@v2 with: @@ -90,7 +93,7 @@ jobs: disable-animations: true disk-size: 6000M heap-size: 600M - script: ./gradlew connectedDemoDebugAndroidTest -x :benchmark:connectedDemoBenchmarkAndroidTest + script: ./gradlew connectedDemoDebugAndroidTest -x :benchmark:connectedDemoBenchmarkAndroidTest --daemon - name: Upload test reports if: always() From 57d1bed2cccfe14e091551c95bb626a49674c88a Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Thu, 27 Apr 2023 00:27:20 +0200 Subject: [PATCH 91/97] Disable unnecessary Android instrumented tests --- .../kotlin/AndroidLibraryConventionPlugin.kt | 2 ++ .../nowinandroid/AndroidInstrumentedTests.kt | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidInstrumentedTests.kt diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index 5b2f76edb..275a26620 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -20,6 +20,7 @@ import com.google.samples.apps.nowinandroid.configureFlavors import com.google.samples.apps.nowinandroid.configureGradleManagedDevices import com.google.samples.apps.nowinandroid.configureKotlinAndroid import com.google.samples.apps.nowinandroid.configurePrintApksTask +import com.google.samples.apps.nowinandroid.disableUnnecessaryAndroidTests import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension @@ -44,6 +45,7 @@ class AndroidLibraryConventionPlugin : Plugin { } extensions.configure { configurePrintApksTask(this) + disableUnnecessaryAndroidTests(target) } val libs = extensions.getByType().named("libs") configurations.configureEach { diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidInstrumentedTests.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidInstrumentedTests.kt new file mode 100644 index 000000000..d0c26e4e6 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidInstrumentedTests.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid + +import com.android.build.api.variant.LibraryAndroidComponentsExtension +import org.gradle.api.Project + +/** + * Disable unnecessary Android instrumented tests for the [project] if there is no `androidTest` folder. + * Otherwise, these projects would be compiled, packaged, installed and ran only to end-up with the following message: + * + * > Starting 0 tests on AVD + * + * Note: this could be improved by checking other potential sourceSets based on buildTypes and flavors. + */ +internal fun LibraryAndroidComponentsExtension.disableUnnecessaryAndroidTests( + project: Project, +) = beforeVariants { + it.enableAndroidTest = it.enableAndroidTest + && project.projectDir.resolve("src/androidTest").exists() +} From c364dfc84e0d50e63cfbf4203e1ea2a9af932498 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Fri, 28 Apr 2023 08:39:03 -0400 Subject: [PATCH 92/97] Update message sender in SyncNotificationsService --- .../nowinandroid/sync/services/SyncNotificationsService.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt index 1d182dda1..e51e30164 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt @@ -19,10 +19,11 @@ package com.google.samples.apps.nowinandroid.sync.services import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.google.samples.apps.nowinandroid.core.data.util.SyncManager -import com.google.samples.apps.nowinandroid.sync.initializers.SYNC_TOPIC import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +private const val SYNC_TOPIC_SENDER = "/topics/sync" + @AndroidEntryPoint class SyncNotificationsService : FirebaseMessagingService() { @@ -30,7 +31,7 @@ class SyncNotificationsService : FirebaseMessagingService() { lateinit var syncManager: SyncManager override fun onMessageReceived(message: RemoteMessage) { - if (SYNC_TOPIC == message.from) { + if (SYNC_TOPIC_SENDER == message.from) { syncManager.requestSync() } } From a68fe38411c7902b0cee1cd5afb0029e92d2b184 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Wed, 26 Apr 2023 23:30:32 +0200 Subject: [PATCH 93/97] Use dedicated build step to package test apps before spawning the emulator --- .github/workflows/AndroidCIWithGmd.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/AndroidCIWithGmd.yaml b/.github/workflows/AndroidCIWithGmd.yaml index 1c1206ed4..9aa8f2ad9 100644 --- a/.github/workflows/AndroidCIWithGmd.yaml +++ b/.github/workflows/AndroidCIWithGmd.yaml @@ -24,6 +24,9 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v2 + - name: Build AndroidTest apps + run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest + - name: Run instrumented tests with GMD run: ./gradlew cleanManagedDevices --unused-only && ./gradlew ${{ matrix.device-config }}DemoDebugAndroidTest -Dorg.gradle.workers.max=1 From ef27c050b62a0ec2c75fa98ef3b585c72fa0a474 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Wed, 26 Apr 2023 22:14:37 +0100 Subject: [PATCH 94/97] Add missing gradle/gradle-build-action setup in AndroidCIWithGmd.yaml This will prevent downloading and unzipping Gradle distribution but fetch it from the cache instead like other workflows. --- .github/workflows/AndroidCIWithGmd.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/AndroidCIWithGmd.yaml b/.github/workflows/AndroidCIWithGmd.yaml index 9aa8f2ad9..4c27018c4 100644 --- a/.github/workflows/AndroidCIWithGmd.yaml +++ b/.github/workflows/AndroidCIWithGmd.yaml @@ -15,11 +15,12 @@ jobs: device-config: [ "pixel4api30aospatd", "pixelcapi30aospatd" ] steps: + - uses: actions/checkout@v3 - uses: actions/setup-java@v3 with: distribution: 'zulu' java-version: 17 - - uses: actions/checkout@v3 + - uses: gradle/gradle-build-action@v2 - name: Setup Android SDK uses: android-actions/setup-android@v2 From 4550743fac032677a4947799b47ea1ecb32bc091 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 29 Apr 2023 12:47:48 +0200 Subject: [PATCH 95/97] Create and use CI specific group of GMD devices This should reduce the total CI time of `AndroidCIWithGmd` workflow. Using the matrix strategy at the GitHub Actions level forces us to run the tests sequentially and download/rebuild everything from scratch at each iteration. --- .github/workflows/AndroidCIWithGmd.yaml | 5 +---- .../apps/nowinandroid/GradleManagedDevices.kt | 22 +++++++++++++------ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/.github/workflows/AndroidCIWithGmd.yaml b/.github/workflows/AndroidCIWithGmd.yaml index 4c27018c4..80a70834e 100644 --- a/.github/workflows/AndroidCIWithGmd.yaml +++ b/.github/workflows/AndroidCIWithGmd.yaml @@ -10,9 +10,6 @@ jobs: android-ci: runs-on: macos-12 - strategy: - matrix: - device-config: [ "pixel4api30aospatd", "pixelcapi30aospatd" ] steps: - uses: actions/checkout@v3 @@ -30,7 +27,7 @@ jobs: - name: Run instrumented tests with GMD run: ./gradlew cleanManagedDevices --unused-only && - ./gradlew ${{ matrix.device-config }}DemoDebugAndroidTest -Dorg.gradle.workers.max=1 + ./gradlew ciDemoDebugAndroidTest -Dorg.gradle.workers.max=1 -Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true - name: Upload test reports diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt index 8129defeb..86e29be33 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt @@ -18,7 +18,7 @@ package com.google.samples.apps.nowinandroid import com.android.build.api.dsl.CommonExtension import com.android.build.api.dsl.ManagedVirtualDevice -import org.gradle.api.Project +import org.gradle.kotlin.dsl.get import org.gradle.kotlin.dsl.invoke /** @@ -27,16 +27,17 @@ import org.gradle.kotlin.dsl.invoke internal fun configureGradleManagedDevices( commonExtension: CommonExtension<*, *, *, *>, ) { - val deviceConfigs = listOf( - DeviceConfig("Pixel 4", 30, "aosp-atd"), - DeviceConfig("Pixel 6", 31, "aosp"), - DeviceConfig("Pixel C", 30, "aosp-atd"), - ) + val pixel4 = DeviceConfig("Pixel 4", 30, "aosp-atd") + val pixel6 = DeviceConfig("Pixel 6", 31, "aosp") + val pixelC = DeviceConfig("Pixel C", 30, "aosp-atd") + + val allDevices = listOf(pixel4, pixel6, pixelC) + val ciDevices = listOf(pixel4, pixelC) commonExtension.testOptions { managedDevices { devices { - deviceConfigs.forEach { deviceConfig -> + allDevices.forEach { deviceConfig -> maybeCreate(deviceConfig.taskName, ManagedVirtualDevice::class.java).apply { device = deviceConfig.device apiLevel = deviceConfig.apiLevel @@ -44,6 +45,13 @@ internal fun configureGradleManagedDevices( } } } + groups { + maybeCreate("ci").apply { + ciDevices.forEach { deviceConfig -> + targetDevices.add(devices[deviceConfig.taskName]) + } + } + } } } } From cd6931d6736c20d7e0046a27219cab43df7c7931 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 29 Apr 2023 14:52:32 +0200 Subject: [PATCH 96/97] Fix instrumented test reports archives - Fixes missing tests reports from `AndroidCIWithGmd.yaml` ``` Warning: No files were found with the provided path: '**/*/build/reports/androidTests/'. No artifacts will be uploaded. ``` - Fixes missing tests reports from `Build.yaml`, where only top-level tests from `:app` were reported. --- .github/workflows/AndroidCIWithGmd.yaml | 3 +-- .github/workflows/Build.yaml | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/AndroidCIWithGmd.yaml b/.github/workflows/AndroidCIWithGmd.yaml index 1c1206ed4..f1af37591 100644 --- a/.github/workflows/AndroidCIWithGmd.yaml +++ b/.github/workflows/AndroidCIWithGmd.yaml @@ -34,5 +34,4 @@ jobs: uses: actions/upload-artifact@v3 with: name: test-reports - path: | - '**/*/build/reports/androidTests/' + path: '**/build/reports/androidTests' diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml index c15daea80..008e1635f 100644 --- a/.github/workflows/Build.yaml +++ b/.github/workflows/Build.yaml @@ -97,4 +97,4 @@ jobs: uses: actions/upload-artifact@v3 with: name: test-reports-${{ matrix.api-level }} - path: '*/build/reports/androidTests' + path: '**/build/reports/androidTests' From 1269b29aee45a282875cf1b94b667f3807633752 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 29 Apr 2023 15:18:36 +0200 Subject: [PATCH 97/97] Fix incomplete build reports - Add all generated lint reports (only app was archived) - Add all unit tests reports (none were archived) - Add all APKs (missing app-nia-catalog) and remove unnecessary files (logs, sdkDependencies, proguard's config/mapping/seeds etc. which are also quite large compared to APKs) --- .github/workflows/Build.yaml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml index c15daea80..2d94c322e 100644 --- a/.github/workflows/Build.yaml +++ b/.github/workflows/Build.yaml @@ -48,15 +48,22 @@ jobs: - name: Upload build outputs (APKs) uses: actions/upload-artifact@v3 with: - name: build-outputs - path: app/build/outputs + name: APKs + path: '**/build/outputs/apk/**/*.apk' - - name: Upload build reports + - name: Upload lint reports (HTML) if: always() uses: actions/upload-artifact@v3 with: - name: build-reports - path: app/build/reports + name: lint-reports + path: '**/build/reports/lint-results-*.html' + + - name: Upload test results (XML) + if: always() + uses: actions/upload-artifact@v3 + with: + name: test-results + path: '**/build/test-results/test*UnitTest/**.xml' androidTest: needs: build