diff --git a/.github/workflows/AndroidCIWithGmd.yaml b/.github/workflows/AndroidCIWithGmd.yaml index bae399f47..a5533a490 100644 --- a/.github/workflows/AndroidCIWithGmd.yaml +++ b/.github/workflows/AndroidCIWithGmd.yaml @@ -10,29 +10,29 @@ jobs: android-ci: runs-on: macos-12 - strategy: - matrix: - device-config: [ "pixel4api30aospatd", "pixelcapi30aospatd" ] steps: + - uses: actions/checkout@v3 - uses: actions/setup-java@v3 with: distribution: 'zulu' - java-version: '11' - - uses: actions/checkout@v3 + java-version: 17 + - uses: gradle/gradle-build-action@v2 - 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 - -Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true --info + ./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 if: success() || failure() 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 978279c11..0389dcf56 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 @@ -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 @@ -73,15 +80,18 @@ 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 + - name: Build AndroidTest apps + run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest --daemon + - name: Run instrumentation tests uses: reactivecircus/android-emulator-runner@v2 with: @@ -90,11 +100,11 @@ jobs: disable-animations: true disk-size: 6000M heap-size: 600M - script: ./gradlew connectedProdDebugAndroidTest -x :benchmark:connectedProdBenchmarkAndroidTest + script: ./gradlew connectedDemoDebugAndroidTest -x :benchmark:connectedDemoBenchmarkAndroidTest --daemon - name: Upload test reports if: always() uses: actions/upload-artifact@v3 with: name: test-reports-${{ matrix.api-level }} - path: '*/build/reports/androidTests' + path: '**/build/reports/androidTests' 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/.google/BUILDME b/.google/BUILDME new file mode 100644 index 000000000..5295ed188 --- /dev/null +++ b/.google/BUILDME @@ -0,0 +1,2 @@ +# This file can be used to trigger an internal build by changing the number below +3 diff --git a/app-nia-catalog/build.gradle.kts b/app-nia-catalog/build.gradle.kts index 8232350d9..1f9ac1e2a 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}") } @@ -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. @@ -65,9 +65,7 @@ android { } dependencies { - implementation(project(":core:ui")) implementation(project(":core:designsystem")) - + implementation(project(":core:ui")) implementation(libs.androidx.activity.compose) - implementation(libs.accompanist.flowlayout) } 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/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 4163090ce..e172ed8bb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,22 +14,22 @@ * limitations under the License. */ import com.google.samples.apps.nowinandroid.NiaBuildType -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("jacoco") - id("nowinandroid.firebase-perf") + id("nowinandroid.android.application.firebase") } 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" @@ -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") @@ -65,7 +65,7 @@ android { } } - packagingOptions { + packaging { resources { excludes.add("/META-INF/{AL2.0,LGPL2.1}") } @@ -74,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" } @@ -94,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")) @@ -101,6 +91,7 @@ dependencies { implementation(project(":core:designsystem")) implementation(project(":core:data")) implementation(project(":core:model")) + implementation(project(":core:analytics")) implementation(project(":sync:work")) @@ -129,7 +120,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/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/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 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..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 @@ -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,19 @@ 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 navigateUp by composeTestRule.stringResource(FeatureForyouR.string.navigate_up) + 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() { 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..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,7 +25,10 @@ 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.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 @@ -63,6 +66,11 @@ class NavigationUiTest { @get:Rule(order = 2) val composeTestRule = createAndroidComposeRule() + val userNewsResourceRepository = CompositeUserNewsResourceRepository( + newsRepository = TestNewsRepository(), + userDataRepository = TestUserDataRepository(), + ) + @Inject lateinit var networkMonitor: NetworkMonitor @@ -81,6 +89,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -100,6 +109,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -119,6 +129,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -138,6 +149,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -157,6 +169,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -176,6 +189,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -195,6 +209,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -214,6 +229,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } @@ -233,6 +249,7 @@ class NavigationUiTest { DpSize(maxWidth, maxHeight), ), networkMonitor = networkMonitor, + userNewsResourceRepository = userNewsResourceRepository, ) } } 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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5c3b889d2..99c233910 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,13 @@ + + + + + + + + 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..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 @@ -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,9 @@ 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.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.model.data.DarkThemeConfig @@ -61,6 +65,12 @@ class MainActivity : ComponentActivity() { @Inject lateinit var networkMonitor: NetworkMonitor + @Inject + lateinit var analyticsHelper: AnalyticsHelper + + @Inject + lateinit var userNewsResourceRepository: UserNewsResourceRepository + val viewModel: MainActivityViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { @@ -104,15 +114,18 @@ 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), + userNewsResourceRepository = userNewsResourceRepository, + ) + } } } } 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/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 a8d321562..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 @@ -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 @@ -44,17 +44,20 @@ 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.lifecycle.compose.ExperimentalLifecycleComposeApi +import androidx.compose.ui.unit.dp 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 @@ -77,15 +80,16 @@ import com.google.samples.apps.nowinandroid.feature.settings.R as settingsR ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class, - ExperimentalLifecycleComposeApi::class, ) @Composable fun NiaApp( windowSizeClass: WindowSizeClass, networkMonitor: NetworkMonitor, + userNewsResourceRepository: UserNewsResourceRepository, appState: NiaAppState = rememberNiaAppState( networkMonitor = networkMonitor, windowSizeClass = windowSizeClass, + userNewsResourceRepository = userNewsResourceRepository, ), ) { val shouldShowGradientBackground = @@ -130,8 +134,10 @@ fun NiaApp( snackbarHost = { SnackbarHost(snackbarHostState) }, bottomBar = { if (appState.shouldShowBottomBar) { + val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle() NiaBottomBar( destinations = appState.topLevelDestinations, + destinationsWithUnreadResources = unreadDestinations, onNavigateToDestination = appState::navigateToTopLevelDestination, currentDestination = appState.currentDestination, modifier = Modifier.testTag("NiaBottomBar"), @@ -143,7 +149,7 @@ fun NiaApp( Modifier .fillMaxSize() .padding(padding) - .consumedWindowInsets(padding) + .consumeWindowInsets(padding) .windowInsetsPadding( WindowInsets.safeDrawing.only( WindowInsetsSides.Horizontal, @@ -167,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, @@ -175,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 @@ -213,6 +224,7 @@ private fun NiaNavRail( imageVector = icon.imageVector, contentDescription = null, ) + is DrawableResourceIcon -> Icon( painter = painterResource(id = icon.id), contentDescription = null, @@ -220,6 +232,7 @@ private fun NiaNavRail( } }, label = { Text(stringResource(destination.iconTextId)) }, + ) } } @@ -228,6 +241,7 @@ private fun NiaNavRail( @Composable private fun NiaBottomBar( destinations: List, + destinationsWithUnreadResources: Set, onNavigateToDestination: (TopLevelDestination) -> Unit, currentDestination: NavDestination?, modifier: Modifier = Modifier, @@ -236,6 +250,7 @@ private fun NiaBottomBar( modifier = modifier, ) { destinations.forEach { destination -> + val hasUnread = destinationsWithUnreadResources.contains(destination) val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) NiaNavigationBarItem( selected = selected, @@ -259,11 +274,31 @@ private fun NiaBottomBar( } }, label = { Text(stringResource(destination.iconTextId)) }, + 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..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 @@ -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 @@ -41,12 +42,15 @@ 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 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 +58,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 +86,7 @@ class NiaAppState( val coroutineScope: CoroutineScope, val windowSizeClass: WindowSizeClass, networkMonitor: NetworkMonitor, + userNewsResourceRepository: UserNewsResourceRepository, ) { val currentDestination: NavDestination? @Composable get() = navController @@ -105,6 +123,22 @@ class NiaAppState( */ val topLevelDestinations: List = TopLevelDestination.values().asList() + /** + * The top level destinations that have unread news resources. + */ + val topLevelDestinationsWithUnreadResources: StateFlow> = + userNewsResourceRepository.observeAllForFollowedTopics() + .combine(userNewsResourceRepository.observeAllBookmarked()) { 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 @@ -139,6 +173,10 @@ class NiaAppState( fun setShowSettingsDialog(shouldShow: Boolean) { shouldShowSettingsDialog = shouldShow } + + fun navigateToSearch() { + navController.navigateToSearch() + } } /** 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/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index 9af89d98d..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") @@ -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 0152a902b..571ba8c2f 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` } @@ -21,12 +23,22 @@ plugins { group = "com.google.samples.apps.nowinandroid.buildlogic" java { + // 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_11.toString() + } +} + dependencies { compileOnly(libs.android.gradlePlugin) + compileOnly(libs.firebase.crashlytics.gradle) + compileOnly(libs.firebase.performance.gradle) compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.ksp.gradlePlugin) } @@ -73,9 +85,13 @@ gradlePlugin { id = "nowinandroid.android.room" implementationClass = "AndroidRoomConventionPlugin" } - register("firebase-perf") { - id = "nowinandroid.firebase-perf" - implementationClass = "FirebasePerfConventionPlugin" + 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 e0efe511b..0e2eaa397 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -14,10 +14,9 @@ * 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.google.samples.apps.nowinandroid.configureGradleManagedDevices +import com.android.build.api.variant.ApplicationAndroidComponentsExtension import com.google.samples.apps.nowinandroid.configureKotlinAndroid import com.google.samples.apps.nowinandroid.configureKotlinAndroidToolchain import com.google.samples.apps.nowinandroid.configurePrintApksTask @@ -37,7 +36,6 @@ class AndroidApplicationConventionPlugin : Plugin { extensions.configure { configureKotlinAndroid(this) defaultConfig.targetSdk = 33 - configureFlavors(this) configureGradleManagedDevices(this) } extensions.configure { diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt new file mode 100644 index 000000000..7b3a0059f --- /dev/null +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +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 +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 AndroidApplicationFirebaseConventionPlugin : Plugin { + override fun apply(target: Project) { + 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("implementation", platform(bom)) + "implementation"(libs.findLibrary("firebase.analytics").get()) + "implementation"(libs.findLibrary("firebase.performance").get()) + "implementation"(libs.findLibrary("firebase.crashlytics").get()) + } + + 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 + } + } + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/FirebasePerfConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationFlavorsConventionPlugin.kt similarity index 66% rename from build-logic/convention/src/main/kotlin/FirebasePerfConventionPlugin.kt rename to build-logic/convention/src/main/kotlin/AndroidApplicationFlavorsConventionPlugin.kt index 48f750678..46b019d7a 100644 --- a/build-logic/convention/src/main/kotlin/FirebasePerfConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationFlavorsConventionPlugin.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,16 +14,18 @@ * 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 FirebasePerfConventionPlugin : Plugin { +class AndroidApplicationFlavorsConventionPlugin : Plugin { override fun apply(target: Project) { with(target) { - pluginManager.findPlugin("com.google.firebase.firebase-perf").apply { - version = "1.4.1" + 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 0c71d9d4c..1b567ae2d 100644 --- a/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt @@ -49,6 +49,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/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/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index c0e269243..2ffeae974 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 @@ -22,6 +21,7 @@ import com.google.samples.apps.nowinandroid.configureGradleManagedDevices import com.google.samples.apps.nowinandroid.configureKotlinAndroid import com.google.samples.apps.nowinandroid.configureKotlinAndroidToolchain 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 @@ -47,6 +47,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/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt index 4da997fc3..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 @@ -21,6 +21,8 @@ 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.withType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.io.File /** @@ -40,16 +42,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/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() +} 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..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,9 +18,8 @@ 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 -import java.util.Locale /** * Configure project for Gradle managed devices @@ -28,16 +27,17 @@ import java.util.Locale 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 @@ -45,6 +45,13 @@ internal fun configureGradleManagedDevices( } } } + groups { + maybeCreate("ci").apply { + ciDevices.forEach { deviceConfig -> + targetDevices.add(devices[deviceConfig.taskName]) + } + } + } } } } @@ -55,7 +62,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<*, *, *>, ) { 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 2b5d1c354..656a66329 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 @@ -25,8 +25,10 @@ import org.gradle.kotlin.dsl.configure 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.KotlinAndroidProjectExtension import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile /** * Configure base Kotlin with Android options @@ -42,27 +44,29 @@ internal fun Project.configureKotlinAndroid( } compileOptions { + // 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 } + } + // Use withType to workaround https://youtrack.jetbrains.com/issue/KT-55947 + tasks.withType().configureEach { kotlinOptions { + // Set JVM target to 17 + 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 "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-opt-in=kotlinx.coroutines.FlowPreview", - "-opt-in=kotlin.Experimental", ) - - // Set JVM target to 11 - jvmTarget = JavaVersion.VERSION_11.toString() } } 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( diff --git a/build-logic/gradle/wrapper/gradle-wrapper.jar b/build-logic/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 41d9927a4..000000000 Binary files a/build-logic/gradle/wrapper/gradle-wrapper.jar and /dev/null differ 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 diff --git a/build.gradle.kts b/build.gradle.kts index 30640d41c..17690f83e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,13 +22,16 @@ buildscript { // Android Build Server maven { url = uri("../nowinandroid-prebuilts/m2repository") } } - } +// 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.ksp) apply false alias(libs.plugins.secrets) apply false diff --git a/build_android_release.sh b/build_android_release.sh index dfdf37500..c7e5fc835 100755 --- a/build_android_release.sh +++ b/build_android_release.sh @@ -25,44 +25,26 @@ 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" -cd $DIR -# Build -GRADLE_PARAMS=" --stacktrace" -$DIR/gradlew :app:clean :app:assemble ${GRADLE_PARAMS} -BUILD_RESULT=$? +echo "Copying google-services.json" +cp $DIR/../nowinandroid-prebuilts/google-services.json $DIR/app -# Demo debug -cp $APP_OUT/apk/demo/debug/app-demo-debug.apk $DIST_DIR +echo "Copying local.properties" +cp $DIR/../nowinandroid-prebuilts/local.properties $DIR -# Demo release -cp $APP_OUT/apk/demo/release/app-demo-release.apk $DIST_DIR +cd $DIR -# Prod debug -cp $APP_OUT/apk/prod/debug/app-prod-debug.apk $DIST_DIR/app-prod-debug.apk +# Build the prodRelease variant +GRADLE_PARAMS=" --stacktrace -Puse-google-services" +$DIR/gradlew :app:clean :app:assembleProdRelease :app:bundleProdRelease ${GRADLE_PARAMS} +BUILD_RESULT=$? -# 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} - -# 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 +# 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 -BUILD_RESULT=$? +# Prod release bundle mapping +cp $APP_OUT/mapping/prodRelease/mapping.txt $DIST_DIR/mobile-release-aab-mapping.txt -exit $BUILD_RESULT \ No newline at end of file +exit $BUILD_RESULT 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..8c573b854 --- /dev/null +++ b/core/analytics/build.gradle.kts @@ -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. + */ +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(platform(libs.firebase.bom)) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.core.ktx) + implementation(libs.firebase.analytics) + implementation(libs.kotlinx.coroutines.android) +} 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..9f875ae6d --- /dev/null +++ b/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/AnalyticsModule.kt @@ -0,0 +1,40 @@ +/* + * 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.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 new file mode 100644 index 000000000..75dfbc468 --- /dev/null +++ b/core/analytics/src/prod/java/com/google/samples/apps/nowinandroid/core/analytics/FirebaseAnalyticsHelper.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.analytics + +import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.analytics.ktx.logEvent +import javax.inject.Inject + +/** + * Implementation of `AnalyticsHelper` which logs events to a Firebase backend. + */ +class FirebaseAnalyticsHelper @Inject constructor( + private val firebaseAnalytics: FirebaseAnalytics, +) : AnalyticsHelper { + + 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/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/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/build.gradle.kts b/core/data/build.gradle.kts index 717082bfe..51dfb5393 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -25,23 +25,24 @@ android { testOptions { unitTests { isIncludeAndroidResources = true + isReturnDefaultValues = true } } } 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")) - - testImplementation(project(":core:testing")) - testImplementation(project(":core:datastore-test")) - + implementation(project(":core:notifications")) implementation(libs.androidx.core.ktx) - - implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) -} \ No newline at end of file + + testImplementation(project(":core:datastore-test")) + testImplementation(project(":core:testing")) +} 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/di/UserNewsResourceRepositoryModule.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/UserNewsResourceRepositoryModule.kt new file mode 100644 index 000000000..1a7a80fff --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/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.data.di + +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 +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface UserNewsResourceRepositoryModule { + @Binds + fun bindsUserNewsResourceRepository( + userDataRepository: CompositeUserNewsResourceRepository, + ): UserNewsResourceRepository +} 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/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/CompositeUserNewsResourceRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/CompositeUserNewsResourceRepository.kt new file mode 100644 index 000000000..64e02e7d9 --- /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 observeAll( + 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 observeAllForFollowedTopics(): Flow> = + userDataRepository.userData.map { it.followedTopics }.distinctUntilChanged() + .flatMapLatest { followedTopics -> + when { + followedTopics.isEmpty() -> flowOf(emptyList()) + else -> observeAll(NewsResourceQuery(filterTopicIds = followedTopics)) + } + } + + override fun observeAllBookmarked(): Flow> = + userDataRepository.userData.map { it.bookmarkedNewsResources }.distinctUntilChanged() + .flatMapLatest { bookmarkedNewsResources -> + when { + bookmarkedNewsResources.isEmpty() -> flowOf(emptyList()) + else -> observeAll(NewsResourceQuery(filterNewsIds = bookmarkedNewsResources)) + } + } +} 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/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..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 @@ -27,31 +27,39 @@ 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 +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 +// 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 + /** * Disk storage backed implementation of the [NewsRepository]. * 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, + private val notifier: Notifier, ) : 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) } @@ -66,26 +74,63 @@ class OfflineFirstNewsRepository @Inject constructor( }, modelDeleter = newsResourceDao::deleteNewsResources, modelUpdater = { changedIds -> - val networkNewsResources = network.getNewsResources(ids = 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 existingNewsResourceIdsThatHaveChanged = 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() + } + + // 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) + + // 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(), + ) + } - // Order of invocation matters to satisfy id and foreign key constraints! + if (hasOnboarded) { + val addedNewsResources = newsResourceDao.getNewsResources( + useFilterTopicIds = true, + filterTopicIds = followedTopicIds, + useFilterNewsIds = true, + filterNewsIds = changedIds.toSet() - existingNewsResourceIdsThatHaveChanged, + ) + .first() + .map(PopulatedNewsResource::asExternalModel) - 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(), - ) + if (addedNewsResources.isNotEmpty()) notifier.onNewsAdded(addedNewsResources) + } }, ) } 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..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 @@ -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,49 @@ 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 setNewsResourceViewed(newsResourceId: String, viewed: Boolean) = + niaPreferencesDataSource.setNewsResourceViewed(newsResourceId, viewed) + + 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/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/UserDataRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserDataRepository.kt index ea093852f..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 @@ -43,6 +43,11 @@ interface UserDataRepository { */ suspend fun updateNewsResourceBookmark(newsResourceId: String, bookmarked: Boolean) + /** + * Updates the viewed status for a news resource + */ + suspend fun setNewsResourceViewed(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/UserNewsResourceRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserNewsResourceRepository.kt new file mode 100644 index 000000000..c0f4c013a --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/UserNewsResourceRepository.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.core.data.repository + +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource +import kotlinx.coroutines.flow.Flow + +/** + * Data layer implementation for [UserNewsResource] + */ +interface UserNewsResourceRepository { + /** + * Returns available news resources as a stream. + */ + fun observeAll( + query: NewsResourceQuery = NewsResourceQuery( + filterTopicIds = null, + filterNewsIds = null, + ), + ): Flow> + + /** + * Returns available news resources for the user's followed topics as a stream. + */ + fun observeAllForFollowedTopics(): Flow> + + /** + * Returns the user's bookmarked news resources as a stream. + */ + fun observeAllBookmarked(): Flow> +} 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/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/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..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,6 +47,9 @@ class FakeUserDataRepository @Inject constructor( niaPreferencesDataSource.toggleNewsResourceBookmark(newsResourceId, bookmarked) } + 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/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/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCaseTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/CompositeUserNewsResourceRepositoryTest.kt similarity index 63% rename from core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCaseTest.kt rename to core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/CompositeUserNewsResourceRepositoryTest.kt index 32ee8773c..eb4241295 100644 --- a/core/domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCaseTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/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. @@ -14,37 +14,37 @@ * 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.mapToUserNewsResources +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.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 com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import kotlinx.coroutines.flow.first 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( + 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.observeAll() // Send some news resources and user data into the data repositories. newsRepository.sendNewsResources(sampleNewsResources) @@ -67,7 +67,14 @@ 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 = + userNewsResourceRepository.observeAll( + NewsResourceQuery( + filterTopicIds = setOf( + sampleTopic1.id, + ), + ), + ) // Send test data into the repositories. newsRepository.sendNewsResources(sampleNewsResources) @@ -81,6 +88,51 @@ class GetUserNewsResourcesUseCaseTest { userNewsResources.first(), ) } + + @Test + fun whenFilteredByFollowedTopics_matchingNewsResourcesAreReturned() = runTest { + // Obtain a stream of user news resources for the given topic id. + val userNewsResources = + userNewsResourceRepository.observeAllForFollowedTopics() + + // 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(), + ) + } + + @Test + fun whenFilteredByBookmarkedResources_matchingNewsResourcesAreReturned() = runTest { + // Obtain the bookmarked user news resources flow. + val userNewsResources = userNewsResourceRepository.observeAllBookmarked() + + // 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 94% 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 8350c5178..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 @@ -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/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..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 @@ -27,32 +27,44 @@ 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 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 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 import org.junit.Test import org.junit.rules.TemporaryFolder import kotlin.test.assertEquals +import kotlin.test.assertTrue class OfflineFirstNewsRepositoryTest { + private val testScope = TestScope(UnconfinedTestDispatcher()) + private lateinit var subject: OfflineFirstNewsRepository + private lateinit var niaPreferencesDataSource: NiaPreferencesDataSource + private lateinit var newsResourceDao: TestNewsResourceDao private lateinit var topicDao: TestTopicDao private lateinit var network: TestNiaNetworkDataSource + private lateinit var notifier: TestNotifier + private lateinit var synchronizer: Synchronizer @get:Rule @@ -60,25 +72,29 @@ class OfflineFirstNewsRepositoryTest { @Before fun setup() { + niaPreferencesDataSource = NiaPreferencesDataSource( + tmpFolder.testUserPreferencesDataStore(testScope), + ) newsResourceDao = TestNewsResourceDao() topicDao = TestTopicDao() network = TestNiaNetworkDataSource() + notifier = TestNotifier() synchronizer = TestSynchronizer( - NiaPreferencesDataSource( - tmpFolder.testUserPreferencesDataStore(), - ), + niaPreferencesDataSource, ) subject = OfflineFirstNewsRepository( + niaPreferencesDataSource = niaPreferencesDataSource, newsResourceDao = newsResourceDao, topicDao = topicDao, network = network, + notifier = notifier, ) } @Test fun offlineFirstNewsRepository_news_resources_stream_is_backed_by_news_resource_dao() = - runTest { + testScope.runTest { assertEquals( newsResourceDao.getNewsResources() .first() @@ -90,23 +106,28 @@ class OfflineFirstNewsRepositoryTest { @Test fun offlineFirstNewsRepository_news_resources_for_topic_is_backed_by_news_resource_dao() = - runTest { + testScope.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(), ) assertEquals( - emptyList(), - subject.getNewsResources( - filterTopicIds = nonPresentInterestsIds, + expected = emptyList(), + actual = subject.getNewsResources( + query = NewsResourceQuery( + filterTopicIds = nonPresentInterestsIds, + ), ) .first(), ) @@ -114,7 +135,9 @@ class OfflineFirstNewsRepositoryTest { @Test fun offlineFirstNewsRepository_sync_pulls_from_network() = - runTest { + testScope.runTest { + // User has not onboarded + niaPreferencesDataSource.setShouldHideOnboarding(false) subject.syncWith(synchronizer) val newsResourcesFromNetwork = network.getNewsResources() @@ -126,20 +149,26 @@ 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, ) + + // Notifier should not have been called + assertTrue(notifier.addedNewsResources.isEmpty()) } @Test fun offlineFirstNewsRepository_sync_deletes_items_marked_deleted_on_network() = - runTest { + testScope.runTest { + // User has not onboarded + niaPreferencesDataSource.setShouldHideOnboarding(false) + val newsResourcesFromNetwork = network.getNewsResources() .map(NetworkNewsResource::asEntity) .map(NewsResourceEntity::asExternalModel) @@ -167,20 +196,26 @@ 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, ) + + // Notifier should not have been called + assertTrue(notifier.addedNewsResources.isEmpty()) } @Test fun offlineFirstNewsRepository_incremental_sync_pulls_from_network() = - runTest { + testScope.runTest { + // User has not onboarded + niaPreferencesDataSource.setShouldHideOnboarding(false) + // Set news version to 7 synchronizer.updateChangeListVersions { copy(newsResourceVersion = 7) @@ -206,43 +241,116 @@ 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, ) + + // Notifier should not have been called + assertTrue(notifier.addedNewsResources.isEmpty()) } @Test fun offlineFirstNewsRepository_sync_saves_shell_topic_entities() = - runTest { + testScope.runTest { 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), ) } @Test fun offlineFirstNewsRepository_sync_saves_topic_cross_references() = - runTest { + testScope.runTest { 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), + ) + } + + @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) + + val followedNewsResourceIdsFromNetwork = 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 = followedNewsResourceIdsFromNetwork, + 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/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 055d8e074..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 @@ -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 @@ -23,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 @@ -33,30 +36,37 @@ 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 + private val analyticsHelper = NoOpAnalyticsHelper() + @get:Rule val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() @Before fun setup() { niaPreferencesDataSource = NiaPreferencesDataSource( - tmpFolder.testUserPreferencesDataStore(), + tmpFolder.testUserPreferencesDataStore(testScope), ) subject = OfflineFirstUserDataRepository( niaPreferencesDataSource = niaPreferencesDataSource, + analyticsHelper, ) } @Test fun offlineFirstUserDataRepository_default_user_data_is_correct() = - runTest { + testScope.runTest { assertEquals( UserData( bookmarkedNewsResources = emptySet(), + viewedNewsResources = emptySet(), followedTopics = emptySet(), themeBrand = ThemeBrand.DEFAULT, darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, @@ -69,7 +79,7 @@ class OfflineFirstUserDataRepositoryTest { @Test fun offlineFirstUserDataRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() = - runTest { + testScope.runTest { subject.toggleFollowedTopicId(followedTopicId = "0", followed = true) assertEquals( @@ -100,7 +110,7 @@ class OfflineFirstUserDataRepositoryTest { @Test fun offlineFirstUserDataRepository_set_followed_topics_logic_delegates_to_nia_preferences() = - runTest { + testScope.runTest { subject.setFollowedTopicIds(followedTopicIds = setOf("1", "2")) assertEquals( @@ -122,7 +132,7 @@ class OfflineFirstUserDataRepositoryTest { @Test fun offlineFirstUserDataRepository_bookmark_news_resource_logic_delegates_to_nia_preferences() = - runTest { + testScope.runTest { subject.updateNewsResourceBookmark(newsResourceId = "0", bookmarked = true) assertEquals( @@ -152,8 +162,39 @@ class OfflineFirstUserDataRepositoryTest { } @Test - fun offlineFirstUserDataRepository_set_theme_brand_delegates_to_nia_preferences() = + fun offlineFirstUserDataRepository_update_viewed_news_resources_delegates_to_nia_preferences() = runTest { + subject.setNewsResourceViewed(newsResourceId = "0", viewed = true) + + assertEquals( + setOf("0"), + subject.userData + .map { it.viewedNewsResources } + .first(), + ) + + subject.setNewsResourceViewed(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 { subject.setThemeBrand(ThemeBrand.ANDROID) assertEquals( @@ -173,7 +214,7 @@ class OfflineFirstUserDataRepositoryTest { @Test fun offlineFirstUserDataRepository_set_dynamic_color_delegates_to_nia_preferences() = - runTest { + testScope.runTest { subject.setDynamicColorPreference(true) assertEquals( @@ -193,7 +234,7 @@ class OfflineFirstUserDataRepositoryTest { @Test fun offlineFirstUserDataRepository_set_dark_theme_config_delegates_to_nia_preferences() = - runTest { + testScope.runTest { subject.setDarkThemeConfig(DarkThemeConfig.DARK) assertEquals( @@ -213,7 +254,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/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..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 @@ -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,56 +35,72 @@ 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() - 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 { newsResourceEntities -> + newsResourceEntities.map { entity -> + entity.asPopulatedNewsResource(topicCrossReferences) + } + } .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 getOneOffNewsResources(): List = emptyList() + 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() } } - override suspend fun updateNewsResources(entities: List) { - throw NotImplementedError("Unused in tests") - } - 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) { @@ -97,16 +111,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/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..a52cc86f6 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 { @@ -52,18 +43,21 @@ 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 { - 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() } } - override suspend fun updateTopics(entities: List) { - throw NotImplementedError("Unused in tests") - } - 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) { diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 79f980b6c..a9c711ae3 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -14,10 +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 { id("nowinandroid.android.library") id("nowinandroid.android.library.jacoco") @@ -31,20 +27,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 +36,4 @@ dependencies { implementation(libs.kotlinx.datetime) androidTestImplementation(project(":core:testing")) -} \ No newline at end of file +} 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/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/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 af0a59bce..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 @@ -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 @@ -34,43 +33,48 @@ 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> + @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 */ @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/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..e876458ee 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 @@ -41,6 +40,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 @@ -55,12 +57,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 */ 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/datastore-test/build.gradle.kts b/core/datastore-test/build.gradle.kts index 40b287b7b..c7c423c25 100644 --- a/core/datastore-test/build.gradle.kts +++ b/core/datastore-test/build.gradle.kts @@ -24,7 +24,8 @@ android { dependencies { api(project(":core:datastore")) - implementation(project(":core:testing")) - api(libs.androidx.dataStore.core) + + implementation(project(":core:common")) + implementation(project(":core:testing")) } 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..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 @@ -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/build.gradle.kts b/core/datastore/build.gradle.kts index 8f3d7ece6..a9ec7a78f 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -14,13 +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 { id("nowinandroid.android.library") id("nowinandroid.android.library.jacoco") @@ -33,6 +26,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 @@ -43,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") } } @@ -57,12 +55,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/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..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 @@ -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 setNewsResourceViewed(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/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) } 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/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/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/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 db274bbbd..000000000 --- a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetUserNewsResourcesUseCase.kt +++ /dev/null @@ -1,60 +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.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 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. - */ - operator fun invoke( - filterTopicIds: Set = emptySet(), - ): Flow> = - if (filterTopicIds.isEmpty()) { - newsRepository.getNewsResources() - } else { - newsRepository.getNewsResources(filterTopicIds = filterTopicIds) - }.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/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/model/build.gradle.kts b/core/model/build.gradle.kts index eec8d82ab..67ecf9006 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") } 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/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/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/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 85% 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 4e12ec95b..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,6 +32,7 @@ data class UserNewsResource internal constructor( val type: NewsResourceType, val followableTopics: List, val isSaved: Boolean, + val hasBeenViewed: Boolean, ) { constructor(newsResource: NewsResource, userData: UserData) : this( id = newsResource.id, @@ -51,6 +49,7 @@ data class UserNewsResource internal constructor( ) }, isSaved = userData.bookmarkedNewsResources.contains(newsResource.id), + hasBeenViewed = userData.viewedNewsResources.contains(newsResource.id), ) } 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/network/build.gradle.kts b/core/network/build.gradle.kts index 57a75a8bf..633e2573d 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -41,14 +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) + + testImplementation(project(":core:testing")) } 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..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 @@ -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/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" } 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..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,12 +22,10 @@ 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 import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.http.GET import retrofit2.http.Query @@ -75,22 +73,13 @@ private data class NetworkResponse( @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()), ) .build() 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 5e0c3e409..2fea5beb3 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -24,23 +24,22 @@ 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(project(":core:notifications")) + implementation(libs.kotlinx.datetime) } 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 381160006..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 @@ -30,6 +32,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, @@ -53,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, @@ -69,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, @@ -85,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, @@ -99,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/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/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/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/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..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 @@ -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 setNewsResourceViewed(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/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/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 39f9bcff1..b7280e757 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -27,27 +27,28 @@ android { } dependencies { - implementation(project(":core:designsystem")) - implementation(project(":core:model")) - implementation(project(":core:domain")) - - 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/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..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 @@ -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, + hasBeenViewed = false, onToggleBookmark = {}, onClick = {}, onTopicClick = {}, @@ -67,6 +69,7 @@ class NewsResourceCardTest { NewsResourceCardExpanded( userNewsResource = newsWithUnknownResourceType, isBookmarked = false, + hasBeenViewed = 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, + hasBeenViewed = 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, + hasBeenViewed = 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/AnalyticsExtensions.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AnalyticsExtensions.kt new file mode 100644 index 000000000..bebaa4711 --- /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) { + 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/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 2ad38a26e..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 @@ -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,14 +32,16 @@ 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 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 +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource /** * An extension on [LazyListScope] defining a feed with news resources. @@ -47,7 +50,9 @@ import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource fun LazyGridScope.newsFeed( feedState: NewsFeedUiState, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, + onExpandedCardClick: () -> Unit = {}, ) { when (feedState) { NewsFeedUiState.Loading -> Unit @@ -57,12 +62,21 @@ 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 = { + onExpandedCardClick() + analyticsHelper.logNewsResourceOpened( + newsResourceId = userNewsResource.id, + ) + launchCustomChromeTab(context, resourceUrl, backgroundColor) + onNewsResourceViewed(userNewsResource.id) + }, + hasBeenViewed = userNewsResource.hasBeenViewed, onToggleBookmark = { onNewsResourcesCheckedChanged( userNewsResource.id, @@ -70,6 +84,7 @@ fun LazyGridScope.newsFeed( ) }, onTopicClick = onTopicClick, + modifier = Modifier.padding(horizontal = 8.dp), ) } } @@ -114,6 +129,7 @@ private fun NewsFeedLoadingPreview() { newsFeed( feedState = NewsFeedUiState.Loading, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -132,6 +148,7 @@ private fun NewsFeedContentPreview( newsFeed( feedState = NewsFeedUiState.Success(userNewsResources), onNewsResourcesCheckedChanged = { _, _ -> }, + 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 cffa59436..a6a7aafc9 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 @@ -35,12 +37,15 @@ 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 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 @@ -57,10 +62,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 @@ -77,6 +82,7 @@ import com.google.samples.apps.nowinandroid.core.designsystem.R as DesignsystemR fun NewsResourceCardExpanded( userNewsResource: UserNewsResource, isBookmarked: Boolean, + hasBeenViewed: Boolean, onToggleBookmark: () -> Unit, onClick: () -> Unit, onTopicClick: (String) -> Unit, @@ -113,7 +119,16 @@ fun NewsResourceCardExpanded( BookmarkButton(isBookmarked, onToggleBookmark) } Spacer(modifier = Modifier.height(12.dp)) - NewsResourceMetaData(userNewsResource.publishDate, userNewsResource.type) + Row(verticalAlignment = Alignment.CenterVertically) { + if (!hasBeenViewed) { + NotificationDot( + 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 +196,24 @@ fun BookmarkButton( ) } +@Composable +fun NotificationDot( + 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()) } @@ -296,15 +329,20 @@ private fun ExpandedNewsResourcePreview( @PreviewParameter(UserNewsResourcePreviewParameterProvider::class) userNewsResources: List, ) { - NiaTheme { - Surface { - NewsResourceCardExpanded( - userNewsResource = userNewsResources[0], - isBookmarked = true, - onToggleBookmark = {}, - onClick = {}, - onTopicClick = {}, - ) + CompositionLocalProvider( + LocalInspectionMode provides true, + ) { + NiaTheme { + Surface { + NewsResourceCardExpanded( + userNewsResource = userNewsResources[0], + isBookmarked = true, + 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 a2c02f84f..5cf7d7313 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,7 +23,8 @@ 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.domain.model.UserNewsResource +import com.google.samples.apps.nowinandroid.core.analytics.LocalAnalyticsHelper +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource /** * Extension function for displaying a [List] of [NewsResourceCardExpanded] backed by a list of @@ -36,6 +37,7 @@ import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource fun LazyListScope.userNewsResourceCardItems( items: List, onToggleBookmark: (item: UserNewsResource) -> Unit, + onNewsResourceViewed: (String) -> Unit, onItemClick: ((item: UserNewsResource) -> Unit)? = null, onTopicClick: (String) -> Unit, itemModifier: Modifier = Modifier, @@ -46,16 +48,22 @@ 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, + hasBeenViewed = userNewsResource.hasBeenViewed, onToggleBookmark = { onToggleBookmark(userNewsResource) }, onClick = { + analyticsHelper.logNewsResourceOpened( + newsResourceId = userNewsResource.id, + ) when (onItemClick) { 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 e32aa1a57..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 @@ -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,8 @@ 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 com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.newsResources import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone @@ -36,100 +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"), - 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/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/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 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/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..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,6 +52,7 @@ class BookmarksScreenTest { feedState = NewsFeedUiState.Loading, removeFromBookmarks = {}, onTopicClick = {}, + onNewsResourceViewed = {}, ) } @@ -71,6 +72,7 @@ class BookmarksScreenTest { ), removeFromBookmarks = {}, onTopicClick = {}, + onNewsResourceViewed = {}, ) } @@ -113,6 +115,7 @@ class BookmarksScreenTest { removeFromBookmarksCalled = true }, onTopicClick = {}, + onNewsResourceViewed = {}, ) } @@ -143,6 +146,7 @@ class BookmarksScreenTest { feedState = NewsFeedUiState.Success(emptyList()), removeFromBookmarks = {}, onTopicClick = {}, + 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 5f43fd235..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,20 +61,21 @@ 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.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 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 +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 -@OptIn(ExperimentalLifecycleComposeApi::class) @Composable internal fun BookmarksRoute( onTopicClick: (String) -> Unit, @@ -74,30 +86,75 @@ internal fun BookmarksRoute( BookmarksScreen( feedState = feedState, removeFromBookmarks = viewModel::removeFromSavedResources, + 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( feedState: NewsFeedUiState, removeFromBookmarks: (String) -> Unit, + 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, 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") } @Composable @@ -115,6 +172,7 @@ private fun LoadingState(modifier: Modifier = Modifier) { private fun BookmarksGrid( feedState: NewsFeedUiState, removeFromBookmarks: (String) -> Unit, + onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, ) { @@ -133,6 +191,7 @@ private fun BookmarksGrid( newsFeed( feedState = feedState, onNewsResourcesCheckedChanged = { id, _ -> removeFromBookmarks(id) }, + onNewsResourceViewed = onNewsResourceViewed, onTopicClick = onTopicClick, ) item(span = { GridItemSpan(maxLineSpan) }) { @@ -159,7 +218,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), @@ -198,6 +257,7 @@ private fun BookmarksGridPreview( BookmarksGrid( feedState = Success(userNewsResources), removeFromBookmarks = {}, + 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 fe631c287..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,17 +16,19 @@ 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 -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.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 @@ -36,23 +38,47 @@ import javax.inject.Inject @HiltViewModel class BookmarksViewModel @Inject constructor( private val userDataRepository: UserDataRepository, - getSaveableNewsResources: GetUserNewsResourcesUseCase, + userNewsResourceRepository: UserNewsResourceRepository, ) : ViewModel() { - val feedUiState: StateFlow = getSaveableNewsResources() - .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, - ) + var shouldDisplayUndoBookmark by mutableStateOf(false) + private var lastRemovedBookmarkId: String? = null + + val feedUiState: StateFlow = + userNewsResourceRepository.observeAllBookmarked() + .map, NewsFeedUiState>(NewsFeedUiState::Success) + .onStart { emit(Loading) } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = Loading, + ) fun removeFromSavedResources(newsResourceId: String) { viewModelScope.launch { + shouldDisplayUndoBookmark = true + lastRemovedBookmarkId = newsResourceId userDataRepository.updateNewsResourceBookmark(newsResourceId, false) } } + + fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) { + viewModelScope.launch { + 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 + 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..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.GetUserNewsResourcesUseCase +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 @@ -43,7 +43,7 @@ class BookmarksViewModelTest { private val userDataRepository = TestUserDataRepository() private val newsRepository = TestNewsRepository() - private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase( + private val userNewsResourceRepository = CompositeUserNewsResourceRepository( newsRepository = newsRepository, userDataRepository = userDataRepository, ) @@ -53,7 +53,7 @@ class BookmarksViewModelTest { fun setup() { viewModel = BookmarksViewModel( userDataRepository = userDataRepository, - getSaveableNewsResources = getUserNewsResourcesUseCase, + userNewsResourceRepository = userNewsResourceRepository, ) } diff --git a/feature/foryou/build.gradle.kts b/feature/foryou/build.gradle.kts index ed7be27dc..8c6747dd1 100644 --- a/feature/foryou/build.gradle.kts +++ b/feature/foryou/build.gradle.kts @@ -28,6 +28,5 @@ android { dependencies { implementation(libs.kotlinx.datetime) - - implementation(libs.accompanist.flowlayout) + implementation(libs.androidx.activity.compose) } 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..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,6 +56,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } } @@ -79,6 +80,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } } @@ -108,6 +110,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } } @@ -152,6 +155,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } } @@ -189,6 +193,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } } @@ -212,6 +217,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } } @@ -236,6 +242,7 @@ class ForYouScreenTest { onTopicClick = {}, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + 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 cb0b0ecd6..06c73c971 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 @@ -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 @@ -56,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 @@ -72,9 +71,7 @@ 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.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 @@ -82,14 +79,14 @@ 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 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( onTopicClick: (String) -> Unit, @@ -108,6 +105,7 @@ internal fun ForYouRoute( onTopicClick = onTopicClick, saveFollowedTopics = viewModel::dismissOnboarding, onNewsResourcesCheckedChanged = viewModel::updateNewsResourceSaved, + onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) }, modifier = modifier, ) } @@ -121,28 +119,14 @@ internal fun ForYouScreen( onTopicClick: (String) -> Unit, saveFollowedTopics: () -> Unit, onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, modifier: Modifier = Modifier, ) { 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") @@ -178,6 +162,7 @@ internal fun ForYouScreen( newsFeed( feedState = feedState, onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, + onNewsResourceViewed = onNewsResourceViewed, onTopicClick = onTopicClick, ) @@ -202,7 +187,9 @@ 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 @@ -211,6 +198,7 @@ internal fun ForYouScreen( ) } } + TrackScreenViewEvent(screenName = "ForYou") } /** @@ -245,7 +233,7 @@ 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, ) @@ -263,8 +251,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), @@ -411,6 +400,7 @@ fun ForYouScreenPopulatedFeed( onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -434,6 +424,7 @@ fun ForYouScreenOfflinePopulatedFeed( onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -451,7 +442,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, @@ -459,6 +451,7 @@ fun ForYouScreenTopicSelection( onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -477,6 +470,7 @@ fun ForYouScreenLoading() { onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -500,6 +494,7 @@ fun ForYouScreenPopulatedAndLoading( onTopicCheckedChanged = { _, _ -> }, saveFollowedTopics = {}, onNewsResourcesCheckedChanged = { _, _ -> }, + 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 cd029b4af..18d24118b 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 @@ -19,20 +19,15 @@ 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.UserDataRepository -import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor +import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository +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 -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 @@ -40,16 +35,16 @@ import javax.inject.Inject @HiltViewModel class ForYouViewModel @Inject constructor( - syncStatusMonitor: SyncStatusMonitor, + syncManager: SyncManager, private val userDataRepository: UserDataRepository, - getUserNewsResources: GetUserNewsResourcesUseCase, + userNewsResourceRepository: UserNewsResourceRepository, getFollowableTopics: GetFollowableTopicsUseCase, ) : ViewModel() { 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), @@ -57,7 +52,7 @@ class ForYouViewModel @Inject constructor( ) val feedState: StateFlow = - userDataRepository.getFollowedUserNewsResources(getUserNewsResources) + userNewsResourceRepository.observeAllForFollowedTopics() .map(NewsFeedUiState::Success) .stateIn( scope = viewModelScope, @@ -94,51 +89,15 @@ class ForYouViewModel @Inject constructor( } } - fun dismissOnboarding() { + fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) { viewModelScope.launch { - userDataRepository.setShouldHideOnboarding(true) + userDataRepository.setNewsResourceViewed(newsResourceId, viewed) } } -} -/** - * 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( - getUserNewsResources: GetUserNewsResourcesUseCase, -): 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 { - getUserNewsResources(filterTopicIds = followedTopics) + fun dismissOnboarding() { + viewModelScope.launch { + userDataRepository.setShouldHideOnboarding(true) } } - -/** - * 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 9e51758f0..62993dc9f 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,21 +16,21 @@ 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.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.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 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,11 +52,11 @@ 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() - private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase( + private val userNewsResourceRepository = CompositeUserNewsResourceRepository( newsRepository = newsRepository, userDataRepository = userDataRepository, ) @@ -70,9 +70,9 @@ class ForYouViewModelTest { @Before fun setup() { viewModel = ForYouViewModel( - syncStatusMonitor = syncStatusMonitor, + syncManager = syncManager, userDataRepository = userDataRepository, - getUserNewsResources = getUserNewsResourcesUseCase, + userNewsResourceRepository = userNewsResourceRepository, 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/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 f8a7a8d90..ec9fd8f10 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 @@ -65,7 +65,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/InterestsScreen.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsScreen.kt index ec0179139..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 @@ -25,16 +25,15 @@ 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 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 -@OptIn(ExperimentalLifecycleComposeApi::class) @Composable internal fun InterestsRoute( onTopicClick: (String) -> Unit, @@ -78,6 +77,7 @@ internal fun InterestsScreen( is InterestsUiState.Empty -> InterestsEmptyScreen() } } + TrackScreenViewEvent(screenName = "Interests") } @Composable 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..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 @@ -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( @@ -35,12 +35,13 @@ fun TopicsTabContent( onTopicClick: (String) -> Unit, onFollowButtonClick: (String, Boolean) -> Unit, modifier: Modifier = Modifier, + withBottomSpacer: Boolean = true, ) { 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 @@ -56,8 +57,10 @@ fun TopicsTabContent( } } - item { - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + if (withBottomSpacer) { + item { + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } } } } 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 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/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/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 3d9fd27d3..847493a83 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 @@ -61,11 +60,11 @@ 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 -@ExperimentalLifecycleComposeApi @Composable fun SettingsDialog( onDismiss: () -> Unit, @@ -134,6 +133,7 @@ fun SettingsDialog( Divider(Modifier.padding(top = 8.dp)) LinksPanel() } + TrackScreenViewEvent(screenName = "Settings") }, confirmButton = { Text( 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/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..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,6 +59,7 @@ class TopicScreenTest { onFollowClick = {}, onTopicClick = {}, onBookmarkChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } @@ -78,6 +79,7 @@ class TopicScreenTest { onFollowClick = {}, onTopicClick = {}, onBookmarkChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } @@ -102,6 +104,7 @@ class TopicScreenTest { onFollowClick = {}, onTopicClick = {}, onBookmarkChanged = { _, _ -> }, + onNewsResourceViewed = {}, ) } @@ -124,6 +127,7 @@ class TopicScreenTest { onFollowClick = {}, onTopicClick = {}, onBookmarkChanged = { _, _ -> }, + 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 9d2c3e817..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 @@ -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 @@ -52,16 +51,16 @@ 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 import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider 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, @@ -72,6 +71,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, @@ -79,6 +79,7 @@ internal fun TopicRoute( onBackClick = onBackClick, onFollowClick = viewModel::followTopicToggle, onBookmarkChanged = viewModel::bookmarkNews, + onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) }, onTopicClick = onTopicClick, ) } @@ -92,6 +93,7 @@ internal fun TopicScreen( onFollowClick: (Boolean) -> Unit, onTopicClick: (String) -> Unit, onBookmarkChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, modifier: Modifier = Modifier, ) { val state = rememberLazyListState() @@ -127,6 +129,7 @@ internal fun TopicScreen( news = newsUiState, imageUrl = topicUiState.followableTopic.topic.imageUrl, onBookmarkChanged = onBookmarkChanged, + onNewsResourceViewed = onNewsResourceViewed, onTopicClick = onTopicClick, ) } @@ -143,6 +146,7 @@ private fun LazyListScope.TopicBody( news: NewsUiState, imageUrl: String, onBookmarkChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> 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, onNewsResourceViewed, 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, + onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, ) { when (news) { @@ -188,6 +193,7 @@ private fun LazyListScope.userNewsResourceCards( userNewsResourceCardItems( items = news.news, onToggleBookmark = { onBookmarkChanged(it.id, !it.isSaved) }, + onNewsResourceViewed = onNewsResourceViewed, onTopicClick = onTopicClick, itemModifier = Modifier.padding(24.dp), ) @@ -214,6 +220,7 @@ private fun TopicBodyPreview() { news = NewsUiState.Success(emptyList()), imageUrl = "", onBookmarkChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -271,6 +278,7 @@ fun TopicScreenPopulated( onBackClick = {}, onFollowClick = {}, onBookmarkChanged = { _, _ -> }, + onNewsResourceViewed = {}, onTopicClick = {}, ) } @@ -288,6 +296,7 @@ fun TopicScreenLoading() { onBackClick = {}, onFollowClick = {}, onBookmarkChanged = { _, _ -> }, + 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 c0c6bbafd..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 @@ -19,13 +19,14 @@ 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.data.repository.UserNewsResourceRepository 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.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 @@ -45,11 +46,13 @@ class TopicViewModel @Inject constructor( stringDecoder: StringDecoder, private val userDataRepository: UserDataRepository, topicsRepository: TopicsRepository, - getSaveableNewsResources: GetUserNewsResourcesUseCase, + userNewsResourceRepository: UserNewsResourceRepository, ) : ViewModel() { private val topicArgs: TopicArgs = TopicArgs(savedStateHandle, stringDecoder) + val topicId = topicArgs.topicId + val topicUiState: StateFlow = topicUiState( topicId = topicArgs.topicId, userDataRepository = userDataRepository, @@ -64,7 +67,7 @@ class TopicViewModel @Inject constructor( val newUiState: StateFlow = newsUiState( topicId = topicArgs.topicId, userDataRepository = userDataRepository, - getSaveableNewsResources = getSaveableNewsResources, + userNewsResourceRepository = userNewsResourceRepository, ) .stateIn( scope = viewModelScope, @@ -83,6 +86,12 @@ class TopicViewModel @Inject constructor( userDataRepository.updateNewsResourceBookmark(newsResourceId, bookmarked) } } + + fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) { + viewModelScope.launch { + userDataRepository.setNewsResourceViewed(newsResourceId, viewed) + } + } } private fun topicUiState( @@ -118,9 +127,11 @@ private fun topicUiState( ), ) } + is Result.Loading -> { TopicUiState.Loading } + is Result.Error -> { TopicUiState.Error } @@ -130,12 +141,12 @@ private fun topicUiState( private fun newsUiState( topicId: String, - getSaveableNewsResources: GetUserNewsResourcesUseCase, + userNewsResourceRepository: UserNewsResourceRepository, userDataRepository: UserDataRepository, ): Flow { // Observe news - val newsStream: Flow> = getSaveableNewsResources( - filterTopicIds = setOf(element = topicId), + val newsStream: Flow> = userNewsResourceRepository.observeAll( + NewsResourceQuery(filterTopicIds = setOf(element = topicId)), ) // Observe bookmarks @@ -154,9 +165,11 @@ private fun newsUiState( val news = newsToBookmarksResult.data.first NewsUiState.Success(news) } + is Result.Loading -> { NewsUiState.Loading } + is Result.Error -> { NewsUiState.Error } 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..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.GetUserNewsResourcesUseCase -import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +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 @@ -53,7 +53,7 @@ class TopicViewModelTest { private val userDataRepository = TestUserDataRepository() private val topicsRepository = TestTopicsRepository() private val newsRepository = TestNewsRepository() - private val getUserNewsResourcesUseCase = GetUserNewsResourcesUseCase( + private val userNewsResourceRepository = CompositeUserNewsResourceRepository( newsRepository = newsRepository, userDataRepository = userDataRepository, ) @@ -66,10 +66,14 @@ class TopicViewModelTest { stringDecoder = FakeStringDecoder(), userDataRepository = userDataRepository, topicsRepository = topicsRepository, - getSaveableNewsResources = getUserNewsResourcesUseCase, + userNewsResourceRepository = userNewsResourceRepository, ) } + @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 38c1f7d8e..ccfdeca99 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,19 +1,20 @@ [versions] accompanist = "0.28.0" androidDesugarJdkLibs = "1.2.2" -androidGradlePlugin = "7.4.1" -androidxActivity = "1.6.1" +androidGradlePlugin = "8.0.0" +androidxActivity = "1.7.0" androidxAppCompat = "1.5.1" androidxBrowser = "1.4.0" -androidxComposeBom = "2022.12.00" -androidxComposeCompiler = "1.4.0" +androidxComposeBom = "2023.01.00" +androidxComposeCompiler = "1.4.5" +androidxComposeMaterial3 = "1.1.0-alpha06" androidxComposeRuntimeTracing = "1.0.0-alpha01" androidxCore = "1.9.0" 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" @@ -28,27 +29,30 @@ androidxUiAutomator = "2.2.0" androidxWindowManager = "1.0.0" androidxWork = "2.7.1" coil = "2.2.2" +firebaseBom = "31.2.0" +firebaseCrashlyticsPlugin = "2.9.2" +firebasePerfPlugin = "1.4.2" +gmsPlugin = "4.3.14" hilt = "2.44.2" hiltExt = "1.0.0" jacoco = "0.8.7" junit4 = "4.13.2" -kotlin = "1.8.0" +kotlin = "1.8.20" 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.20-1.0.11" 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" +retrofitKotlinxSerializationJson = "1.0.0" room = "2.5.0" 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" } @@ -60,8 +64,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" } @@ -82,20 +86,26 @@ 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" } 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-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" } @@ -127,6 +137,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" } 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/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e5832f..943f0cbfa 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ 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 c283382d7..c217e995c 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 @@ -35,8 +35,8 @@ 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 +# 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 export JAVA_HOME= diff --git a/lint/build.gradle.kts b/lint/build.gradle.kts index c87bb7877..4ae719aa6 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") @@ -20,8 +23,16 @@ plugins { } java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + // 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_11.toString() + } } dependencies { diff --git a/settings.gradle.kts b/settings.gradle.kts index 809efd27d..d0c477b3d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -46,10 +46,14 @@ include(":core:model") include(":core:network") include(":core:ui") include(":core:testing") +include(":core:analytics") +include(":core:notifications") + 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/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 70f6b2e89..79902e486 100644 --- a/sync/work/build.gradle.kts +++ b/sync/work/build.gradle.kts @@ -27,23 +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(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.work.ktx) + implementation(libs.firebase.cloud.messaging) 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) } diff --git a/sync/work/src/demo/AndroidManifest.xml b/sync/work/src/demo/AndroidManifest.xml new file mode 100644 index 000000000..8dc32c86f --- /dev/null +++ b/sync/work/src/demo/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + 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 70% 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 68f9eee93..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 @@ -16,8 +16,10 @@ 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.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 import dagger.hilt.InstallIn @@ -28,6 +30,11 @@ import dagger.hilt.components.SingletonComponent interface SyncModule { @Binds fun bindsSyncStatusMonitor( - syncStatusMonitor: WorkManagerSyncStatusMonitor, - ): SyncStatusMonitor + syncStatusMonitor: WorkManagerSyncManager, + ): SyncManager + + @Binds + fun bindsSyncSubscriber( + syncSubscriber: StubSyncSubscriber, + ): SyncSubscriber } diff --git a/sync/work/src/main/AndroidManifest.xml b/sync/work/src/main/AndroidManifest.xml index 2487eb105..fed519807 100644 --- a/sync/work/src/main/AndroidManifest.xml +++ b/sync/work/src/main/AndroidManifest.xml @@ -18,18 +18,13 @@ 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" 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 new file mode 100644 index 000000000..e51e30164 --- /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_SENDER = "/topics/sync" + +@AndroidEntryPoint +class SyncNotificationsService : FirebaseMessagingService() { + + @Inject + lateinit var syncManager: SyncManager + + override fun onMessageReceived(message: RemoteMessage) { + if (SYNC_TOPIC_SENDER == message.from) { + syncManager.requestSync() + } + } +} 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/status/WorkManagerSyncStatusMonitor.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncManager.kt similarity index 59% 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 9edb630eb..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 @@ -17,31 +17,41 @@ 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.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 = - Transformations.map( - WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(SyncWorkName), - MutableList::anyRunning, - ) + 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 } 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..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 @@ -24,8 +24,10 @@ 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.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 @@ -33,6 +35,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 @@ -51,7 +54,10 @@ 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, ) : CoroutineWorker(appContext, workerParams), Synchronizer { override suspend fun getForegroundInfo(): ForegroundInfo = @@ -59,13 +65,20 @@ class SyncWorker @AssistedInject constructor( override suspend fun doWork(): Result = withContext(ioDispatcher) { traceAsync("Sync", 0) { + analyticsHelper.logSyncStarted() + + syncSubscriber.subscribe() + // First sync the repositories in parallel val syncedSuccessfully = awaitAll( async { topicRepository.sync() }, async { newsRepository.sync() }, ).all { it } + analyticsHelper.logSyncFinished(syncedSuccessfully) + if (syncedSuccessfully) { + searchContentsRepository.populateFtsData() Result.success() } else { Result.retry() 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() + } +} 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) -}