From a4eddf6fa37f538a6e53512915dbf07134a28a88 Mon Sep 17 00:00:00 2001 From: vishesh Date: Tue, 31 Jan 2023 23:53:48 +0530 Subject: [PATCH 001/107] bug: fixed bug #534 of multiple clicks on the interests screen --- build.gradle.kts | 2 ++ .../feature/interests/TabContent.kt | 25 ++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index 6f6d375d7..fe4cad4d5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,6 +26,8 @@ buildscript { } plugins { + java + application alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.serialization) apply false diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt index 0cff8c82c..bad2c1220 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 @@ -25,9 +25,12 @@ import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic +import kotlinx.coroutines.coroutineScope @Composable fun TopicsTabContent( @@ -39,7 +42,8 @@ fun TopicsTabContent( LazyColumn( modifier = modifier .padding(horizontal = 16.dp) - .testTag("interests:topics"), + .testTag("interests:topics") + .disableSplitMotionEvents(), contentPadding = PaddingValues(top = 8.dp) ) { topics.forEach { followableTopic -> @@ -61,3 +65,22 @@ fun TopicsTabContent( } } } + +fun Modifier.disableSplitMotionEvents() = + pointerInput(Unit) { + coroutineScope { + var currentId: Long = -1L + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial).changes.forEach { pointerInfo -> + when { + pointerInfo.pressed && currentId == -1L -> currentId = pointerInfo.id.value + pointerInfo.pressed.not() && currentId == pointerInfo.id.value -> currentId = -1 + pointerInfo.id.value != currentId && currentId != -1L -> pointerInfo.consume() + else -> Unit + } + } + } + } + } + } From ea7bcf33c594b0687c21a4e9a65c3c33c763b00f Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 11 Feb 2023 11:52:54 +0100 Subject: [PATCH 002/107] Configure jvmToolchain for Android projects --- .../main/kotlin/AndroidApplicationConventionPlugin.kt | 2 ++ .../src/main/kotlin/AndroidLibraryConventionPlugin.kt | 2 ++ .../google/samples/apps/nowinandroid/KotlinAndroid.kt | 11 +++++++++++ 3 files changed, 15 insertions(+) diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt index 6bc5f9f9e..e0efe511b 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationConventionPlugin.kt @@ -19,6 +19,7 @@ import com.android.build.api.dsl.ApplicationExtension import com.google.samples.apps.nowinandroid.configureFlavors import com.google.samples.apps.nowinandroid.configureGradleManagedDevices import com.google.samples.apps.nowinandroid.configureKotlinAndroid +import com.google.samples.apps.nowinandroid.configureKotlinAndroidToolchain import com.google.samples.apps.nowinandroid.configurePrintApksTask import org.gradle.api.Plugin import org.gradle.api.Project @@ -32,6 +33,7 @@ class AndroidApplicationConventionPlugin : Plugin { apply("org.jetbrains.kotlin.android") } + configureKotlinAndroidToolchain() extensions.configure { configureKotlinAndroid(this) defaultConfig.targetSdk = 33 diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index 842873bae..c0e269243 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -20,6 +20,7 @@ import com.android.build.gradle.LibraryExtension import com.google.samples.apps.nowinandroid.configureFlavors import com.google.samples.apps.nowinandroid.configureGradleManagedDevices import com.google.samples.apps.nowinandroid.configureKotlinAndroid +import com.google.samples.apps.nowinandroid.configureKotlinAndroidToolchain import com.google.samples.apps.nowinandroid.configurePrintApksTask import org.gradle.api.Plugin import org.gradle.api.Project @@ -37,6 +38,7 @@ class AndroidLibraryConventionPlugin : Plugin { apply("org.jetbrains.kotlin.android") } + configureKotlinAndroidToolchain() extensions.configure { configureKotlinAndroid(this) defaultConfig.targetSdk = 33 diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index da0cab531..2b5d1c354 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 @@ -21,9 +21,11 @@ import org.gradle.api.JavaVersion import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.api.plugins.ExtensionAware +import org.gradle.kotlin.dsl.configure import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.provideDelegate +import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions /** @@ -74,3 +76,12 @@ internal fun Project.configureKotlinAndroid( fun CommonExtension<*, *, *, *>.kotlinOptions(block: KotlinJvmOptions.() -> Unit) { (this as ExtensionAware).extensions.configure("kotlinOptions", block) } + +/** + * Configure Kotlin's jvm toolchain for Android projects + */ +internal fun Project.configureKotlinAndroidToolchain() { + extensions.configure { + jvmToolchain(11) + } +} From e1f8c51c056ddb9a6b6c16837537a54ed91c588d Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 11 Feb 2023 11:53:36 +0100 Subject: [PATCH 003/107] Configure jvmToolchain on the single Kotlin jvm project --- core/model/build.gradle.kts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index edfcc4596..eec8d82ab 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -19,6 +19,10 @@ plugins { id("kotlin") } +kotlin { + jvmToolchain(11) +} + dependencies { implementation(libs.kotlinx.datetime) } \ No newline at end of file From 1489bf66e38130ced97187000ac473129e5e13bb Mon Sep 17 00:00:00 2001 From: Keisuke Takagi Date: Thu, 16 Feb 2023 01:47:29 +0900 Subject: [PATCH 004/107] remove unused updateTopics method --- .../apps/nowinandroid/core/database/dao/TopicDao.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt index 37724af69..210f55c28 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt @@ -20,7 +20,6 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query -import androidx.room.Update import androidx.room.Upsert import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import kotlinx.coroutines.flow.Flow @@ -55,12 +54,6 @@ interface TopicDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertOrIgnoreTopics(topicEntities: List): List - /** - * Updates [entities] in the db that match the primary key, and no-ops if they don't - */ - @Update - suspend fun updateTopics(entities: List) - /** * Inserts or updates [entities] in the db under the specified primary keys */ From 67412b272034d540453ffa59d69dd4235925a27e Mon Sep 17 00:00:00 2001 From: Keisuke Takagi Date: Thu, 16 Feb 2023 01:49:09 +0900 Subject: [PATCH 005/107] remove invalid overriding method --- .../apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt index 8ac0dc0b8..96402309f 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt @@ -58,10 +58,6 @@ class TestTopicDao : TopicDao { return topicEntities.map { it.id.toLong() } } - override suspend fun updateTopics(entities: List) { - throw NotImplementedError("Unused in tests") - } - override suspend fun upsertTopics(entities: List) { entitiesStateFlow.value = entities } From fb32580e0c0302268ccde7abde5ab6245f99d2bd Mon Sep 17 00:00:00 2001 From: Keisuke Takagi Date: Thu, 16 Feb 2023 01:49:37 +0900 Subject: [PATCH 006/107] remove unused updateNewsResources method --- .../apps/nowinandroid/core/database/dao/NewsResourceDao.kt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt index af0a59bce..4d89177ff 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt @@ -21,7 +21,6 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction -import androidx.room.Update import androidx.room.Upsert import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef @@ -65,12 +64,6 @@ interface NewsResourceDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertOrIgnoreNewsResources(entities: List): List - /** - * Updates [entities] in the db that match the primary key, and no-ops if they don't - */ - @Update - suspend fun updateNewsResources(entities: List) - /** * Inserts or updates [newsResourceEntities] in the db under the specified primary keys */ From 86c021a54a39c045061d373a240749cb8e0cc602 Mon Sep 17 00:00:00 2001 From: Keisuke Takagi Date: Thu, 16 Feb 2023 01:49:51 +0900 Subject: [PATCH 007/107] remove invalid overriding method --- .../nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt index f63014075..ba1d20581 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt @@ -75,10 +75,6 @@ class TestNewsResourceDao : NewsResourceDao { return entities.map { it.id.toLong() } } - override suspend fun updateNewsResources(entities: List) { - throw NotImplementedError("Unused in tests") - } - override suspend fun upsertNewsResources(newsResourceEntities: List) { entitiesStateFlow.value = newsResourceEntities } From 2596cf712c1e021293ed9f8369851f6afcdeb54a Mon Sep 17 00:00:00 2001 From: daryeou Date: Mon, 6 Mar 2023 13:09:30 +0900 Subject: [PATCH 008/107] Remove unused datastore-preferences repo from versions.toml --- gradle/libs.versions.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 737ad43a0..6444cd40e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -78,7 +78,6 @@ androidx-compose-ui-util = { group = "androidx.compose.ui", name = "ui-util" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } androidx-core-splashscreen = { group = "androidx.core", name = "core-splashscreen", version.ref = "androidxCoreSplashscreen" } androidx-dataStore-core = { group = "androidx.datastore", name = "datastore", version.ref = "androidxDataStore" } -androidx-dataStore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "androidxDataStore" } androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" } androidx-lifecycle-livedata-ktx = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "androidxLifecycle" } androidx-lifecycle-runtimeCompose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "androidxLifecycle" } From 462b6a1b3a8c0ec0f37dac0a935a37c087044e9a Mon Sep 17 00:00:00 2001 From: vketteni Date: Fri, 17 Mar 2023 19:28:58 +0100 Subject: [PATCH 009/107] Correct a table entry of a code reference from ArchitectureLearningJourney.md --- docs/ArchitectureLearningJourney.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ArchitectureLearningJourney.md b/docs/ArchitectureLearningJourney.md index 9d7c77e1c..925858111 100644 --- a/docs/ArchitectureLearningJourney.md +++ b/docs/ArchitectureLearningJourney.md @@ -64,7 +64,7 @@ Here's what's happening in each step. The easiest way to find the associated cod On app startup, a WorkManager job to sync all repositories is enqueued. - SyncInitializer.create + Sync.initialize From 086dc957c067c54f4aafcd254ce4e08bb0c0880b Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Fri, 14 Apr 2023 08:58:16 -0700 Subject: [PATCH 010/107] Update to AGP 8.0 Change-Id: I27eb106e484e2003d732782a39612dc72c845282 --- .github/workflows/AndroidCIWithGmd.yaml | 2 +- .github/workflows/Build.yaml | 8 ++++---- .github/workflows/Release.yml | 4 ++-- build-logic/convention/build.gradle.kts | 8 ++++++++ .../apps/nowinandroid/AndroidCompose.kt | 14 ++++++++++---- .../apps/nowinandroid/KotlinAndroid.kt | 15 +++++++-------- core/datastore/build.gradle.kts | 5 ----- gradle/libs.versions.toml | 4 ++-- gradle/wrapper/gradle-wrapper.jar | Bin 60756 -> 61574 bytes gradle/wrapper/gradle-wrapper.properties | 3 ++- gradlew | 12 ++++++++---- gradlew.bat | 1 + kokoro/build.sh | 2 +- lint/build.gradle.kts | 9 +++++++++ 14 files changed, 55 insertions(+), 32 deletions(-) diff --git a/.github/workflows/AndroidCIWithGmd.yaml b/.github/workflows/AndroidCIWithGmd.yaml index 44f8141f6..1c1206ed4 100644 --- a/.github/workflows/AndroidCIWithGmd.yaml +++ b/.github/workflows/AndroidCIWithGmd.yaml @@ -18,7 +18,7 @@ jobs: - uses: actions/setup-java@v3 with: distribution: 'zulu' - java-version: '11' + java-version: 17 - uses: actions/checkout@v3 - name: Setup Android SDK diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml index 342714245..c15daea80 100644 --- a/.github/workflows/Build.yaml +++ b/.github/workflows/Build.yaml @@ -24,11 +24,11 @@ jobs: - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: distribution: 'zulu' - java-version: 11 + java-version: 17 - name: Setup Gradle uses: gradle/gradle-build-action@v2 @@ -73,11 +73,11 @@ jobs: - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: distribution: 'zulu' - java-version: 11 + java-version: 17 - name: Setup Gradle uses: gradle/gradle-build-action@v2 diff --git a/.github/workflows/Release.yml b/.github/workflows/Release.yml index 86f88a920..534e9d893 100644 --- a/.github/workflows/Release.yml +++ b/.github/workflows/Release.yml @@ -20,11 +20,11 @@ jobs: - name: Copy CI gradle.properties run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties - - name: Set up JDK 11 + - name: Set up JDK 17 uses: actions/setup-java@v3 with: distribution: 'zulu' - java-version: 11 + java-version: 17 - name: Build app run: ./gradlew :app:assembleDemoRelease diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 281434b87..5408b218a 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -14,6 +14,8 @@ * limitations under the License. */ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { `kotlin-dsl` } @@ -25,6 +27,12 @@ java { targetCompatibility = JavaVersion.VERSION_11 } +tasks.withType().configureEach { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } +} + dependencies { compileOnly(libs.android.gradlePlugin) compileOnly(libs.firebase.crashlytics.gradle) diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt index 4da997fc3..cdcc65a7f 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt @@ -17,10 +17,14 @@ package com.google.samples.apps.nowinandroid import com.android.build.api.dsl.CommonExtension +import org.gradle.api.JavaVersion import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType +import org.gradle.kotlin.dsl.provideDelegate +import org.gradle.kotlin.dsl.withType +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.io.File /** @@ -40,16 +44,18 @@ internal fun Project.configureAndroidCompose( kotlinCompilerExtensionVersion = libs.findVersion("androidxComposeCompiler").get().toString() } - kotlinOptions { - freeCompilerArgs = freeCompilerArgs + buildComposeMetricsParameters() - } - dependencies { val bom = libs.findLibrary("androidx-compose-bom").get() add("implementation", platform(bom)) add("androidTestImplementation", platform(bom)) } } + + tasks.withType().configureEach { + kotlinOptions { + freeCompilerArgs = freeCompilerArgs + buildComposeMetricsParameters() + } + } } private fun Project.buildComposeMetricsParameters(): List { diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index da0cab531..bf510a2c4 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -24,7 +24,9 @@ import org.gradle.api.plugins.ExtensionAware import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.provideDelegate +import org.gradle.kotlin.dsl.withType import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile /** * Configure base Kotlin with Android options @@ -44,13 +46,17 @@ internal fun Project.configureKotlinAndroid( targetCompatibility = JavaVersion.VERSION_11 isCoreLibraryDesugaringEnabled = true } + } + // Use withType to workaround https://youtrack.jetbrains.com/issue/KT-55947 + tasks.withType().configureEach { kotlinOptions { + // Set JVM target to 11 + jvmTarget = JavaVersion.VERSION_11.toString() // Treat all Kotlin warnings as errors (disabled by default) // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties val warningsAsErrors: String? by project allWarningsAsErrors = warningsAsErrors.toBoolean() - freeCompilerArgs = freeCompilerArgs + listOf( "-opt-in=kotlin.RequiresOptIn", // Enable experimental coroutines APIs, including Flow @@ -58,9 +64,6 @@ internal fun Project.configureKotlinAndroid( "-opt-in=kotlinx.coroutines.FlowPreview", "-opt-in=kotlin.Experimental", ) - - // Set JVM target to 11 - jvmTarget = JavaVersion.VERSION_11.toString() } } @@ -70,7 +73,3 @@ internal fun Project.configureKotlinAndroid( add("coreLibraryDesugaring", libs.findLibrary("android.desugarJdkLibs").get()) } } - -fun CommonExtension<*, *, *, *>.kotlinOptions(block: KotlinJvmOptions.() -> Unit) { - (this as ExtensionAware).extensions.configure("kotlinOptions", block) -} diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 1787a5b8f..59a3a8e32 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -14,11 +14,6 @@ * limitations under the License. */ -import com.google.protobuf.gradle.builtins -import com.google.protobuf.gradle.generateProtoTasks -import com.google.protobuf.gradle.protobuf -import com.google.protobuf.gradle.protoc - // TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed @Suppress("DSL_SCOPE_VIOLATION") plugins { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 677438412..08af52b65 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] accompanist = "0.28.0" androidDesugarJdkLibs = "1.2.2" -androidGradlePlugin = "7.4.1" +androidGradlePlugin = "8.0.0" androidxActivity = "1.7.0" androidxAppCompat = "1.5.1" androidxBrowser = "1.4.0" @@ -45,7 +45,7 @@ ksp = "1.8.0-1.0.9" lint = "30.3.1" okhttp = "4.10.0" protobuf = "3.21.12" -protobufPlugin = "0.8.19" +protobufPlugin = "0.9.1" retrofit = "2.9.0" retrofitKotlinxSerializationJson = "0.8.0" room = "2.5.0" diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e5832f090a2944b7473328c07c9755baa3196..943f0cbfa754578e88a3dae77fce6e3dea56edbf 100644 GIT binary patch delta 36524 zcmZ6yQ*&aJ*i+pKn$=zKxk7ICNNX(G9gnUwow3iT2Ov?s|4Q$^qH|&1~>6K_f6Q@z)!W6o~05E1}7HS1}Bv=ef%?3Rc##Sb1)XzucCDxr#(Nfxotv ze%V_W`66|_=BK{+dN$WOZ#V$@kI(=7e7*Y3BMEum`h#%BJi{7P9=hz5ij2k_KbUm( zhz-iBt4RTzAPma)PhcHhjxYjxR6q^N4p+V6h&tZxbs!p4m8noJ?|i)9ATc@)IUzb~ zw2p)KDi7toTFgE%JA2d_9aWv7{xD{EzTGPb{V6+C=+O-u@I~*@9Q;(P9sE>h-v@&g ztSnY;?gI0q;XWPTrOm!4!5|uwJYJVPNluyu5}^SCc1ns-U#GrGqZ1B#qCcJbqoMAc zF$xB#F!(F?RcUqZtueR`*#i7DQ2CF?hhYV&goK!o`U?+H{F-15he}`xQ!)+H>0!QM z`)D&7s@{0}iVkz$(t{mqBKP?~W4b@KcuDglktFy&<2_z)F8Q~73;QcP`+pO=L}4yjlzNuLzuvnVAO``skBd=rV%VWQTd0x6_%ddY*G(AJt06`GHq zJVxl`G*RiYAeT=`Cf(SUN$kUEju!>SqwEd8RWUIk$|8A& zAvW|Uo<=TWC~u}V?SNFv`Fq9OeF_VpfyXHPIIay@Pu5J6$$pg{;xE9D7CROVYV>5c zv^IYXPo_Z4)bg5h?JSUX!K`q_u{>F%FzrG>*!Db_^7*7(F@f%i34Ps`JBAH6{s=ygSr^CVO)voP`v=SO z7v;4cFM_D>iVl{&X*N7pe4_^YKV%`5J774`5!DC}g;D@50h?VA!;fU1?Hf%%`N8R1 zSg@hZ8%Dq^eYV1!g8;`6vCSJoK+V1Q6N8ImtfE3iXs!s~B>js)sLHB9w$r+6Q>Oh#Ig&awvm%OBLg!7alaf}9Cuf;M4%Ig9 zx4K}IQfPr&u?k8xWp!wI4{CP#GTs#qR0b+G{&+=vL}I{b-Pha43^%8=K3997~* z>A|oxYE%Vo4~DiOih`87u|{8!Ql5|9Y+(ZY2nRP+oLdGErjV&YeVKw>A$JyPPAL+C zA36S!dNVf z;xJ)YR;^VPE1?`h-5>{~gwY2pY8RqhrsiIBmJ}n3G@Zs!!fD6y&KWPq&i8HEm*ZAx`G} zjq2CD5U==ID^we8k?=geue4Y>_+%u3$-TzVS6QMlb4NoS%_V>;E2hQ)+1Q@v(reC5 zLeK*f%%{PNO-mtrBVl|-!WaiKAkZv-?wnOwmZ=Tv57k=4PX=C?=I4V*THRFRE8a_{ zb>5YwDf4o>>$o{XYlLN{PZ^Ff?0FJl4>A9C-q9A$$&44l122Qsc|6Fd6aTam{=JO3 zBFfFe9seUPSUeyXQc*RA>2{WoKIYVltA&@5spdIW;rzOOqoQo`CN;~UNgU{{m9^c1 zTrN|8w_7+Nws4}Z-4eS9WMpF3h<@81a)oK9njh;-TB74vR;u{vE?>6FDG7<%GVXFL zUR9l{z*eEND6pp)+hpNT$VVM^Pw*S;#NrbCmH{dhBm?%6D|k)0C@Z9H>T|kby1^)# zOPmJ8Hq`8waoEK(9}IfP_q4yr(s?ME+T%UV-ikxW!XFb^6w02t30j$n_VSwevg;{9 zx0OXK_uGBFej=gbG>G^pEv^`I8&_a@t9>Nr;#r?XNKquD&Ho|`)qK6C^-7SCdo=S& z)vUi;m5*qIePEIbL=wJ|WCBNY;zCm2F-+@N2i{I^uR9UVZm$o`I|@<&2}w)C`h)vV zW{)yGJ3?GCZNtFe53Kb#uzrC7v-{JygKZUiXDV5mR z5la_vAFOvoh#yn)B`$^ZN*Dxp5Uo~_k8G9skn2)Tb>Kw#Vgxi`bti)^(z--X9F~oR zZ6=^_x@mDT~=h_@GGVcgBtLzssB1|Xy(xc(lUYJ#_ zgwc&ajE%^cCYW7d;xAxi{#LN*1}s>{K79MZrq!tYMpRA{T!#^tgXP=J5FvkbZ@gx~ ztq-E&c$`|KX8GS2a_voZHf=y8C{6~f~`DpC- zjQfrt2OGi-WGx}Y4>vM`8<4frU*!bq*NJ*Tyn0cqk=zpDdYth-PJIfz5>pLF@qnai zzj2FEhuOa-7$JR=U!L{UWWJBA%~SW-6Nh&3;<}iQO)DvOI&VKi1L8rmICePWqoY^F z-dC8X8~1T}=C9m&yb1kZzbKd2;29_Pm*Cs=y{Z06QZDlT7Poci>1@hFa%t0<`1()UTxcQ}e`fAh6K`<5C_SG`dw$IqzwEYNKvIH3VWlhz z_#^(T53W}jeWF#WIhj^U7AdIB~3feC--5iUiiT4Qyu81 z;Xa^8#~M@p%6B`LCKWWTa7I+35BLP=EOa&Gp2pbTWw5HOIjrx;2J(KI$$HT|w8}R-8fbp9sot&LiLs7ILlyZc8 zWbss7=*Ah|X$LEt1O|T?ABkIn-0NN`I8+ipfoBZcW>(WiaASG_khBtKM{hfkm5VBS zy0Q`4*G6HRRa#9G)10Ik3$C3|nQbFzmU-dA`LjKQY8icnx?2OE40%z852{OJH=?mbvwr9 zhlx0RDo^D;p*xKx?yT(`s7wj7BHA~rHF2yxnL<1PcU7FM57;?g^ z&CyPh9W4KvZ;T8w;AuNMn|nQ-xJ~CvVT7gAPAGi7w8udw_LOp+p4eZiI`JEC@Mq9F z#dA2AM_};CnL=y0#tZALdB(P~Rz*KqGqjwec%Fy?K(PGoO0tfskWw-aGhd7$ zTi~x1G>4h5q>ek=tIoT(VBQxrq)&#`_0UHC(j*ZO%%}%C)|EzTWEpvYDqCYXLexR9 zlww1ESB+IiO}=oq)8WZj%cY_FTQcEJ`JdABa=_S;O|kLhX*|5|D>0c{12DoC?K95f ztNxm(sTU6cWWd$tv`5X(=x?yAo)IYQ3G*2+o#|EfXko6erF;M4Pc;G0)pUDY)t`H9 z76Z8V9HqbWA@!`BelAT&ErrGTz7}%M*605PEY@3{gv+`yEhr{=EVp_tU%`b54Pn4a zz8nN7`eNx=*`f1t#^7>7G07IEnbnn&`RWZ}4Cp8W_DFDs-5)GU`bw}uBmOQfKmi2@ z(cWWmvHFTUNInRH!0y_ZtuI9Eh@O3+64wy-_2DF~E@KF3abM`0gC%|kHi@&hP_#B$ zLN{Z?$V_;+h?%2zEC{2ITyWOup*w*K?~vpwB(DX1i6oY+F)??;nyHpzaPLIt6G$4; z6>iAsB+&&NN0;ObWVOL+-^ZwD?nHgY>0k>0I3iA7o)f# zN&aX$lM@r_Iu|nSdPjoF{#QD9M6>|JSNPLxX^T2!jCKjS5mwNaO+SmBfOY z;6ZdwfzhO6Vs|9u81f4e%7*mU%8K>A7QWO0;QcX7W@|NSUVl)_>7VEf#&N6E~ zn9Wv88@Suo9P+M_G2(f+JFf#Q^GV#7QQ`qH#$N1y{A*_t^`5H1=V^u?Ec|EF6W+6B z(@Q8ChIUyq;+I5CmjEa1*v%d5{WHyhcHSjQuwzQq?;^BmfV#okq3v8bp7dBdk z54B+%D3=JWd-2w$)puXxZyZH>-$O-?tbSIlGc{em9xHN!44iaCr}6uZ^FpN7IvNh8 zbp!%4xR9np`>AOEd1e2_y}xW#v@@h3wYc?WiwL6Q>fxPQA81V^J)XtGs|Z&er6w~M z!1Ph~85TMG>R&ixNUnevc(w>fgb%+X#Wds6Yl+wH29aE%;RuDeZz5dEt%#p&2VK1n zKkqgl&*_YwnO%9`0<6MVP=O3{02EcR7PvvZPbL2KMuoRsU|Y%zw38qeOL#!YFp#_~+rtNJVl>lJSh_*B0A6n3XkE5po z9RpE_h=pnmDJFX*n6wmsWJ9GLu2=L8y!_R;;Aa2Jl|)I}Qff&`Fy@iOhop8>Y2{F} zbVk3rNMi$XX(q1JrgcIhC08@d5Zc>wLUL3wYm}hzS^!5d&Mec$Sp^$DUS1lD1>KAt z|Efof3nJ4^k(WKL_t-u8ud4L(t>q#9ECj?v#W~W#2zTt>|MCh&*H8Wh1_I&^2Li&M zq9j0`(zk~P7}dB`+15b*j%VPGr$;@4MBQ5AT>-y?0Fxfr2nC1kM2D(y7qMN+p-0yo zOlND}ImY;a_K$HZCrD=P{byToyC7*@;Y$v6wL!c*DfeH#$QS6|3)pJe68d>R#{zNn zB0r*Es<6^ZWeH`M)Cdoyz`@Z&Fu_^pu8*089j{gbbd!jV@s7`eI5_X5J3|poVGlq` zDo9}G;CsjW!hgN2O9=1|GpE;RpQvrBc+&dF)L>V&>9kd6^YIL?+*WDmcQlvwnq`Lf z&N$gF>3+E*NcJojXXI^}B(B-;@ebpVY}l#EcDWles7s;Ft+KZ@m+6FWaD^oYPBXVw z3sq|aKIDh1x5Ff=tW$(LO|!e&G?Xvh^H!GfiA(emluL!LmD=EV@|u|8S7w6ibUePJ z>{sOC6L27R+b&}e?VH;KvV3a;O3G=gwG}YzrkSTV6(&=;o)EV~2OD(Eh4mu@K0G)i z3#44IZhqN6+Hb2h#3R8YwJW7LesDA9=n)75u#46_ZmSh@6Q-4oHvGxFPY8x;Q+)d@ z*-SDqhVeyPGkoD)iq;z0r*M)IhY5I>gMA@RS&EIYPq}Z{$Q4Jbfd76EVhSF-sR^TO z!=o?>V(^bx!pG$26J~Z>Tvu&Uu+0;>m+pg(fmbu(97^(OHBH4;J8WIfv-f5}VP#VS z$Y$}SHKdphDUHlbdIVW!k$L6T{LY)|H}MT=l$22kIl>|46FK9dt$?3Fjk2RA-~AX7 z1|Xe`n)%h~e-O_qLpoFXJ$%gmocq`v0%hRw1k_6nh|+3pvJDy}m)V|xjL&!Z6?%pU z+m)r2*pWjEl!etAYxdzWb0{mGc;#$>rE%)b z@Rnj78P;$lrzY!XCa0&x+8a^YF*G|Q|C}bGeczz(5m_gq08wJHIH`WqHH?A}!~_3{ zQEvMXmL<*nThl^pL58nbHgQ1n9cYmN{C8J^6AKS%?~>1DCt70Q2Vp0;E@`GF%Tzkc zSUt&LJ=wHI6@#8_%=2s=j^4VBd1-h_)3 zeozYua!|{x(qk#z;tavf28rj_5Oen-cYG%;R6I}Hz$yMXeg^)_$OUUXx1r^qrl!DG zYXkAXKBMrVM-rJwAo<5J{NW1XJhW;Nh*&`nFV-Z;Vd({KSkMxV#cn|bXJ z50GtvFE##sqGhV#lv2s6?^yeBShlhR%XaPIo)iXOue}jwZ;Zq#dgDn8H?74Y+$Z?C z2Y5mCC66>dp%sVMecUzCirWq99Ea(TDwClZxtEB~4N-2JmlH#>Z2jOcaNaw4tn?P->BBGNHxUHez7>C@TZNT5Z zHerlG0a4~06L%>tn!~$s^L5`~{ueLZ5?`$46nHvwKxM0V9VQ(k{A40xDVw{+Qt)RV zQ)T2Df)cp0nv!lUFt3D=i~k!V|7dUjpz?K2ZiynO)$d{2*YT$N^CQ{t=luZ>WcE!> zg25p}If9RTho%G@PZp;5zBwv`n+e9iO=6dx1V^|4Ty%`oE=f7O&QC^s!4MJ+lMG>^ za!mgpz*^SHT+M_zm;{H#E~SaU^Kn*y)nTAF*2@t5mF+l)bte+a+goaA*zXJ4P)H|y z{4OwbJnIPtMp4E~=64gM-Y{#o{x)+8YCg$C7Yy=;9hdyBgRFIY2_L9DL3*B@%$5#m z8P}+)glf*}UPD$C;_yntx}9VPmSSnY9`Thd09nfoR;3`kar*FRfS)`+as*t2l*USWgmaZ!qFubr1DegTGZspyYMgic{inI0dSt+rJR z((jjMrdq^?VSZ8FCO;0NW@>O_b67gDHP%W*^O?J z91NQ7ZFODMSvHj3cvT#6RJUF7x=-BJFQ^6<&mOd15Z&M!?b+3Tg!UcgldD9tOAt5K z3X>MlE-a=sj;K&}sSng48jQ7sp|&u3;@e>V4Cuf(!s@9lZ0Cg^DKWmki%>$<85tOG zU;e{%zHU~KREBUg?FbcseK{lmK-`*S1p9j_4hF=F$y)NB;HsHwuf_A0Zhy395eU7o8^A zi2t7Ch|KVprUn03N0T2XshT!g$HTErcQBBG=TWaHkYtaI2CJY7ajI%yr&9 zVC^zJ3WW03bjwGNx{l}#+D&Ml_uI4PQhV}qZPXOP7ffSv(O;hX{Ff1|HoA~v)V!4y{CdALyi2YPjrRVmRYilRv z5PSkj*Z_8Fa*sCqGN?7YTnkr9=i9X`qcw7nqz#{bj?B7NiV9fWF+%~Rb1X@MuS^Mw zC)d#K{(-9!?xStM2K5x%x~ogWxgIK>s5r_RT1jU_lxdTtIEFWvi4eJSAiGec&HXQ( z5t7!J1b#SL|8s4)u147PWQUq_e33!5Z#f$Ja&az)(Htl`Z0@Ez)0d74BzNHHfH|<-8q*ZMf?%eJzoGS!0S6Y zSU7y^1+;V$Je9F027>1eN#_tz+2t}Y^N zYfi9}J!N^SU1CYoNBDbD39@84xLroY@0f%%c^(5CE+}!b5-Mt3oXe2nBdyicgGIL+rzTTKv`}Pp%fG1f^s?sgNH8=Q}s4Z>0ZCZ8ZYF z4og8nK%OA~zZMJX01uFtrmwhcgg*XbiMP9kfkPYFASbp7*Bk^5ZBzV)dL)JhPwDkM zkgdHeKw)orJcj4^)a^wQC2|->G=OBzuc-SskRrrf+H-E%HQ==Ex}d*504#GbIUXIB zcZs@Oo0i61MG}&0bu%@2N?MMJMRXyTVb8@3wF5eY3G6-1NdT~{{~YFs8f&SNebdaq zKmP>XqCQ@iaamuvY2m%xJ~gdSLSj~DBhB`NCj_c}NbSjB{r(E`_-+6a#vx*|S>-GU zHsw^dxxu`e)q1HbH==rLFap?cebKumnTo=iJQ zJD1#=o>0%Y@&jP?^)Q5bTV!pzrf=FoHq2c_59pq@my{D4AW8VU*7LVp;LF-qESV;L zClRfyQ6CcD$sd84K@e@p_ALH%j(Pz@Em@QFyY`AG&(|!(cG8!oV#ejr`y(LolX}Iu zL$)G)8^y4sUAYCWprzVR?`#OJ%NU)9U^B!OGSj>Ly;<)<(nNh`?z*GvJ|ZBKfZ`0 z=q_yGHWPp~R+J+{{@APVwmp8`=%N!L7AT^l^oaM|JrCFu7J#@frf=z(vGq2>sQ^@u zk=^d#gDf}ME!~9PaLfw44~rsG!)T7h8~dY^VcZQa+ueWPGG$mWXB|H2$$0BT(QAIu|=DJXPQDNes3Q>-|Mh=Ih zy{WR)QmhL5rQbBYPBa+e7)8Vo;_aKrg`}izmN>#ATuSDu!QUFA zsgM|Kv@W(S}Ag^6e8)9pQc@JLj_2ZIkO=8)#ARm#mU=NncWbmd-SbO;ad=y|k`shy3b z*8o0@EJo3b$#zSgmnlT7KAp)U!qI2M`hiC@Gp0)pNGHYMe1$MBNE}Hd{Sv^`wI7>MzNwgVv1ZzL zttmyv!=TKuPH$b>r7$lgP5?vho;#Ks4+zLzaz-1b{p-Fn6dWy1Agg7O2{&VQ5@s3A zAqzC9QokRD59!@ex#k>xy61kq6h~O$lb;lB;Q|chv&wzR+N zgXdIo%?q1Y$TzsdCo+n$^NODN7yd}cAv+rkG|u-(wTp?zUSUxaA-W3dwqikdrokwz) z68)Gn$Nwc1zB$F9`#(af|C3v;|2$bo7fU8f7h^NK6h&@xi2m`)g4mW$?l@5JEc*VV z6d67@Fl2w6mO;MYUl2U>R996gQUX$d>$D>)TNGq*arz}f21yh^uvIM!3u$H{_CH5! zrjt9L^&J8UqEV_lLn&}nc|Q=MDei6t=vL_>X-i8B%f5FDi)|qQ;2V-T!qOi*uqq{U zElET6#2cb>Z_6p_vw44&mN!;T&~ubi&p`XGepCNAfa0-T zC84V@VN^R6%z({m=$%iXrbiggxvMiBpww~ktD&=9-JPK3kPCOGCJNQj8+l9k#!QeS zv3h$Ej>@j<-zBW0Qr`5tNQVRfYK_$3>nWUzf&c*tCpl@aYwa%b;JNeTX10OevcxY7 zqnLgKU-X9G8~&?Dr)`*7GryqhN#;9v`D_c=_xBcD{j-cLop~pSnM?&7HggX6gb++ftBq$idM1|>5t+68sWf{ixREbMkZesmpjJsAFPQ#2+8Uek z$BPbu3cQuNDQq+^M}&ZuSHjxUgxOjF<^%4 z*8lc$CgA<$n=DYg_DsrHB7zYM0Ro|gS8ZnUq$u3GQ+{owv9RdB$wG%d-;R+I>?i?b z+r_mu{IL6WTYftdz?0#pbHkmQP31LvXcMK6;mAP+;q^L@q}v~TD}Ni>f7@QYcbM!T zX5kShHv3X1U=>B!2*si9=AEJCBt~GIH7DL4^+gHj+q}tk0F_?Q-=z{JY%77nkw>$F zG}6ROaL_)3t$jX=ZtFG{Q=LZfNjNb2LK=m9l|7iaB++N|S$vAr1 z_gf3JpIB|?dptfQ{sOZGlhyj~D;T#hjaNh0X5(o&7)87^t@@Hteh{0DOM{tCu$l#& z&NhA&V4VR}nzZP{7i(5bGB17<7bu+RJ1}k}=ffSg%=+213Oy@Aj1vv2U>U>8tRhKM z=*e<21)u6SSb{CC&We%#6X@duqLWGJ>O)Ls`uM98``34g11;D}*7>c3+^c|Os&;t}`(BWMD zfbyr~$j%{6%DZ`kR-}s~p?0#&-5a}b?6tDqwtqY%ep0ypSRIB54G@|0J5E#LkxQk# z_&xE=d(U}q?*Rh7L7f8AM5{qdGpC<&t~9YI!%j2G@nUPoLPSiWHjCVP{JAe?cBjQ zTqI=R{nv5c@|R)8Oi3cTL{&6%XdTgDP4CNYT}q2f5|Xf_hID#;83kd+v0RRyNKYn} zyPahwd=4ncDORLvatBc~KzT+jiiD{tzd3d*T(f7ayS;J&I1X!xaL2~POrw2ST=Pr5 zu*c}fb@)0P6jv))kNl38C7gmnWGmlL@{PWOVYt9se*cS0w#@W=N+dY#V08ci=Zmg9 z+${f#Qfs5)hOPxC;q{(J{Kx4HF)2QMzlVtXz0-O&h2$VxtT;ROvZ13nN{IG>Asv{% zHuDqgZ{R2(X*hkO+!HYHHWvRYrvN9fl-1?x6b)oseZY)@dQ6O>9Y#8*23~%bzN~Nf zpHGMdS-G|%F^v3Gnlsc$s4Wl=ZEu+J6y~*Ih2tpmHfO56JXKjldm$BxDvW6ZH>JrU zdRo}=^466lAq6!qY_@nQ}5ETUEoF;`>7b8W910_Z17!r`D?QNvC z+WF%@IkPi43n4;0Ks`M{x*0-^GK7oCAp?pFK1`~RoMSe@jAlV8vQruCUNyQ_7wk?` zSKe*|!4ar@VSA}!ThlIB*Qa5){pu&HS!a)-{lWL2@o1486ZK_!!}FSZ>vyUPIOX#+ z5d3~J24Op?!f!oNytub~egnkB`}h?eh!QyX6&^LbNuA#9vH#N_7IL|#6kIDhLL=be zEg3Cwmw{A(cm{&T zPg>XIWX24$Mj_#^k2I91C@h;b$8WNVr&MLjEwgAUtSeJ2W0)6Fit}PF!K&1j=*+6g zL{XOUrqhNyPLemIF4C&hThR8fie9^fYg$yl$m!1|YgcPlO>TB-(X{lkN~X}R=GA!Q zou<9ZJV6*}SN_4WRsqzRGI&p$;9DxDFTlyPw6Q9rlo@E3tMN&Wo4eFs{1=RCUij$V z`8)kmh0fhTTiEyvRl90B%q2(Moh$jg7{NeQiy> ze!H{zbG7<3BcK}XE&V_1kFfGA7D^ODxn*@nqlp!{LhYb47zIUlV^m+7kZh^a7L1^D zvI?m^9PECMnnN$0hi^Ur0b-~QgEORanrv|`dd;ek$4rAgEEof3HyvuYoZ)H*;+TgO z8CJY~4YDI^7RD7O)m&2h2K`-4e-I$1zcZ*K>Cd7~sSxEXc{d7-;f z5Ykr56Nkie%=z4_LIA}H>c81e$%ey=2hjqzTxoO0MDe!J&PE@EmX49jQJJg?HNw;B zHRHr)3do7CGDa3lPAZ4LAnpT)spnk8(ZiFz$|F$1m*A@!qCPug>Isp|MPI24i>jp~ z((9EQ9W#Rz)0AYT&ZWOWKBNtdNYYm2QytK$o-_|W5j7Abr&73(MG+Ar4K!Ij=nKu# z;SNkveY?Oc!I|Vta2{rb@c50#p_byn|_tu>Pv}6YDydl|}X#4oZW2 zvq)Y@8iG5@6c3?uu4vdLSBq23P&qUSvtGcu_qgH*?KfaT)@QueLx6apA97FI7sXP=foe zmrEu7;%Z=yTTGUsHsjR(wU54xNPI$hLFZUOwh=uhZ&rLammOQ?w*)}?Ah#%&K~OZc zl#Owj1OCEeXt!ALV7LgJ=MVbCo}<%92WX$wCS~Ins}%5+sb*C{WoOT5*2%sgjya;~ z|A#;k?j~J9qB)Tku1BGX=MrZ}<%Z4}i$OvCHv_3vtH_NZoK zjJljjt(~Yh%aI@gFnM*e*@_*N190p^@w5?SjRMb66N_^3EZ#Yoh<8FM>Yx$+mTbp$ zjQQS7(rs2j^54CJXdkH|$1&$wPOGDvm^@1o1pl9~!5&B+I=U-f_M-M&r3zfp2%TH%Ib3lz-^t)+Z9E+>W1Bt1`B}rZ$hZ3{0n|nZKM9O z$?_1+y}fB2$zEzE$zC#46=0E_4x7-VXY5}<+d!g2+Kg$gvU-Xm-A9DBZz+bZ*zDTx z$Wfb93))oLQf;wKi5JBJ%$yq}m42lacy`bC9PjFg*}pCnqn@dv{k9WiwCC07;6n#e zJ499v3YGQ^WyYY=x*s`q*;@R_ai1NKNA}<6=F8IvJArr{-YbdY#{l1K{(4l$7^7We zo~>}l=+L8IJ`BhgR&b$J3hW!ljy5F`+4NA06g$&4oC-`oGb@e5aw-1dSDL}GOnUuy z)z1W)8W9t(7w%OCn_~#0;^F)xic6It5)3h);vuLAKFS4b)G;Z$n-R&{b6h@yGxGo> zT-cq0W7~n+qN10;1OS+*c>H$(GoKq4hGG% zL&XJG$PDQ6K^BD#s_MsnlGPE+$W^B`&a+Z+4;`*nyKil99^E(wW?t>#V_xYWHLl2} zIV`uiR-__g+<&m#Z*4E|wjKY1R2mCm%k2ayMSDw`Rz_KA!3P$uIbB`dl`3&A zmT@gMT@ZpAxBys8zRtgoH+ebSaVA)maP?G1=G4x^Nw3mV0?qehWL35vMI~p$y0hGL z6@vHf-50P~uoe6yY&*D)Ekmi06LF!Jqz9#7kMvWexYMbAn{}`{3ZBsd6$5jBCujDp z<0N?b*1%T<-_Nxh`lKtla|FFqs7RZMtjHAwZ0Ck&s{x`#^S?36BNQN1JU^0f&TRoC z$}c)LW7)-n$CmAg&n(96AycC4!4_*D(~HvXyLW>HORuI0;ny$f9h{!Ud0=X0x%{l6NH$ z?lttWn}DQL521;-r~Kf$N_YPo)7H>3gI@Ivt}GnR=8W~Nn7_PE_3{sRNn`R~bs`g1 zoTh`7o4H*TRp7VBp=%>&t&Cd*Ny~@;{C)P;62d^dipuJYUV3-Dh<#a&AIxtrmX42( zYEH-8F3|^nY-=yw(?^d!hTojNxr~A!n$Ao+2mq*kZ&>Zm+BDC*sul=~!LUtWiokIB zxc(dNwyk&5o;>WRt)Q-Wj;fvuvJO&DLPe%mt@t!Oq^VsoIN0iTh%fh#`-{Ha?a8gf zj^yA3`=_NEONO0Z?}YVP*dL{T}v|A&cE7$_0G=g;1s*WDQuRcq>cJ?z=8b5&i<)=3ELSW%Kff zs=my9Q%8?aMxZeDq=RBHg*&HnIeQ_}X@oh=f#?C^HSg?1dwLn#wu(o^uANrRZD;H; zYbOec$#wJB(u?w22{gV+zb~pv|Ag!q$N@^|6n+FV5-X=lR$jajjeRh$1tjht$URz1 zhw)(ksAr2;QBXH9T#A$6V4PsR7K)){JQb?79o6&*IwDPZknNqySIa6pwcs)~xN81I zKc-GmzZ$i(8RaU==$Dx{tD@4nph-V*=W{Ln97*VEN^F+u0!F<%$l=K`ikIp#<^Yt} z{rx1gk>;rVccPIo6hD=xPQ$PxVwl6Cl;YI6iLf3!aevhsyXXZovK#TOv0|*T+^ii5 z+YO`u(SO3@ybv-DG)w)E;@+ULoj_+<;mc#iW8{9Y!99vE`HdAK=Utac&Eq1uy!TLgOS-C1E90Am)B{Tiw z$>$Er{s{snLEaO5@u&zqxE@v;p6D&?u@40t{#VNA&7SZael};kGEwnHgD4V5RNM@g z(EL~B=A8&?pPPW-fTja0Oi6SVtI_(3ME!qWLg-uK2afWhBn(C2PAmUyu^2h?Y402i z9P03g5$1#etGdUUo?#skjQ|$*()ybRGMXM`-2?jjThnTcPV==7sg$k{GxYdF+S*zz z%dtBo(R9!7SW6Utq|wFpsKMSAH-x{WB|Cz62A8!p8!kHz1tM=9I=M&xqQG zz17xBW7t?Q?C%@4YC`p*za(>hOrK&ELyDQu{5ACOg9noZS1SGh{-FcLy_W;nf$N`N zGYxdIzy7mL3K@Kw65DmvPH0@&;T{y&jP^AsaYENi}q|A z3}l}5V?z_VvpHf%CkpN@IK`czOuLPY=yBUf8Q3b9$X|kEiYROV$`T8T7ZjFPvKhbK zDYxzz99JRNzsx0f1Y>IrIQq9o+W(TsB(ZtN@4*)DMGr3?4~Jt|37IBI|7oQknQI3X zAWs`45xiCHga9;8+W{|!Yy>tic?%SNq=3EX@z2Mk!P0dKG0NCHNz0*F-a z`7K?6d*D4ri*=>wyQyQt{_t=t95*gB1|tdTg45fR{KmKD|3ZuM$QlkX{-tUkq@3Qd z-6X|jEyZa@tuxB}qrdlJdc0{8``%3M$xl8$9pUzkFa$Ww{Jocp9>;5~oNC8o`3GK& zy7_X8YoQDCO1TU_a%#Q+rC?Rr`r)W8CdpEe=>uMYDx6^46V_1DthgX`6CnF*E+%bY z=GYih(DizXEVFDuQRPQY&dc2p;Pwo7L{I2r3;QV8IEPg1McP{PchEUDf} zbtSAoBMPt?&Q@{fG_3a7gzHl58O7e(h_F6^rKgU=a&(^WpgH3U%`tpj3CMVRA-uol z(hA)(VF{4@`k@PREUQJ_8w6CcMW4Pm06{fw^*>aMH%#ik6lD{{j~nT}Vw=wZ(;Ct& zi1nt}RmOGrVHP++5;Z@eE*lkdw~?>AJL_Yg!~p*adS_s1`_oT1B26S zt&1-4twO45pMl<5B9T;SLH9Q?E>dBXcy@5k-{YQ5K!A`=YMYMlLOYc(+LdC<@@UIZ zxq%vI<;6P)=W4nRb7nxQ9KGzXsOjWs_3V-2*V+r}?dAZA7{7f*>^PxEw|6+WS0wAs zen2zj2cFKIr`~Ai`YU|OR4%DQw8uM=|g2B{;1Ho`mx@??e)rX!p$MSlA70pKVcvZ@|fYLpEV~s7G z>#?88yv{ekJpeJL<-?FY7wf10XpS{B4}jy{uc)7esm&J1)ZYt5LI_{)0BkN8Nc}ep zg%SYD0Cub3?KXLY*-dYntrghE|}%?RY5i3yVcPFlheiJUMLIr=Xp=U-^siywr8MF^JAEwl2uQ$VIfuDFPisd}4W2ZxY$C`2`tBTA~ zG2P62@*~(9gYmO6#Ya<1TG#3rQd0BwVyNP@Ayt7B(h%z<@N>Iz;|2VkT8T3`anW@3 z03^F>TCLS9Y*sY)#=BX5!LYD9Z;z4QSOL2^Zw~0e;OutRfp)Xu83Yz~srLh8rR}fp z=#yHH{&=!mHgDg!b;9K@Ux99VmQ*K2Xn%gV6YWHHw(<_uA&($p}$2U2TIs7y+ zM7X5Yk#^wpDE4kQZmN3&VC{!nno7wD2`bEeAwS;W6>$oUt#~E57Imre?b54{c$`tHdB6GMC`IZWLL(%j20Bh zW@}9_@4EsYT$u1Q3ZPWkvYxUX{6AcsV{;{1w60^@wv!dJW7}rOw!LE8wrwXJr(>&Q z+xFe(e7mP=RLy@dYSfEoS{pC8KXH4kGf zd``z`=z(*mSdLiXj&Y{>&akI{IMzo@tD>a^<(r*Ssf6Nz;ZsaLra9mcD`MN8$2`!w zj#+BZCrV}b_c=qEqt7{oF$>wI5*0B0kP{DNQ5_-V9dZ<9u;vm!(L2I_#p*nprX%tU z!{;Gb7IuVBg7pdB2!{X!ZgHqp5+?drImJ(UE6~P2|C?+`E9th5QSv!}?=L}=tvcFMQuyE`=pek1zbRxBAFdgqqB#0~EkA_CpTe0`e$i(eyMD!C!D0SjSaixQMIl zQ>-Dj?K($9qMGwhRqIt28n$`*FH_6v*JjZRnIMxz-qVe_KzSGY5Ph0$(^e$r-hLD4T4m@eV#69bG7_fQ>o`!yu97p=$)>fb; z&!>)wS*Fj!ag#iKWRWiC735;`@XxXFT)nniSe~^1r0v?bQ6_Fokmx~(-O5D{7$d>R z#Us$PxL8^}t1rpnJ@#E}+O?`@a4wB;n{#!lX6WlOwo}C3TgP%?N=BT*FrxR=JR(g$ zJn3EhTI~xj_mVxhFImqt22JE`CI;B~Pb~*cFE>{uL*2mnfeKb_aYO6sDC{Khp%ba`v>+M4WqY2KK4@w{=P~Tzx42!1yHniJT#~*CHF5|TVC_n_ z&;r3b9d!f0;?+iQ8rT1N>MM-D(HQrU-WWU9=w|>nbeG#luD0;ayPj`4=&7Ik$Z{Z3~ z!oob~d$cMHx9;vjAfJ{XC6R@pzkLW4q1ak{?IimWUVBKithq`vKQD14&60gGKCCale{X}Ft0By269l*P6r zuTm0E33lN!&zezRh=5l@mQP_RAR5sr^}&4j;(eFAj2@K*7>|(4IdGb4yB%g88|TKZ z^M@nOtS|f?{!z}s#}S=w{R0`LbVP{k5xhlw?;F>N1tIByWsnp`Bg)hb4sZR>Y12=3 z!#Anh?EEZFm==f$1I@Zw1Y6-%6aE;!l&t#!4vB-%4AfB{X;!sT(jBKx*-5qZn|89Z zK%Is6JLf#w>eauBET9VUE&>aD*^+~!ilaiM?p&mM&kqY3D1*5QUGBbUOI)=eY1dMv zJ=ybPA_VaWPE1+MDhiYq4$DfAeVIv!IP-*#v53?V-c^a) zG6p$+O#_1{V`nNcS`{^%iBn8Oi4fO$#Q7x-$tp2dRs-etYmui-mt@P{hh?ldJJP!? z`!i88d>h`9rIRd6=^pZVuo5}3zUbAX>~uzA4C%servKlplCW0(Ta+B&Eey1CQ5DDV zf2Mk*YRAVjE>){hi_9poOCsx=BU4gQV)kovP|^v!npW_>^LFUzYHx;MKo!BEj7Xy9Xg-A6>kWs*$)aMAWh^_0Fnx;eR|2;L0ZjLl*+F1Moh4?D&8h6H6jJQ+OxgwJV51#)zSmqvRnQ5 zz~62JXPCCiwK9W;yo9-%7Xka%OtQeVDK5SGr51}$q@i)OE>BHgfOFiV%SZ5E(VC*q zYujoHFnnF^qs^WhZG}uBRIs4{4xGP&Tbtr=RJ?=4?;IaVA9Yzp!}H z9QDT#L{7Y?)r=m^ucWOjUuJh*FSmqL?!<1x{iOcP?l7BCorp91#(gUNGIQf@1)d1lXx(RAI zhm*TFNYgXZn_A}FPfh;WMHE%oCs8d+1emobQCt@YTjxcWoK81LeXY~+9)^+UOmeCk z)#LMg9G1`jWr;WZrrR$Gwve9&X+lKpB~*OkxAEnRpO&^BwsOm&TDeQBlvTv^nuju5 zyB8jH2{_Xtz=1n}8hD4nhhZvyxynbGz%2iKM-8|$N`wX8O-Toi=&@x087+joKHd4@ zsx+@?mPB(R?mMWCIeejm^dhs63ARzdm}jsA(O)QqT|m}QRWm-(Hzh#M1)wVV%1iJL zg(a=;b~-ZkGDk#mk1~G*z!7zGrRGL-8}=VILi|%;0knSAjJX1jZXYa@^cU6K|NAIP zkrpm_?r8?!`$D^>c>@hwX{b1l4f&cY;wwU&Q2vPM9oGB`Uj2&haf>bY84LFfn>4P} zUwt~VVTwui2oj$uGt#`OH>|MYjm8`R#n z{C%^u?$@fW&NV}iCuMF`&DU3gT0TNA(vM@&mV$M7yWD^p3 zN996Z8he29k4NFCg+9PbnZ$<&>5-W0fbtK7!ePTkfP37tvtUFQiW$|1%XoEZO`#0Q z2^XjxY40!DruxCn-p%m|j1RfInIaROco}Cf&3zhkkBHj&Rt=WZ_VkNJdliOb-H{>p z4n>c+XW~q#1M6<*boFS%=vdUE3ndU*iM+EFUvAM1=)%}A49e~^iF9Tr^(nqF(J^n~ z49*I<-WXCZ`1EG0hYOd%nsoM{LT8_q$a&QSBz;#S3YCwj?)0mjn_saa@O3c^sMqwF z!ZcWHQHCT~S|SVe5eVTt=z64&T=nI)wG<+4e2@}Gp9#uWEM+p-{L1PUC zM9N-bN73qWRRpT*YCLuK_D+uRgFcwsV}^odrD$A zI~cJDK#5qb8UPL(A_=P(=)Z0U`Aq`WLGuPhE^-isi?g-0`OZ?4kK^MyAsY+mxqt5G z-B14#h=^(sGv*CF8}cd}Xwl*_z1KEt!uP`_(wPBT8=FmK<+VOOk}fZ4Gj*{W-MSmu zygps+?d@%?tx#Fn|0(KF86C^QEgcz^1&!sUz|u||p8_`(gR(h#GELI8FrjSjfNCc zYJ9BHx9555<@$3ttNMYtIMa?NQe?V&_luijx2?!gBJ8tg}l4R@z5x73q4 zfZVtX0lZOzVV%@yTg!w5oMcYuMfGrD!RFwqChHhY`G22|vNLn!6a7VRi4gD!@Ae2K zT6A|%SwkYp{k$!ki4db&5nZ!Hg{8dj)h57Z<$r$9=s?;uzmx54DcKt)m0_ow(XjO@ z{}vbrW9)Fk2;8-9>tkzX!IEOW7lMb$gf~wwZgu2{whBB$YvW7BQSPQZQDy~)5Wh@8*P!VrB-YNi~zFb27ia7UtoAd`4C|JS~iU%&Qw1UMjN zC(CRqwMFj@{DT5Q%Z!g{RpCq?CpzVQqdKjxHQ1xa=u_EKr1ec5)TH;7hvWIn?hs@&K~48_$RK3+ zdu{2({Eh&7HD%B{)|+9CYaV^V1<$`JDFoj0UB!kwzCp*vlO(9kJe-Iv4aj7J^fJER zTEQS`H@RGhfs9w?M)S`;LliZ`Qvu3g2?r)nr?wT^cRJy(wBCr0MDqtRFHm$E%-!6g zMLRw$2+YPDN~0`{Vm}H&to@Nr&fF{~L0>m}Ghn>Vj81s`EIQnE@l@Jse`#}N0!!DL zkzs?x4I;fLH-LS+=E9Vl88}Td=@l&5&xyb1KaYf^1>c=cC+$#bcr7(`-gQsjD7Tws zxszZy^8Sv(2%nbY|4UVV<}>Y_l1lTjrKy;Y5${ej*V%OT0+D~Ec3-9;X zs?8%af6+X@s}jQO+NREG?W&1rhl(x1!Yfpt@?JLkH~UV_9l*DG6qvuakx_O+bAq=s z({A;t{jPMtJAA3|O@KE~J3M!)@g5`5KHrMBrNC_Vh4B|&pimlm=+i4!K-R<3m20bD zzS$Ki+QfH%hnUo)1S~{GWomug`!{WD(v+ zuvqIy(f7nrv3AgZ=8rf6?es-84@=OK6qbY0wJ-G zL(2?kPhb zZ{|(D3#69jUn8s@S7FY>F%&HMCc-%c24`6k2TkwB}T>7a66k$Rk>2x3dp&D-EP;6vCr%iE>GKFx;(izH3Le$SQsp0A%5 zm-Se9<@jb?{00JSx_;^KuDtmei!?oLZDoJ59(**b_6Y`2ZP$kvK4#2^Lk;B5oCirY zRlPg?{iEPr_J_ES2=O`sJ_qloEFsXBDQ+Z4sZubH45vc)72Y|~@)oVTzXL$U?w#*n zclYx8f%j*|f#eOo&_;}Am3`vA@XpB}-9L>H4kiQkO%r&~{%W@YWSeD_%B5+F67d*j z?Utu*W~cd#8x`Co76I~a0hZ}GzEOX;;hDT#z2m$G4zcHYIefxJIe3HizO!1pDziPE z*|lfM&rHZW`dhSY#7rpieqo!w>m&7!e)!(++5So5!vv0pL0Wxlkw z;_!rN(U5yR9=>CNO_J%S#)QEl@X^i< z$-v~-byW{BRXav4GT1VHt3jrFK9-@DZunt&iHnR->YIe?0!h%8oHlN&$VawG{+?<< zoY3lysffn`42Anr(od87p_%kBvtEl~1Jq51oU>0Cs?E%&n0t{t#)ExsgW$H{YuO*? z(`4X_deFhMU*%36&*Y&?o78sAOZl$&98gl@b9zEa>Ul`Eht&~4&@b1AzPD7{!Ati$ zwXVr7)>u0Sv&p#{4{|Qcx56H> zF?_X1-NV9Zi{jD!EQY!op(nLS=XU(DmJtXhf;wDL&4dvd`O>zAaBzN(?%law3sn1p z_#_Z!M+Gw0@Qk>REY&5+l&ECBG20Y4{6#618u0a_FxP38r-^@-!(PFvJl*UdjdBDn z11S4BYW3AgDE#Gc`TX_x<1XiTCER)+z?$_X z7n&6Ev$hKOggBsrg&CpBUpqPE1~%I*WKQW)@&B^`ZW5)SBHYAX27S#;6vo)8c5BcH z!iREPvmG%-xk%IahqAZVSke7KH%Rm!>V_tpH`>bSS4Y|tT-m!g!=Ni9VbK>Rx}WE8 z1ss1w(!|#dy?b|&w)Q0+&&lInD4O`WjJ{*tN3GHw8{8SD?rdB!ZRgxa1F<=81)1({ z2JvQ>m?i8VI<$}9MmtE)MyKN(H%%Ec)=3jmP)K#QS&7qL0o;%>!jhlVO3 z&jsJtdo5DnGgt&A^6{Y8a8ne9+lmC2B)oq7mWC?KoKbd`r)Uj|vMQx$o%)qPrk?b_ zW1Nh}Mw*Y_&LN|blw(R7 zFqMcuihIjBcSQDyLEoxd@%w52JEp%6+H?S#HPt_I1T@F@jW@935OmoG zE^SH~5V5=!n&E+yvOEFgM<8j%Fift}(j53d3V%1r9NT`}I%2p0$%QVx!#G2{NyO0x+|GF&XFcta601En$nx7I1 zQqAX}hG!*oND@sdrvXZQ=WU5MOE7QtKbgX45%?B?waqj`sNjDd- zUTH|{!iKvo{j~L-X=^?Us9D+2O!SG>$w%in^7zGGy+BMpnFr)#L4Zc0>7HJeEGS(u z(RiPD!>0L<(^-m_3%r!)MMdobk+T+6rOX^H>@PRjP^E3Fvx;U$0pz%a=(m-W6LZ}U zX2QnW7lPQm!-pgsRh$Rxq+tS|LfE_T9hZ*a3%%5EE8!rlmCi9s zC%T&Q39zQ(krY&I&{y3pYWA%5nHIL{j;9dmcaU{*@}l1i1fbF-HD&(6I+spEHr?l5 z6XUR+=CRY)I%wupKQI4-`6@A*Z2p1C5}Q+EOD4Yb@LB`10Ghl=YqM}RO`lWgijdXcY?-_PlpTe z5*pPp$8~kOI0r-}EJwDCeZBX!`~Vja_Xl`%VEZe$l0N#Q`pQFV5Kk9_nkJD}iNtEl z0C^Kr-ATPgZ(oeg!%ExcVXg|I_d=BoM=ZHAT`5PDZJr04Ur3RdN~zCSJui+P?cOm? zZ_4uvSbO6q9^3ohA?X&NT{--uRs)j1^n_QP0Q$3&rxFIzTz7O`nX?jRXhg1DeB#5) z(GfV1DF?0?JQ|Qk@MriD8NQBaWeKv2Q%Q{4hBkh-u_vne>zF%J~@`u;J25*=?$ zdhu8F1#*^Vel)g8@`n!4w}b9O5MZ9mGr6l(IoOWq9%{A1u0kLk75}< z&VTouJCQe<1WILdAsGA2MManwFz@+UBd8q0t~Z?>7i9wlMSc4rIngyRBL7^uYc7hA zBHUFVhg$Uoyx@ss=>vt^E5y7o;$7KRvv{t|CpAnB&qk`W5$c_mfC9N(b79uh8{1b@ z`%f{Lmb-*Z{$${zz}Myib@*kI7yMEizc6;Irq>h1)$KEnLBTf!E}{B15VVoV)p+aT z76}rh#zlkeIT-ez_6b@mR`!5_WT}T{kciOQ8yX_<@OT6_PmxrmJyWnWqxT>-Aho3b*pIl1(z(06k|pbILiK8h1e<%dkjsXB~8Vf{m4 z;ClZn{kzSkl4$w-j^Qx`(3BIce`g>_bgmJy8*cgJ=8Ty6LZs*o(tJ?TUi$1Et5WlE zPm1hE>IZ@-G>o3sf#8sEAr@8W4+aYgQTPkDDhUV$hNQpvpEmwC*qRWQY}4A92_0DZ zmPs>)&dZ8l5)X-zicS159QB4{Zwz=3=NVHv+vF*NB9 z1yz|msvE4PVio9vx4?D z{ZQdbB!aR@k>T3)149tjYac!k9CIDV$2WZDZLI0o-b>X4G9HSuePIX}6fDMrw_{k4w^WTJKctikHje-7u zn7gF^^f9vkrII_IBPZA9zyVn%O~I^a3h^!RY1?E;v_(46klc%M2I=TV%+aGbx1n_|{GwNit$QzspH)ZRKc+9Ky0a-Mj~~W; z9=1QW{@mQWZ0CL4h$4e)g#u@U;Tecj_=E}U`TnGM7>o{0dU4MT*|8>hhQ`?UB!zFB>>~9<{V@O>aC9U~Une3IWIR5R z_5_;sDvxI0ns0l_QeF?}X5QNM`1(*9drDI7dr~8llWtCKyo`HdZv%?+Yo+%2`Fb=5 zKSVr%FvKu>!KA)Y5&sPD zuJbS|=5`k){vruC`iTofuv9tp)kTGFd-$o@dfQ&XgVVImF;1#Xx#`I3vul#F$qWYb z%LOU(SbQDVH4RnT>9}Wa7hO`?yKvd%M<7B)^-9gvI0d9NpIMkS zRT00KAyowFDZ=SlDLo`s`r?978R0T>hJCU9`HXoWFBuyu7Ifhz-OU9hFUQuonGfWr zokmWPK)otgYn@!v?`Dtcubl8K1%*k2j$mrp>~SkW z=^_So$+T1|P2fC#QyVCNlVUHq?y@pBngYPoosbeTuE5F>N&Y)$kL=WDpkyH~cO!1J zMU8RHS*10ceS^H7l>?Ax-ySAEq;fFak>8M}foyYCs-;Rmzg$T;k1$Bi^ZQD=+=cv~ zbPGjC8@KD2%G>R7`kXxj(wO;v?YYy^+8h$cQIphb3NS8{p_AkYO+3 z@r-QEvcg|3shClf+$g=3b_M|nrQ|lu+E$yX&=MQ;_k3cF{6!0wx6Dg;;-oBc9EN>k zD#NH0R)&||qCZOZwIv9erOFWBUabK&8^iW^&#Oat0LxZ=F3cTrBau=&v4cK^>5k@gj#zWtyXj%YL_X!h>bYx@JNuVPpBwJE56w;HXl zZ1;k@d>8+2?a%T+rZv`KSlm|ckXJH62?JJAR z7ldHyEgPiZ7!yX$7!&3vTs-Y7hkx;Id(DrB6cEMyABU(*M((X7YWt-L#i`S$!5}fl zC#oXNEBbfMF4HSLYC0$tY1Q-u&Ykz7^Eumbt#?%(T*Y>yC7L`~p}oAkt~tH*7e4Q& z$EWB(at2C8c9em~sOw`1CvA#}IOF9Z2~%FBmb4G8IYeC!Dm&P!zH#Jna-NO;Qd{(7 zATVoYNg}*h`Jn02H$^WRu1L+psWjwYMr~!BZZ{afjMr|Rh^JQYjck*m8ZE0?)~vqw zSAykMDOKwNT}~IGR-3e435!bEmBPlvKn{**+>sru9y;ynv+RdQX`cNo_%uiQyM~gY zkNXTcZ~J38fc(I+Tg@T>ta#K|CyTKv73iu?Y3>J!+07C?lcTyZWvw|?(w33jJN{5- zynWxvFsqw231<32Aj^xVe zS{qBm^{P2re~|C%4rPHF|F>PqE#D4Gqy(PQqW(YSb36aV+ngr7;Z^rsa`1CFOVGl|5mBdB0*q*?%XBXPjPm^A~cwh}`D~ z?6gO&d^<6m>+l5?;>v6BSph|=1uthK(GEITC3RddQQ6I%I8e=$ZwLj#N5a1>8ivCg zc9PxY9k%zK80_2>^XcdCV4!Dqbplas_v^F62wKZCbfyb7Wbkyg+t5R?jVp_p=87)rAsVG;p?@}0DhfjF2KY=ur_sDRN5Z@ zBoczZ8+*l`4CNsWF7`5M9V-hSSKJz^0xO62%BvUldB37t{XX4Ba8~4nB7(_iRUV7C zZ;UVO848`?$wGFpL>#F1+QXS!7Eecu#h!577tuSg z6^-(>A_N+VK1MVMP=Fhb(cBTDWU#U9m4gz0I*3`Ekeu#d_-kiPg!qv3`67kym=Gc@ z4AmeEJ6{D5GT9l)0Nt?D)UZ!J6$_sfK%VCX&4dy{lH3oNgOFQ2La|}=(_+;?BPZhJ zbklwJ?_h@!#;1t8lY{2DbWMd63lRBe~A zUI018Hx{L;2 zP!4pmu_b}ynHxga0}8?m18nj=$kLnve9s^Ie^-H@{|7@7h%5N$^Is(t_dm!303><- zFJ^N8IbO0tDI&&}NbSz6da0ByoGx4z$_S2h1eJKQLn#puSq70^es*d-_l4(XJ#*_n zK*J}P(truL6NXuaq7uz`1IeN|p&1V&u2eyhN#=m1r|%dhlWusBQB&9Kj?1K#Hhvs^ z-dw2ubqArME!@rtqD~^LMn}(jgSFkP6{lq?QJpdKZ;mfckF6(uBjSn{+8(#`kG@;n zm3xcjQ0qycjaDG+MetaBT!=+z$|gzdx#dMIAswr_Th_kYiKDKk!&_UmUaRf(O6SR6 zzMcwVclitdu{K&Gt?B%0$DH%Ka)m`JL6Z#Jpcu<41@jFbBz1!FpuJbOJ)Z8kHKT}Q z_!}IRR?c>0&Nt&Qj;h!jwPEdQD`+lYT-#aWIWB5Cq~_MoaCWl~Jf%0pW3b z-Ku(nGC90fjj`rXh7Cc(Xf)$}yt?d+VM=r=6)FS@`OQ&6LV5%jY**8LDEo=q2-2;W zXLFz5Yj$C0KPF35%Za62bizyq5V&Un=D1ejqYy`jNUkEZx`7gG{jZU)SoHqE-`bUo zsxgy5URx|pOM9qlM|Bp2^+Otw#8?sx1ynFD)OACtwIT+Y1B}#snwfkd`ZNWUuZ1Dg z3J5J&JYAt6fN_#GTqdGv#wb8&nj)t%)0R_2(EHvf6Pta)r*dD@@=u{net~%WnTTt@ zjak199mId#cZ9@4m$bZo{wloNngnd}jm87j!n|hi9Gq)eq)1}J2NY6a=#-LWMACKc?Fn0eJgkvFVwzHPJSCda^P{jTCuDdIo7gYl<=sY)}+_Q3T%^*<8y46+?f*t zH^<~z8%7i-y{g&sZx`Wx(?%_9eB=1?F3Q=~ZWpcXS2{)%Z9?Cz?VlQHnd}xq*zI2y zC9dbVFHaskv)NGv?a~q}@_}vlro>|<@v`XmF4Xxq2O;^%wnr{e?a?y4zMGVO?J%x^ zqr6{Bq#9Sdib%!nZ>kG=6?f%d7)P_OZ)Dq)iWU>+(HwnZ2ea?AwD@Sgm6u&|?0uVx zHxW#~O1#4B=U!!E>x~yKjHM?d#H@c!rP-Zxm{VDkNw8W`WrERLYXUVKYIYoFqPj*A zFD}v?HkI1j_Hx{o@ika5m+~!ax#-9xYI>XIWkO7@)a8b3_C=V??O4fZ7soW&yvXmK z-Ps1%D+Tf_>unWrYEhe=B?nJ0+0j#f@%V`N7WrAJ=nVTZJE zu||VpNVe*I9}B7xo>6jqrpD3elbe=GMt4c$PzD=N*o1C^{TEqP{ol-`R~MW*V!kQ% zn+%OSPE%}dn?Wye?nKP0-xm5TJ80J_9&2daEWBpADhIPefDBt{al>tbKt)<2snTIu zZ=8K+!iMD>YoHCf*0G)b%;7n6H#1R~!v@As4^5D1lst)5TM3#`b+OnbI8 ze2bnPSnwdjYL}M91Q_*VgiH&E$IwTZ8S_za4*+yAgj5BfnG{is4=6UmO(6JZKUR5SgyC~B8+P%s38NFVIE@Q6rfXPzmilun?o|)VM7f+` zBdcF#M3FbOR$Q@j4_G#;NQenj3gRkK>d0ZD3{BN3G>@?AF2^t#o1j%e<=&-KcS+6# zm6Eq30rjfpO$--s?Bj7Y=s=H~<(V?^04ns*QVD^CIxlO0hb~rThyP*JH%;Os3o-J4%j@DjkQ* zLeNu35%fvejsqOEvSa^M)%+~Sb>V1HspK+y1Fw_zI1{Y*=POV}KhLx<6ibQ~4s47T z9GzXb!%Psmx}s#;glavT22gg7+Otqq7wiTH1hgtBRnI*GQ#>D9U4?Q(U=8Ef&r_)N z0=gyY`$sC*AdM`2lT31sy!%Z?Ys5TOU?=+5bRrov=-JL8B#s+Yvyd!I7ej~T!?yqB z0G*_hL^v2o@bg96In$!D)){V8(7HmoIrS38vkt=Hk`(G)a-;#YyjiDcdB0a)e+l(c zZm;JipJkXo>r!!n|Drb)#WeSzW$q%|2m4c~$7Z)uqb+w8Cuw%9_w^&^?xo*ck_nj3 z@uxkG#F&A0mw=OGT>nKcYT1XP=j~}ze zn><9CpZC;te(7Psr&pm%h}d%@$tGvUmk74-*flv?d+qOAVh6;i))(ag1T^!K6{7w~ue z!|EGUtV7CwfxW&=hxs>+K1hz!@B+U!ly3QxjW>KHQcY2c$WirWOqv|mZz>>sCYc8( zb%Zcz*FDj9+sw}1&G{$)chro>?Mq@q&LmDOu;2mtO(FN?UjNt5^ovxp;t5fo@QHzU z;@Re6YR|x?3ORQ%4G;Mm9#`^!7H|`;Xumbak->7ftC1n_fQOOC(Y%4vPXoHvvjLG> zc8D~=@;n6U(W)GDu&xX|!V_A-YIzVVtZDOu0=ci9mBwRhz zFqbia8@GeR7L*&w&8f2`d^!*4v5n9uA^pY1j~onD8Uz=Xti(&Y5Vt=jP7-gF6G4=5qf>o$TuBF<{bDQW z0b?DoR%bxUoO?s<1AS5!>{}@}*5I}_zrca*l2lfIwAeWp8$3sC3 ztEe~-=&EHrxI++EdY}cv7fZKqiMa;iYSBl>2Oym1mZ4f5e0y;F2GSZMs^!hUS$x*a z2x9lgyVN0Mf+2;s^Orv`y{3ztYA$?w2dJ!1D4*;^h;JGzMmFu3ry}jIu)6VTR`}{ypXCA07t@KT>O#Gs%@vd7>me@^RA7eN=#Q>CzXb-L%&MZzWdOV}12D8!Qm# z!NxL)Cak9k8f)TR!7r3e|{Z$-S|MS9FN8DrR3$qkh}! z<`ucgSNcmAQP!FnVJ+dIMQmR>##46@b&ruT(WY`9yt%YXg3x?K^J#|)6Kj>n_;2)0 zm3y_Qk*;Ud)nT%?iqrJm(>i>`eX-3+%cjK$o3rJfDbTKEad5T1T|O7#9NrqHu~rmt zN#ozS^(SDrA zsv(RB8@C1~R?f8Zekms{TPVD5IM3Z5td7{^#dnE0>oo=gjzot0pc|W2-CS6Sq_xY2 zKMDYyz&m62bzH&UjDIx#Y3dY%4v<=hB-68UFkV`UdO2n=$ z#L&BUcq-2)V8}*ybjF?kFjFJjt1T<@KGe!$-^(q=N1LgKCHaX=4v=|7;o~<0rzSEhRMu+*`oOKW z5?SX<;N?sF@l6-Kc}=7kTvS>_d~#^UkwD#!5W!16`VLA}O#fomaSk+2EKlne)J(XWzpHxYn7?p-1nR=c# zTBjb)7n*)FYNEN|o3!YkmYQ&hI$^e|!bc*!!0>rekNz!DNYZ#$6A^S^LvoH_P$Rlp7@a zv#OyyvAiwaMX5Am9pv?V@u_5A0mA!KU|3&r8 zpROC7?dY#2mr0fJZOR46^c1;}+FVaQ9q~Ysb}-iX@Fj05!hZBw3NZdz=k&|W(w7ht zbW%mADXI^t)}f#^V80V&k3;4+rO}GH9b8#W9#VgsSAjF*maJdH`dPzgJo81_2Xj6B zJ?M*!zA#+fIE5N^f$!-N9dpW~a%ubr zd_d2GxJYsVk4Ts)vAZiCi+n{SDW=MO5zSQ=ui$AD&S~!p9(aku@VF^KE&Dp%D0f|I?$O6l|8FC5g+$-iz8m9mo|L&C8{W5`2ds*u}tmk?Njg-NH$ zuYOT^Z6+X4k3hP4;z6TETdvNR=lR#Nrl9yIl_xy=)8Zrf?T?DGarFi;1Ez}5*}eDF z*k0GJ++IymAM%H#tFlzTmafY98Ox-XcLSY8SwvFPht`ItUu$z4q86N?zTuX>LiAb= zlK=f#yCxc&orpOyjF0y`XPSLU#kcRfrbv8KNQJvbMg)Z051D(nq^I#O+N~k_rE3^b z7d~@V=<*_xEmBf5X;pk)FMi%&)Db#b=!dc5kMQgRc5;-gb;nNfstPyH)^Ix8@L!5{ zlF1VP3$6U7zVU~d<_qiWn#c2qxq?4l>5EY05pwrj9OV5a;9Pd1I5*(JJPX!(wjzNZ ztk+_oHW*koHw&sj%v}q8^&1R8`YYHU@|{TOdBLH70I};=UY@EUkS01XT#dOHO5)we zAg~vu^3FrMVKr&i1H#u2m-wJuqWB1}w_x5H(JExSxDp4Qq{9U}k>OtiWp+5U@H6vL zBilZ%XL1Ifs^Mk%ad$;&xX#5S+!T>@H@Oek$1*TUQ21Cg<@w+eVAbh%`sIUJ;&s28 z&b|j-P)*TP#fmBIGS^y9D=0=;SE@SUw34e=<)|rOh7_X)eQ7I@l7#=2=zL~?Q_zyY-NH*)p__8 zXl=T?l&$Mk;T~zeH{2`IHP5}e<7FBv*>4~b*qco{T4Fe{QmTwndm8vgt**DfC7CYj^x4(3e#4BnUZyCm>k zsypku(lIZ7|KRtdLkDg0(`D|@fP#}ehZPFpUFrPB%_3QBQU4Pv^DH7{W{U;8ceoPy zV~^F5{ZZp<93x z9h#!%4@8_||RJ`FEIb~EFW}a)A)E--&5iii? z%}-rwtJHPYM=>hb??##Q1)hIGlDOZ+-FDeHJ%>og3OCN~H?Z~H=Cn>dYeGTf&^G!HJ;=j{ObHef}gi_Ld zJJ5hmjNqRtez^0*hgfd>{R0Zxyw&rJ0*4)#u8s9yzg-C?d25;-n4+(`D1;FQ>!(sUC3!(_REC? zbP^_^zyPg9hK;2vAV8PR6|A__<*1qLq6$Eq8l4S6miweXq5?a-nHN^HdIY!f_-o@u zp>Y<5g14Q{Vq)T-cj+<(iSIn49(9+qkL2C3?9iuc1&4aE89IqL*f&6a^^zfQ!1XvI zfXQM>34_t9t82$vL;XRil9PbsK+TGPzDy#&S3cjbOdEm~NI6t9>84uAq4u_*#>l9q z>VI>bQwUr-2dEYXydv#&S)X**ktfYGV57CIm05Omhc}Jl(!cnjYr1cFV7GftkGncB z&Hn2ZS{d3RwD9IFW43<+gepDlSxb;sKMd4%92<=IMHrjqXOhMtmgBT~)AzY1_Q_Nj zw@j(JDHekRvv=jqG7SP@l9|N~)7YfFU*pUw<#ReCAH21<$J61cB~wM-4wnZuf?!x8 z&@&FDqPxuKW1#{Qs|nwITE(P<^g=KYP1JZt=8t1#dyQx~P)ChKLSV$ir527yem+}C z&!-)ct4_`<5j}3Z5e_5){UC0`%OIs5&V!TEOyxa5zGJiDegY_wdbk620d=Q*!#?^i z2(l5VjooD9Z%&w*U%NHIDy}RGVS6`mlYp4y-LVW1;yhH5ADCa|jvjb^77b)wd5-wz zEa)Y94>QRui~kZH!G|4I!~88=%0&5G0eO<-nmHrap#K1XR^grjSe|Z|icAjz75nrP zACVIcUvi7-|NNp!+-;Hwr2EQhS0&}q%-04`%he-MLZ%u)DE3(ue zxb}WfOasYLv|TI5YXcSpqy`fNgeG}+nlPF93JI91>1BvY--xvJTv2LSv#U(gM20pcy6m*!qT-REi98kj;igw`RKd( zC~Lj(W4oNOhm!qSdy9MN+v(nUxk~==dUOJzzjMH4O1xV@F(@m5V@h|b4a{J?WriGBkzCCt>v1AD;OO~ud zS+hiL*0B>p#vMeuS<-!EH+B=*GRP8IgoH@h#@K0WF;|rG%kOEr_vJO6f6jBx^PclP zbLRXpXXg8SK7qpH#M2sM(~zwCG;wtNyn?vMWGJEWiqBj0IAtfzk9VBXz_y~AHU6~9 zecjKYtN>+acdRx@uVVO?`NcJ&LhT1VM{@&HtRG3?=|2^Z60B~K*p@boc23}r-TbaD z!>XBP(u5m`S#SH_8J3gct?H5V^cvy_&#begx)Yl6h2xK*oRO@Z_Bk#4%g%EXE^a;b zkdlQ0F~ST`@j9*Ukp#&{yF1LU&!?+q4-voEIiw6U1cY^&#p3_)YP{yLY(Agqbw4*} z8(ZHtUQ70I_%0rD;mz}WmdC+0xKo3QFeYCmLt{d-lfmT;q-hFyBwF=F%k9>_`t!PruazqK8B3CmUW_dDa zB)FO$wiBn55}KS%KJ)C|1^w#z0|)Q6S9)z{ffONO7hcJN5)R|W9vdu zoyY?Fc{jh}d(4(E0)-LvT6x;Xw+t|wZ!NgmE6k&T#;PUpagBt@kH>C#&)1QC7t?o_ zAGL6{))=~`ebD+i!0lx%G|ZSqFsmA;M>fkEdtL1C89?>1IG+_kb(Cs5{gGC1!-(ON zM}(4=p|PQTfWwU^_usPnyyi7ADZw^bJ=~J+bw8SzTDySd=E@>hxg8&3{L`~}(y3Z% zTbEOv62Z1^`_1$_4C`-6(Z~G7_vh=SAG#x|65B2UCPq!?^i5{&D_Tm_eSWw1uIHig zn@TUk&u!KYG7rm4?ApX8yR0$1&ey!0O9w)5rKNLOWZR)+LC!X^mE!XjZypOQMFo== zmvnO_yf}T-26K4YI!MOfmLivK-8F#=<~6fxyZh< zDenbKj-#aen^9$u0nf~#{nX>NLw5e4-uETs@zK<|UKD6Yl2Ed0Icys!G>* z`dZe_AfCIqLx1P1+N6?X{7YMGtt7VEB{zz~#I=XoGkH}LvBRHap207-`iz$gn{&4{ zh&b+cohV1@otped*^G;Fg|p-3hRt5gX+$C`FV>nOxo6+yY`w>cwW2^NMP27@_Lw}y zeaVVqMbe^?%#osXsOgU-hFW-hvZ9_)GLOA;>wpBC`+#W8jq)h_D@5#SkY(|uF!^Be zvpDxpLH;k;0&3`IV|#nk1OM7EvmXh2`2Dis?iDd54f*uw}jI5THWNIpIqj#NNJ0^2-^Wl*XFz;=xU8n9fv&FLCRIMSj7Q{ZWQ@hZc50(s; z3m6Qr;uqSO66T^?IXs83+G)5t6Sk}PG{2s=Wk-sPcMR5+`7w%`ajV|Oy3(43TSu+C zM~-Zmxa(}^%;=3m237SDD%R~xy8}xO5~CNQrV)Ltrk&z;N6jZt9)3}| z@p0saOnkL#elg?UO_@Ig`wP$CW^}0K&8wf#eIy++_>C90jd2LruH+s%w`}ihw92os zil}cNBDANCIN?G$uC+&?1()6!CWQzL*!D=s5W4p6HKG=QYwh{gCf&{3AST zrcNN5Ph~ju9%GXq_H!sthKqWX%||#6QQ)I!eFR95MgKL%q5H-4IkR`d3zHeeKHiFy z(u>-81|;aIADIjbIk)%244uctVlG#1_LwwztihjJ%A5%KqOMyC2rvu|l#eN|91lN5 z=Nt%}c-$Ej=SrDJCxNO7n}28o!M0qw?(~+_vJ6vZYt6Tye z6T%7!VXP5SO7V$#{fL1jMC{}K@z(d_t)^>op*uwbQ*~aco^uJ0YYm$`n&-3CT0M4^ zFXv+7eDBVP03x6O-dE>vRE;nbk$iI7r0?Z}g>Ni#E!lJJj2W&fiz6x=Nh+D04r|@# zfX;@vAkD%`Z1>BilpnVOI0lkfdtaiv2ozv;#fqmZm`>4^9_7-NWrc7gB~{=VO0r|6 zi%rTpc9bR18A3{*7gMjq+3UOVpKWMM)QH+;&%Km}>K;^!mqB|X7TOYb9#>(mT>XWq4gBjFX0woPN(1n^o!XP zq~rFHG`l8OKHGr&=M^G~PMXO+(xsUFhg$FK8?}<)`m7;V2eyLo#pS zkX&aXT3)!$R%e?x&V7=z5>efncx|Ql+l*CJ5z3#j#p$}#Gqc4tP0QJgNXW1p`S}VFsL_g(d*5kcnN{R|e&8PrW zKTs&SOM>;#Ax#=6M1~6G&d35Z&T2GJkrEZ6pOpa)9IJjGsXzsSkdS{BB;hyeOv! zKFJJDEwaGMyunY48gwI|%#ti{pmXrs)Mit$ZQHhO+qP}J;Tzko*tRRSU9oMal2ljs=<)aX`hJabHP3$5o@<>0 z+y`6!4c0*S13}rfE2|m?1cU(-1cWwa-VZZH@dqxz8+{Dp8!E4*e5J^>D2lW|f-j0x zo<(~QnFNO1pI8`Gd=Dh1B^mL?ab$;(Lh-=8JXtcDpd5?J1y(UPr2%wU(aZOC<-9lL zfcxF*)xE2UIN)87z5VfIhVHN5;|_d+;QhP>h}{S&#GHB~#GGp3!G^1MJbr%lo)4`o zc_%nvPRltX1nccyRLGDVhDq}twP!iOEwD#^U`j(>W|X!^l(A2Bq}thVpjupbJb$tJs_GSbRy=NhT>;2vm1Jp_7P7}k!J11JV$6$a@ojwipW`qx8>vXJJ zJ?zdA<96Wd;j-7&y8wUZb`0vX<7W{%()c?7O2Z!-sp^ecl~$6a?0}R|mAP(@jFxjh zIhxOTBZ1C!Nb1X5dw}fW(aiP!kXA5QDScnJ7E8 zW{-~6^Pn2k&Fjj}2Ckjx{MvEXtEAXY>rYahfIyx>Hw5VZ;Rj7GOVwBeZnpy+Dv>P! zGjqds6s?W0{q=I8gany>eP?xNX%WZKX==PuvH9xy+WvMz8S6wDjx)_Zewge9Gq_0k zEAWR=HIJ|Z#=i8{dR{C6TMglt_Hv?R_Lr}FzoWzvzrxeTP*T{hrUn}X4n&;~;bm)n zhjTJA;7Z3(7NN6M_mgz4;=Ac5MkX47SN*K1*q|LqUH{umM_55_r&15}m{Drjev2>) zSD%5XQJ(QP3Kf{R!Uun#|9FREeI%^-Jz|lJy~g+~DJU z@}jhnz%n*4U3{jH#O4aLo;oZ~;-*?!?e`q^m&_*lUsR@Vuugr{mlw7#;AMPBJq!28 zFJVD=aoQsXXU9xeE7pV7LVn#q{p!VZ3%Y7}jE47Oc_kZjN{$2I_Ih`Hid_gb!z77k zLEPp?R;<|(jHShvV>3q;6{-VZbkCCwhse5}9x5_xyKM(xnjv^V-XBsASA(EHumh^r zu4uRPY+C7=BU8QW{OGSZAfm^B!Ait0-jY>*sG>$R-+;7@n-8id2AU2mHkJf0=Ox7L z3wA>N`?)k>o~;OBOg*l9-c&2Ax>sd#(g1YY--PWe-tT@R^ihOGFOUaF!s{7t|8@Ch z_a_pXzZ3hE9!TK$1W#azp-gEOQ-WuU#0`utpn2;A8trA^l6q$YQF51^@s+gh=n(ox zoxo50I#y^dUD+qqZWwdRChW+6_RmN-hX4{Bk=n^oC1Z8WWcqd|_FqA#1Txzjttspk z$qnVX*9wL95^mN zFaghCQlK}=ONlTTi^uzFqhx1MtD@5q52vJ+NFxQ!u7FgleEERVM{9Q0KxyV+k(#!U zjP{AHSQz$~(Idp)Q>buZc_HZTh*;6r2LVj?1C+I;u46gWXMuJCdyY<=&+h zm4(^0&>UeXB@WOkTUHnuLdRJ}V^~#YwH&^#l%E<;i*sXUO>N1{m4ma@FJx=_#Nw;< z>DuvrnXPe9bTKX@WWBobWN|7oK=)Lm*uH{jQz)jjk}-j>shi7zn|@FwV-hX@U0v25h!EE-T`2>;fbnoybY~s9BLR+`KF%Q zDzbQ>Qv(mtg1L{<#PeylU~f84G=c~OVgw9kph^bB%mbG$j0Gi*<7%^`biLCi$6A3Ua2o<@&WZB%x_Qab`4f8RYu2zo&RGMRxDj1!RG($dfM3s(BZguTy zLQ~Oa_37Ex6x&lHa@^$nGLNS@^H2-MXqXBgn+7g$+NPHtFwcLI4Xtep*>ku19Ga^p zp#I$0_;mELs}quj#0<%t{k44%{7sS|V3?G1-3ZXqJ$R|-W>adjIc-=-Eg~5@2km53 z@Xnl(UkDbZjcc2EDxRKDmzlg3g;+`NXn<32Cs&Gr8M9>iNKNBkYED;3NV$c>%@2(7 zGuZSz;-4HW^C9IKoKie9{tDcJelMU3LgIin!vgno;{>zF^|F}Zn0+;$q2u1o;iwNQ z*ah^oyIql#CiRE(k02Ch-UkgWPBjjbKsFW>pRn$MumX$j zqFLTNU8r{i;*{D$hD+hOUa3_r7*l8 zv!m^zk9RI`jl^J^vt>t_yJad>q#1C=@BvNJ3MPiI931*tyGN(dfE8@a@$)+PFz%6ktHtd^7EFEspL&_D^Xzo&X6_DQ78wf zz1psXF}CZ($`6(2F%C09Pw5W0$pQWGyoi+#B$=AsBzZ;_@JF(*yWu_ba8?#NS)qv3 zq)8|X$tO8<*Cm-6pLzt=@HH~~Whyl@SnX7DTU)W*f~rdggk(W%Z<}b!YT6ltALyJV z&W{eSCYIj#IUky_2kCU`3+UF0CXWJ{R8hft0T~UY^%aGF@Oo1BC3Im`#{kkc7=7sS z8CyJwKM+!`5Ng(Bjw7C=YqBjR4pZ2q^G&dX1t1Bk9B9@gNUD)hE_4oC1LkMMj*Bml z!1|Cs$=oA49A5dB(J*y(pS)A`;qu&G&y}CmAx;G$aS6rh0|Wz#;j$XWiYE!A`t z-nl(heIYdB4%$A?#G8lH%12=MhxWT30nM>+I;h~}7?yr1=LE_C8i57|Wo6{sNQ^>; z76_DvAknlKbXXCYyWKW}OVJIAO$mR9f1kA z`gr)*`~ttfA25CqYm&2*ElP{2i^7qjnqohhLcekYd2ZllD!}7e;-T;lQF}5|iT6py z$l_@r6W(PRz>DAk+cMkZ60X498M-8S!#MJ%S_YjdN(}{_^tcey;R#>;6?L~{leV>u zPbWCJT!zM&*IJeiG+#{cHEvY+ z+Lzy+60#``hEJ4SM{BO+Om>~)RW=p6jE0QoZkC2X1^f$hGAhP8_=LV(#|^Z~1k`J`5Y4{&kph&!7&$xsda&#_|163LJY#sev-!dySjv~soVP|ZwnwS8hqE7eW=?jZIr zi|q0V2R4CbUK!WWlN?7FFNm=IV8vl((EGk<62$xUXcUio))$cnA|RzW;>9U(Bnp6*3SvPm@L)RUplH%j@jDW74248VZ*?j*TrNov+S$c>Dg~fOE1Sik8ABjAeJthLGdbJHnAQl>~+P~ z#8EO}Y7Or4mzgHx>OH=BF}4#ZoI}bJDIC?5J}a%Y(U;mvo%ZW1r2&8f2;ee-6!*6Q zFsae|^`2GCb)p)TzZ{-!^I1Vp@Gyr_M=`Yr)@w?iR~9Kw1~6sAY<}DOF4BFc>oH<+*sWy5S1`mn zF_U-HR381t#PQ`v5doZKTAbNU&Q!FVsUhGIj1!oSU@eSlp5BJPTk$s@L7bUstn`sLU5{#Kyg$T}jmaPaIaQUY)z>ik7Gtj+=Nj;AU=gg&6F~`6+*>>bh zaKRIBVV{_t+a0vt?L;AJae1#NN3)b4T4J^{&oTSdK$>TA&jL2srV0Bw&K~20G=K|j zcmh{_ur7h{M7$gy0P9R^qHnt{2bc55gi`-njR>CF3==d!!^0k-~D{^(9K>;EN-H(QO zcZVNtB+4?UGKW*dGw=#54>WJ8zmpFY%WPBA)rS~ zPf*sTprcOzJg7evUSu! zamXo{%o5}g-xEvC$qkF|h4Yc;6zl5`G@*CeNRuDYY_Il}tj5jasMb`Qx$ZH!@Y3k6 z+vHg^XC|{@Ma$u!yS5RwTtFrB_OZi>IH14e>hHj(Hr+h7{XhjbX zmagNjzDdLH2|so87G^T9=ht^OPok%n@-B7JZd+EBohHA~h|rvTnJWJ-cH5wU9a3e0 zvh1;5>}1vXA)efRhiI*5y=m#|(c|RZ5MCv^G^Vm~bPhcT-P#6llM1*B)Q=|}n#G%- z`-^P3y#>dghcZ-yeS&?^yJeObqdBxnZ6z*>=yfI!cY~2T5*cEWyWcUED2Q2p@DKoz z^OkzZ20>xZGW_|beg{&(M*r^H<#dy|iqOg^qS$Jzp;gQ?*iK&xyqwoSNqVV9;-wY>Bspr8Ti;34;h$o4MC1^b+y{g*55ZzjeWc6f)u8Ng9YEkK>jNC-{Gs}VJgcq(_Z-0ggT3-5t0G)sPE93~qXib;- z5LBi{NKsUJY%s)ymtC2A6uR|VkQQsmlZ8kUrOP}~K7(I=^oSkGxQw1GjA0^MV%;%L z0MBEeSY!ch`*juR$+7!jxlX!YaQFf2)qaVx6X=@~yOIY|;Q7Tu&urcxOemAGWQ(_% z&%;!GQtn8uG%}mcAx~*me%RC!O0xY2>NJ^*f>P#Kp-eBx45d;fTDndGZeXa&yJQ*0 za^P$+D(OSmdXmuwlJN$mZO$v0QWU^gG(CY-0dir%z;;(1zsS?Q1AKQj86wg$o7 ztaYCK?g)FeF_ehxGfp3bBUXIuApba`PhLixgH}sI7BA?5T!650fhsDPJussQVzT~L zP5z4y@!x}?g|=E(0Tcw}790dbGQ|XgAO(pKDn<8@0#K@EpoAuZF5va2QMp}pDk7RR zQo~vV)0?F%tU^IPdpV&b?6r{KV$U;U+A#_+^7mH^Q|6no{|gb${o(8lWT=GQf!OKn z7SHRJpQ4oz;O`yEFG^0h1{E6PX?mV5jwt~=Im%x9VoS4;QCgDzQhy8wG}fsV1JO1V zcM6lDQh@)v|NL%>uhf-KE=_w#{GDgG=1DGP^8y_P>Ioics)A5zUA;TspE3o<7$qF=&{j!*nQi@J1H*qy&fRj5}9W1>v(;&Vb7tAwk0(9 zX1sh-ItRzL-7*><-FadFS0C!q8K!i%5?|hQ67tW-8Q|}R+f@|t;Ic$CbWHI!seIY3 zIe^OgvEl}gt)2MvJ z;gtLYk>PVo4kG_^Iw>~XrqR+p-OR`089eK{vweJqASd7@vpFlX(jNH;^z~{Ws{A6+fmmO=-OL;THV; zus@QT@>O?g;0>5_oN7s6A7PvE~9pb-ae#N05e%sWJJtWYNI&ELSq4mldQ2=9# z`vU(jc>Y(av-6N3Ae1N|AOimb-s~ZM${Za5pr%El7L$$7&vy&yFYxq@%bWY6mo25l0o3OGDC2c!%j@--0`U3x+zz69A0F$wMN$02 zORhsol7=%CP5jV;jLF3iwdX9hOGcD6I_cCYPwEqhIezA^T%Q<77F`*0GiNr`~`L^B*Mo>e6ZO63)@J@Fqo>rU@%4g zBQ>m?f}iZCwpg7>R&Sj{rVPv+iupA-bbx1enWI+;``7|Oa603ZVjH;wL(-z&0Znn~ z5H9}mw0MTe1(!`*@n#Iwq7e=93k5VifES@sNo*bC9=`!3ii(saI8k~MU(3w{W)7{j zUX%$8JUix+_eX&S!K$iFTT_!=GiOa}i2>Qlq6IhOcG@ehjGEgLCyOEfv2W?$yv1pA zIb$!pW<8rs;3lQ>&p@Cd-A&~|d{)*yLI7wXBAv);-Uzk8`9NG(Ky@37L}C>qfUd6e zgMD-F76jWB3f@)Y8FvYnC7_nl=kLP-EIK8{+(i0@Bh^x9*Ey`dUcv1SFbl|8Wbv+X z+>Dkf5qZzB{ae|1+de+rvRmLoGeaFkTUW>|t2w31FZASyo~G8RV~8!DIzpA#uX0+B zXHtKPVE(#Qq>@_9kejW*=R5@qa7|1{-a~8>5rzd3_~-AbzRQ(`p<%kc!Q>RHp{|e4 z>=bO>kc~5O#H+3iU!9SYvvKvKb2bkFx_(qz&lP%RPW6rF=4zWu)Z>aAEaQj;Y>~C* zd`Ky5dZEUEtA5d*WDQDWo^GBzYRzxlwa^Miq`Dkc_xcY5)mpuSg>3PXOZ9jr@1l63yCA+^HtdWt8pJ@|jO!LFGFVy}u}e z`9~i8`sn_Hh=0)wWZv|J88rD}5%(K@m0GQ%LFkt2%%nt~pa*fxR4_oZ&z6)y*p{zV zRUn*J)hw+z%(U9$zKy`?{&d8xow>zdcD6xKtAXOU=+D5)B){w~17M;fWPpO18Wz$F zPpfrhxkK^mad29hK&^B(9#oyT-bQm*N)ngJ+l_Z0NGuDw{ zp-TM`@@k|JAodN{0HDOHmUqiSZjMZv*}sq(&f21cTnsw7^9vEr-tqJd5DV08SVD{1 zDi$GWtahLiXqnw(&tZ%5tDgmLru-2(yb4vjZ(qv5W3bNpeGw|#&y9OFCXZ9)J-kpE zU7p*%^z+d(+ha%34Ov~uopAsIdP(*$g;)#4oa*b1rnr}r77$-V?h9Y~C56Hp(qw%F zJ-9GRmRO`9g&Z|YW&CcEAca>8NAkmzX>yoQJ$j8rsV5k>5eX~uOPh3OcqOcP@HE!W znPD$aTWvp2dkyt=_;I>RMQkU?8!MSxIJ-YV*9F<(K+HWl zfgi3a;9LjJw*hu7#j*MvUvvTj?%W@Y7tDdn`!|@JbUr(@HCM^e?U%fAWYDIa&pXU9bBOn4OH)GDN@ z!C859;_}Q9pQ>Btil0}X`c44zc{qF2d0_zX_hEycusnBiKQCvX`r0HMy7gwSAF$ZS zf4Z#M1i(MwK8bchM%z_W2mBH^kcy2gXpsAiRk?@jO%5D#x#tT+1?*|L3_fb5`ZvWq zwB;P=M;{(_5>Bem&Y=Y(Z8m_}xu_*Vz#+%y9Z{{#P^mEPr}wM4p+l^Ba! z^ZK?EMLCCHGQ9UQ=|*cl&?WM3mGivfZtrv-tEkKkF~T?3@IW)kyU>5Lj(oVUsPtcx z_4F_A`2Q#Cc#iM@d1($xOUmeDf4%UwS21vCBNODsH^7<@l1M6GW+SkvvW=Msw6IpE zvu`k+_=@i1oSv56L{YwJaQt!9grhmvmP9@*uZn_1YHeMI>_XmPyjwHu}yYeQF zQ_0X$d+18Ra;isQFq1C8Dugvb=j^7A;-)T z8Kw>?m8MpJmwyhH10(K;hEnpTs$(9>q=neA*AeB=PclT})o$W0;XjvwlPGlY>qu$5 z%)3zAuD1jy#z8G)yz+!myes)LwIeKJcV+cauP-!z^ibZFRWn$Jj$HJypESxTxMs%E ze>(K3yoRkWh{Z1(r;RdLwaI*MJ@*htv`fr3Y+B?*Tk zPDkcp8W}1Y(Fcpzh&?}(5E+Ov{KJUC0zOyyw!#U|cpQBM6$~RJmDIz_zt>A?e1Af~ z|6Cl#{$l=BDx%hbDN2}Z!EU`yxISBGo=t!u;mK*g=+u*3cL+3ENWIM}%?^ecw&te5 zW_gC7GXcN&qcMoFNQF+E_xAt!FLiJ^!K!~m5C0?j|8;M>92CSQE(aatshs+g6eTnY z+j75!X?mS$FeESvi6JCto$$s|$T=AR!@b<75zp6Sfx(qnco*g)2L$0em0$*S%hbZ z`hR{Vo>@$__3*(XJr3L%zu&`(nXgo;G|8N=TXR&Gd5=~jJiw>ohjP*CYcIY4@=&rE z#Xct5tax4~5wZGoHx3C$T0J&7M{Gm8>ts5@f6=@3W}O+RDSWrtCR6kTzz-?+Jw^AQ zghRGphBr~sclWV>=aNiI7*K9ul%#XN0L_Sy$>YiW`mqe0N2Qjo%HtZJGoAims7@)$ zVV`7E#JR7X+f-JNM5O|kGMDB732L~GrrHBNKs{~ch6)pyDR{TwteT!X`9@2aHM;hy zz)X{d485vt%S>Lv)4<+}VBK;W9_yDArFAvn1fa4uq#NFBz%4(=Va{dR6{#y12G{=r zw|<4N=N`QNPIBsV%3PzXvTM0=e~VduZDwX>o`Fzcv^N#4``PH`*2NCcyi@AwT4&G9 zm|QqlDoM1640-GiR+*aX{SbyyNP-J8gwrG&2ECNMNaZ=;{(?ag;EJ`c^sO_m6WvU& z&KW{JWfJLc6TN_=I|p{1w+xMP3IYFTI>ua1UA^EfWIRHwk9uU_fq;KOET5Y30Cfb1 zk?ipC>Sui%?L`3!WtAX6cY{lOm!ucULQR)dG;3^!tTW=R%&CfK(}|8lW8zmCve^`iz7gS6@&q+I{Bt&^)2la;H9xqXTQ2Fm}r=k9Vqrd)7KLHr%9Fp6vDyI_5UvX;1dCZ4Zv>} z$ryCl=d0hZ1NyKUXwe#Ps)wBY*-M@Z=iYd)UZvQHuDZ1>wM;%h{+pgbM z)wWWm6In6A*7gjrvMBF64|94eJB^eNp6T@<>=JdtS@E8V!;aO+YJd^DfZO#Nj2wE6RN-CJ?_k8a;F8f z02oeQBD8u)&aFG<5~D*;8i7#oOmpg9UV#=Hc*jdM$QC3g*sfMlW@m?O*WxO5{6cd3 zX`ejZ3ysbJ4C^osr=4^_<}DyInJB!z@Tf3ms3<=>a}YcWQyM(IagxaqV5^+3PRm0S zETO@Ck9QOso5yG%6F3H6>UM8A{s|Z|+TQZKdP_YYw=42PI*Tz6EO+ZmT3cr0cyVA^y%#9?eYNQ2o-rbVekn1#E|tto40;x zKcvM&tt1g8<&8v4kVLh!d^QxbXF|0dDGpU)vO-C0#it~lciKZ0=teFhq38x5LHsW3 zmVFmKm-vu)H3_ccBrwtdF@;CkT(u*-lG9TC+)?U`%n}V%SHy4%WbPm557IYD&Mb8X(*P4x^A(SGZECio_ z*s4!Y947&NIu%xz8-5lJC+fEw@NF3@KZF}VwjNyT!HaQhw&u6R177I=cCNcov*|zL z4sKxdF&uJN0--#AC2sH_I?UBZ^j&k(?JP9jNu0gIORjh@^dCeLH$b;*K7N*MJdO03 zWg(1l!uXMI1#Dbp-GNQb85mVg|Kuo&%$_~6i#QO^jCanlgwna0MXz!njj2i_|HJs} z_=PkI8Q(iln)~HJ3Lw0pE`T1Vr8Mlqf1NhU=NF+#M(tAP-M(s9~Q+LW5xZ)iOJ z1(#je@5p6<(pG|a2{2uPbr}1k+3|h7!c&*6_haZcaoBWik=N?>@fi;aP7S7@xAUHE z*hn#x0M}eWpyz53`!jsehk_=6+;mtHtYVJ6*#Bs${WS;Y4k*=@q6a2jE}Ldvd@0RS zxX`!b5Q@(M9e0b9np0*xXq zOmUzs5|0}@2Q>f4|3$1sI>jOXD0tKvk4p3lRY@W&oln6`bg?^p6J>&7izET9lOlGX zab=n`!tbc^C|HpyPT>Uu^0LO)H)a$kVN8djN0gI8?-Sf1KJfI+?yp3OdW5L%Xo^b` zM-xA0ssWRA8Cb_r!LI=Mg}x9d6v2pyq`XmuCbQIADUu&UM+(y3T?u70KO-A&|4XT{ zLZAkCO1+p6VAp9;8U0(41|7~VXmgnd1BDA4Z>1L}mJ(G#e%vx-V`ztQzJc+0b<0!o zFO`x1!Z6fdkiXQ2oeVkK#3I=(r&9fodAGTn-`|gqSV3Sd4(2M&Nn#8MW1JV>rY2*e zp^1L`GEBZQfJHdqpb+Nd(mlJ4WVxXMC9@+r12TU!qw#5sgwj-wc}Q4jdCPPT{ETF?@Uj>Nt8%IAvk(o0faQv<++d z^?{2ephHKDBrzhm2lOkIhqLVJ^fhW2TD{@?xA_z1IGCgR-Mf!ATb5BBTW z<>EuEG9#_MtNM2?NFkdi`!x|invBmdf}BIi01*t0GdJHs_i+SZoI-BAG8E|ROq3vP z)j<=o%JEUO_Grn7S~%HV8Wa8z@6Wh1y7J9Q!l>En-QgU_Xmy8*^8Q#kxl~)->TA(v zef4ykvNXkEO(it9N^k|u9A#!R=ozZMO&PvT-a!#AIvk@yg9>dq<99g@HJO}R_J^FC zBn${l$A3ZpONaA}Hp2G5WVV9>0TKG2WM-Dsf=RQmWE$xFjS!((M_MX8>^?*%zX2k@Xy$a~*t`>n;%zt)IZVEq<~ z$RxOMPxD>j_Q8hmw|rme{S85It?&?zz~@bM$b^9G{?s3TV8Q=tjAaFXEeu^N=8ZyX z40~c_xY(@6`|CihpJU|>Ln1%kpy&^U(F}GKPNAjbhXuMv5@>(yYKiigyZ>OGMJ%P6 zN9rD0KLEWk!=(zRo}03Q@+Ww1$x(hyc9g7A%x$VaKU2#3UIk@}$Fg)IW%)%Wof>;q z)dV}iqeWM|E{}rB?0kv%n5nObtjBU?8ZOOJiT;=?#hpXeQ3kB91nr7!no-pXBb$a> z7i04gJV$ozM6Q2LI&Ob%<%B**Zh2eH^OS$-D*&{gUcDd7rb%0h4Ppuv|5*CM8+@|H z5~qGbwVz(ilVPn-I!lIP%bdt88T^TJug8iaNclGU|UAFJt|9q z96;UBx%57ZCC@F?B!Ie&(}=YOZsx+anhH%RudwPi=BCupCc^yN;saDfMU0y8boIs7 zpk`aQh{3}FhRt$rl*0xyw$*YLcH|(c?8af)PKtR^_J`a|oAvZ`_L{lbdYNPFr*2X%M5x^>k$K`6R_9iuS%>}$6YR!#e*x(9F^Y)fT zFJ8NQ5QCBlJJ?pKkf;nIXHUd&=BF(MGOOXAI9`0fqW_X z;!=^x<^JJaZOxT6?Q(J8R_XS*_D(i!;4!rv3WyX(?eL!^JdCE1GIXA;nG^FHq?vlj zk{WZ5s?kVJd_$`1_cg{ZiIR$V=z!DI12(eSSO-FRfl%V?SoULOtY-@HdHbTJ2|SON zSp-@bvu$}3baxB7TUSy?$P3Kk6b}utoD7@wj_IJYb6LpnoG}AYeTX|~Si6l`^agE? zPUQyM^{XM?;R!Gr(MV@dYC|j>=}a4nQ1H(1dPf-DnNK@BNBHh2obBYi34l?apkiBj zQ3xy+A}Y!pcrGQI2#}4{3KJemmHleLygC|QHAH2zN-TxjXuigz$H+A2C3G?ygw13v>_}Q)=jIGy(J;k;GZ)u$c9OXKm!Zk4L{=it zOtz-}!cADTgcd@Ua}TknHh?>i=Ah>2U!GV}D;)Qje1rwu#P2Z_|vpx0h50+0zWP@{TNcP;s0?A5KD4E$zWB(1)gq8MCVzJTr2npH)Wk9bQYzkJ0{|s zfSgN(g&S=+JF@WcLr9q_Raf|}Xg&C?AUuSv8p+*(Yw?O;hFO?VzK%Fb24G9H&7NO} zk}^N~6=L#03rmRt;CE-Jdj+sveP_3Vq$BS;uyy=h{ocMJ=^Ot%dEH;=h@gb8IW-IB*TzqHV`{AfTZAvjsWQMAAOx zrK8>Xt0X!Oi*?q+V4B^hE@UY}2NQvxD%I{*c_t6IMd3vi=ib29v~BMJnxMlYzrT@y zE!Ic%YM!YIz>0zJLuX|pr;SGF2?a2lx9c+nk@y`MiuEzQTDukma~(qgw+cq`LG8o{ zmG@7w2nz@&B6;zCAiNjq+mDAnAirig5-cQOOWYrrju?**(TNszhb!$iEKz`Z;n+LWu zM3sRu6IuFr$w7e;h6QO->}chMx_INTlVMSY5e5SOMoge~?tSG;Q&%lpRUfPI_0Zap zi`WZ*PJ%Ms-q8R3q;BeBFx79QY`MbqGQCMvEI*Oze3`^7isChyBns#+IESY?9A&sT z6y^2m)n>f92FQbl3RAk1EMViOCwMX^aul=@+Je9^I`v`2ZWlVuCYzn}(n4CvyE+on+*XzbWTn({Mq&|Lh!8xIr6BWqd4Y`+e(;ED! z8}OY%YYdEKpz)y7h4TdWYpcv~rcd%u#YpQ&4aHmW`#!ia=FXQ$k<}R8A9V=i7a-r@I|I}1Cc2k z$Hr64_0FCw9RBM@Yp*q6;_q^1fy4P z(bpznR@&%Kclg7aE87k#9EDJzM=(NYXL?PS6m%!s!P8 zt=)MxPIKMf7}{!W6SJd~s_shuy$C;q9?PW)AF(x#TrcHdIgSkro4 zahz;Q+4qLXxHZRNVdh4*uK=JD{PrYdb?~euzuzcniLv0(g_gGwGYE^SvMQq(|5*~a zM``!z@O|HDALpbIFaZACba;zWvX7U2?e%Vl;>vU2y79w%@?+mY5M-Ba+-LBhC$x5! zFcS>veT<7Aqj-Lc%i2_M#QP&@Z40Tl^UCJviNwemWb{X@_1W0?NfRtjkV@Qf z0QDZ+AlluNNsDoNPn~3VNdI7_u9L;D&6vjSB*~}X_~?M1gFOf zyGLns1g)gx_sIJxX9|0&nusXS)pfO3V_YTlcVb{ylxhIaP@laOTXBOyLN<&V z0}8fXRSSA4TB+swnqR~xi?rXWo)~KvS)?9PCHbg2E8Y(ISA5?Gg7jsK$#r$jeMn0Y zi*hLEt4TBVTVD2-7EFru>rN7p(dASs126pY#;EcVXcrBLbS{FM&(Nk|ZHJ&wKXJ57 z$(D@K%pBMVM==5Xad7u*>(NGsq&;$zuMG$V#Smi)v}DGU-YpX}))}Vm(lors^7a{& zVHRkf(o{u@;f$T2SW^m-6NbabD&K*Se8)Ub<5L~#JHuQ@V)`_IUmOoObtyuJzC1uY zH`mN`+83e`>x<(dBxj+`Zf2Z+YoYi8u_~*%k~8prXrVh``3XKSVW@?^J@^79zF=4l5r1YsRur~&`VroB>cy&XzE=IajU9avpDm28 zj?_Fcl8^d85er3&g)_fVA~K`RE_bu$?gYe=Bb7^&urdPA|y#{y*qP-Bnd!Gf@yZk>oc?|SUZ1E4fJcD>O|q7 za>m?fsDnGse3uJ6-GJS`hbSXZY5s#`Mw*4V53xznIp@qb*zj3J_g=+I`L|{AQdrWAXd}y3 zXs4q$<%((|qq6JC8WPVXH5ta?+pl4KsQVHAN)6gY$o+7}48I;a3O+6xm>PS9{0z4u z8s^ywr(LFNWFp&5?uF9bmsRuz_4(0@bP713{r52%w8v15Dkt5wKP@i(HDzT|ah~Rp z#xKnPWCRYw(Fju;{OQFsQ=QtL`3Mfo?$-ASjPO&R{ITCB`mOWi))ynZxa{?$HgoUn zrIFU1ea@i{sa&Bw8;8;@I0?Jc+&z0y>hOk>9VBK1CRdIG zzr2tP`Yw)=jVb&)7os6i>9}tF$P7SKXg2JsxuNruT+gWTYzo#rmv^2Ha$@;C-NUJA z`c@2=Hm^^`{iAn^&S`6t(}Cj-mO&i*a8)zq2N#G9Y5n#CFdwhw-*qGxZZ zNnM(8zlmYGE%88jxU7}B9R>4}Pb%bmOYjSKHY&Il~N#SFlVf}YJQ zEPU+9AOPD9{rANMT9aCS!066cpoLI24l5oWf6Sy&aJ}G;prH5R4ct54 zv;}C%13Kdhn%DLscVV*2`d8L}HwNH#CotTsmd~xeqwHd>;uu#x?lu{^uA_34rE%FR zynUIf6dY*pz}Pb`BjB_o0*+*i7sCp{#4z!^di6|YLhID}TojNXwggC0aI1~*8j1U= zu+dz3_z{LnOTRAH&r7LMCOm9*eq1SSI_Ia!k!t7D50ntNBN;s)+o2?CR{kp>@Csx1 zQ)vMxbl_TN5GTYkC1@275IK5J_VMHPfHhk%*`_tDi*I<4-lmOEZJ#7L)$B~Os(fJZ ziLf5qYiEontFR1G6a>Up8vXJ^m(XNqBQM8%yT5%yI<>5`tVdMrZ?Ma18!WMXUbM(oKC z;dZB286@@4LBTktO`7{TPx=n60%s?MqGVF3J!YkkRp5-(oFLp-Fef-GIMA1Kz-ZE+ z^2PWfK$zE)*Ad%4*4&@_g>ls{GC{UsH1VBtRsV2w*TUz5a9(c#AUM}VqcOZc{t{}Q z)l))30Q)YS{P-uKsQ!(IC{ylj@l$@CBLKqH_0*Px(ZAC%QDr+I)X|44h>=_GVQDL< z4_ZUmo>_k~$>~g*W-pu59pngseFrfKRv?X^Ros44k2M#HuFPge2y~ym1e`8@zrDZX z1+it${6rbTxf+Q4u{P`iM#ahuniH>J0GIE^&45qp9n{#r-B^*?(iTG^2_GN|*gYBPo&T~Vlmu#} z*|gG|0m(Xlf9)vPgRI#p;iaZG3%9(OdnP7<3dU73W$IDw?eD<2KgJ zgs$dS;DxRo#X3Co78@wp8O1S^s%D;SGmJHnA*{?c`?z&>9W-!U%;UfK;Q&jx83Jb3 zb3lHt80xjzvpFLl&juOp9VuGlG$B>*4XVP8auhtDuO8 zkdxIMcVp72m|D}oJ`=-EkpdQN+6j_vQy9uRIr%4Vuhim#wc9F~vFf6&qsKVtbT8G) zx$(=4bjY4EAeZb!t&n>8lVi<`|G-><8Q?Y)%$A97go3&2ZX%vZ5KUO(ivu{k5hYD8 zz1rs+;`5oLXEx5CwAg1$w>~km1qa@4`lu4rlUw7+t%=~_RqG0~uK-`%;1Ngr!x_&g z@D45*CkRQ4ie@*I(+Iil*Cz_*oXmT_874~CT5Aw@rquZ|{(`3OhTiU%FWrJ(XI|Icw^M z(FAMEe#t9+)LvXHG-_UOG=WC&Y0>+|{%_lO{hyx|`S-&Cq7>rGf7`|yyJ~nE=--Z< zIpG#)s?yZxy26{dpcEQ(ur_vj#JIS!6zJmBvlN{On~dEZ8^V8qf^W+ieP=04SVp{L zq8?=dOIhD!-@Xetc?&L*0q^L4>Q`fa2m6*Z6}RwJ85h* zww-*jZQE93+qTWdR&%;9&c)vUVLi`WbBr0WJ$0(TxqLxS^PB(X3S47h2m_CvjB zB7?Uy=zA>A7`#0RX!R2 z;o7Nr!cluI)=i!ozV4x|SQ56Da&V@1u$d0BagE$bBP#08#J&lWbU)&!rc7e3I~{2p zv>JsLOVU5L%K0_>gq*5Ae$T{uIB)?>`=$!3b6 zTBrT0a5kLQ{}wuon7oC4YIu}NA+T$WH1WB9m@J^_w9R9wH!9dFjqL{|-}QX`l~Cqh zn3l`wDa!&IM_uY*vogsvuKP^?d#mjpm=4Dc@jtCVC0q1*SB`!Yjhs9C?}@n`Bt1Fp zV*T}kFyfM_3%2|Uu2jB~*Q?mAgIp_l{N=_`YnkiB@F>4nE!Io3cK)#Tp1hpwR^E8& zT?YWh!J(*VRBJrQ#MaIz|88r^64~8Sf%j9(dW31rMA=;Cqxnz1x874+v$66THzFs? z!>mmj$Zc>4#u}6J=kL*yd?vE@kl`P%9rj6onBH0hFL0v6AGkHz0fhXAUYw?;=8zjO z^d-4w1n#wK>L)1HeTl&vRN_xr_q^N)2}U5M@`63zK0QO~5NWEMsa;7=N$n)3-j=$*Wn9dn+^T7noK(ucN@W9% z47Md5UMq809N9y}eC0a>Qbri^=ec`jhgpjp1}K*=;i2ZRh78$@XK2@j9-?26bFbfh z@asnq(O!^{o6ec_1i{t-BvJ{?!ebL+_4Fhe>?3E%7gxBrt9P`#0#IO-(?Y&j{5p?zJ- zoyysAuntO>Ym}of{o_W6edLMd73CSc8TRBgfo^1GKkPqlyF2|l6F6ky&M27V3#Ts@2vRIH*{iygOb~`f|oexMToOL4dkot;ZCLlfShXg?hY3*`P zTPqH5L{fWfRTDiz{0lCUolF#xtkXAcM2ktfHj6s;R%@uDQE#%2H2!*o^r=V~dxjJ1 z*vlm3mzr}qwm%(ZJYWoF$kB!uSiyQpxu?wIMjE1nUQT&lbxnl>89fa6JIuk?p70+P z2a>f0k(R0`6gy|9hk8(GZh+=nqjC41XK@MNgbS8@$^1~qzE!+aQSJtzD1j0Bk(-$| zIr8diKlRD6&y3?Zcm&d@o7{?N805=PMbXQz`|ck-X(-7=>iD_LI;WHRBk&Snp1-|3 z*rJ%TI6{JcYq$S+T?WWqsw-Zc81u)EL(2|Qe zE*ENq>O|eRvg$TDIrS~W6eq@WWJy@}de}C{sV=?BxxQjmts0_MjZPrh&%mFq+Db0j z*{`b?#d`s44Rzg7b12!*45f?JVHY3XgBpKIG8)Eh@9}$9YVy|DB1;jQpZ`>%?2%u` zo@dR7o}5LTW!8rFk;w@8hSLEJ#ygD5dMC(k4{A4urO9-M_Op%TXtJ zULnG0+8z1?5+54IVAqFLQOMJ0QAYYi`rYaUf=?M3=rOV;)aXQK=exsgN0BHYB&p}+ z{W(IbecGka*X=1FDGA{f(M{ERjkb^a=EqxXH_MVWM5r;8+Zxzouy3bwqYx(>0;(s* zxJ^-slyA3(pMbR%MJkp+QnW0|Cif+g#}`^&X!ib0=#DqIrx@rj#SBf|%`BpA@P5zH z8g0(csXG5dH4tJRx1cRVzR>=Rks$x(?T1hO*ZpJPMb zKvq;rmqeaa;-vxGL|5#bA5=U$i^A0>m`4xeb!P4Sbk>wj%`(~TYJTzextmh6Az11p z^E%V}*5^6L>#FS}=RViz>bL&aloKP$9L--P>Lp+fa6c6|>)}29Y%%vOpZ#(l6(e*% zb$Clo^_A#I(ZJque1c6pR9G~+y#=BW<@0c__ zx(vWc^}G8i0>8rE{m?V$93Ar1&pEpL+04$(fu&AiRyNp`3Z0YuC7o-M+uDG@mVm^Gfm67L>0tdcME^L5M z9;aNzjLZbb!1&JJd3U$HiOXnkax~9&ScvZWdV6uJvD#~8`Dt6Rt`yfg+v~x{^Os62 z0!PTCF&X>jq{=czY_Tk#sqIpsg*k@VUGtOO>g;w0E!yVx^q>%w5*yRh`sRj{s+|{A zQ)M++1AhOn*_!Ioj*hNsM4mtAaIV1b=ZELZb68hbNRi7lO~U^DBXrrn+fObRk<35Z z3UBue9b$sBZx8Jc?0+IkL=S&T@x}j0h|YFI$)Lee_5jU5^sQ?RWrBlNO2JOS3IWRNUR~Uz;ewb>#+%A(%H) z#f*>}gUf$=h7{&RH=%2%XW87=5vxQGMqNFe+LEr7UdQ0{&)o{~wW}(K53W*hPsKxj zcb%4P_K&!SJgE1n6E@F~N>M+__H-=p7-Cg!0~t6J^4_Sv-V}}@Pk`rFAW`sEbvXNh z(+Tkc7ZdOcU)DHwSx45lTiFwEy=H=(IzB_&OKONKN4y&1rk2|a>R+LS$8yQu@}F6M z=a@Nt*nwy;Ydk=!h3@6O`zq_z)RHP|gGR!OfG3?VIcCGYiLvY}3bEOW3$PX#f^V$v z;V_?w9>nDkEeJ^}JKd|BC6ua)Lmy+XE}E2_OyR4vrzcwXHJFtQlcED^Mz64=(#4re zBnG-HT5O@I4>W&2w5fYf>KjuTj^$+H?#7Pes4$85vIQ523WC{t$(+TdR!d#gX z>-!e<5Cs^`etP%!OIM=fG2glrVR4w*`Rp9I(FixK(tP5TNORc#=_E7$4h-Y=y*W+k zl9@j`^J9(L$xtRBXiR~?`VT4cVnpoEu~W2nmxA3AGe{9FXooD*^SyXgoG8In2vd zwy_A~#_d(@k~Q>d9JC<_3tCBkm?z^obvlV+87<(&>a`2mpnQR;xJgaDAsh<0%7*M@ z15=@nR?4*+%0lEmHjY@@9pMBA8-haZ0@!R1586ZB0%iGLlhM&+$)dosGFzNaE}1O- zP3_>3l$6LZnkot+XMi_+;RSYZ%-$eFSyv@MVzwElzOJ>%z1m-QoR+fGk=2dY1pRZ~ zohG-Hfs2#G78D2!gia-=W$cVA&o}p+SZY3VsW=2t^ANsucAQ1JjnRrbvPJ5|*%H%N ze1VJ>80N5iF!7Wu^g5H$R+9M{nuFud%5>W_%yByfyHjvW+^u>LdvAjS1R(xf(0}H# z{v{(^eo=nN8P3J%nz=D!d&Be5D~}~ z46>pkz{LOCYFPjB5(-TtFD{Z{yJlG|oT*Va6{vwiTo3rR;sK<~^omr5wp?OsMEhAS?(=bMc_|KrgcSOILA8 zal2i)CmrS5n){rG?08?f=u$>bE)8nzRS zR-At7_(`6UW1gH6x&I;!gFBtPfoR=zgHE7E-#}R2iNMPO<^9rraRAwDXbvg1Xq==uFW(SZ8Z|vW8mc9X6 zWX&%j|2~>q!a_GRuh~-5CidJIch{5EuLZaYx!fq2H4^_^XYBC*Vf|F^ zZ4%GMQ&K&a%6$3C_cd^A5G84?@6Gt(W`X?cPZ~B)8#o>Ovgd44&nTU%@a;sN*pdy) zo_wCs9orQ_1f_(FQv{$U_WdhA%(mpdEC$}F-JkccRQnX^tp!C1#wQD7*5)C6^X12I z?j$Y%d!TR|3i-8_@I^2`+mqTI_9T<{hlqpg zmcF+9sQnF9#W4Wy*P*vK^G@h;Amf}EYoyx3=joEhp9c^=sxLrGg`vf44HY(NG)J+| z|F?U2U_kV$f4xSVN0tuQufwaVu{g&Bm6DqFM3r%*Zb*E@1)0OknrZfV29iRO0Y;K6h1VcKwT!0*Za171EDtI+fsc@_|X>g|s zNk=>k9ZiZ0E6-{Lz%bU&j#34iXzzv_W z2D_9C?6=D=)@M#tf14cpSP_CZZ%J}Xf0&xQpY15NS`vU$89J3k;ZakLWw|a+-q1Sf zNppMF#yOe1wDEPAbLJ@w6t{^&-U#_r;o65=9~Hwp-A@0E@GGYUMy)A2`cmpuC`d$*xH`Q(~S z)I#_{A-VTwlQ$upw&Un*STJ3R3SNO8*A%K2k*2wUtpq|}{&)nn0b`9yM^+?Z1=mk+ zO0_MZYB0qslkYW?8q|d4XFKz1B7EPGyaoaeW=>7tV37Vg8P7eR5q*+wfymh&iaDd^ zN^smWa}TmP({jw(bfT=O865K){6a@r$6BUd<&vX>eueAMk(u!?Mavj8$KykMSd*Dq zfD8K~Hh(7ZG~pb<<_I*)x@IPgFAbF0CNnd; z(AwglQw8@c1&g4g+(vo)r^eALl*>f&SI|6l^EuEwmGfJSL19sOkmpcAzGQXi+8D|* z{O+Wc_>+=gvg!>I{!pu(M$`%0DGK?7GHTj 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 VwlMN}`%er%aGR6olj~j${vQ;P=LY}) diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 070cb702f..0c85a1f75 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip +networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index a69d9cb6c..65dcd68d6 100755 --- a/gradlew +++ b/gradlew @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,10 +80,10 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' @@ -143,12 +143,16 @@ fi if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac diff --git a/gradlew.bat b/gradlew.bat index 53a6b238d..6689b85be 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -26,6 +26,7 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% diff --git a/kokoro/build.sh b/kokoro/build.sh index 5c50a814f..2036d1e07 100755 --- a/kokoro/build.sh +++ b/kokoro/build.sh @@ -36,7 +36,7 @@ echo y | ${ANDROID_HOME}/tools/bin/sdkmanager --licenses cd $KOKORO_ARTIFACTS_DIR/git/nowinandroid # The build needs Java 11, set it as the default Java version. -sudo update-java-alternatives --set java-1.11.0-openjdk-amd64 +sudo update-java-alternatives --set java-1.17.0-openjdk-amd64 # Also clear JAVA_HOME variable so java -version is used instead export JAVA_HOME= diff --git a/lint/build.gradle.kts b/lint/build.gradle.kts index bbf24126d..08ab9bec4 100644 --- a/lint/build.gradle.kts +++ b/lint/build.gradle.kts @@ -13,6 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + plugins { `java-library` kotlin("jvm") @@ -24,6 +27,12 @@ java { targetCompatibility = JavaVersion.VERSION_11 } +tasks.withType().configureEach { + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } +} + dependencies { compileOnly(libs.kotlin.stdlib) compileOnly(libs.lint.api) From 96a0449ba963da5ecfd660c8e4390784e4e7bc1b Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Fri, 14 Apr 2023 12:15:51 -0700 Subject: [PATCH 011/107] Add dontwarn for proguard rules Change-Id: I316513bd13b30cfcdbde0caba1d9c669815601f2 --- app/proguard-rules.pro | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 1ea4feef3..41012b47a 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -24,3 +24,13 @@ # @Serializable and @Polymorphic are used at runtime for polymorphic serialization. -keepattributes RuntimeVisibleAnnotations,AnnotationDefault + +-dontwarn org.bouncycastle.jsse.BCSSLParameters +-dontwarn org.bouncycastle.jsse.BCSSLSocket +-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider +-dontwarn org.conscrypt.Conscrypt$Version +-dontwarn org.conscrypt.Conscrypt +-dontwarn org.conscrypt.ConscryptHostnameVerifier +-dontwarn org.openjsse.javax.net.ssl.SSLParameters +-dontwarn org.openjsse.javax.net.ssl.SSLSocket +-dontwarn org.openjsse.net.ssl.OpenJSSE From 1713f14f53f22b861b181e0ef6f8eff00e82b4a1 Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Fri, 14 Apr 2023 12:16:20 -0700 Subject: [PATCH 012/107] Update Java version name Change-Id: Ife3d1c2defe8e8e7eac67e53fe5813cd3fd267a9 --- kokoro/build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kokoro/build.sh b/kokoro/build.sh index 2036d1e07..c217e995c 100755 --- a/kokoro/build.sh +++ b/kokoro/build.sh @@ -35,7 +35,7 @@ echo y | ${ANDROID_HOME}/tools/bin/sdkmanager --licenses cd $KOKORO_ARTIFACTS_DIR/git/nowinandroid -# The build needs Java 11, set it as the default Java version. +# The build needs Java 17, set it as the default Java version. sudo update-java-alternatives --set java-1.17.0-openjdk-amd64 # Also clear JAVA_HOME variable so java -version is used instead From 38260e5a86d7bbe79d8c438053db5a06aabcedbf Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Fri, 14 Apr 2023 12:17:31 -0700 Subject: [PATCH 013/107] Update to VERSION_17 everywhere Change-Id: I3e75e8a833f45365657a24c4af652c2449af804c --- build-logic/convention/build.gradle.kts | 6 +++--- .../com/google/samples/apps/nowinandroid/KotlinAndroid.kt | 6 +++--- lint/build.gradle.kts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 5408b218a..4da2b86dd 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -23,13 +23,13 @@ plugins { group = "com.google.samples.apps.nowinandroid.buildlogic" java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } tasks.withType().configureEach { kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + jvmTarget = JavaVersion.VERSION_17.toString() } } diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index bf510a2c4..719fbd399 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -42,8 +42,8 @@ internal fun Project.configureKotlinAndroid( } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true } } @@ -52,7 +52,7 @@ internal fun Project.configureKotlinAndroid( tasks.withType().configureEach { kotlinOptions { // Set JVM target to 11 - jvmTarget = JavaVersion.VERSION_11.toString() + jvmTarget = JavaVersion.VERSION_17.toString() // Treat all Kotlin warnings as errors (disabled by default) // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties val warningsAsErrors: String? by project diff --git a/lint/build.gradle.kts b/lint/build.gradle.kts index 08ab9bec4..b665ba8a7 100644 --- a/lint/build.gradle.kts +++ b/lint/build.gradle.kts @@ -23,13 +23,13 @@ plugins { } java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } tasks.withType().configureEach { kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + jvmTarget = JavaVersion.VERSION_17.toString() } } From e323c2155fd6fdf4a383af37f7a4343ea837789b Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Fri, 14 Apr 2023 12:18:15 -0700 Subject: [PATCH 014/107] Remove unused imports in build-config Change-Id: I9fa6da0490fd9dc43cf5ed21aaa770c76b461a55 --- .../com/google/samples/apps/nowinandroid/KotlinAndroid.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index 719fbd399..701ef4e5c 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -20,12 +20,10 @@ import com.android.build.api.dsl.CommonExtension import org.gradle.api.JavaVersion import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension -import org.gradle.api.plugins.ExtensionAware import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.provideDelegate import org.gradle.kotlin.dsl.withType -import org.jetbrains.kotlin.gradle.dsl.KotlinJvmOptions import org.jetbrains.kotlin.gradle.tasks.KotlinCompile /** From 4cdfaa93b2e6a8a421afc19a5b71517b55b05d08 Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Fri, 14 Apr 2023 14:23:09 -0700 Subject: [PATCH 015/107] Fix comment referring to 11 Change-Id: Ibef7bf42f60dd132535e70a7619958b242b7afe9 --- .../com/google/samples/apps/nowinandroid/KotlinAndroid.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index 701ef4e5c..d4cb010a2 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -49,7 +49,7 @@ internal fun Project.configureKotlinAndroid( // Use withType to workaround https://youtrack.jetbrains.com/issue/KT-55947 tasks.withType().configureEach { kotlinOptions { - // Set JVM target to 11 + // Set JVM target to 17 jvmTarget = JavaVersion.VERSION_17.toString() // Treat all Kotlin warnings as errors (disabled by default) // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties From 05be2855d86f3070aceb1c67be67b424ddec7050 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 4 Apr 2023 09:24:32 -0400 Subject: [PATCH 016/107] Wire up backend requested sync Change-Id: I1d4485b589c7e94527a2a02f371cd3f030231622 --- .../repository/OfflineFirstNewsRepository.kt | 49 ++++---- .../OfflineFirstNewsRepositoryTest.kt | 106 ++++++++++++++---- .../data/testdoubles/TestNewsResourceDao.kt | 36 +++--- .../core/database/dao/NewsResourceDao.kt | 7 -- .../apps/nowinandroid/sync/di/SyncModule.kt | 7 ++ .../sync/initializers/SyncWorkHelpers.kt | 1 + .../sync/services/SyncNotificationsService.kt | 3 +- .../sync/status/StubSyncSubscriber.kt | 31 +++++ .../sync/status/SyncSubscriber.kt | 24 ++++ .../nowinandroid/sync/workers/SyncWorker.kt | 4 + .../apps/nowinandroid/sync/di/SyncModule.kt | 51 +++++++++ .../sync/status/FirebaseSyncSubscriber.kt | 35 ++++++ 12 files changed, 291 insertions(+), 63 deletions(-) rename sync/work/src/{main => demo}/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt (81%) create mode 100644 sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/StubSyncSubscriber.kt create mode 100644 sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/SyncSubscriber.kt create mode 100644 sync/work/src/prod/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt create mode 100644 sync/work/src/prod/java/com/google/samples/apps/nowinandroid/sync/status/FirebaseSyncSubscriber.kt diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt index 02c58d855..27590b0b7 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt @@ -27,6 +27,7 @@ import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsRes import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions +import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource @@ -45,6 +46,7 @@ private const val SYNC_BATCH_SIZE = 40 * Reads are exclusively from local storage to support offline access. */ class OfflineFirstNewsRepository @Inject constructor( + private val niaPreferencesDataSource: NiaPreferencesDataSource, private val newsResourceDao: NewsResourceDao, private val topicDao: TopicDao, private val network: NiaNetworkDataSource, @@ -72,15 +74,25 @@ class OfflineFirstNewsRepository @Inject constructor( }, modelDeleter = newsResourceDao::deleteNewsResources, modelUpdater = { changedIds -> + val userData = niaPreferencesDataSource.userData.first() + val hasOnBoarded = userData.shouldHideOnboarding + val followedTopicIds = userData.followedTopics + // TODO: Make this more efficient, there is no need to retrieve populated // news resources when all that's needed are the ids - val existingNewsResourceIds = newsResourceDao.getNewsResources( - useFilterNewsIds = true, - filterNewsIds = changedIds.toSet(), - ) - .first() - .map { it.entity.id } - .toSet() + val existingFollowedChangedNewsResourceIds = when { + hasOnBoarded -> newsResourceDao.getNewsResources( + useFilterTopicIds = true, + filterTopicIds = followedTopicIds, + useFilterNewsIds = true, + filterNewsIds = changedIds.toSet(), + ) + .first() + .map { it.entity.id } + .toSet() + // No need to retrieve anything if notifications won't be sent + else -> emptySet() + } changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds -> val networkNewsResources = network.getNewsResources(ids = chunkedIds) @@ -106,19 +118,18 @@ class OfflineFirstNewsRepository @Inject constructor( ) } - val addedNewsResources = newsResourceDao.getNewsResources( - useFilterNewsIds = true, - filterNewsIds = changedIds.toSet(), - ) - .first() - .filter { !existingNewsResourceIds.contains(it.entity.id) } - .map(PopulatedNewsResource::asExternalModel) + if (hasOnBoarded) { + val addedNewsResources = newsResourceDao.getNewsResources( + useFilterTopicIds = true, + filterTopicIds = followedTopicIds, + useFilterNewsIds = true, + filterNewsIds = changedIds.toSet() - existingFollowedChangedNewsResourceIds, + ) + .first() + .map(PopulatedNewsResource::asExternalModel) - // TODO: Define business logic for notifications on first time sync. - // we probably do not want to send notifications on first install. - // We can easily check if the change list version is 0 and not send notifications - // if it is. - if (addedNewsResources.isNotEmpty()) notifier.onNewsAdded(addedNewsResources) + if (addedNewsResources.isNotEmpty()) notifier.onNewsAdded(addedNewsResources) + } }, ) } diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt index 6cdbf67d0..9e6834f8f 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt @@ -34,6 +34,7 @@ import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.testing.notifications.TestNotifier @@ -46,6 +47,7 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import kotlin.test.assertEquals +import kotlin.test.assertTrue class OfflineFirstNewsRepositoryTest { @@ -53,6 +55,8 @@ class OfflineFirstNewsRepositoryTest { private lateinit var subject: OfflineFirstNewsRepository + private lateinit var niaPreferencesDataSource: NiaPreferencesDataSource + private lateinit var newsResourceDao: TestNewsResourceDao private lateinit var topicDao: TestTopicDao @@ -68,17 +72,19 @@ class OfflineFirstNewsRepositoryTest { @Before fun setup() { + niaPreferencesDataSource = NiaPreferencesDataSource( + tmpFolder.testUserPreferencesDataStore(testScope), + ) newsResourceDao = TestNewsResourceDao() topicDao = TestTopicDao() network = TestNiaNetworkDataSource() notifier = TestNotifier() synchronizer = TestSynchronizer( - NiaPreferencesDataSource( - tmpFolder.testUserPreferencesDataStore(testScope), - ), + niaPreferencesDataSource, ) subject = OfflineFirstNewsRepository( + niaPreferencesDataSource = niaPreferencesDataSource, newsResourceDao = newsResourceDao, topicDao = topicDao, network = network, @@ -130,6 +136,8 @@ class OfflineFirstNewsRepositoryTest { @Test fun offlineFirstNewsRepository_sync_pulls_from_network() = testScope.runTest { + // User has not onboarded + niaPreferencesDataSource.setShouldHideOnboarding(false) subject.syncWith(synchronizer) val newsResourcesFromNetwork = network.getNewsResources() @@ -151,16 +159,16 @@ class OfflineFirstNewsRepositoryTest { actual = synchronizer.getChangeListVersions().newsResourceVersion, ) - // Notifier should have been called with new news resources - assertEquals( - expected = newsResourcesFromDb.map(NewsResource::id).sorted(), - actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(), - ) + // Notifier should not have been called + assertTrue(notifier.addedNewsResources.isEmpty()) } @Test fun offlineFirstNewsRepository_sync_deletes_items_marked_deleted_on_network() = testScope.runTest { + // User has not onboarded + niaPreferencesDataSource.setShouldHideOnboarding(false) + val newsResourcesFromNetwork = network.getNewsResources() .map(NetworkNewsResource::asEntity) .map(NewsResourceEntity::asExternalModel) @@ -198,17 +206,16 @@ class OfflineFirstNewsRepositoryTest { actual = synchronizer.getChangeListVersions().newsResourceVersion, ) - // Notifier should have been called with news resources from network that are not - // deleted - assertEquals( - expected = (newsResourcesFromNetwork.map(NewsResource::id) - deletedItems).sorted(), - actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(), - ) + // Notifier should not have been called + assertTrue(notifier.addedNewsResources.isEmpty()) } @Test fun offlineFirstNewsRepository_incremental_sync_pulls_from_network() = testScope.runTest { + // User has not onboarded + niaPreferencesDataSource.setShouldHideOnboarding(false) + // Set news version to 7 synchronizer.updateChangeListVersions { copy(newsResourceVersion = 7) @@ -244,11 +251,8 @@ class OfflineFirstNewsRepositoryTest { actual = synchronizer.getChangeListVersions().newsResourceVersion, ) - // Notifier should have been called with only added news resources from network - assertEquals( - expected = newsResourcesFromNetwork.map(NewsResource::id).sorted(), - actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(), - ) + // Notifier should not have been called + assertTrue(notifier.addedNewsResources.isEmpty()) } @Test @@ -283,4 +287,68 @@ class OfflineFirstNewsRepositoryTest { .sortedBy(NewsResourceTopicCrossRef::toString), ) } + + @Test + fun offlineFirstNewsRepository_sends_notifications_for_newly_synced_news_that_is_followed() = + testScope.runTest { + // User has onboarded + niaPreferencesDataSource.setShouldHideOnboarding(true) + + val networkNewsResources = network.getNewsResources() + + // Follow roughly half the topics + val followedTopicIds = networkNewsResources + .flatMap(NetworkNewsResource::topicEntityShells) + .mapNotNull { topic -> + when (topic.id.chars().sum() % 2) { + 0 -> topic.id + else -> null + } + } + .toSet() + + // Set followed topics + niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds) + + subject.syncWith(synchronizer) + + // Notifier should have been called with only news resources that have topics + // that the user follows + assertEquals( + expected = networkNewsResources + .filter { (it.topics intersect followedTopicIds).isNotEmpty() } + .map(NetworkNewsResource::id) + .sorted(), + actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(), + ) + } + + @Test + fun offlineFirstNewsRepository_does_not_send_notifications_for_existing_news_resources() = + testScope.runTest { + // User has onboarded + niaPreferencesDataSource.setShouldHideOnboarding(true) + + val networkNewsResources = network.getNewsResources() + .map(NetworkNewsResource::asEntity) + + val newsResources = networkNewsResources + .map(NewsResourceEntity::asExternalModel) + + // Prepopulate dao with news resources + newsResourceDao.upsertNewsResources(networkNewsResources) + + val followedTopicIds = newsResources + .flatMap(NewsResource::topics) + .map(Topic::id) + .toSet() + + // Follow all topics + niaPreferencesDataSource.setFollowedTopicIds(followedTopicIds) + + subject.syncWith(synchronizer) + + // Notifier should not have been called bc all news resources existed previously + assertTrue(notifier.addedNewsResources.isEmpty()) + } } diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt index bb1ac20ab..d5d8932e7 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt @@ -47,7 +47,11 @@ class TestNewsResourceDao : NewsResourceDao { filterNewsIds: Set, ): Flow> = entitiesStateFlow - .map { it.map(NewsResourceEntity::asPopulatedNewsResource) } + .map { newsResourceEntities -> + newsResourceEntities.map { entity -> + entity.asPopulatedNewsResource(topicCrossReferences) + } + } .map { resources -> var result = resources if (useFilterTopicIds) { @@ -78,10 +82,6 @@ class TestNewsResourceDao : NewsResourceDao { return entities.map { it.id.toLong() } } - override suspend fun updateNewsResources(entities: List) { - throw NotImplementedError("Unused in tests") - } - override suspend fun upsertNewsResources(newsResourceEntities: List) { entitiesStateFlow.update { oldValues -> // New values come first so they overwrite old values @@ -109,16 +109,20 @@ class TestNewsResourceDao : NewsResourceDao { } } -private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource( +private fun NewsResourceEntity.asPopulatedNewsResource( + topicCrossReferences: List, +) = PopulatedNewsResource( entity = this, - topics = listOf( - TopicEntity( - id = filteredInterestsIds.random(), - name = "name", - shortDescription = "short description", - longDescription = "long description", - url = "URL", - imageUrl = "image URL", - ), - ), + topics = topicCrossReferences + .filter { it.newsResourceId == id } + .map { newsResourceTopicCrossRef -> + TopicEntity( + id = newsResourceTopicCrossRef.topicId, + name = "name", + shortDescription = "short description", + longDescription = "long description", + url = "URL", + imageUrl = "image URL", + ) + }, ) diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt index 782e5c87a..a05507a8b 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt @@ -21,7 +21,6 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction -import androidx.room.Update import androidx.room.Upsert import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef @@ -72,12 +71,6 @@ interface NewsResourceDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insertOrIgnoreNewsResources(entities: List): List - /** - * Updates [entities] in the db that match the primary key, and no-ops if they don't - */ - @Update - suspend fun updateNewsResources(entities: List) - /** * Inserts or updates [newsResourceEntities] in the db under the specified primary keys */ diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt b/sync/work/src/demo/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt similarity index 81% rename from sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt rename to sync/work/src/demo/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt index bbc45dc42..40d094cd2 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt +++ b/sync/work/src/demo/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt @@ -17,6 +17,8 @@ package com.google.samples.apps.nowinandroid.sync.di import com.google.samples.apps.nowinandroid.core.data.util.SyncManager +import com.google.samples.apps.nowinandroid.sync.status.StubSyncSubscriber +import com.google.samples.apps.nowinandroid.sync.status.SyncSubscriber import com.google.samples.apps.nowinandroid.sync.status.WorkManagerSyncManager import dagger.Binds import dagger.Module @@ -30,4 +32,9 @@ interface SyncModule { fun bindsSyncStatusMonitor( syncStatusMonitor: WorkManagerSyncManager, ): SyncManager + + @Binds + fun bindsSyncSubscriber( + syncSubscriber: StubSyncSubscriber, + ): SyncSubscriber } diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt index 334b3f0c7..a3cff5fb9 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt @@ -27,6 +27,7 @@ import androidx.work.ForegroundInfo import androidx.work.NetworkType import com.google.samples.apps.nowinandroid.sync.R +const val SYNC_TOPIC = "sync" private const val SyncNotificationId = 0 private const val SyncNotificationChannelID = "SyncNotificationChannel" diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt index ab318776a..1d182dda1 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt @@ -19,11 +19,10 @@ package com.google.samples.apps.nowinandroid.sync.services import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.google.samples.apps.nowinandroid.core.data.util.SyncManager +import com.google.samples.apps.nowinandroid.sync.initializers.SYNC_TOPIC import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject -private const val SYNC_TOPIC = "sync" - @AndroidEntryPoint class SyncNotificationsService : FirebaseMessagingService() { diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/StubSyncSubscriber.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/StubSyncSubscriber.kt new file mode 100644 index 000000000..0ef90fb29 --- /dev/null +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/StubSyncSubscriber.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.sync.status + +import android.util.Log +import javax.inject.Inject + +private const val TAG = "StubSyncSubscriber" + +/** + * Stub implementation of [SyncSubscriber] + */ +class StubSyncSubscriber @Inject constructor() : SyncSubscriber { + override suspend fun subscribe() { + Log.d(TAG, "Subscribing to sync") + } +} diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/SyncSubscriber.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/SyncSubscriber.kt new file mode 100644 index 000000000..b1845b070 --- /dev/null +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/SyncSubscriber.kt @@ -0,0 +1,24 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.sync.status + +/** + * Subscribes to backend requested synchronization + */ +interface SyncSubscriber { + suspend fun subscribe() +} diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt index 211940ddb..d8f1ef91c 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt @@ -34,6 +34,7 @@ import com.google.samples.apps.nowinandroid.core.network.Dispatcher import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.sync.initializers.SyncConstraints import com.google.samples.apps.nowinandroid.sync.initializers.syncForegroundInfo +import com.google.samples.apps.nowinandroid.sync.status.SyncSubscriber import dagger.assisted.Assisted import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineDispatcher @@ -54,6 +55,7 @@ class SyncWorker @AssistedInject constructor( private val newsRepository: NewsRepository, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, private val analyticsHelper: AnalyticsHelper, + private val syncSubscriber: SyncSubscriber, ) : CoroutineWorker(appContext, workerParams), Synchronizer { override suspend fun getForegroundInfo(): ForegroundInfo = @@ -63,6 +65,8 @@ class SyncWorker @AssistedInject constructor( traceAsync("Sync", 0) { analyticsHelper.logSyncStarted() + syncSubscriber.subscribe() + // First sync the repositories in parallel val syncedSuccessfully = awaitAll( async { topicRepository.sync() }, diff --git a/sync/work/src/prod/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt b/sync/work/src/prod/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt new file mode 100644 index 000000000..af4508406 --- /dev/null +++ b/sync/work/src/prod/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.sync.di + +import com.google.firebase.ktx.Firebase +import com.google.firebase.messaging.FirebaseMessaging +import com.google.firebase.messaging.ktx.messaging +import com.google.samples.apps.nowinandroid.core.data.util.SyncManager +import com.google.samples.apps.nowinandroid.sync.status.FirebaseSyncSubscriber +import com.google.samples.apps.nowinandroid.sync.status.SyncSubscriber +import com.google.samples.apps.nowinandroid.sync.status.WorkManagerSyncManager +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface SyncModule { + @Binds + fun bindsSyncStatusMonitor( + syncStatusMonitor: WorkManagerSyncManager, + ): SyncManager + + @Binds + fun bindsSyncSubscriber( + syncSubscriber: FirebaseSyncSubscriber, + ): SyncSubscriber + + companion object { + @Provides + @Singleton + fun provideFirebaseMessaging(): FirebaseMessaging = Firebase.messaging + } +} diff --git a/sync/work/src/prod/java/com/google/samples/apps/nowinandroid/sync/status/FirebaseSyncSubscriber.kt b/sync/work/src/prod/java/com/google/samples/apps/nowinandroid/sync/status/FirebaseSyncSubscriber.kt new file mode 100644 index 000000000..c2405bccc --- /dev/null +++ b/sync/work/src/prod/java/com/google/samples/apps/nowinandroid/sync/status/FirebaseSyncSubscriber.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.sync.status + +import com.google.firebase.messaging.FirebaseMessaging +import com.google.samples.apps.nowinandroid.sync.initializers.SYNC_TOPIC +import kotlinx.coroutines.tasks.await +import javax.inject.Inject + +/** + * Implementation of [SyncSubscriber] that subscribes to the FCM [SYNC_TOPIC] + */ +class FirebaseSyncSubscriber @Inject constructor( + private val firebaseMessaging: FirebaseMessaging, +) : SyncSubscriber { + override suspend fun subscribe() { + firebaseMessaging + .subscribeToTopic(SYNC_TOPIC) + .await() + } +} From bf747434cdc8e1ec64b55499fed0a2116e551acc Mon Sep 17 00:00:00 2001 From: Neelansh Sahai Date: Wed, 29 Mar 2023 01:31:06 +0530 Subject: [PATCH 017/107] Add Undo snackbar on Bookmark removal Change-Id: I1fefd6e72378e26ae35b66e032529a116cff9a79 --- feature/bookmarks/build.gradle.kts | 4 +- .../feature/bookmarks/BookmarksScreen.kt | 67 +++++++++++++++++-- .../feature/bookmarks/BookmarksViewModel.kt | 22 ++++++ .../bookmarks/src/main/res/values/strings.xml | 4 +- 4 files changed, 87 insertions(+), 10 deletions(-) diff --git a/feature/bookmarks/build.gradle.kts b/feature/bookmarks/build.gradle.kts index 5dfd7e014..667e674ec 100644 --- a/feature/bookmarks/build.gradle.kts +++ b/feature/bookmarks/build.gradle.kts @@ -14,8 +14,6 @@ * limitations under the License. */ -import com.android.build.api.dsl.ManagedVirtualDevice - plugins { id("nowinandroid.android.feature") id("nowinandroid.android.library.compose") @@ -28,4 +26,4 @@ android { dependencies { implementation(libs.androidx.compose.material3.windowSizeClass) -} \ No newline at end of file +} diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt index 2ed6a76b3..e2eb4524b 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt @@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks import androidx.annotation.VisibleForTesting import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -34,13 +35,23 @@ import androidx.compose.foundation.lazy.grid.GridCells.Adaptive import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration.Short +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult.ActionPerformed import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -50,6 +61,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaLoadingWheel import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalTintTheme @@ -76,12 +89,16 @@ internal fun BookmarksRoute( onNewsResourceViewed = { viewModel.setNewsResourceViewed(it, true) }, onTopicClick = onTopicClick, modifier = modifier, + shouldDisplayUndoBookmark = viewModel.shouldDisplayUndoBookmark, + undoBookmarkRemoval = viewModel::undoBookmarkRemoval, + clearUndoState = viewModel::clearUndoState, ) } /** * Displays the user's bookmarked articles. Includes support for loading and empty states. */ +@OptIn(ExperimentalMaterial3Api::class) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @Composable internal fun BookmarksScreen( @@ -90,13 +107,51 @@ internal fun BookmarksScreen( onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, modifier: Modifier = Modifier, + shouldDisplayUndoBookmark: Boolean = false, + undoBookmarkRemoval: () -> Unit = {}, + clearUndoState: () -> Unit = {}, ) { - when (feedState) { - Loading -> LoadingState(modifier) - is Success -> if (feedState.feed.isNotEmpty()) { - BookmarksGrid(feedState, removeFromBookmarks, onNewsResourceViewed, onTopicClick, modifier) - } else { - EmptyState(modifier) + val bookmarkRemovedMessage = stringResource(id = R.string.bookmark_removed) + val undoText = stringResource(id = R.string.undo) + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(shouldDisplayUndoBookmark) { + if (shouldDisplayUndoBookmark) { + val snackBarResult = snackbarHostState.showSnackbar( + message = bookmarkRemovedMessage, + actionLabel = undoText, + duration = Short, + ) + when (snackBarResult) { + ActionPerformed -> { undoBookmarkRemoval() } + else -> { clearUndoState() } + } + } + } + + val lifecycleOwner = LocalLifecycleOwner.current + DisposableEffect(lifecycleOwner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_STOP) { + clearUndoState() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + + Scaffold(snackbarHost = { SnackbarHost(hostState = snackbarHostState) }) { + Box( + modifier = Modifier.padding(it).fillMaxSize(), + ) { + when (feedState) { + Loading -> LoadingState(modifier) + is Success -> if (feedState.feed.isNotEmpty()) { + BookmarksGrid(feedState, removeFromBookmarks, onNewsResourceViewed, onTopicClick, modifier) + } else { + EmptyState(modifier) + } + } } } TrackScreenViewEvent(screenName = "Saved") diff --git a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt index 8a1869322..7b6cac76a 100644 --- a/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt +++ b/feature/bookmarks/src/main/java/com/google/samples/apps/nowinandroid/feature/bookmarks/BookmarksViewModel.kt @@ -16,6 +16,9 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository @@ -38,6 +41,9 @@ class BookmarksViewModel @Inject constructor( userNewsResourceRepository: UserNewsResourceRepository, ) : ViewModel() { + var shouldDisplayUndoBookmark by mutableStateOf(false) + private var lastRemovedBookmarkId: String? = null + val feedUiState: StateFlow = userNewsResourceRepository.observeAllBookmarked() .map, NewsFeedUiState>(NewsFeedUiState::Success) @@ -50,6 +56,8 @@ class BookmarksViewModel @Inject constructor( fun removeFromSavedResources(newsResourceId: String) { viewModelScope.launch { + shouldDisplayUndoBookmark = true + lastRemovedBookmarkId = newsResourceId userDataRepository.updateNewsResourceBookmark(newsResourceId, false) } } @@ -59,4 +67,18 @@ class BookmarksViewModel @Inject constructor( userDataRepository.setNewsResourceViewed(newsResourceId, viewed) } } + + fun undoBookmarkRemoval() { + viewModelScope.launch { + lastRemovedBookmarkId?.let { + userDataRepository.updateNewsResourceBookmark(it, true) + } + } + clearUndoState() + } + + fun clearUndoState() { + shouldDisplayUndoBookmark = false + lastRemovedBookmarkId = null + } } diff --git a/feature/bookmarks/src/main/res/values/strings.xml b/feature/bookmarks/src/main/res/values/strings.xml index 61781ad6e..2dd36659e 100644 --- a/feature/bookmarks/src/main/res/values/strings.xml +++ b/feature/bookmarks/src/main/res/values/strings.xml @@ -22,4 +22,6 @@ Menu No saved updates Updates you save will be stored here\nto read later - \ No newline at end of file + Bookmark removed + UNDO + From 80b8ae7a98b616031673d6036a93510840026261 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 22 Apr 2023 13:53:10 +0100 Subject: [PATCH 018/107] Don't rely on `finalizeDsl` API to configure Crashlytics --- ...droidApplicationFirebaseConventionPlugin.kt | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt index 598da727d..7b3a0059f 100644 --- a/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidApplicationFirebaseConventionPlugin.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -import com.android.build.api.variant.ApplicationAndroidComponentsExtension +import com.android.build.api.dsl.ApplicationExtension import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension import org.gradle.api.Plugin import org.gradle.api.Project @@ -41,15 +41,13 @@ class AndroidApplicationFirebaseConventionPlugin : Plugin { "implementation"(libs.findLibrary("firebase.crashlytics").get()) } - extensions.configure { - finalizeDsl { - it.buildTypes.forEach { buildType -> - // Disable the Crashlytics mapping file upload. This feature should only be - // enabled if a Firebase backend is available and configured in - // google-services.json. - buildType.configure { - mappingFileUploadEnabled = false - } + extensions.configure { + buildTypes.configureEach { + // Disable the Crashlytics mapping file upload. This feature should only be + // enabled if a Firebase backend is available and configured in + // google-services.json. + configure { + mappingFileUploadEnabled = false } } } From 091abad348bc5ff748ec662cf5dbf0990c10446e Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 22 Apr 2023 14:14:05 +0100 Subject: [PATCH 019/107] Simplify String resource loading in NavigationTest.kt --- .../apps/nowinandroid/ui/NavigationTest.kt | 46 +++++++------------ 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt index a37eb7a77..382bd9a46 100644 --- a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt @@ -16,12 +16,14 @@ package com.google.samples.apps.nowinandroid.ui +import androidx.annotation.StringRes import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithContentDescription @@ -34,10 +36,10 @@ import com.google.samples.apps.nowinandroid.R import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest -import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder +import kotlin.properties.ReadOnlyProperty import com.google.samples.apps.nowinandroid.feature.bookmarks.R as BookmarksR import com.google.samples.apps.nowinandroid.feature.foryou.R as FeatureForyouR import com.google.samples.apps.nowinandroid.feature.interests.R as FeatureInterestsR @@ -69,35 +71,21 @@ class NavigationTest { @get:Rule(order = 2) val composeTestRule = createAndroidComposeRule() - // The strings used for matching in these tests - private lateinit var done: String - private lateinit var navigateUp: String - private lateinit var forYouLoading: String - private lateinit var forYou: String - private lateinit var interests: String - private lateinit var sampleTopic: String - private lateinit var appName: String - private lateinit var saved: String - private lateinit var settings: String - private lateinit var brand: String - private lateinit var ok: String + private fun AndroidComposeTestRule<*, *>.stringResource(@StringRes resId: Int) = + ReadOnlyProperty { _, _ -> activity.getString(resId) } - @Before - fun setup() { - composeTestRule.activity.apply { - done = getString(FeatureForyouR.string.done) - navigateUp = getString(FeatureForyouR.string.navigate_up) - forYouLoading = getString(FeatureForyouR.string.for_you_loading) - forYou = getString(FeatureForyouR.string.for_you) - interests = getString(FeatureInterestsR.string.interests) - sampleTopic = "Headlines" - appName = getString(R.string.app_name) - saved = getString(BookmarksR.string.saved) - settings = getString(SettingsR.string.top_app_bar_action_icon_description) - brand = getString(SettingsR.string.brand_android) - ok = getString(SettingsR.string.dismiss_dialog_button_text) - } - } + // The strings used for matching in these tests + private val done by composeTestRule.stringResource(FeatureForyouR.string.done) + private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.navigate_up) + private val forYouLoading by composeTestRule.stringResource(FeatureForyouR.string.for_you_loading) + private val forYou by composeTestRule.stringResource(FeatureForyouR.string.for_you) + private val interests by composeTestRule.stringResource(FeatureInterestsR.string.interests) + private val sampleTopic = "Headlines" + private val appName by composeTestRule.stringResource(R.string.app_name) + private val saved by composeTestRule.stringResource(BookmarksR.string.saved) + private val settings by composeTestRule.stringResource(SettingsR.string.top_app_bar_action_icon_description) + private val brand by composeTestRule.stringResource(SettingsR.string.brand_android) + private val ok by composeTestRule.stringResource(SettingsR.string.dismiss_dialog_button_text) @Test fun firstScreen_isForYou() { From aa701064a278aedc4d17775c2a895026a6e6b341 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 22 Apr 2023 15:38:35 +0200 Subject: [PATCH 020/107] Add Application-wide `CoroutineScope` in the DI graph Following the work of #607. --- .../core/network/di/CoroutineScopesModule.kt | 39 +++++++++++++++++++ .../datastore/test/TestDataStoreModule.kt | 7 +--- .../core/datastore/di/DataStoreModule.kt | 6 +-- 3 files changed, 43 insertions(+), 9 deletions(-) create mode 100644 core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt diff --git a/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt b/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt new file mode 100644 index 000000000..bc44e394d --- /dev/null +++ b/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.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.network.di + +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.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object CoroutineScopesModule { + @Provides + @Singleton + @Dispatcher(IO) + fun providesIOCoroutineScope( + @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + ioDispatcher) +} 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 fad7ac382..cb7e38db7 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 @@ -27,9 +27,7 @@ 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 @@ -43,13 +41,12 @@ object TestDataStoreModule { @Provides @Singleton fun providesUserPreferencesDataStore( - @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, + @Dispatcher(IO) ioScope: CoroutineScope, userPreferencesSerializer: UserPreferencesSerializer, tmpFolder: TemporaryFolder, ): DataStore = tmpFolder.testUserPreferencesDataStore( - // TODO: Provide an application-wide CoroutineScope in the DI graph - coroutineScope = CoroutineScope(SupervisorJob() + ioDispatcher), + coroutineScope = ioScope, userPreferencesSerializer = userPreferencesSerializer, ) } diff --git a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/di/DataStoreModule.kt index 895e22307..89a6eb734 100644 --- a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/di/DataStoreModule.kt @@ -30,9 +30,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import javax.inject.Singleton @Module @@ -43,12 +41,12 @@ object DataStoreModule { @Singleton fun providesUserPreferencesDataStore( @ApplicationContext context: Context, - @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, + @Dispatcher(IO) ioScope: CoroutineScope, userPreferencesSerializer: UserPreferencesSerializer, ): DataStore = DataStoreFactory.create( serializer = userPreferencesSerializer, - scope = CoroutineScope(ioDispatcher + SupervisorJob()), + scope = ioScope, migrations = listOf( IntToStringIdsMigration, ), From 5a43b4b74e66670b95b6e821dfed439ea98562ca Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 22 Apr 2023 15:46:15 +0200 Subject: [PATCH 021/107] Run Spotless --- .../core/network/di/CoroutineScopesModule.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt b/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt index bc44e394d..b73c63568 100644 --- a/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt +++ b/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt @@ -1,17 +1,17 @@ /* * 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 + * 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 + * 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. + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. */ package com.google.samples.apps.nowinandroid.core.network.di From 3a810f015bafc37bbc059c039b5e6a9643849a93 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Mon, 24 Apr 2023 20:04:11 +0200 Subject: [PATCH 022/107] Remove unused variables --- .../com/google/samples/apps/nowinandroid/ui/NavigationTest.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt index 382bd9a46..5aa3ab02e 100644 --- a/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt +++ b/app/src/androidTest/java/com/google/samples/apps/nowinandroid/ui/NavigationTest.kt @@ -75,9 +75,7 @@ class NavigationTest { ReadOnlyProperty { _, _ -> activity.getString(resId) } // The strings used for matching in these tests - private val done by composeTestRule.stringResource(FeatureForyouR.string.done) private val navigateUp by composeTestRule.stringResource(FeatureForyouR.string.navigate_up) - private val forYouLoading by composeTestRule.stringResource(FeatureForyouR.string.for_you_loading) private val forYou by composeTestRule.stringResource(FeatureForyouR.string.for_you) private val interests by composeTestRule.stringResource(FeatureInterestsR.string.interests) private val sampleTopic = "Headlines" From 022cd92f4c23a884dbad517965d17b3dd09f2f1d Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 25 Apr 2023 05:38:24 -0400 Subject: [PATCH 023/107] Update core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt Co-authored-by: Don Turner --- .../core/data/repository/OfflineFirstNewsRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt index 27590b0b7..5467deb73 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt @@ -75,7 +75,7 @@ class OfflineFirstNewsRepository @Inject constructor( modelDeleter = newsResourceDao::deleteNewsResources, modelUpdater = { changedIds -> val userData = niaPreferencesDataSource.userData.first() - val hasOnBoarded = userData.shouldHideOnboarding + val hasOnboarded = userData.shouldHideOnboarding val followedTopicIds = userData.followedTopics // TODO: Make this more efficient, there is no need to retrieve populated From 337c940d66b18823807679bb40bba01316ab5d09 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 25 Apr 2023 05:41:30 -0400 Subject: [PATCH 024/107] Add comments explaining backend sync and made variable names easier to grok Change-Id: I90cd7444de95efa20bf243a922a772f7849a23ec --- .../core/data/repository/OfflineFirstNewsRepository.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt index 5467deb73..3e22103b9 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt @@ -80,8 +80,8 @@ class OfflineFirstNewsRepository @Inject constructor( // TODO: Make this more efficient, there is no need to retrieve populated // news resources when all that's needed are the ids - val existingFollowedChangedNewsResourceIds = when { - hasOnBoarded -> newsResourceDao.getNewsResources( + val existingNewsResourceIdsThatHaveChanged = when { + hasOnboarded -> newsResourceDao.getNewsResources( useFilterTopicIds = true, filterTopicIds = followedTopicIds, useFilterNewsIds = true, @@ -94,6 +94,7 @@ class OfflineFirstNewsRepository @Inject constructor( else -> emptySet() } + // Obtain the news resources which have changed from the network and upsert them locally changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds -> val networkNewsResources = network.getNewsResources(ids = chunkedIds) @@ -118,12 +119,12 @@ class OfflineFirstNewsRepository @Inject constructor( ) } - if (hasOnBoarded) { + if (hasOnboarded) { val addedNewsResources = newsResourceDao.getNewsResources( useFilterTopicIds = true, filterTopicIds = followedTopicIds, useFilterNewsIds = true, - filterNewsIds = changedIds.toSet() - existingFollowedChangedNewsResourceIds, + filterNewsIds = changedIds.toSet() - existingNewsResourceIdsThatHaveChanged, ) .first() .map(PopulatedNewsResource::asExternalModel) From f86d174c1bb235bc4bfff9f424013545e4d4b23d Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 25 Apr 2023 05:44:59 -0400 Subject: [PATCH 025/107] Used more explicit variable names in tests Change-Id: I2e0ebead70441eced05eb786aaa7683bfefa8976 --- .../data/repository/OfflineFirstNewsRepositoryTest.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt index 9e6834f8f..d3c1851f9 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt @@ -312,13 +312,15 @@ class OfflineFirstNewsRepositoryTest { subject.syncWith(synchronizer) + val followedNewsResourcesFromNetwork = networkNewsResources + .filter { (it.topics intersect followedTopicIds).isNotEmpty() } + .map(NetworkNewsResource::id) + .sorted() + // Notifier should have been called with only news resources that have topics // that the user follows assertEquals( - expected = networkNewsResources - .filter { (it.topics intersect followedTopicIds).isNotEmpty() } - .map(NetworkNewsResource::id) - .sorted(), + expected = followedNewsResourcesFromNetwork, actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(), ) } From b3f2502ec2846f3c27840c06e20c2062faf836d8 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 25 Apr 2023 06:58:50 -0400 Subject: [PATCH 026/107] Update core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt Co-authored-by: Don Turner --- .../core/data/repository/OfflineFirstNewsRepositoryTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt index d3c1851f9..c24918475 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt @@ -312,7 +312,7 @@ class OfflineFirstNewsRepositoryTest { subject.syncWith(synchronizer) - val followedNewsResourcesFromNetwork = networkNewsResources + val followedNewsResourceIdsFromNetwork = networkNewsResources .filter { (it.topics intersect followedTopicIds).isNotEmpty() } .map(NetworkNewsResource::id) .sorted() From 6b834b6f4c93c8b92e457ba96bd3808bdea0e05e Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Tue, 25 Apr 2023 06:59:34 -0400 Subject: [PATCH 027/107] Applied code review suggestions Change-Id: I12fab8e0d27a8a1805e0063054ec3382bebd78f1 --- .../core/data/repository/OfflineFirstNewsRepositoryTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt index c24918475..a38d9c621 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt @@ -320,7 +320,7 @@ class OfflineFirstNewsRepositoryTest { // Notifier should have been called with only news resources that have topics // that the user follows assertEquals( - expected = followedNewsResourcesFromNetwork, + expected = followedNewsResourceIdsFromNetwork, actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(), ) } From b3cdc172cd7596b98b2696e9fc5d1cbeeff30f3f Mon Sep 17 00:00:00 2001 From: Takeshi Hagikura Date: Wed, 26 Apr 2023 13:13:54 +0900 Subject: [PATCH 028/107] Implement search feature (#685) Implement search feature - Add a feature module named "search" - Add a SearchScreen that is navigated by tapping the search icon at the top left corner - Add a data layer that takes care of populating the *Fts tables and querying them by a search query - Add a SearchViewModel that wires up the data layer of the Fts tables with the SearchScreen The SearchScreen has following features: - The user is able to type the search query in the TextField - The search result is displayed as the user types - When the search result is clicked, it navigates to: - The InterestsScreen when a topic is clicked - Chrome custom tab with the URL of the clicked news resource - When the search result is clicked or the IME is explicitly closed by the user, the current search query in the TextField is saved as recent searches - Latest recent searches are displayed in the SearchScreen --- app/build.gradle.kts | 1 + .../nowinandroid/navigation/NiaNavHost.kt | 12 +- .../samples/apps/nowinandroid/ui/NiaApp.kt | 7 +- .../apps/nowinandroid/ui/NiaAppState.kt | 5 + .../core/data/test/TestDataModule.kt | 14 + .../nowinandroid/core/data/di/DataModule.kt | 14 + .../core/data/model/RecentSearchQuery.kt | 31 + .../DefaultRecentSearchRepository.kt | 55 ++ .../DefaultSearchContentsRepository.kt | 85 +++ .../data/repository/RecentSearchRepository.kt | 41 ++ .../repository/SearchContentsRepository.kt | 38 ++ .../fake/FakeRecentSearchRepository.kt | 35 ++ .../fake/FakeSearchContentsRepository.kt | 33 + .../data/testdoubles/TestNewsResourceDao.kt | 2 + .../core/data/testdoubles/TestTopicDao.kt | 2 + .../13.json | 282 +++++++++ .../14.json | 308 ++++++++++ .../nowinandroid/core/database/DaosModule.kt | 18 + .../nowinandroid/core/database/NiaDatabase.kt | 16 +- .../core/database/dao/NewsResourceDao.kt | 4 + .../core/database/dao/NewsResourceFtsDao.kt | 39 ++ .../core/database/dao/RecentSearchQueryDao.kt | 38 ++ .../core/database/dao/TopicDao.kt | 3 + .../core/database/dao/TopicFtsDao.kt | 39 ++ .../database/model/NewsResourceFtsEntity.kt | 44 ++ .../database/model/PopulatedNewsResource.kt | 6 + .../database/model/RecentSearchQueryEntity.kt | 35 ++ .../core/database/model/TopicFtsEntity.kt | 48 ++ .../domain/GetRecentSearchQueriesUseCase.kt | 32 + .../domain/GetSearchContentsCountUseCase.kt | 31 + .../core/domain/GetSearchContentsUseCase.kt | 61 ++ .../core/model/data/SearchResult.kt.kt | 23 + .../core/model/data/UserSearchResult.kt | 26 + .../repository/TestRecentSearchRepository.kt | 38 ++ .../TestSearchContentsRepository.kt | 65 ++ .../apps/nowinandroid/core/ui/NewsFeed.kt | 2 + ...serNewsResourcePreviewParameterProvider.kt | 192 +++--- .../feature/interests/TabContent.kt | 7 +- feature/search/.gitignore | 1 + feature/search/build.gradle.kts | 33 + .../feature/search/SearchScreenTest.kt | 218 +++++++ feature/search/src/main/AndroidManifest.xml | 17 + .../search/RecentSearchQueriesUiState.kt | 27 + .../feature/search/SearchResultUiState.kt | 46 ++ .../feature/search/SearchScreen.kt | 565 ++++++++++++++++++ .../SearchUiStatePreviewParameterProvider.kt | 38 ++ .../feature/search/SearchViewModel.kt | 122 ++++ .../search/navigation/SearchNavigation.kt | 45 ++ .../search/src/main/res/values/strings.xml | 29 + .../feature/search/SearchViewModelTest.kt | 128 ++++ .../settings/src/main/res/values/strings.xml | 1 + settings.gradle.kts | 1 + .../nowinandroid/sync/workers/SyncWorker.kt | 3 + 53 files changed, 2905 insertions(+), 101 deletions(-) create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/model/RecentSearchQuery.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultRecentSearchRepository.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/RecentSearchRepository.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/SearchContentsRepository.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeRecentSearchRepository.kt create mode 100644 core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeSearchContentsRepository.kt create mode 100644 core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/13.json create mode 100644 core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/14.json create mode 100644 core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceFtsDao.kt create mode 100644 core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/RecentSearchQueryDao.kt create mode 100644 core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicFtsDao.kt create mode 100644 core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceFtsEntity.kt create mode 100644 core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/RecentSearchQueryEntity.kt create mode 100644 core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/TopicFtsEntity.kt create mode 100644 core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetRecentSearchQueriesUseCase.kt create mode 100644 core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsCountUseCase.kt create mode 100644 core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsUseCase.kt create mode 100644 core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/SearchResult.kt.kt create mode 100644 core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserSearchResult.kt create mode 100644 core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestRecentSearchRepository.kt create mode 100644 core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestSearchContentsRepository.kt create mode 100644 feature/search/.gitignore create mode 100644 feature/search/build.gradle.kts create mode 100644 feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt create mode 100644 feature/search/src/main/AndroidManifest.xml create mode 100644 feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/RecentSearchQueriesUiState.kt create mode 100644 feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt create mode 100644 feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt create mode 100644 feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt create mode 100644 feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt create mode 100644 feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt create mode 100644 feature/search/src/main/res/values/strings.xml create mode 100644 feature/search/src/test/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 81c128b91..42dee2602 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,6 +83,7 @@ dependencies { implementation(project(":feature:foryou")) implementation(project(":feature:bookmarks")) implementation(project(":feature:topic")) + implementation(project(":feature:search")) implementation(project(":feature:settings")) implementation(project(":core:common")) diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt index bc950ee92..e43dfaba7 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/NiaNavHost.kt @@ -18,14 +18,16 @@ package com.google.samples.apps.nowinandroid.navigation import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import com.google.samples.apps.nowinandroid.feature.bookmarks.navigation.bookmarksScreen import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavigationRoute import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouScreen import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsGraph +import com.google.samples.apps.nowinandroid.feature.search.navigation.searchScreen import com.google.samples.apps.nowinandroid.feature.topic.navigation.navigateToTopic import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen +import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.INTERESTS +import com.google.samples.apps.nowinandroid.ui.NiaAppState /** * Top-level navigation graph. Navigation is organized as explained at @@ -36,10 +38,11 @@ import com.google.samples.apps.nowinandroid.feature.topic.navigation.topicScreen */ @Composable fun NiaNavHost( - navController: NavHostController, + appState: NiaAppState, modifier: Modifier = Modifier, startDestination: String = forYouNavigationRoute, ) { + val navController = appState.navController NavHost( navController = navController, startDestination = startDestination, @@ -48,6 +51,11 @@ fun NiaNavHost( // TODO: handle topic clicks from each top level destination forYouScreen(onTopicClick = {}) bookmarksScreen(onTopicClick = {}) + searchScreen( + onBackClick = navController::popBackStack, + onInterestsClick = { appState.navigateToTopLevelDestination(INTERESTS) }, + onTopicClick = navController::navigateToTopic, + ) interestsGraph( onTopicClick = { topicId -> navController.navigateToTopic(topicId) diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index 780849cf2..83fa4d45b 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt @@ -173,6 +173,10 @@ fun NiaApp( if (destination != null) { NiaTopAppBar( titleRes = destination.titleTextId, + navigationIcon = NiaIcons.Search, + navigationIconContentDescription = stringResource( + id = settingsR.string.top_app_bar_navigation_icon_description, + ), actionIcon = NiaIcons.Settings, actionIconContentDescription = stringResource( id = settingsR.string.top_app_bar_action_icon_description, @@ -181,10 +185,11 @@ fun NiaApp( containerColor = Color.Transparent, ), onActionClick = { appState.setShowSettingsDialog(true) }, + onNavigationClick = { appState.navigateToSearch() }, ) } - NiaNavHost(appState.navController) + NiaNavHost(appState) } // TODO: We may want to add padding or spacer when the snackbar is shown so that diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt index df6fe1da2..fb6ae1bc6 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaAppState.kt @@ -42,6 +42,7 @@ import com.google.samples.apps.nowinandroid.feature.foryou.navigation.forYouNavi import com.google.samples.apps.nowinandroid.feature.foryou.navigation.navigateToForYou import com.google.samples.apps.nowinandroid.feature.interests.navigation.interestsRoute import com.google.samples.apps.nowinandroid.feature.interests.navigation.navigateToInterestsGraph +import com.google.samples.apps.nowinandroid.feature.search.navigation.navigateToSearch import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.BOOKMARKS import com.google.samples.apps.nowinandroid.navigation.TopLevelDestination.FOR_YOU @@ -172,6 +173,10 @@ class NiaAppState( fun setShowSettingsDialog(shouldShow: Boolean) { shouldShowSettingsDialog = shouldShow } + + fun navigateToSearch() { + navController.navigateToSearch() + } } /** diff --git a/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt b/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt index f4fc9c7b0..2ec2bcf9c 100644 --- a/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt +++ b/core/data-test/src/main/java/com/google/samples/apps/nowinandroid/core/data/test/TestDataModule.kt @@ -18,9 +18,13 @@ package com.google.samples.apps.nowinandroid.core.data.test import com.google.samples.apps.nowinandroid.core.data.di.DataModule import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository +import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeNewsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeRecentSearchRepository +import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeSearchContentsRepository import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeTopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.fake.FakeUserDataRepository import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor @@ -50,6 +54,16 @@ interface TestDataModule { userDataRepository: FakeUserDataRepository, ): UserDataRepository + @Binds + fun bindsRecentSearchRepository( + recentSearchRepository: FakeRecentSearchRepository, + ): RecentSearchRepository + + @Binds + fun bindsSearchContentsRepository( + searchContentsRepository: FakeSearchContentsRepository, + ): SearchContentsRepository + @Binds fun bindsNetworkMonitor( networkMonitor: AlwaysOnlineNetworkMonitor, diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt index b4dda701e..26f0bbc51 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/di/DataModule.kt @@ -16,10 +16,14 @@ package com.google.samples.apps.nowinandroid.core.data.di +import com.google.samples.apps.nowinandroid.core.data.repository.DefaultRecentSearchRepository +import com.google.samples.apps.nowinandroid.core.data.repository.DefaultSearchContentsRepository import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstNewsRepository import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstTopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.OfflineFirstUserDataRepository +import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository +import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.util.ConnectivityManagerNetworkMonitor @@ -48,6 +52,16 @@ interface DataModule { userDataRepository: OfflineFirstUserDataRepository, ): UserDataRepository + @Binds + fun bindsRecentSearchRepository( + recentSearchRepository: DefaultRecentSearchRepository, + ): RecentSearchRepository + + @Binds + fun bindsSearchContentsRepository( + searchContentsRepository: DefaultSearchContentsRepository, + ): SearchContentsRepository + @Binds fun bindsNetworkMonitor( networkMonitor: ConnectivityManagerNetworkMonitor, diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/model/RecentSearchQuery.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/model/RecentSearchQuery.kt new file mode 100644 index 000000000..76dd08811 --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/model/RecentSearchQuery.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.model + +import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +data class RecentSearchQuery( + val query: String, + val queriedDate: Instant = Clock.System.now(), +) + +fun RecentSearchQueryEntity.asExternalModel() = RecentSearchQuery( + query = query, + queriedDate = queriedDate, +) diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultRecentSearchRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultRecentSearchRepository.kt new file mode 100644 index 000000000..983c6af3e --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultRecentSearchRepository.kt @@ -0,0 +1,55 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.repository + +import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery +import com.google.samples.apps.nowinandroid.core.data.model.asExternalModel +import com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao +import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity +import com.google.samples.apps.nowinandroid.core.network.Dispatcher +import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import kotlinx.datetime.Clock +import javax.inject.Inject + +class DefaultRecentSearchRepository @Inject constructor( + private val recentSearchQueryDao: RecentSearchQueryDao, + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, +) : RecentSearchRepository { + override suspend fun insertOrReplaceRecentSearch(searchQuery: String) { + withContext(ioDispatcher) { + recentSearchQueryDao.insertOrReplaceRecentSearchQuery( + RecentSearchQueryEntity( + query = searchQuery, + queriedDate = Clock.System.now(), + ), + ) + } + } + + override fun getRecentSearchQueries(limit: Int): Flow> = + recentSearchQueryDao.getRecentSearchQueryEntities(limit).map { searchQueries -> + searchQueries.map { + it.asExternalModel() + } + } + + override suspend fun clearRecentSearches() = recentSearchQueryDao.clearRecentSearchQueries() +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt new file mode 100644 index 000000000..40b170cbe --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/DefaultSearchContentsRepository.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.repository + +import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao +import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao +import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao +import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao +import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel +import com.google.samples.apps.nowinandroid.core.database.model.asFtsEntity +import com.google.samples.apps.nowinandroid.core.model.data.SearchResult +import com.google.samples.apps.nowinandroid.core.network.Dispatcher +import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class DefaultSearchContentsRepository @Inject constructor( + private val newsResourceDao: NewsResourceDao, + private val newsResourceFtsDao: NewsResourceFtsDao, + private val topicDao: TopicDao, + private val topicFtsDao: TopicFtsDao, + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, +) : SearchContentsRepository { + + override suspend fun populateFtsData() { + withContext(ioDispatcher) { + newsResourceFtsDao.insertAll( + newsResourceDao.getOneOffNewsResources().map { it.asFtsEntity() }, + ) + topicFtsDao.insertAll(topicDao.getOneOffTopicEntities().map { it.asFtsEntity() }) + } + } + + override fun searchContents(searchQuery: String): Flow { + // Surround the query by asterisks to match the query when it's in the middle of + // a word + val newsResourceIds = newsResourceFtsDao.searchAllNewsResources("*$searchQuery*") + val topicIds = topicFtsDao.searchAllTopics("*$searchQuery*") + + val newsResourcesFlow = newsResourceIds + .mapLatest { it.toSet() } + .distinctUntilChanged() + .flatMapLatest { + newsResourceDao.getNewsResources(useFilterNewsIds = true, filterNewsIds = it) + } + val topicsFlow = topicIds + .mapLatest { it.toSet() } + .distinctUntilChanged() + .flatMapLatest(topicDao::getTopicEntities) + return combine(newsResourcesFlow, topicsFlow) { newsResources, topics -> + SearchResult( + topics = topics.map { it.asExternalModel() }, + newsResources = newsResources.map { it.asExternalModel() }, + ) + } + } + + override fun getSearchContentsCount(): Flow = + combine( + newsResourceFtsDao.getCount(), + topicFtsDao.getCount(), + ) { newsResourceCount, topicsCount -> + newsResourceCount + topicsCount + } +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/RecentSearchRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/RecentSearchRepository.kt new file mode 100644 index 000000000..87a2ce9dc --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/RecentSearchRepository.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.repository + +import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery +import kotlinx.coroutines.flow.Flow + +/** + * Data layer interface for the recent searches. + */ +interface RecentSearchRepository { + + /** + * Get the recent search queries up to the number of queries specified as [limit]. + */ + fun getRecentSearchQueries(limit: Int): Flow> + + /** + * Insert or replace the [searchQuery] as part of the recent searches. + */ + suspend fun insertOrReplaceRecentSearch(searchQuery: String) + + /** + * Clear the recent searches. + */ + suspend fun clearRecentSearches() +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/SearchContentsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/SearchContentsRepository.kt new file mode 100644 index 000000000..2fe6bd820 --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/SearchContentsRepository.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.repository + +import com.google.samples.apps.nowinandroid.core.model.data.SearchResult +import kotlinx.coroutines.flow.Flow + +/** + * Data layer interface for the search feature. + */ +interface SearchContentsRepository { + + /** + * Populate the fts tables for the search contents. + */ + suspend fun populateFtsData() + + /** + * Query the contents matched with the [searchQuery] and returns it as a [Flow] of [SearchResult] + */ + fun searchContents(searchQuery: String): Flow + + fun getSearchContentsCount(): Flow +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeRecentSearchRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeRecentSearchRepository.kt new file mode 100644 index 000000000..fc649f3ec --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeRecentSearchRepository.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.repository.fake + +import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery +import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +/** + * Fake implementation of the [RecentSearchRepository] + */ +class FakeRecentSearchRepository @Inject constructor() : RecentSearchRepository { + override suspend fun insertOrReplaceRecentSearch(searchQuery: String) { /* no-op */ } + + override fun getRecentSearchQueries(limit: Int): Flow> = + flowOf(emptyList()) + + override suspend fun clearRecentSearches() { /* no-op */ } +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeSearchContentsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeSearchContentsRepository.kt new file mode 100644 index 000000000..d15890a10 --- /dev/null +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/fake/FakeSearchContentsRepository.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.data.repository.fake + +import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository +import com.google.samples.apps.nowinandroid.core.model.data.SearchResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject + +/** + * Fake implementation of the [SearchContentsRepository] + */ +class FakeSearchContentsRepository @Inject constructor() : SearchContentsRepository { + + override suspend fun populateFtsData() { /* no-op */ } + override fun searchContents(searchQuery: String): Flow = flowOf() + override fun getSearchContentsCount(): Flow = flowOf(1) +} diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt index d5d8932e7..09af77213 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt @@ -67,6 +67,8 @@ class TestNewsResourceDao : NewsResourceDao { result } + override suspend fun getOneOffNewsResources(): List = emptyList() + override suspend fun insertOrIgnoreNewsResources( entities: List, ): List { diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt index c0cef479f..e891dcfdc 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestTopicDao.kt @@ -43,6 +43,8 @@ class TestTopicDao : TopicDao { getTopicEntities() .map { topics -> topics.filter { it.id in ids } } + override suspend fun getOneOffTopicEntities(): List = emptyList() + override suspend fun insertOrIgnoreTopics(topicEntities: List): List { // Keep old values over new values entitiesStateFlow.update { oldValues -> diff --git a/core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/13.json b/core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/13.json new file mode 100644 index 000000000..387049dea --- /dev/null +++ b/core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/13.json @@ -0,0 +1,282 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "b6b299e53da623b16360975581ebfcfe", + "entities": [ + { + "tableName": "news_resources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "headerImageUrl", + "columnName": "header_image_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishDate", + "columnName": "publish_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "news_resources_topics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `topic_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "newsResourceId", + "columnName": "news_resource_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "topicId", + "columnName": "topic_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "news_resource_id", + "topic_id" + ] + }, + "indices": [ + { + "name": "index_news_resources_topics_news_resource_id", + "unique": false, + "columnNames": [ + "news_resource_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_topics_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)" + }, + { + "name": "index_news_resources_topics_topic_id", + "unique": false, + "columnNames": [ + "topic_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_topics_topic_id` ON `${TABLE_NAME}` (`topic_id`)" + } + ], + "foreignKeys": [ + { + "table": "news_resources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "news_resource_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "topics", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "topic_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [], + "tableName": "newsResourcesFts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`newsResourceId` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "newsResourceId", + "columnName": "newsResourceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "topics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "longDescription", + "columnName": "longDescription", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [], + "tableName": "topicsFts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`topicId` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "topicId", + "columnName": "topicId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "longDescription", + "columnName": "longDescription", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b6b299e53da623b16360975581ebfcfe')" + ] + } +} \ No newline at end of file diff --git a/core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/14.json b/core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/14.json new file mode 100644 index 000000000..aa90a9723 --- /dev/null +++ b/core/database/schemas/com.google.samples.apps.nowinandroid.core.database.NiaDatabase/14.json @@ -0,0 +1,308 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "51271b81bde7c7997d67fb23c8f31780", + "entities": [ + { + "tableName": "news_resources", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL, `url` TEXT NOT NULL, `header_image_url` TEXT, `publish_date` INTEGER NOT NULL, `type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "headerImageUrl", + "columnName": "header_image_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "publishDate", + "columnName": "publish_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "news_resources_topics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`news_resource_id` TEXT NOT NULL, `topic_id` TEXT NOT NULL, PRIMARY KEY(`news_resource_id`, `topic_id`), FOREIGN KEY(`news_resource_id`) REFERENCES `news_resources`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`topic_id`) REFERENCES `topics`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "newsResourceId", + "columnName": "news_resource_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "topicId", + "columnName": "topic_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "news_resource_id", + "topic_id" + ] + }, + "indices": [ + { + "name": "index_news_resources_topics_news_resource_id", + "unique": false, + "columnNames": [ + "news_resource_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_topics_news_resource_id` ON `${TABLE_NAME}` (`news_resource_id`)" + }, + { + "name": "index_news_resources_topics_topic_id", + "unique": false, + "columnNames": [ + "topic_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_news_resources_topics_topic_id` ON `${TABLE_NAME}` (`topic_id`)" + } + ], + "foreignKeys": [ + { + "table": "news_resources", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "news_resource_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "topics", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "topic_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [], + "tableName": "newsResourcesFts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`newsResourceId` TEXT NOT NULL, `title` TEXT NOT NULL, `content` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "newsResourceId", + "columnName": "newsResourceId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "content", + "columnName": "content", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "topics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL DEFAULT '', `url` TEXT NOT NULL DEFAULT '', `imageUrl` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "longDescription", + "columnName": "longDescription", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "imageUrl", + "columnName": "imageUrl", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [], + "tableName": "topicsFts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`topicId` TEXT NOT NULL, `name` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `longDescription` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "topicId", + "columnName": "topicId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "longDescription", + "columnName": "longDescription", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "recentSearchQueries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, `queriedDate` INTEGER NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "queriedDate", + "columnName": "queriedDate", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '51271b81bde7c7997d67fb23c8f31780')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/DaosModule.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/DaosModule.kt index 1cb17f110..34840a733 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/DaosModule.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/DaosModule.kt @@ -17,7 +17,10 @@ package com.google.samples.apps.nowinandroid.core.database import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao +import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao +import com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao +import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -35,4 +38,19 @@ object DaosModule { fun providesNewsResourceDao( database: NiaDatabase, ): NewsResourceDao = database.newsResourceDao() + + @Provides + fun providesTopicFtsDao( + database: NiaDatabase, + ): TopicFtsDao = database.topicFtsDao() + + @Provides + fun providesNewsResourceFtsDao( + database: NiaDatabase, + ): NewsResourceFtsDao = database.newsResourceFtsDao() + + @Provides + fun providesRecentSearchQueryDao( + database: NiaDatabase, + ): RecentSearchQueryDao = database.recentSearchQueryDao() } diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt index 83bd46967..96714f9a9 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/NiaDatabase.kt @@ -21,10 +21,16 @@ import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao +import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceFtsDao +import com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao +import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity +import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceFtsEntity import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef +import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity +import com.google.samples.apps.nowinandroid.core.database.model.TopicFtsEntity import com.google.samples.apps.nowinandroid.core.database.util.InstantConverter import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeConverter @@ -32,9 +38,12 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC entities = [ NewsResourceEntity::class, NewsResourceTopicCrossRef::class, + NewsResourceFtsEntity::class, TopicEntity::class, + TopicFtsEntity::class, + RecentSearchQueryEntity::class, ], - version = 12, + version = 14, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3, spec = DatabaseMigrations.Schema2to3::class), @@ -47,6 +56,8 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC AutoMigration(from = 9, to = 10), AutoMigration(from = 10, to = 11, spec = DatabaseMigrations.Schema10to11::class), AutoMigration(from = 11, to = 12, spec = DatabaseMigrations.Schema11to12::class), + AutoMigration(from = 12, to = 13), + AutoMigration(from = 13, to = 14), ], exportSchema = true, ) @@ -57,4 +68,7 @@ import com.google.samples.apps.nowinandroid.core.database.util.NewsResourceTypeC abstract class NiaDatabase : RoomDatabase() { abstract fun topicDao(): TopicDao abstract fun newsResourceDao(): NewsResourceDao + abstract fun topicFtsDao(): TopicFtsDao + abstract fun newsResourceFtsDao(): NewsResourceFtsDao + abstract fun recentSearchQueryDao(): RecentSearchQueryDao } diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt index a05507a8b..b5949c6d2 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt @@ -65,6 +65,10 @@ interface NewsResourceDao { filterNewsIds: Set = emptySet(), ): Flow> + @Transaction + @Query(value = "SELECT * FROM news_resources ORDER BY publish_date DESC") + suspend fun getOneOffNewsResources(): List + /** * Inserts [entities] into the db if they don't exist, and ignores those that do */ diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceFtsDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceFtsDao.kt new file mode 100644 index 000000000..86cc5529e --- /dev/null +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceFtsDao.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceFtsEntity +import kotlinx.coroutines.flow.Flow + +/** + * DAO for [NewsResourceFtsEntity] access. + */ +@Dao +interface NewsResourceFtsDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(newsResources: List) + + @Query("SELECT newsResourceId FROM newsResourcesFts WHERE newsResourcesFts MATCH :query") + fun searchAllNewsResources(query: String): Flow> + + @Query("SELECT count(*) FROM newsResourcesFts") + fun getCount(): Flow +} diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/RecentSearchQueryDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/RecentSearchQueryDao.kt new file mode 100644 index 000000000..826575828 --- /dev/null +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/RecentSearchQueryDao.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.database.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity +import kotlinx.coroutines.flow.Flow + +/** + * DAO for [RecentSearchQueryEntity] access + */ +@Dao +interface RecentSearchQueryDao { + @Query(value = "SELECT * FROM recentSearchQueries ORDER BY queriedDate DESC LIMIT :limit") + fun getRecentSearchQueryEntities(limit: Int): Flow> + + @Upsert + suspend fun insertOrReplaceRecentSearchQuery(recentSearchQuery: RecentSearchQueryEntity) + + @Query(value = "DELETE FROM recentSearchQueries") + suspend fun clearRecentSearchQueries() +} diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt index 37724af69..693a85b77 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicDao.kt @@ -41,6 +41,9 @@ interface TopicDao { @Query(value = "SELECT * FROM topics") fun getTopicEntities(): Flow> + @Query(value = "SELECT * FROM topics") + suspend fun getOneOffTopicEntities(): List + @Query( value = """ SELECT * FROM topics diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicFtsDao.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicFtsDao.kt new file mode 100644 index 000000000..25dea5b83 --- /dev/null +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/TopicFtsDao.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.database.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.google.samples.apps.nowinandroid.core.database.model.TopicFtsEntity +import kotlinx.coroutines.flow.Flow + +/** + * DAO for [TopicFtsEntity] access. + */ +@Dao +interface TopicFtsDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(topics: List) + + @Query("SELECT topicId FROM topicsFts WHERE topicsFts MATCH :query") + fun searchAllTopics(query: String): Flow> + + @Query("SELECT count(*) FROM topicsFts") + fun getCount(): Flow +} diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceFtsEntity.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceFtsEntity.kt new file mode 100644 index 000000000..0ef9333c1 --- /dev/null +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceFtsEntity.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.database.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Fts4 + +/** + * Fts entity for the news resources. See https://developer.android.com/reference/androidx/room/Fts4. + */ +@Entity(tableName = "newsResourcesFts") +@Fts4 +data class NewsResourceFtsEntity( + + @ColumnInfo(name = "newsResourceId") + val newsResourceId: String, + + @ColumnInfo(name = "title") + val title: String, + + @ColumnInfo(name = "content") + val content: String, +) + +fun NewsResourceEntity.asFtsEntity() = NewsResourceFtsEntity( + newsResourceId = id, + title = title, + content = content, +) diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/PopulatedNewsResource.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/PopulatedNewsResource.kt index ec8acfb3f..a70342401 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/PopulatedNewsResource.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/PopulatedNewsResource.kt @@ -49,3 +49,9 @@ fun PopulatedNewsResource.asExternalModel() = NewsResource( type = entity.type, topics = topics.map(TopicEntity::asExternalModel), ) + +fun PopulatedNewsResource.asFtsEntity() = NewsResourceFtsEntity( + newsResourceId = entity.id, + title = entity.title, + content = entity.content, +) diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/RecentSearchQueryEntity.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/RecentSearchQueryEntity.kt new file mode 100644 index 000000000..9c7439233 --- /dev/null +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/RecentSearchQueryEntity.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.database.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey +import kotlinx.datetime.Instant + +/** + * Defines an database entity that stored recent search queries. + */ +@Entity( + tableName = "recentSearchQueries", +) +data class RecentSearchQueryEntity( + @PrimaryKey + val query: String, + @ColumnInfo + val queriedDate: Instant, +) diff --git a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/TopicFtsEntity.kt b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/TopicFtsEntity.kt new file mode 100644 index 000000000..23d56f2df --- /dev/null +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/TopicFtsEntity.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.database.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Fts4 + +/** + * Fts entity for the topic. See https://developer.android.com/reference/androidx/room/Fts4. + */ +@Entity(tableName = "topicsFts") +@Fts4 +data class TopicFtsEntity( + + @ColumnInfo(name = "topicId") + val topicId: String, + + @ColumnInfo(name = "name") + val name: String, + + @ColumnInfo(name = "shortDescription") + val shortDescription: String, + + @ColumnInfo(name = "longDescription") + val longDescription: String, +) + +fun TopicEntity.asFtsEntity() = TopicFtsEntity( + topicId = id, + name = name, + shortDescription = shortDescription, + longDescription = longDescription, +) diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetRecentSearchQueriesUseCase.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetRecentSearchQueriesUseCase.kt new file mode 100644 index 000000000..51f87d6fd --- /dev/null +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetRecentSearchQueriesUseCase.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain + +import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery +import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +/** + * A use case which returns the recent search queries. + */ +class GetRecentSearchQueriesUseCase @Inject constructor( + private val recentSearchRepository: RecentSearchRepository, +) { + operator fun invoke(limit: Int = 10): Flow> = + recentSearchRepository.getRecentSearchQueries(limit) +} diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsCountUseCase.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsCountUseCase.kt new file mode 100644 index 000000000..3e3e1952e --- /dev/null +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsCountUseCase.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain + +import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +/** + * A use case which returns total count of *Fts tables + */ +class GetSearchContentsCountUseCase @Inject constructor( + private val searchContentsRepository: SearchContentsRepository, +) { + operator fun invoke(): Flow = + searchContentsRepository.getSearchContentsCount() +} diff --git a/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsUseCase.kt b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsUseCase.kt new file mode 100644 index 000000000..d1065e87c --- /dev/null +++ b/core/domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/GetSearchContentsUseCase.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain + +import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.SearchResult +import com.google.samples.apps.nowinandroid.core.model.data.UserData +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource +import com.google.samples.apps.nowinandroid.core.model.data.UserSearchResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import javax.inject.Inject + +/** + * A use case which returns the searched contents matched with the search query. + */ +class GetSearchContentsUseCase @Inject constructor( + private val searchContentsRepository: SearchContentsRepository, + private val userDataRepository: UserDataRepository, +) { + + operator fun invoke( + searchQuery: String, + ): Flow = + searchContentsRepository.searchContents(searchQuery) + .mapToUserSearchResult(userDataRepository.userData) +} + +private fun Flow.mapToUserSearchResult(userDataStream: Flow): Flow = + combine(userDataStream) { searchResult, userData -> + UserSearchResult( + topics = searchResult.topics.map { topic -> + FollowableTopic( + topic = topic, + isFollowed = topic.id in userData.followedTopics, + ) + }, + newsResources = searchResult.newsResources.map { news -> + UserNewsResource( + newsResource = news, + userData = userData, + ) + }, + ) + } diff --git a/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/SearchResult.kt.kt b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/SearchResult.kt.kt new file mode 100644 index 000000000..060347035 --- /dev/null +++ b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/SearchResult.kt.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.model.data + +/** An entity that holds the search result */ +data class SearchResult( + val topics: List = emptyList(), + val newsResources: List = emptyList(), +) diff --git a/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserSearchResult.kt b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserSearchResult.kt new file mode 100644 index 000000000..acc2cdc69 --- /dev/null +++ b/core/model/src/main/java/com/google/samples/apps/nowinandroid/core/model/data/UserSearchResult.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.model.data + +/** + * An entity of [SearchResult] with additional user information such as whether the user is + * following a topic. + */ +data class UserSearchResult( + val topics: List = emptyList(), + val newsResources: List = emptyList(), +) diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestRecentSearchRepository.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestRecentSearchRepository.kt new file mode 100644 index 000000000..961473401 --- /dev/null +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestRecentSearchRepository.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.testing.repository + +import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery +import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +class TestRecentSearchRepository : RecentSearchRepository { + + private val cachedRecentSearches: MutableList = mutableListOf() + + override fun getRecentSearchQueries(limit: Int): Flow> = + flowOf(cachedRecentSearches.sortedByDescending { it.queriedDate }.take(limit)) + + override suspend fun insertOrReplaceRecentSearch(searchQuery: String) { + cachedRecentSearches.add(RecentSearchQuery(searchQuery)) + } + + override suspend fun clearRecentSearches() { + cachedRecentSearches.clear() + } +} diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestSearchContentsRepository.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestSearchContentsRepository.kt new file mode 100644 index 000000000..2aa54e463 --- /dev/null +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/repository/TestSearchContentsRepository.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.testing.repository + +import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import com.google.samples.apps.nowinandroid.core.model.data.SearchResult +import com.google.samples.apps.nowinandroid.core.model.data.Topic +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf + +class TestSearchContentsRepository : SearchContentsRepository { + + private val cachedTopics: MutableList = mutableListOf() + private val cachedNewsResources: MutableList = mutableListOf() + + override suspend fun populateFtsData() { /* no-op */ } + + override fun searchContents(searchQuery: String): Flow = flowOf( + SearchResult( + topics = cachedTopics.filter { + it.name.contains(searchQuery) || + it.shortDescription.contains(searchQuery) || + it.longDescription.contains(searchQuery) + }, + newsResources = cachedNewsResources.filter { + it.content.contains(searchQuery) || + it.title.contains(searchQuery) + }, + ), + ) + + override fun getSearchContentsCount(): Flow = flow { + emit(cachedTopics.size + cachedNewsResources.size) + } + + /** + * Test only method to add the topics to the stored list in memory + */ + fun addTopics(topics: List) { + cachedTopics.addAll(topics) + } + + /** + * Test only method to add the news resources to the stored list in memory + */ + fun addNewsResources(newsResources: List) { + cachedNewsResources.addAll(newsResources) + } +} diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt index 981de26f9..58ec216fd 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt @@ -52,6 +52,7 @@ fun LazyGridScope.newsFeed( onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, onNewsResourceViewed: (String) -> Unit, onTopicClick: (String) -> Unit, + onExpandedCardClick: () -> Unit = {}, ) { when (feedState) { NewsFeedUiState.Loading -> Unit @@ -68,6 +69,7 @@ fun LazyGridScope.newsFeed( userNewsResource = userNewsResource, isBookmarked = userNewsResource.isSaved, onClick = { + onExpandedCardClick() analyticsHelper.logNewsResourceOpened( newsResourceId = userNewsResource.id, ) diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt index 3f3f9bddd..84d3ce165 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/UserNewsResourcePreviewParameterProvider.kt @@ -25,6 +25,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource +import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.newsResources import kotlinx.datetime.Instant import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone @@ -36,101 +37,102 @@ import kotlinx.datetime.toInstant * provides list of [UserNewsResource] for Composable previews. */ class UserNewsResourcePreviewParameterProvider : PreviewParameterProvider> { - override val values: Sequence> - get() { - val userData: UserData = UserData( - bookmarkedNewsResources = setOf("1", "3"), - viewedNewsResources = setOf("1", "2", "4"), - followedTopics = emptySet(), - themeBrand = ThemeBrand.ANDROID, - darkThemeConfig = DarkThemeConfig.DARK, - shouldHideOnboarding = true, - useDynamicColor = false, - ) - val topics = listOf( - Topic( - id = "2", - name = "Headlines", - shortDescription = "News we want everyone to see", - longDescription = "Stay up to date with the latest events and announcements from Android!", - imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f", - url = "", - ), - Topic( - id = "3", - name = "UI", - shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets", - longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on tocpis such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!", - imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594", - url = "", - ), - Topic( - id = "4", - name = "Testing", - shortDescription = "CI, Espresso, TestLab, etc", - longDescription = "Testing is an integral part of the app development process. By running tests against your app consistently, you can verify your app's correctness, functional behavior, and usability before you release it publicly. Stay up to date on the latest tricks in CI, Espresso, and Firebase TestLab.", - imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Testing.svg?alt=media&token=a11533c4-7cc8-4b11-91a3-806158ebf428", - url = "", - ), - ) + override val values: Sequence> = sequenceOf(newsResources) +} + +object PreviewParameterData { + + private val userData: UserData = UserData( + bookmarkedNewsResources = setOf("1", "3"), + viewedNewsResources = setOf("1", "2", "4"), + followedTopics = emptySet(), + themeBrand = ThemeBrand.ANDROID, + darkThemeConfig = DarkThemeConfig.DARK, + shouldHideOnboarding = true, + useDynamicColor = false, + ) + + val topics = listOf( + Topic( + id = "2", + name = "Headlines", + shortDescription = "News we want everyone to see", + longDescription = "Stay up to date with the latest events and announcements from Android!", + imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Headlines.svg?alt=media&token=506faab0-617a-4668-9e63-4a2fb996603f", + url = "", + ), + Topic( + id = "3", + name = "UI", + shortDescription = "Material Design, Navigation, Text, Paging, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets", + longDescription = "Learn how to optimize your app's user interface - everything that users can see and interact with. Stay up to date on tocpis such as Material Design, Navigation, Text, Paging, Compose, Accessibility (a11y), Internationalization (i18n), Localization (l10n), Animations, Large Screens, Widgets, and many more!", + imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_UI.svg?alt=media&token=0ee1842b-12e8-435f-87ba-a5bb02c47594", + url = "", + ), + Topic( + id = "4", + name = "Testing", + shortDescription = "CI, Espresso, TestLab, etc", + longDescription = "Testing is an integral part of the app development process. By running tests against your app consistently, you can verify your app's correctness, functional behavior, and usability before you release it publicly. Stay up to date on the latest tricks in CI, Espresso, and Firebase TestLab.", + imageUrl = "https://firebasestorage.googleapis.com/v0/b/now-in-android.appspot.com/o/img%2Fic_topic_Testing.svg?alt=media&token=a11533c4-7cc8-4b11-91a3-806158ebf428", + url = "", + ), + ) - return sequenceOf( - listOf( - UserNewsResource( - newsResource = NewsResource( - id = "1", - title = "Android Basics with Compose", - content = "We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. You’ll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Android’s modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey", - url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html", - headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg", - publishDate = LocalDateTime( - year = 2022, - monthNumber = 5, - dayOfMonth = 4, - hour = 23, - minute = 0, - second = 0, - nanosecond = 0, - ).toInstant(TimeZone.UTC), - type = NewsResourceType.Codelab, - topics = listOf(topics[2]), - ), - userData = userData, - ), - UserNewsResource( - newsResource = NewsResource( - id = "2", - title = "Thanks for helping us reach 1M YouTube Subscribers", - content = "Thank you everyone for following the Now in Android series and everything the " + - "Android Developers YouTube channel has to offer. During the Android Developer " + - "Summit, our YouTube channel reached 1 million subscribers! Here’s a small video to " + - "thank you all.", - url = "https://youtu.be/-fJ6poHQrjM", - headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", - publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), - type = Video, - topics = topics.take(2), - ), - userData = userData, - ), - UserNewsResource( - newsResource = NewsResource( - id = "3", - title = "Transformations and customisations in the Paging Library", - content = "A demonstration of different operations that can be performed " + - "with Paging. Transformations like inserting separators, when to " + - "create a new pager, and customisation options for consuming " + - "PagingData.", - url = "https://youtu.be/ZARz0pjm5YM", - headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", - publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), - type = Video, - topics = listOf(topics[2]), - ), - userData = userData, - ), - ), - ) - } + val newsResources = listOf( + UserNewsResource( + newsResource = NewsResource( + id = "1", + title = "Android Basics with Compose", + content = "We released the first two units of Android Basics with Compose, our first free course that teaches Android Development with Jetpack Compose to anyone; you do not need any prior programming experience other than basic computer literacy to get started. You’ll learn the fundamentals of programming in Kotlin while building Android apps using Jetpack Compose, Android’s modern toolkit that simplifies and accelerates native UI development. These two units are just the beginning; more will be coming soon. Check out Android Basics with Compose to get started on your Android development journey", + url = "https://android-developers.googleblog.com/2022/05/new-android-basics-with-compose-course.html", + headerImageUrl = "https://developer.android.com/images/hero-assets/android-basics-compose.svg", + publishDate = LocalDateTime( + year = 2022, + monthNumber = 5, + dayOfMonth = 4, + hour = 23, + minute = 0, + second = 0, + nanosecond = 0, + ).toInstant(TimeZone.UTC), + type = NewsResourceType.Codelab, + topics = listOf(topics[2]), + ), + userData = userData, + ), + UserNewsResource( + newsResource = NewsResource( + id = "2", + title = "Thanks for helping us reach 1M YouTube Subscribers", + content = "Thank you everyone for following the Now in Android series and everything the " + + "Android Developers YouTube channel has to offer. During the Android Developer " + + "Summit, our YouTube channel reached 1 million subscribers! Here’s a small video to " + + "thank you all.", + url = "https://youtu.be/-fJ6poHQrjM", + headerImageUrl = "https://i.ytimg.com/vi/-fJ6poHQrjM/maxresdefault.jpg", + publishDate = Instant.parse("2021-11-09T00:00:00.000Z"), + type = Video, + topics = topics.take(2), + ), + userData = userData, + ), + UserNewsResource( + newsResource = NewsResource( + id = "3", + title = "Transformations and customisations in the Paging Library", + content = "A demonstration of different operations that can be performed " + + "with Paging. Transformations like inserting separators, when to " + + "create a new pager, and customisation options for consuming " + + "PagingData.", + url = "https://youtu.be/ZARz0pjm5YM", + headerImageUrl = "https://i.ytimg.com/vi/ZARz0pjm5YM/maxresdefault.jpg", + publishDate = Instant.parse("2021-11-01T00:00:00.000Z"), + type = Video, + topics = listOf(topics[2]), + ), + userData = userData, + ), + ) } diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt index 289776164..d55cd9a38 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt @@ -35,6 +35,7 @@ fun TopicsTabContent( onTopicClick: (String) -> Unit, onFollowButtonClick: (String, Boolean) -> Unit, modifier: Modifier = Modifier, + withBottomSpacer: Boolean = true, ) { LazyColumn( modifier = modifier @@ -56,8 +57,10 @@ fun TopicsTabContent( } } - item { - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + if (withBottomSpacer) { + item { + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } } } } diff --git a/feature/search/.gitignore b/feature/search/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/search/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/search/build.gradle.kts b/feature/search/build.gradle.kts new file mode 100644 index 000000000..cbaa767bc --- /dev/null +++ b/feature/search/build.gradle.kts @@ -0,0 +1,33 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + id("nowinandroid.android.feature") + id("nowinandroid.android.library.compose") + id("nowinandroid.android.library.jacoco") +} + +android { + namespace = "com.google.samples.apps.nowinandroid.feature.search" +} + +dependencies { + implementation(project(":feature:bookmarks")) + implementation(project(":feature:foryou")) + implementation(project(":feature:interests")) + implementation(libs.kotlinx.datetime) +} + diff --git a/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt b/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt new file mode 100644 index 000000000..53f00c0dc --- /dev/null +++ b/feature/search/src/androidTest/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreenTest.kt @@ -0,0 +1,218 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithContentDescription +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery +import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig.DARK +import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand.ANDROID +import com.google.samples.apps.nowinandroid.core.model.data.UserData +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource +import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData +import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import com.google.samples.apps.nowinandroid.feature.interests.R as interestsR + +/** + * UI test for checking the correct behaviour of the Search screen. + */ +class SearchScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private lateinit var clearSearchContentDesc: String + private lateinit var followButtonContentDesc: String + private lateinit var unfollowButtonContentDesc: String + private lateinit var clearRecentSearchesContentDesc: String + private lateinit var topicsString: String + private lateinit var updatesString: String + private lateinit var tryAnotherSearchString: String + private lateinit var searchNotReadyString: String + + private val userData: UserData = UserData( + bookmarkedNewsResources = setOf("1", "3"), + viewedNewsResources = setOf("1", "2", "4"), + followedTopics = emptySet(), + themeBrand = ANDROID, + darkThemeConfig = DARK, + shouldHideOnboarding = true, + useDynamicColor = false, + ) + + @Before + fun setup() { + composeTestRule.activity.apply { + clearSearchContentDesc = getString(R.string.clear_search_text_content_desc) + clearRecentSearchesContentDesc = getString(R.string.clear_recent_searches_content_desc) + followButtonContentDesc = + getString(interestsR.string.card_follow_button_content_desc) + unfollowButtonContentDesc = + getString(interestsR.string.card_unfollow_button_content_desc) + topicsString = getString(R.string.topics) + updatesString = getString(R.string.updates) + tryAnotherSearchString = getString(R.string.try_another_search) + + " " + getString(R.string.interests) + " " + getString(R.string.to_browse_topics) + searchNotReadyString = getString(R.string.search_not_ready) + } + } + + @Test + fun searchTextField_isFocused() { + composeTestRule.setContent { + SearchScreen() + } + + composeTestRule + .onNodeWithTag("searchTextField") + .assertIsFocused() + } + + @Test + fun emptySearchResult_emptyScreenIsDisplayed() { + composeTestRule.setContent { + SearchScreen( + searchResultUiState = SearchResultUiState.Success(), + ) + } + + composeTestRule + .onNodeWithText(tryAnotherSearchString) + .assertIsDisplayed() + } + + @Test + fun emptySearchResult_nonEmptyRecentSearches_emptySearchScreenAndRecentSearchesAreDisplayed() { + val recentSearches = listOf("kotlin") + composeTestRule.setContent { + SearchScreen( + searchResultUiState = SearchResultUiState.Success(), + recentSearchesUiState = RecentSearchQueriesUiState.Success( + recentQueries = recentSearches.map(::RecentSearchQuery), + ), + ) + } + + composeTestRule + .onNodeWithText(tryAnotherSearchString) + .assertIsDisplayed() + composeTestRule + .onNodeWithContentDescription(clearRecentSearchesContentDesc) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("kotlin") + .assertIsDisplayed() + } + + @Test + fun searchResultWithTopics_allTopicsAreVisible_followButtonsVisibleForTheNumOfFollowedTopics() { + composeTestRule.setContent { + SearchScreen( + searchResultUiState = SearchResultUiState.Success(topics = followableTopicTestData), + ) + } + + composeTestRule + .onNodeWithText(topicsString) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(followableTopicTestData[0].topic.name) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(followableTopicTestData[1].topic.name) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(followableTopicTestData[2].topic.name) + .assertIsDisplayed() + + composeTestRule + .onAllNodesWithContentDescription(followButtonContentDesc) + .assertCountEquals(2) + composeTestRule + .onAllNodesWithContentDescription(unfollowButtonContentDesc) + .assertCountEquals(1) + } + + @Test + fun searchResultWithNewsResources_firstNewsResourcesIsVisible() { + composeTestRule.setContent { + SearchScreen( + searchResultUiState = SearchResultUiState.Success( + newsResources = newsResourcesTestData.map { + UserNewsResource( + newsResource = it, + userData = userData, + ) + }, + ), + ) + } + + composeTestRule + .onNodeWithText(updatesString) + .assertIsDisplayed() + composeTestRule + .onNodeWithText(newsResourcesTestData[0].title) + .assertIsDisplayed() + } + + @Test + fun emptyQuery_notEmptyRecentSearches_verifyClearSearchesButton_displayed() { + val recentSearches = listOf("kotlin", "testing") + composeTestRule.setContent { + SearchScreen( + searchResultUiState = SearchResultUiState.EmptyQuery, + recentSearchesUiState = RecentSearchQueriesUiState.Success( + recentQueries = recentSearches.map(::RecentSearchQuery), + ), + ) + } + + composeTestRule + .onNodeWithContentDescription(clearRecentSearchesContentDesc) + .assertIsDisplayed() + composeTestRule + .onNodeWithText("kotlin") + .assertIsDisplayed() + composeTestRule + .onNodeWithText("testing") + .assertIsDisplayed() + } + + @Test + fun searchNotReady_verifySearchNotReadyMessageIsVisible() { + composeTestRule.setContent { + SearchScreen( + searchResultUiState = SearchResultUiState.SearchNotReady, + ) + } + + composeTestRule + .onNodeWithText(searchNotReadyString) + .assertIsDisplayed() + } +} diff --git a/feature/search/src/main/AndroidManifest.xml b/feature/search/src/main/AndroidManifest.xml new file mode 100644 index 000000000..70c188dd8 --- /dev/null +++ b/feature/search/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/RecentSearchQueriesUiState.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/RecentSearchQueriesUiState.kt new file mode 100644 index 000000000..8628d2e54 --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/RecentSearchQueriesUiState.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search + +import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery + +sealed interface RecentSearchQueriesUiState { + object Loading : RecentSearchQueriesUiState + + data class Success( + val recentQueries: List = emptyList(), + ) : RecentSearchQueriesUiState +} diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt new file mode 100644 index 000000000..68ea623e8 --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchResultUiState.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search + +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource + +sealed interface SearchResultUiState { + object Loading : SearchResultUiState + + /** + * The state query is empty or too short. To distinguish the state between the + * (initial state or when the search query is cleared) vs the state where no search + * result is returned, explicitly define the empty query state. + */ + object EmptyQuery : SearchResultUiState + + object LoadFailed : SearchResultUiState + + data class Success( + val topics: List = emptyList(), + val newsResources: List = emptyList(), + ) : SearchResultUiState { + fun isEmpty(): Boolean = topics.isEmpty() && newsResources.isEmpty() + } + + /** + * A state where the search contents are not ready. This happens when the *Fts tables are not + * populated yet. + */ + object SearchNotReady : SearchResultUiState +} diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt new file mode 100644 index 000000000..e3a9be8dc --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchScreen.kt @@ -0,0 +1,565 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells.Adaptive +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons +import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic +import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource +import com.google.samples.apps.nowinandroid.core.ui.DevicePreviews +import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState +import com.google.samples.apps.nowinandroid.core.ui.R.string +import com.google.samples.apps.nowinandroid.core.ui.TrackScreenViewEvent +import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank +import com.google.samples.apps.nowinandroid.core.ui.newsFeed +import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksViewModel +import com.google.samples.apps.nowinandroid.feature.foryou.ForYouViewModel +import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel +import com.google.samples.apps.nowinandroid.feature.interests.TopicsTabContent +import com.google.samples.apps.nowinandroid.feature.search.R as searchR + +@Composable +internal fun SearchRoute( + modifier: Modifier = Modifier, + onBackClick: () -> Unit, + onInterestsClick: () -> Unit, + onTopicClick: (String) -> Unit, + bookmarksViewModel: BookmarksViewModel = hiltViewModel(), + interestsViewModel: InterestsViewModel = hiltViewModel(), + searchViewModel: SearchViewModel = hiltViewModel(), + forYouViewModel: ForYouViewModel = hiltViewModel(), +) { + val recentSearchQueriesUiState by searchViewModel.recentSearchQueriesUiState.collectAsStateWithLifecycle() + val searchResultUiState by searchViewModel.searchResultUiState.collectAsStateWithLifecycle() + val searchQuery by searchViewModel.searchQuery.collectAsStateWithLifecycle() + SearchScreen( + modifier = modifier, + onBackClick = onBackClick, + onClearRecentSearches = searchViewModel::clearRecentSearches, + onFollowButtonClick = interestsViewModel::followTopic, + onInterestsClick = onInterestsClick, + onSearchQueryChanged = searchViewModel::onSearchQueryChanged, + onSearchTriggered = searchViewModel::onSearchTriggered, + onTopicClick = onTopicClick, + onNewsResourcesCheckedChanged = forYouViewModel::updateNewsResourceSaved, + onNewsResourceViewed = { bookmarksViewModel.setNewsResourceViewed(it, true) }, + recentSearchesUiState = recentSearchQueriesUiState, + searchQuery = searchQuery, + searchResultUiState = searchResultUiState, + ) +} + +@Composable +internal fun SearchScreen( + modifier: Modifier = Modifier, + onBackClick: () -> Unit = {}, + onClearRecentSearches: () -> Unit = {}, + onFollowButtonClick: (String, Boolean) -> Unit = { _, _ -> }, + onInterestsClick: () -> Unit = {}, + onNewsResourcesCheckedChanged: (String, Boolean) -> Unit = { _, _ -> }, + onNewsResourceViewed: (String) -> Unit = {}, + onSearchQueryChanged: (String) -> Unit = {}, + onSearchTriggered: (String) -> Unit = {}, + onTopicClick: (String) -> Unit = {}, + searchQuery: String = "", + recentSearchesUiState: RecentSearchQueriesUiState = RecentSearchQueriesUiState.Loading, + searchResultUiState: SearchResultUiState = SearchResultUiState.Loading, +) { + TrackScreenViewEvent(screenName = "Search") + Column(modifier = modifier) { + Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) + SearchToolbar( + onBackClick = onBackClick, + onSearchQueryChanged = onSearchQueryChanged, + onSearchTriggered = onSearchTriggered, + searchQuery = searchQuery, + ) + when (searchResultUiState) { + SearchResultUiState.Loading, + SearchResultUiState.LoadFailed, + -> Unit + + SearchResultUiState.SearchNotReady -> SearchNotReadyBody() + SearchResultUiState.EmptyQuery, + -> { + if (recentSearchesUiState is RecentSearchQueriesUiState.Success) { + RecentSearchesBody( + onClearRecentSearches = onClearRecentSearches, + onRecentSearchClicked = { + onSearchQueryChanged(it) + onSearchTriggered(it) + }, + recentSearchQueries = recentSearchesUiState.recentQueries.map { it.query }, + ) + } + } + + is SearchResultUiState.Success -> { + if (searchResultUiState.isEmpty()) { + EmptySearchResultBody( + onInterestsClick = onInterestsClick, + searchQuery = searchQuery, + ) + if (recentSearchesUiState is RecentSearchQueriesUiState.Success) { + RecentSearchesBody( + onClearRecentSearches = onClearRecentSearches, + onRecentSearchClicked = { + onSearchQueryChanged(it) + onSearchTriggered(it) + }, + recentSearchQueries = recentSearchesUiState.recentQueries.map { it.query }, + ) + } + } else { + SearchResultBody( + topics = searchResultUiState.topics, + onFollowButtonClick = onFollowButtonClick, + onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, + onNewsResourceViewed = onNewsResourceViewed, + onSearchTriggered = onSearchTriggered, + onTopicClick = onTopicClick, + newsResources = searchResultUiState.newsResources, + searchQuery = searchQuery, + ) + } + } + } + Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) + } +} + +@Composable +fun EmptySearchResultBody( + onInterestsClick: () -> Unit, + searchQuery: String, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 48.dp), + ) { + val message = stringResource(id = searchR.string.search_result_not_found, searchQuery) + val start = message.indexOf(searchQuery) + Text( + text = AnnotatedString( + text = message, + spanStyles = listOf( + AnnotatedString.Range( + SpanStyle(fontWeight = FontWeight.Bold), + start = start, + end = start + searchQuery.length, + ), + ), + ), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 24.dp), + ) + val interests = stringResource(id = searchR.string.interests) + val tryAnotherSearchString = buildAnnotatedString { + append(stringResource(id = searchR.string.try_another_search)) + append(" ") + withStyle( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + fontWeight = FontWeight.Bold, + ), + ) { + pushStringAnnotation(tag = interests, annotation = interests) + append(interests) + } + append(" ") + append(stringResource(id = searchR.string.to_browse_topics)) + } + ClickableText( + text = tryAnotherSearchString, + style = MaterialTheme.typography.bodyLarge.merge( + TextStyle( + textAlign = TextAlign.Center, + ), + ), + modifier = Modifier + .padding(start = 36.dp, end = 36.dp, bottom = 24.dp) + .clickable {}, + ) { offset -> + tryAnotherSearchString.getStringAnnotations(start = offset, end = offset) + .firstOrNull() + ?.let { + onInterestsClick() + } + } + } +} + +@Composable +private fun SearchNotReadyBody() { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(horizontal = 48.dp), + ) { + Text( + text = stringResource(id = searchR.string.search_not_ready), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(vertical = 24.dp), + ) + } +} + +@Composable +private fun SearchResultBody( + topics: List, + newsResources: List, + onFollowButtonClick: (String, Boolean) -> Unit, + onNewsResourcesCheckedChanged: (String, Boolean) -> Unit, + onNewsResourceViewed: (String) -> Unit, + onSearchTriggered: (String) -> Unit, + onTopicClick: (String) -> Unit, + searchQuery: String = "", +) { + if (topics.isNotEmpty()) { + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(stringResource(id = searchR.string.topics)) + } + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + TopicsTabContent( + topics = topics, + onTopicClick = { + // Pass the current search query to ViewModel to save it as recent searches + onSearchTriggered(searchQuery) + onTopicClick(it) + }, + onFollowButtonClick = onFollowButtonClick, + withBottomSpacer = false, + ) + } + + if (newsResources.isNotEmpty()) { + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(stringResource(id = searchR.string.updates)) + } + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + + val state = rememberLazyGridState() + TrackScrollJank(scrollableState = state, stateName = "search:newsResource") + LazyVerticalGrid( + columns = Adaptive(300.dp), + contentPadding = PaddingValues(16.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .fillMaxSize() + .testTag("search:newsResources"), + state = state, + ) { + newsFeed( + feedState = NewsFeedUiState.Success(feed = newsResources), + onNewsResourcesCheckedChanged = onNewsResourcesCheckedChanged, + onNewsResourceViewed = onNewsResourceViewed, + onTopicClick = onTopicClick, + onExpandedCardClick = { + onSearchTriggered(searchQuery) + }, + ) + } + } +} + +@Composable +private fun RecentSearchesBody( + onClearRecentSearches: () -> Unit, + onRecentSearchClicked: (String) -> Unit, + recentSearchQueries: List, +) { + Column { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = buildAnnotatedString { + withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { + append(stringResource(id = searchR.string.recent_searches)) + } + }, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + if (recentSearchQueries.isNotEmpty()) { + IconButton( + onClick = { + onClearRecentSearches() + }, + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Icon( + imageVector = NiaIcons.Close, + contentDescription = stringResource( + id = searchR.string.clear_recent_searches_content_desc, + ), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + LazyColumn(modifier = Modifier.padding(horizontal = 16.dp)) { + items(recentSearchQueries) { recentSearch -> + Text( + text = recentSearch, + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .padding(vertical = 16.dp) + .clickable { + onRecentSearchClicked(recentSearch) + } + .fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun SearchToolbar( + modifier: Modifier = Modifier, + onBackClick: () -> Unit, + onSearchQueryChanged: (String) -> Unit, + searchQuery: String = "", + onSearchTriggered: (String) -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.fillMaxWidth(), + ) { + IconButton(onClick = { onBackClick() }) { + Icon( + imageVector = NiaIcons.ArrowBack, + contentDescription = stringResource( + id = string.back, + ), + ) + } + SearchTextField( + onSearchQueryChanged = onSearchQueryChanged, + onSearchTriggered = onSearchTriggered, + searchQuery = searchQuery, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun SearchTextField( + onSearchQueryChanged: (String) -> Unit, + searchQuery: String, + onSearchTriggered: (String) -> Unit, +) { + val focusRequester = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + + val onSearchExplicitlyTriggered = { + keyboardController?.hide() + onSearchTriggered(searchQuery) + } + + TextField( + colors = TextFieldDefaults.textFieldColors( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), + leadingIcon = { + Icon( + imageVector = NiaIcons.Search, + contentDescription = stringResource( + id = searchR.string.search, + ), + tint = MaterialTheme.colorScheme.onSurface, + ) + }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton( + onClick = { + onSearchQueryChanged("") + }, + ) { + Icon( + imageVector = NiaIcons.Close, + contentDescription = stringResource( + id = searchR.string.clear_search_text_content_desc, + ), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + }, + onValueChange = { + if (!it.contains("\n")) { + onSearchQueryChanged(it) + } + }, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .focusRequester(focusRequester) + .onKeyEvent { + if (it.key == Key.Enter) { + onSearchExplicitlyTriggered() + true + } else { + false + } + } + .testTag("searchTextField"), + shape = RoundedCornerShape(32.dp), + value = searchQuery, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Search, + ), + keyboardActions = KeyboardActions( + onSearch = { + onSearchExplicitlyTriggered() + }, + ), + maxLines = 1, + singleLine = true, + ) + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } +} + +@Preview +@Composable +private fun SearchToolbarPreview() { + NiaTheme { + SearchToolbar( + onBackClick = {}, + onSearchQueryChanged = {}, + onSearchTriggered = {}, + ) + } +} + +@Preview +@Composable +private fun EmptySearchResultColumnPreview() { + NiaTheme { + EmptySearchResultBody( + onInterestsClick = {}, + searchQuery = "C++", + ) + } +} + +@Preview +@Composable +private fun RecentSearchesBodyPreview() { + NiaTheme { + RecentSearchesBody( + onClearRecentSearches = {}, + onRecentSearchClicked = {}, + recentSearchQueries = listOf("kotlin", "jetpack compose", "testing"), + ) + } +} + +@Preview +@Composable +private fun SearchNotReadyBodyPreview() { + NiaTheme { + SearchNotReadyBody() + } +} + +@DevicePreviews +@Composable +private fun SearchScreenPreview( + @PreviewParameter(SearchUiStatePreviewParameterProvider::class) + searchResultUiState: SearchResultUiState, +) { + NiaTheme { + SearchScreen(searchResultUiState = searchResultUiState) + } +} diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt new file mode 100644 index 000000000..4268893da --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchUiStatePreviewParameterProvider.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic +import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.newsResources +import com.google.samples.apps.nowinandroid.core.ui.PreviewParameterData.topics + +/* ktlint-disable max-line-length */ +/** + * This [PreviewParameterProvider](https://developer.android.com/reference/kotlin/androidx/compose/ui/tooling/preview/PreviewParameterProvider) + * provides list of [SearchResultUiState] for Composable previews. + */ +class SearchUiStatePreviewParameterProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + SearchResultUiState.Success( + topics = topics.mapIndexed { i, topic -> + FollowableTopic(topic = topic, isFollowed = i % 2 == 0) + }, + newsResources = newsResources, + ), + ) +} diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt new file mode 100644 index 000000000..f4b4485bc --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModel.kt @@ -0,0 +1,122 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository +import com.google.samples.apps.nowinandroid.core.domain.GetRecentSearchQueriesUseCase +import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsCountUseCase +import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsUseCase +import com.google.samples.apps.nowinandroid.core.result.Result +import com.google.samples.apps.nowinandroid.core.result.asResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SearchViewModel @Inject constructor( + getSearchContentsUseCase: GetSearchContentsUseCase, + getSearchContentsCountUseCase: GetSearchContentsCountUseCase, + recentSearchQueriesUseCase: GetRecentSearchQueriesUseCase, + private val recentSearchRepository: RecentSearchRepository, + private val savedStateHandle: SavedStateHandle, +) : ViewModel() { + + val searchQuery = savedStateHandle.getStateFlow(SEARCH_QUERY, "") + + val searchResultUiState: StateFlow = + getSearchContentsCountUseCase().flatMapLatest { totalCount -> + if (totalCount < SEARCH_MIN_FTS_ENTITY_COUNT) { + flowOf(SearchResultUiState.SearchNotReady) + } else { + searchQuery.flatMapLatest { query -> + if (query.length < SEARCH_QUERY_MIN_LENGTH) { + flowOf(SearchResultUiState.EmptyQuery) + } else { + getSearchContentsUseCase(query).asResult().map { + when (it) { + is Result.Success -> { + SearchResultUiState.Success( + topics = it.data.topics, + newsResources = it.data.newsResources, + ) + } + + is Result.Loading -> { + SearchResultUiState.Loading + } + + is Result.Error -> { + SearchResultUiState.LoadFailed + } + } + } + } + } + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = SearchResultUiState.Loading, + ) + + val recentSearchQueriesUiState: StateFlow = + recentSearchQueriesUseCase().map(RecentSearchQueriesUiState::Success) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = RecentSearchQueriesUiState.Loading, + ) + + fun onSearchQueryChanged(query: String) { + savedStateHandle[SEARCH_QUERY] = query + } + + /** + * Called when the search action is explicitly triggered by the user. For example, when the + * search icon is tapped in the IME or when the enter key is pressed in the search text field. + * + * The search results are displayed on the fly as the user types, but to explicitly save the + * search query in the search text field, defining this method. + */ + fun onSearchTriggered(query: String) { + viewModelScope.launch { + recentSearchRepository.insertOrReplaceRecentSearch(query) + } + } + + fun clearRecentSearches() { + viewModelScope.launch { + recentSearchRepository.clearRecentSearches() + } + } +} + +/** Minimum length where search query is considered as [SearchResultUiState.EmptyQuery] */ +private const val SEARCH_QUERY_MIN_LENGTH = 2 + +/** Minimum number of the fts table's entity count where it's considered as search is not ready */ +private const val SEARCH_MIN_FTS_ENTITY_COUNT = 1 +private const val SEARCH_QUERY = "searchQuery" diff --git a/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt new file mode 100644 index 000000000..42bf3f475 --- /dev/null +++ b/feature/search/src/main/java/com/google/samples/apps/nowinandroid/feature/search/navigation/SearchNavigation.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.google.samples.apps.nowinandroid.feature.search.SearchRoute + +const val searchRoute = "search_route" + +fun NavController.navigateToSearch(navOptions: NavOptions? = null) { + this.navigate(searchRoute, navOptions) +} + +fun NavGraphBuilder.searchScreen( + onBackClick: () -> Unit, + onInterestsClick: () -> Unit, + onTopicClick: (String) -> Unit, +) { + // TODO: Handle back stack for each top-level destination. At the moment each top-level + // destination may have own search screen's back stack. + composable(route = searchRoute) { + SearchRoute( + onBackClick = onBackClick, + onInterestsClick = onInterestsClick, + onTopicClick = onTopicClick, + ) + } +} diff --git a/feature/search/src/main/res/values/strings.xml b/feature/search/src/main/res/values/strings.xml new file mode 100644 index 000000000..62db1da1d --- /dev/null +++ b/feature/search/src/main/res/values/strings.xml @@ -0,0 +1,29 @@ + + + + Search + Clear search text + Sorry, there is no content found for your search \"%1$s\" + Sorry, we are still processing the search index. Please come back later + Try another search or explorer + Interests + to browse topics + Topics + Updates + Recent searches + Clear searches + \ No newline at end of file diff --git a/feature/search/src/test/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt b/feature/search/src/test/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt new file mode 100644 index 000000000..1d50b75fd --- /dev/null +++ b/feature/search/src/test/java/com/google/samples/apps/nowinandroid/feature/search/SearchViewModelTest.kt @@ -0,0 +1,128 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.feature.search + +import androidx.lifecycle.SavedStateHandle +import com.google.samples.apps.nowinandroid.core.domain.GetRecentSearchQueriesUseCase +import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsCountUseCase +import com.google.samples.apps.nowinandroid.core.domain.GetSearchContentsUseCase +import com.google.samples.apps.nowinandroid.core.testing.data.newsResourcesTestData +import com.google.samples.apps.nowinandroid.core.testing.data.topicsTestData +import com.google.samples.apps.nowinandroid.core.testing.repository.TestRecentSearchRepository +import com.google.samples.apps.nowinandroid.core.testing.repository.TestSearchContentsRepository +import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository +import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule +import com.google.samples.apps.nowinandroid.feature.search.RecentSearchQueriesUiState.Success +import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.EmptyQuery +import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.Loading +import com.google.samples.apps.nowinandroid.feature.search.SearchResultUiState.SearchNotReady +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +/** + * To learn more about how this test handles Flows created with stateIn, see + * https://developer.android.com/kotlin/flow/test#statein + */ +class SearchViewModelTest { + + @get:Rule + val dispatcherRule = MainDispatcherRule() + + private val userDataRepository = TestUserDataRepository() + private val searchContentsRepository = TestSearchContentsRepository() + private val getSearchContentsUseCase = GetSearchContentsUseCase( + searchContentsRepository = searchContentsRepository, + userDataRepository = userDataRepository, + ) + private val recentSearchRepository = TestRecentSearchRepository() + private val getRecentQueryUseCase = GetRecentSearchQueriesUseCase(recentSearchRepository) + private val getSearchContentsCountUseCase = GetSearchContentsCountUseCase(searchContentsRepository) + private lateinit var viewModel: SearchViewModel + + @Before + fun setup() { + viewModel = SearchViewModel( + getSearchContentsUseCase = getSearchContentsUseCase, + getSearchContentsCountUseCase = getSearchContentsCountUseCase, + recentSearchQueriesUseCase = getRecentQueryUseCase, + savedStateHandle = SavedStateHandle(), + recentSearchRepository = recentSearchRepository, + ) + } + + @Test + fun stateIsInitiallyLoading() = runTest { + assertEquals(Loading, viewModel.searchResultUiState.value) + } + + @Test + fun stateIsEmptyQuery_withEmptySearchQuery() = runTest { + searchContentsRepository.addNewsResources(newsResourcesTestData) + searchContentsRepository.addTopics(topicsTestData) + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() } + + viewModel.onSearchQueryChanged("") + + assertEquals(EmptyQuery, viewModel.searchResultUiState.value) + + collectJob.cancel() + } + + @Test + fun emptyResultIsReturned_withNotMatchingQuery() = runTest { + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() } + + viewModel.onSearchQueryChanged("XXX") + searchContentsRepository.addNewsResources(newsResourcesTestData) + searchContentsRepository.addTopics(topicsTestData) + + val result = viewModel.searchResultUiState.value + // TODO: Figure out to get the latest emitted ui State? The result is emitted as EmptyQuery + // assertIs(result) + + collectJob.cancel() + } + + @Test + fun recentSearches_verifyUiStateIsSuccess() = runTest { + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.recentSearchQueriesUiState.collect() } + viewModel.onSearchTriggered("kotlin") + + val result = viewModel.recentSearchQueriesUiState.value + assertIs(result) + + collectJob.cancel() + } + + @Test + fun searchNotReady_withNoFtsTableEntity() = runTest { + val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.searchResultUiState.collect() } + + viewModel.onSearchQueryChanged("") + + assertEquals(SearchNotReady, viewModel.searchResultUiState.value) + + collectJob.cancel() + } +} diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 5efaeb577..cbd4df8ed 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ --> Settings + Search Settings Loading... Privacy policy diff --git a/settings.gradle.kts b/settings.gradle.kts index 2af582a7b..d0c477b3d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -53,6 +53,7 @@ include(":feature:foryou") include(":feature:interests") include(":feature:bookmarks") include(":feature:topic") +include(":feature:search") include(":feature:settings") include(":lint") include(":sync:work") diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt index d8f1ef91c..1948b49a3 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt @@ -27,6 +27,7 @@ import androidx.work.WorkerParameters import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper import com.google.samples.apps.nowinandroid.core.data.Synchronizer import com.google.samples.apps.nowinandroid.core.data.repository.NewsRepository +import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository import com.google.samples.apps.nowinandroid.core.data.repository.TopicsRepository import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSource @@ -53,6 +54,7 @@ class SyncWorker @AssistedInject constructor( private val niaPreferences: NiaPreferencesDataSource, private val topicRepository: TopicsRepository, private val newsRepository: NewsRepository, + private val searchContentsRepository: SearchContentsRepository, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, private val analyticsHelper: AnalyticsHelper, private val syncSubscriber: SyncSubscriber, @@ -76,6 +78,7 @@ class SyncWorker @AssistedInject constructor( analyticsHelper.logSyncFinished(syncedSuccessfully) if (syncedSuccessfully) { + searchContentsRepository.populateFtsData() Result.success() } else { Result.retry() From e1bb03b33d1f0eb5b24b40ffd33212d88c732bbc Mon Sep 17 00:00:00 2001 From: Takeshi Hagikura Date: Wed, 26 Apr 2023 18:50:02 +0900 Subject: [PATCH 029/107] Set the source/target compatibility to 11 This is because up to Java 11 features are supported without requiring minimum API level nor AGP versions at the moment as in https://developer.android.com/studio/write/java11-minimal-support-table --- build-logic/convention/build.gradle.kts | 8 +++++--- .../google/samples/apps/nowinandroid/AndroidCompose.kt | 2 -- .../com/google/samples/apps/nowinandroid/KotlinAndroid.kt | 8 +++++--- lint/build.gradle.kts | 8 +++++--- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 4da2b86dd..571ba8c2f 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -23,13 +23,15 @@ plugins { group = "com.google.samples.apps.nowinandroid.buildlogic" java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + // Up to Java 11 APIs are available through desugaring + // https://developer.android.com/studio/write/java11-minimal-support-table + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } tasks.withType().configureEach { kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() + jvmTarget = JavaVersion.VERSION_11.toString() } } diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt index cdcc65a7f..5997f7d4e 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidCompose.kt @@ -17,12 +17,10 @@ package com.google.samples.apps.nowinandroid import com.android.build.api.dsl.CommonExtension -import org.gradle.api.JavaVersion import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension import org.gradle.kotlin.dsl.dependencies import org.gradle.kotlin.dsl.getByType -import org.gradle.kotlin.dsl.provideDelegate import org.gradle.kotlin.dsl.withType import org.jetbrains.kotlin.gradle.tasks.KotlinCompile import java.io.File diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index d4cb010a2..e800d0570 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -40,8 +40,10 @@ internal fun Project.configureKotlinAndroid( } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + // Up to Java 11 APIs are available through desugaring + // https://developer.android.com/studio/write/java11-minimal-support-table + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 isCoreLibraryDesugaringEnabled = true } } @@ -50,7 +52,7 @@ internal fun Project.configureKotlinAndroid( tasks.withType().configureEach { kotlinOptions { // Set JVM target to 17 - jvmTarget = JavaVersion.VERSION_17.toString() + jvmTarget = JavaVersion.VERSION_11.toString() // Treat all Kotlin warnings as errors (disabled by default) // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties val warningsAsErrors: String? by project diff --git a/lint/build.gradle.kts b/lint/build.gradle.kts index b665ba8a7..4ae719aa6 100644 --- a/lint/build.gradle.kts +++ b/lint/build.gradle.kts @@ -23,13 +23,15 @@ plugins { } java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + // Up to Java 11 APIs are available through desugaring + // https://developer.android.com/studio/write/java11-minimal-support-table + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } tasks.withType().configureEach { kotlinOptions { - jvmTarget = JavaVersion.VERSION_17.toString() + jvmTarget = JavaVersion.VERSION_11.toString() } } From 1998fc1a207e31d10e894b6e857d102a475acf77 Mon Sep 17 00:00:00 2001 From: Alex Vanyo Date: Wed, 26 Apr 2023 08:50:00 -0700 Subject: [PATCH 030/107] Fix JVM target comment Change-Id: I9e62b796ed096f6cf586b51dc3a8e81f79c62e50 --- .../com/google/samples/apps/nowinandroid/KotlinAndroid.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index e800d0570..b1df1cbff 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 @@ -51,7 +51,7 @@ internal fun Project.configureKotlinAndroid( // Use withType to workaround https://youtrack.jetbrains.com/issue/KT-55947 tasks.withType().configureEach { kotlinOptions { - // Set JVM target to 17 + // Set JVM target to 11 jvmTarget = JavaVersion.VERSION_11.toString() // Treat all Kotlin warnings as errors (disabled by default) // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties From 1921c6544d2a50558dc30e5e076d3ed85daaa9be Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sun, 16 Apr 2023 22:48:57 +0200 Subject: [PATCH 031/107] Replace deprecated methods --- app-nia-catalog/build.gradle.kts | 2 +- app/build.gradle.kts | 2 +- .../google/samples/apps/nowinandroid/GradleManagedDevices.kt | 3 +-- .../kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt | 5 +++++ 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app-nia-catalog/build.gradle.kts b/app-nia-catalog/build.gradle.kts index 42ffd7039..7a8d61339 100644 --- a/app-nia-catalog/build.gradle.kts +++ b/app-nia-catalog/build.gradle.kts @@ -47,7 +47,7 @@ android { missingDimensionStrategy(FlavorDimension.contentType.name, NiaFlavor.demo.name) } - packagingOptions { + packaging { resources { excludes.add("/META-INF/{AL2.0,LGPL2.1}") } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 42dee2602..70160f3e1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -65,7 +65,7 @@ android { } } - packagingOptions { + packaging { resources { excludes.add("/META-INF/{AL2.0,LGPL2.1}") } diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt index 624afeea9..8129defeb 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt @@ -20,7 +20,6 @@ import com.android.build.api.dsl.CommonExtension import com.android.build.api.dsl.ManagedVirtualDevice import org.gradle.api.Project import org.gradle.kotlin.dsl.invoke -import java.util.Locale /** * Configure project for Gradle managed devices @@ -55,7 +54,7 @@ private data class DeviceConfig( val systemImageSource: String, ) { val taskName = buildString { - append(device.toLowerCase(Locale.ROOT).replace(" ", "")) + append(device.lowercase().replace(" ", "")) append("api") append(apiLevel.toString()) append(systemImageSource.replace("-", "")) diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt index a34cd7c1c..d801d7b69 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/Jacoco.kt @@ -27,6 +27,7 @@ import org.gradle.kotlin.dsl.withType import org.gradle.testing.jacoco.plugins.JacocoPluginExtension import org.gradle.testing.jacoco.plugins.JacocoTaskExtension import org.gradle.testing.jacoco.tasks.JacocoReport +import java.util.Locale private val coverageExclusions = listOf( // Android @@ -36,6 +37,10 @@ private val coverageExclusions = listOf( "**/Manifest*.*" ) +private fun String.capitalize() = replaceFirstChar { + if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() +} + internal fun Project.configureJacoco( androidComponentsExtension: AndroidComponentsExtension<*, *, *>, ) { From 459717f8baca2baa0b851c5df4753350d02991a3 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sun, 16 Apr 2023 22:50:09 +0200 Subject: [PATCH 032/107] Remove `DSL_SCOPE_VIOLATION` suppression as it is now fixed in Gradle 8.1 --- core/database/build.gradle.kts | 2 -- core/datastore/build.gradle.kts | 2 -- core/model/build.gradle.kts | 3 +-- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 10eb4363c..a9c711ae3 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -14,8 +14,6 @@ * limitations under the License. */ -// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed -@Suppress("DSL_SCOPE_VIOLATION") plugins { id("nowinandroid.android.library") id("nowinandroid.android.library.jacoco") diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 59a3a8e32..6fecb37d4 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -14,8 +14,6 @@ * limitations under the License. */ -// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed -@Suppress("DSL_SCOPE_VIOLATION") plugins { id("nowinandroid.android.library") id("nowinandroid.android.library.jacoco") diff --git a/core/model/build.gradle.kts b/core/model/build.gradle.kts index edfcc4596..d49c5b3ee 100644 --- a/core/model/build.gradle.kts +++ b/core/model/build.gradle.kts @@ -13,8 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed -@Suppress("DSL_SCOPE_VIOLATION") + plugins { id("kotlin") } From 1341f7c2c59162b852ab7e70f096eac8f36e46c3 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sun, 16 Apr 2023 22:50:57 +0200 Subject: [PATCH 033/107] Replace unused variable with simpler method calls --- app-nia-catalog/build.gradle.kts | 2 +- app/build.gradle.kts | 4 ++-- benchmarks/build.gradle.kts | 2 +- core/datastore/build.gradle.kts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app-nia-catalog/build.gradle.kts b/app-nia-catalog/build.gradle.kts index 7a8d61339..1f9ac1e2a 100644 --- a/app-nia-catalog/build.gradle.kts +++ b/app-nia-catalog/build.gradle.kts @@ -55,7 +55,7 @@ android { namespace = "com.google.samples.apps.niacatalog" buildTypes { - val release by getting { + release { // To publish on the Play store a private signing key is required, but to allow anyone // who clones the code to sign and run the release variant, use the debug signing key. // TODO: Abstract the signing configuration to a separate file to avoid hardcoding this. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70160f3e1..e172ed8bb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -39,7 +39,7 @@ android { } buildTypes { - val debug by getting { + debug { applicationIdSuffix = NiaBuildType.DEBUG.applicationIdSuffix } val release by getting { @@ -52,7 +52,7 @@ android { // TODO: Abstract the signing configuration to a separate file to avoid hardcoding this. signingConfig = signingConfigs.getByName("debug") } - val benchmark by creating { + create("benchmark") { // Enable all the optimizations from release build through initWith(release). initWith(release) matchingFallbacks.add("release") diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index fa8aeefb0..fb46ae63f 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -39,7 +39,7 @@ android { // This benchmark buildType is used for benchmarking, and should function like your // release build (for example, with minification on). It's signed with a debug key // for easy local/CI testing. - val benchmark by creating { + create("benchmark") { // Keep the build type debuggable so we can attach a debugger if needed. isDebuggable = true signingConfig = signingConfigs.getByName("debug") diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 6fecb37d4..a9ec7a78f 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -41,10 +41,10 @@ protobuf { generateProtoTasks { all().forEach { task -> task.builtins { - val java by registering { + register("java") { option("lite") } - val kotlin by registering { + register("kotlin") { option("lite") } } From f19dd06200808ea2f6e49df3db5983bbb22a35ac Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sun, 16 Apr 2023 22:54:45 +0200 Subject: [PATCH 034/107] Remove deprecated `kotlin.Experimental` opt-in This would produce the following warning messages: ``` w: Opt-in requirement marker kotlin.Experimental is unresolved. Please make sure it's present in the module dependencies ``` --- .../kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index e800d0570..65a734895 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -62,7 +62,6 @@ internal fun Project.configureKotlinAndroid( // Enable experimental coroutines APIs, including Flow "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", "-opt-in=kotlinx.coroutines.FlowPreview", - "-opt-in=kotlin.Experimental", ) } } From 661dd2bc2e581690b468cd7ccdcfba218b55e131 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Wed, 26 Apr 2023 20:26:22 +0200 Subject: [PATCH 035/107] Update Kotlin 1.8.20 stack - KSP 1.8.20-1.0.10 - Compose compiler 1.4.5 --- gradle/libs.versions.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 95b084fa7..289f6a363 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,7 +6,7 @@ androidxActivity = "1.7.0" androidxAppCompat = "1.5.1" androidxBrowser = "1.4.0" androidxComposeBom = "2023.01.00" -androidxComposeCompiler = "1.4.4" +androidxComposeCompiler = "1.4.5" androidxComposeMaterial3 = "1.1.0-alpha06" androidxComposeRuntimeTracing = "1.0.0-alpha01" androidxCore = "1.9.0" @@ -37,11 +37,11 @@ hilt = "2.44.2" hiltExt = "1.0.0" jacoco = "0.8.7" junit4 = "4.13.2" -kotlin = "1.8.10" +kotlin = "1.8.20" kotlinxCoroutines = "1.6.4" kotlinxDatetime = "0.4.0" kotlinxSerializationJson = "1.5.0" -ksp = "1.8.10-1.0.9" +ksp = "1.8.20-1.0.10" lint = "30.3.1" okhttp = "4.10.0" protobuf = "3.21.12" From 5500edc37bc27a502ebe3371495fac23ede3a3d1 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Tue, 18 Apr 2023 07:54:21 +0200 Subject: [PATCH 036/107] Update KSP 1.8.20-1.0.11 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 289f6a363..ccfdeca99 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,7 @@ kotlin = "1.8.20" kotlinxCoroutines = "1.6.4" kotlinxDatetime = "0.4.0" kotlinxSerializationJson = "1.5.0" -ksp = "1.8.20-1.0.10" +ksp = "1.8.20-1.0.11" lint = "30.3.1" okhttp = "4.10.0" protobuf = "3.21.12" From 246d6c652865caabf20678b9b033c506ab4d06a0 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 4 Feb 2023 17:36:56 +0100 Subject: [PATCH 037/107] Use dedicated build step to package test apps before spawning the emulator --- .github/workflows/Build.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml index c15daea80..e7a23f358 100644 --- a/.github/workflows/Build.yaml +++ b/.github/workflows/Build.yaml @@ -82,6 +82,9 @@ jobs: - name: Setup Gradle uses: gradle/gradle-build-action@v2 + - name: Build AndroidTest apps + run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest --daemon + - name: Run instrumentation tests uses: reactivecircus/android-emulator-runner@v2 with: @@ -90,7 +93,7 @@ jobs: disable-animations: true disk-size: 6000M heap-size: 600M - script: ./gradlew connectedDemoDebugAndroidTest -x :benchmark:connectedDemoBenchmarkAndroidTest + script: ./gradlew connectedDemoDebugAndroidTest -x :benchmark:connectedDemoBenchmarkAndroidTest --daemon - name: Upload test reports if: always() From da39abb07efb9c666135fbee7e381bac4a5a352c Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Wed, 26 Apr 2023 23:30:32 +0200 Subject: [PATCH 038/107] Use dedicated build step to package test apps before spawning the emulator --- .github/workflows/AndroidCIWithGmd.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/AndroidCIWithGmd.yaml b/.github/workflows/AndroidCIWithGmd.yaml index 1c1206ed4..9aa8f2ad9 100644 --- a/.github/workflows/AndroidCIWithGmd.yaml +++ b/.github/workflows/AndroidCIWithGmd.yaml @@ -24,6 +24,9 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v2 + - name: Build AndroidTest apps + run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest + - name: Run instrumented tests with GMD run: ./gradlew cleanManagedDevices --unused-only && ./gradlew ${{ matrix.device-config }}DemoDebugAndroidTest -Dorg.gradle.workers.max=1 From 42ba433be394103ddafcc8a3ddfd61bbda8c1d62 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Wed, 26 Apr 2023 22:14:37 +0100 Subject: [PATCH 039/107] Add missing gradle/gradle-build-action setup in AndroidCIWithGmd.yaml This will prevent downloading and unzipping Gradle distribution but fetch it from the cache instead like other workflows. --- .github/workflows/AndroidCIWithGmd.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/AndroidCIWithGmd.yaml b/.github/workflows/AndroidCIWithGmd.yaml index 1c1206ed4..36d7115ae 100644 --- a/.github/workflows/AndroidCIWithGmd.yaml +++ b/.github/workflows/AndroidCIWithGmd.yaml @@ -15,11 +15,12 @@ jobs: device-config: [ "pixel4api30aospatd", "pixelcapi30aospatd" ] steps: + - uses: actions/checkout@v3 - uses: actions/setup-java@v3 with: distribution: 'zulu' java-version: 17 - - uses: actions/checkout@v3 + - uses: gradle/gradle-build-action@v2 - name: Setup Android SDK uses: android-actions/setup-android@v2 From 57d1bed2cccfe14e091551c95bb626a49674c88a Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Thu, 27 Apr 2023 00:27:20 +0200 Subject: [PATCH 040/107] Disable unnecessary Android instrumented tests --- .../kotlin/AndroidLibraryConventionPlugin.kt | 2 ++ .../nowinandroid/AndroidInstrumentedTests.kt | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+) create mode 100644 build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidInstrumentedTests.kt diff --git a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt index 5b2f76edb..275a26620 100644 --- a/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt +++ b/build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt @@ -20,6 +20,7 @@ import com.google.samples.apps.nowinandroid.configureFlavors import com.google.samples.apps.nowinandroid.configureGradleManagedDevices import com.google.samples.apps.nowinandroid.configureKotlinAndroid import com.google.samples.apps.nowinandroid.configurePrintApksTask +import com.google.samples.apps.nowinandroid.disableUnnecessaryAndroidTests import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension @@ -44,6 +45,7 @@ class AndroidLibraryConventionPlugin : Plugin { } extensions.configure { configurePrintApksTask(this) + disableUnnecessaryAndroidTests(target) } val libs = extensions.getByType().named("libs") configurations.configureEach { diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidInstrumentedTests.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidInstrumentedTests.kt new file mode 100644 index 000000000..d0c26e4e6 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/AndroidInstrumentedTests.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid + +import com.android.build.api.variant.LibraryAndroidComponentsExtension +import org.gradle.api.Project + +/** + * Disable unnecessary Android instrumented tests for the [project] if there is no `androidTest` folder. + * Otherwise, these projects would be compiled, packaged, installed and ran only to end-up with the following message: + * + * > Starting 0 tests on AVD + * + * Note: this could be improved by checking other potential sourceSets based on buildTypes and flavors. + */ +internal fun LibraryAndroidComponentsExtension.disableUnnecessaryAndroidTests( + project: Project, +) = beforeVariants { + it.enableAndroidTest = it.enableAndroidTest + && project.projectDir.resolve("src/androidTest").exists() +} From c364dfc84e0d50e63cfbf4203e1ea2a9af932498 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Fri, 28 Apr 2023 08:39:03 -0400 Subject: [PATCH 041/107] Update message sender in SyncNotificationsService --- .../nowinandroid/sync/services/SyncNotificationsService.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt index 1d182dda1..e51e30164 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt @@ -19,10 +19,11 @@ package com.google.samples.apps.nowinandroid.sync.services import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import com.google.samples.apps.nowinandroid.core.data.util.SyncManager -import com.google.samples.apps.nowinandroid.sync.initializers.SYNC_TOPIC import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject +private const val SYNC_TOPIC_SENDER = "/topics/sync" + @AndroidEntryPoint class SyncNotificationsService : FirebaseMessagingService() { @@ -30,7 +31,7 @@ class SyncNotificationsService : FirebaseMessagingService() { lateinit var syncManager: SyncManager override fun onMessageReceived(message: RemoteMessage) { - if (SYNC_TOPIC == message.from) { + if (SYNC_TOPIC_SENDER == message.from) { syncManager.requestSync() } } From a68fe38411c7902b0cee1cd5afb0029e92d2b184 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Wed, 26 Apr 2023 23:30:32 +0200 Subject: [PATCH 042/107] Use dedicated build step to package test apps before spawning the emulator --- .github/workflows/AndroidCIWithGmd.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/AndroidCIWithGmd.yaml b/.github/workflows/AndroidCIWithGmd.yaml index 1c1206ed4..9aa8f2ad9 100644 --- a/.github/workflows/AndroidCIWithGmd.yaml +++ b/.github/workflows/AndroidCIWithGmd.yaml @@ -24,6 +24,9 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v2 + - name: Build AndroidTest apps + run: ./gradlew packageDemoDebug packageDemoDebugAndroidTest + - name: Run instrumented tests with GMD run: ./gradlew cleanManagedDevices --unused-only && ./gradlew ${{ matrix.device-config }}DemoDebugAndroidTest -Dorg.gradle.workers.max=1 From ef27c050b62a0ec2c75fa98ef3b585c72fa0a474 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Wed, 26 Apr 2023 22:14:37 +0100 Subject: [PATCH 043/107] Add missing gradle/gradle-build-action setup in AndroidCIWithGmd.yaml This will prevent downloading and unzipping Gradle distribution but fetch it from the cache instead like other workflows. --- .github/workflows/AndroidCIWithGmd.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/AndroidCIWithGmd.yaml b/.github/workflows/AndroidCIWithGmd.yaml index 9aa8f2ad9..4c27018c4 100644 --- a/.github/workflows/AndroidCIWithGmd.yaml +++ b/.github/workflows/AndroidCIWithGmd.yaml @@ -15,11 +15,12 @@ jobs: device-config: [ "pixel4api30aospatd", "pixelcapi30aospatd" ] steps: + - uses: actions/checkout@v3 - uses: actions/setup-java@v3 with: distribution: 'zulu' java-version: 17 - - uses: actions/checkout@v3 + - uses: gradle/gradle-build-action@v2 - name: Setup Android SDK uses: android-actions/setup-android@v2 From 4550743fac032677a4947799b47ea1ecb32bc091 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 29 Apr 2023 12:47:48 +0200 Subject: [PATCH 044/107] Create and use CI specific group of GMD devices This should reduce the total CI time of `AndroidCIWithGmd` workflow. Using the matrix strategy at the GitHub Actions level forces us to run the tests sequentially and download/rebuild everything from scratch at each iteration. --- .github/workflows/AndroidCIWithGmd.yaml | 5 +---- .../apps/nowinandroid/GradleManagedDevices.kt | 22 +++++++++++++------ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/.github/workflows/AndroidCIWithGmd.yaml b/.github/workflows/AndroidCIWithGmd.yaml index 4c27018c4..80a70834e 100644 --- a/.github/workflows/AndroidCIWithGmd.yaml +++ b/.github/workflows/AndroidCIWithGmd.yaml @@ -10,9 +10,6 @@ jobs: android-ci: runs-on: macos-12 - strategy: - matrix: - device-config: [ "pixel4api30aospatd", "pixelcapi30aospatd" ] steps: - uses: actions/checkout@v3 @@ -30,7 +27,7 @@ jobs: - name: Run instrumented tests with GMD run: ./gradlew cleanManagedDevices --unused-only && - ./gradlew ${{ matrix.device-config }}DemoDebugAndroidTest -Dorg.gradle.workers.max=1 + ./gradlew ciDemoDebugAndroidTest -Dorg.gradle.workers.max=1 -Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true - name: Upload test reports diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt index 8129defeb..86e29be33 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/GradleManagedDevices.kt @@ -18,7 +18,7 @@ package com.google.samples.apps.nowinandroid import com.android.build.api.dsl.CommonExtension import com.android.build.api.dsl.ManagedVirtualDevice -import org.gradle.api.Project +import org.gradle.kotlin.dsl.get import org.gradle.kotlin.dsl.invoke /** @@ -27,16 +27,17 @@ import org.gradle.kotlin.dsl.invoke internal fun configureGradleManagedDevices( commonExtension: CommonExtension<*, *, *, *>, ) { - val deviceConfigs = listOf( - DeviceConfig("Pixel 4", 30, "aosp-atd"), - DeviceConfig("Pixel 6", 31, "aosp"), - DeviceConfig("Pixel C", 30, "aosp-atd"), - ) + val pixel4 = DeviceConfig("Pixel 4", 30, "aosp-atd") + val pixel6 = DeviceConfig("Pixel 6", 31, "aosp") + val pixelC = DeviceConfig("Pixel C", 30, "aosp-atd") + + val allDevices = listOf(pixel4, pixel6, pixelC) + val ciDevices = listOf(pixel4, pixelC) commonExtension.testOptions { managedDevices { devices { - deviceConfigs.forEach { deviceConfig -> + allDevices.forEach { deviceConfig -> maybeCreate(deviceConfig.taskName, ManagedVirtualDevice::class.java).apply { device = deviceConfig.device apiLevel = deviceConfig.apiLevel @@ -44,6 +45,13 @@ internal fun configureGradleManagedDevices( } } } + groups { + maybeCreate("ci").apply { + ciDevices.forEach { deviceConfig -> + targetDevices.add(devices[deviceConfig.taskName]) + } + } + } } } } From cd6931d6736c20d7e0046a27219cab43df7c7931 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 29 Apr 2023 14:52:32 +0200 Subject: [PATCH 045/107] Fix instrumented test reports archives - Fixes missing tests reports from `AndroidCIWithGmd.yaml` ``` Warning: No files were found with the provided path: '**/*/build/reports/androidTests/'. No artifacts will be uploaded. ``` - Fixes missing tests reports from `Build.yaml`, where only top-level tests from `:app` were reported. --- .github/workflows/AndroidCIWithGmd.yaml | 3 +-- .github/workflows/Build.yaml | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/AndroidCIWithGmd.yaml b/.github/workflows/AndroidCIWithGmd.yaml index 1c1206ed4..f1af37591 100644 --- a/.github/workflows/AndroidCIWithGmd.yaml +++ b/.github/workflows/AndroidCIWithGmd.yaml @@ -34,5 +34,4 @@ jobs: uses: actions/upload-artifact@v3 with: name: test-reports - path: | - '**/*/build/reports/androidTests/' + path: '**/build/reports/androidTests' diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml index c15daea80..008e1635f 100644 --- a/.github/workflows/Build.yaml +++ b/.github/workflows/Build.yaml @@ -97,4 +97,4 @@ jobs: uses: actions/upload-artifact@v3 with: name: test-reports-${{ matrix.api-level }} - path: '*/build/reports/androidTests' + path: '**/build/reports/androidTests' From 1269b29aee45a282875cf1b94b667f3807633752 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 29 Apr 2023 15:18:36 +0200 Subject: [PATCH 046/107] Fix incomplete build reports - Add all generated lint reports (only app was archived) - Add all unit tests reports (none were archived) - Add all APKs (missing app-nia-catalog) and remove unnecessary files (logs, sdkDependencies, proguard's config/mapping/seeds etc. which are also quite large compared to APKs) --- .github/workflows/Build.yaml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml index c15daea80..2d94c322e 100644 --- a/.github/workflows/Build.yaml +++ b/.github/workflows/Build.yaml @@ -48,15 +48,22 @@ jobs: - name: Upload build outputs (APKs) uses: actions/upload-artifact@v3 with: - name: build-outputs - path: app/build/outputs + name: APKs + path: '**/build/outputs/apk/**/*.apk' - - name: Upload build reports + - name: Upload lint reports (HTML) if: always() uses: actions/upload-artifact@v3 with: - name: build-reports - path: app/build/reports + name: lint-reports + path: '**/build/reports/lint-results-*.html' + + - name: Upload test results (XML) + if: always() + uses: actions/upload-artifact@v3 + with: + name: test-results + path: '**/build/test-results/test*UnitTest/**.xml' androidTest: needs: build From 487a288191f583cf50aeb8c0a50606b7f1cf97d5 Mon Sep 17 00:00:00 2001 From: vishesh211 Date: Sun, 30 Apr 2023 00:06:57 +0530 Subject: [PATCH 047/107] fix: removed disableSplitMotionEvents --- .../feature/interests/TabContent.kt | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) 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 bad2c1220..5fe24dcf4 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 @@ -42,8 +42,7 @@ fun TopicsTabContent( LazyColumn( modifier = modifier .padding(horizontal = 16.dp) - .testTag("interests:topics") - .disableSplitMotionEvents(), + .testTag("interests:topics"), contentPadding = PaddingValues(top = 8.dp) ) { topics.forEach { followableTopic -> @@ -65,22 +64,3 @@ fun TopicsTabContent( } } } - -fun Modifier.disableSplitMotionEvents() = - pointerInput(Unit) { - coroutineScope { - var currentId: Long = -1L - awaitPointerEventScope { - while (true) { - awaitPointerEvent(PointerEventPass.Initial).changes.forEach { pointerInfo -> - when { - pointerInfo.pressed && currentId == -1L -> currentId = pointerInfo.id.value - pointerInfo.pressed.not() && currentId == pointerInfo.id.value -> currentId = -1 - pointerInfo.id.value != currentId && currentId != -1L -> pointerInfo.consume() - else -> Unit - } - } - } - } - } - } From 079f92ea32871595f1186fab3f5de81cec327d94 Mon Sep 17 00:00:00 2001 From: vishesh211 Date: Sun, 30 Apr 2023 00:07:52 +0530 Subject: [PATCH 048/107] feat: added launchSingleTop in navigate --- .../nowinandroid/feature/topic/navigation/TopicNavigation.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt index 1cc43bbd1..abba3a116 100644 --- a/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt +++ b/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt @@ -37,7 +37,9 @@ internal class TopicArgs(val topicId: String) { fun NavController.navigateToTopic(topicId: String) { val encodedId = Uri.encode(topicId) - this.navigate("topic_route/$encodedId") + this.navigate("topic_route/$encodedId") { + launchSingleTop = true + } } fun NavGraphBuilder.topicScreen( From 51379a249db820fac375ac8c793285a36300861e Mon Sep 17 00:00:00 2001 From: gururani-abhishek Date: Sun, 30 Apr 2023 14:04:15 +0530 Subject: [PATCH 049/107] fix #614 : removed unused onItemClick parameter from NewsResourceCardList.kt 1. removed the unused onItemClick parameter 2. removed now redundant variables it used 3. removed its description from the comments. --- .../apps/nowinandroid/core/ui/NewsResourceCardList.kt | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) 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 5cf7d7313..4d04ea620 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 @@ -31,23 +31,18 @@ import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource * [UserNewsResource]s. * * [onToggleBookmark] defines the action invoked when a user wishes to bookmark an item - * [onItemClick] optional parameter for action to be performed when the card is clicked. The - * default action launches an intent matching the card. + * Default action launches an intent matching the card. */ fun LazyListScope.userNewsResourceCardItems( items: List, onToggleBookmark: (item: UserNewsResource) -> Unit, onNewsResourceViewed: (String) -> Unit, - onItemClick: ((item: UserNewsResource) -> Unit)? = null, onTopicClick: (String) -> Unit, itemModifier: Modifier = Modifier, ) = items( items = items, key = { it.id }, itemContent = { userNewsResource -> - val resourceUrl = Uri.parse(userNewsResource.url) - val backgroundColor = MaterialTheme.colorScheme.background.toArgb() - val context = LocalContext.current val analyticsHelper = LocalAnalyticsHelper.current NewsResourceCardExpanded( @@ -59,10 +54,6 @@ fun LazyListScope.userNewsResourceCardItems( analyticsHelper.logNewsResourceOpened( newsResourceId = userNewsResource.id, ) - when (onItemClick) { - null -> launchCustomChromeTab(context, resourceUrl, backgroundColor) - else -> onItemClick(userNewsResource) - } onNewsResourceViewed(userNewsResource.id) }, onTopicClick = onTopicClick, From bbc80bb0e2b836981e3d335a6e457d910375fd3a Mon Sep 17 00:00:00 2001 From: gururani-abhishek Date: Wed, 3 May 2023 16:40:36 +0530 Subject: [PATCH 050/107] added back launchCustomChromeTab() --- .../samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt | 4 ++++ 1 file changed, 4 insertions(+) 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 4d04ea620..81a5b9c4a 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 @@ -43,6 +43,9 @@ fun LazyListScope.userNewsResourceCardItems( items = items, key = { it.id }, itemContent = { userNewsResource -> + val resourceUrl = Uri.parse(userNewsResource.url) + val backgroundColor = MaterialTheme.colorScheme.background.toArgb() + val context = LocalContext.current val analyticsHelper = LocalAnalyticsHelper.current NewsResourceCardExpanded( @@ -54,6 +57,7 @@ fun LazyListScope.userNewsResourceCardItems( analyticsHelper.logNewsResourceOpened( newsResourceId = userNewsResource.id, ) + launchCustomChromeTab(context, resourceUrl, backgroundColor) onNewsResourceViewed(userNewsResource.id) }, onTopicClick = onTopicClick, From f6bdba08d9f3f65acc27777f97e0fcda38bf8961 Mon Sep 17 00:00:00 2001 From: gururani-abhishek Date: Wed, 3 May 2023 22:39:50 +0530 Subject: [PATCH 051/107] added comment to document what happens on tapping news resource card. --- .../samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 81a5b9c4a..ea1c09d01 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 @@ -31,7 +31,7 @@ import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource * [UserNewsResource]s. * * [onToggleBookmark] defines the action invoked when a user wishes to bookmark an item - * Default action launches an intent matching the card. + * When a news resource card is tapped it will open the news resource URL in a Chrome Custom Tab. */ fun LazyListScope.userNewsResourceCardItems( items: List, From 794d84cf6778d1084a1da3f022c658fe1456b3ed Mon Sep 17 00:00:00 2001 From: vishesh211 Date: Thu, 4 May 2023 22:52:18 +0530 Subject: [PATCH 052/107] done required cleanup --- build.gradle.kts | 1 - .../apps/nowinandroid/feature/interests/TabContent.kt | 9 +++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index fe4cad4d5..7384aa1a2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -26,7 +26,6 @@ buildscript { } plugins { - java application alias(libs.plugins.android.application) apply false alias(libs.plugins.kotlin.jvm) apply false 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 5fe24dcf4..dcca35795 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 @@ -25,25 +25,22 @@ import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.google.samples.apps.nowinandroid.core.domain.model.FollowableTopic -import kotlinx.coroutines.coroutineScope @Composable fun TopicsTabContent( topics: List, onTopicClick: (String) -> Unit, onFollowButtonClick: (String, Boolean) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { LazyColumn( modifier = modifier .padding(horizontal = 16.dp) .testTag("interests:topics"), - contentPadding = PaddingValues(top = 8.dp) + contentPadding = PaddingValues(top = 8.dp), ) { topics.forEach { followableTopic -> val topicId = followableTopic.topic.id @@ -54,7 +51,7 @@ fun TopicsTabContent( description = followableTopic.topic.shortDescription, topicImageUrl = followableTopic.topic.imageUrl, onClick = { onTopicClick(topicId) }, - onFollowButtonClick = { onFollowButtonClick(topicId, it) } + onFollowButtonClick = { onFollowButtonClick(topicId, it) }, ) } } From 1e75be21e7532411e3e32c2eb19ce20b377f0c70 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Thu, 4 May 2023 21:35:20 +0200 Subject: [PATCH 053/107] Add missing steps in `AndroidCIWithGmd.yaml` - `gradle/wrapper-validation-action` - copy CI's `gradle.properties` file And restore explicit steps names. --- .github/workflows/AndroidCIWithGmd.yaml | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/AndroidCIWithGmd.yaml b/.github/workflows/AndroidCIWithGmd.yaml index 36d7115ae..78902cb32 100644 --- a/.github/workflows/AndroidCIWithGmd.yaml +++ b/.github/workflows/AndroidCIWithGmd.yaml @@ -15,12 +15,23 @@ jobs: device-config: [ "pixel4api30aospatd", "pixelcapi30aospatd" ] steps: - - uses: actions/checkout@v3 - - uses: actions/setup-java@v3 + - name: Checkout + uses: actions/checkout@v3 + + - name: Validate Gradle Wrapper + uses: gradle/wrapper-validation-action@v1 + + - name: Copy CI gradle.properties + run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties + + - name: Set up JDK 17 + uses: actions/setup-java@v3 with: distribution: 'zulu' java-version: 17 - - uses: gradle/gradle-build-action@v2 + + - name: Setup Gradle + uses: gradle/gradle-build-action@v2 - name: Setup Android SDK uses: android-actions/setup-android@v2 From 2499c0a0bd0df9a00c27903c5e8d8f461f84fc8b Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Fri, 5 May 2023 12:34:09 +0100 Subject: [PATCH 054/107] Notify users when news are updated --- core/notifications/build.gradle.kts | 1 + .../src/main/AndroidManifest.xml | 4 +- .../notifications/AndroidSystemNotifier.kt | 115 +++++++++++++++++- .../src/main/res/values/strings.xml | 8 +- feature/foryou/build.gradle.kts | 1 + .../feature/foryou/ForYouScreen.kt | 22 ++++ gradle/libs.versions.toml | 1 + 7 files changed, 144 insertions(+), 8 deletions(-) diff --git a/core/notifications/build.gradle.kts b/core/notifications/build.gradle.kts index 608e59a38..86094c2c8 100644 --- a/core/notifications/build.gradle.kts +++ b/core/notifications/build.gradle.kts @@ -24,6 +24,7 @@ android { } dependencies { + implementation(project(":core:common")) implementation(project(":core:model")) implementation(libs.kotlinx.coroutines.android) diff --git a/core/notifications/src/main/AndroidManifest.xml b/core/notifications/src/main/AndroidManifest.xml index 31c889874..5f602c346 100644 --- a/core/notifications/src/main/AndroidManifest.xml +++ b/core/notifications/src/main/AndroidManifest.xml @@ -14,4 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. --> - + + + 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 index 00d97fcb3..6b4c53ef3 100644 --- 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 @@ -16,17 +16,126 @@ package com.google.samples.apps.nowinandroid.core.notifications +import android.Manifest.permission +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.InboxStyle +import androidx.core.app.NotificationManagerCompat import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton +private const val NEWS_NOTIFICATION_SUMMARY_ID = 1 +private const val NEWS_NOTIFICATION_CHANNEL_ID = "" +private const val NEWS_NOTIFICATION_GROUP = "NEWS_NOTIFICATIONS" + /** * Implementation of [Notifier] that displays notifications in the system tray. */ @Singleton -class AndroidSystemNotifier @Inject constructor() : Notifier { +class AndroidSystemNotifier @Inject constructor( + @ApplicationContext private val context: Context, +) : Notifier { + + override fun onNewsAdded( + newsResources: List, + ) = with(context) { + if (ActivityCompat.checkSelfPermission( + this, + permission.POST_NOTIFICATIONS, + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + + val newsNotifications = newsResources.map { newsResource -> + newsNotification { + setSmallIcon( + com.google.samples.apps.nowinandroid.core.common.R.drawable.ic_nia_notification, + ) + .setContentTitle(newsResource.title) + .setContentText(newsResource.content) + .setGroup(NEWS_NOTIFICATION_GROUP) + } + } + val summaryNotification = newsNotification { + val title = getString( + R.string.news_notification_group_summary, + newsNotifications.size, + ) + setContentTitle(title) + .setContentText(title) + .setSmallIcon( + com.google.samples.apps.nowinandroid.core.common.R.drawable.ic_nia_notification, + ) + // Build summary info into InboxStyle template. + .setStyle(newsInboxStyle(newsResources, title)) + .setGroup(NEWS_NOTIFICATION_GROUP) + .setGroupSummary(true) + .build() + } + + with(NotificationManagerCompat.from(this)) { + newsNotifications.forEachIndexed { index, notification -> + notify(newsResources[index].id.hashCode(), notification) + } + notify(NEWS_NOTIFICATION_SUMMARY_ID, summaryNotification) + } + } + + /** + * Creates an inbox style summary notification for news updates + */ + private fun newsInboxStyle( + newsResources: List, + title: String, + ): InboxStyle = newsResources + // Show at most 5 lines + .take(5) + .fold(InboxStyle()) { inboxStyle, newsResource -> + inboxStyle.addLine(newsResource.title) + } + .setBigContentTitle(title) + .setSummaryText(title) +} + +/** + * Creates a notification for configured for news updates + */ +private fun Context.newsNotification( + block: NotificationCompat.Builder.() -> Unit, +): Notification { + ensureNotificationChannel() + return NotificationCompat.Builder( + this, + NEWS_NOTIFICATION_CHANNEL_ID, + ) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .apply(block) + .build() +} + +/** + * Ensures the a notification channel is is present if applicable + */ +private fun Context.ensureNotificationChannel() { + if (VERSION.SDK_INT < VERSION_CODES.O) return - override fun onNewsAdded(newsResources: List) { - // TODO, create notification and display to the user + val channel = NotificationChannel( + NEWS_NOTIFICATION_CHANNEL_ID, + getString(R.string.news_notification_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = getString(R.string.news_notification_channel_description) } + // Register the channel with the system + NotificationManagerCompat.from(this).createNotificationChannel(channel) } diff --git a/core/notifications/src/main/res/values/strings.xml b/core/notifications/src/main/res/values/strings.xml index e3fd73ff8..a3f8a4e61 100644 --- a/core/notifications/src/main/res/values/strings.xml +++ b/core/notifications/src/main/res/values/strings.xml @@ -15,8 +15,8 @@ limitations under the License. --> - Now in Android - Sync - Background tasks for Now in Android - + Now in Android + News updates + The latest updates on what\'s new in Android + %1$d news updates diff --git a/feature/foryou/build.gradle.kts b/feature/foryou/build.gradle.kts index 8c6747dd1..6cd5216d6 100644 --- a/feature/foryou/build.gradle.kts +++ b/feature/foryou/build.gradle.kts @@ -29,4 +29,5 @@ android { dependencies { implementation(libs.kotlinx.datetime) implementation(libs.androidx.activity.compose) + implementation(libs.accompanist.permissions) } 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 06c73c971..a7059e420 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,6 +16,8 @@ package com.google.samples.apps.nowinandroid.feature.foryou +import android.os.Build.VERSION +import android.os.Build.VERSION_CODES import androidx.activity.compose.ReportDrawnWhen import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -57,6 +59,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -73,6 +76,9 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.util.trace import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus.Denied +import com.google.accompanist.permissions.rememberPermissionState import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaIconToggleButton @@ -199,6 +205,7 @@ internal fun ForYouScreen( } } TrackScreenViewEvent(screenName = "ForYou") + requestNotificationsPermission() } /** @@ -383,6 +390,21 @@ fun TopicIcon( ) } +@Composable +@OptIn(ExperimentalPermissionsApi::class) +private fun requestNotificationsPermission() { + if (VERSION.SDK_INT < VERSION_CODES.TIRAMISU) return + val notificationsPermissionState = rememberPermissionState( + android.Manifest.permission.POST_NOTIFICATIONS, + ) + LaunchedEffect(notificationsPermissionState) { + val status = notificationsPermissionState.status + if (status is Denied && !status.shouldShowRationale) { + notificationsPermissionState.launchPermissionRequest() + } + } +} + @DevicePreviews @Composable fun ForYouScreenPopulatedFeed( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ccfdeca99..d3af7678f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -55,6 +55,7 @@ turbine = "0.12.1" [libraries] accompanist-systemuicontroller = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "accompanist" } accompanist-testharness = { group = "com.google.accompanist", name = "accompanist-testharness", version.ref = "accompanist" } +accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" } From 8a3a16de212763e8e5b37ab54cda31c1f2b8e35f Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Fri, 5 May 2023 13:21:14 +0100 Subject: [PATCH 055/107] Add pending intent for opening notification links --- core/notifications/build.gradle.kts | 1 + .../notifications/AndroidSystemNotifier.kt | 32 +++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/core/notifications/build.gradle.kts b/core/notifications/build.gradle.kts index 86094c2c8..012c6f3f3 100644 --- a/core/notifications/build.gradle.kts +++ b/core/notifications/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { implementation(project(":core:model")) implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.browser) implementation(libs.androidx.compose.runtime) implementation(libs.androidx.core.ktx) 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 index 6b4c53ef3..c8f3ed9b1 100644 --- 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 @@ -20,12 +20,16 @@ import android.Manifest.permission import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context import android.content.pm.PackageManager +import android.net.Uri import android.os.Build.VERSION import android.os.Build.VERSION_CODES +import androidx.browser.customtabs.CustomTabsIntent import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.EXTRA_NOTIFICATION_ID import androidx.core.app.NotificationCompat.InboxStyle import androidx.core.app.NotificationManagerCompat import com.google.samples.apps.nowinandroid.core.model.data.NewsResource @@ -33,6 +37,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton +private const val NEWS_NOTIFICATION_REQUEST_CODE = 0 private const val NEWS_NOTIFICATION_SUMMARY_ID = 1 private const val NEWS_NOTIFICATION_CHANNEL_ID = "" private const val NEWS_NOTIFICATION_GROUP = "NEWS_NOTIFICATIONS" @@ -52,9 +57,7 @@ class AndroidSystemNotifier @Inject constructor( this, permission.POST_NOTIFICATIONS, ) != PackageManager.PERMISSION_GRANTED - ) { - return - } + ) return val newsNotifications = newsResources.map { newsResource -> newsNotification { @@ -63,7 +66,9 @@ class AndroidSystemNotifier @Inject constructor( ) .setContentTitle(newsResource.title) .setContentText(newsResource.content) + .setContentIntent(newsPendingIntent(newsResource)) .setGroup(NEWS_NOTIFICATION_GROUP) + .setAutoCancel(true) } } val summaryNotification = newsNotification { @@ -80,6 +85,7 @@ class AndroidSystemNotifier @Inject constructor( .setStyle(newsInboxStyle(newsResources, title)) .setGroup(NEWS_NOTIFICATION_GROUP) .setGroupSummary(true) + .setAutoCancel(true) .build() } @@ -139,3 +145,23 @@ private fun Context.ensureNotificationChannel() { // Register the channel with the system NotificationManagerCompat.from(this).createNotificationChannel(channel) } + +private fun Context.newsPendingIntent(newsResource: NewsResource): PendingIntent? = + PendingIntent.getActivity( + this, + NEWS_NOTIFICATION_REQUEST_CODE, + // TODO: Read color from material theme to style the chrome custom tab + // this is currently only readable from composition. + CustomTabsIntent.Builder() + .build() + .apply { + intent.data = Uri.parse(newsResource.url) + if (VERSION.SDK_INT < VERSION_CODES.O) { + intent.putExtra( + EXTRA_NOTIFICATION_ID, + newsResource.id.hashCode(), + ) + } + }.intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) From ef97cb941c72ac89c1c1790db708966b3d8bbdd9 Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Sat, 6 May 2023 11:01:31 +0100 Subject: [PATCH 056/107] Route notification deep link through for you screen --- app/src/main/AndroidManifest.xml | 6 +- .../repository/OfflineFirstNewsRepository.kt | 4 +- .../core/notifications/NoOpNotifier.kt | 2 +- .../core/notifications/Notifier.kt | 2 +- ...ystemNotifier.kt => SystemTrayNotifier.kt} | 109 ++++++++++-------- .../testing/notifications/TestNotifier.kt | 2 +- .../feature/foryou/ForYouScreenTest.kt | 14 +++ .../feature/foryou/ForYouScreen.kt | 47 +++++++- .../feature/foryou/ForYouViewModel.kt | 40 +++++++ .../foryou/navigation/ForYouNavigation.kt | 16 ++- .../feature/foryou/ForYouViewModelTest.kt | 33 ++++++ 11 files changed, 217 insertions(+), 58 deletions(-) rename core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/{AndroidSystemNotifier.kt => SystemTrayNotifier.kt} (61%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 99c233910..0b0482c13 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,9 +42,13 @@ android:exported="true"> - + + + + + - \ No newline at end of file + + + 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 76b6494c3..c31bcbce5 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 @@ -16,12 +16,15 @@ package com.google.samples.apps.nowinandroid.feature.settings +import android.content.Intent import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row 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.width import androidx.compose.foundation.layout.widthIn @@ -40,6 +43,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -48,6 +52,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.DialogProperties import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.android.gms.oss.licenses.OssLicensesMenuActivity +import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTextButton import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme import com.google.samples.apps.nowinandroid.core.designsystem.theme.supportsDynamicTheming import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig @@ -236,56 +242,43 @@ fun SettingsDialogThemeChooserRow( } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun LinksPanel() { - Row( - modifier = Modifier.padding(top = 16.dp), + FlowRow( + horizontalArrangement = Arrangement.spacedBy( + space = 16.dp, + alignment = Alignment.CenterHorizontally, + ), + modifier = Modifier.fillMaxWidth() ) { - Column( - Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, + val uriHandler = LocalUriHandler.current + NiaTextButton( + onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) } ) { - Row { - TextLink( - text = stringResource(string.privacy_policy), - url = PRIVACY_POLICY_URL, - ) - Spacer(Modifier.width(16.dp)) - TextLink( - text = stringResource(string.licenses), - url = LICENSES_URL, - ) - } - Spacer(Modifier.height(16.dp)) - Row { - TextLink( - text = stringResource(string.brand_guidelines), - url = BRAND_GUIDELINES_URL, - ) - Spacer(Modifier.width(16.dp)) - TextLink( - text = stringResource(string.feedback), - url = FEEDBACK_URL, - ) + Text(text = stringResource(string.privacy_policy)) + } + val context = LocalContext.current + NiaTextButton( + onClick = { + context.startActivity(Intent(context, OssLicensesMenuActivity::class.java)) } + ) { + Text(text = stringResource(string.licenses)) + } + NiaTextButton( + onClick = { uriHandler.openUri(BRAND_GUIDELINES_URL) } + ) { + Text(text = stringResource(string.brand_guidelines)) + } + NiaTextButton( + onClick = { uriHandler.openUri(FEEDBACK_URL) } + ) { + Text(text = stringResource(string.feedback)) } } } -@Composable -private fun TextLink(text: String, url: String) { - val uriHandler = LocalUriHandler.current - - Text( - text = text, - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier - .padding(vertical = 8.dp) - .clickable { uriHandler.openUri(url) }, - ) -} - @Preview @Composable private fun PreviewSettingsDialog() { @@ -322,8 +315,5 @@ private fun PreviewSettingsDialogLoading() { /* ktlint-disable max-line-length */ private const val PRIVACY_POLICY_URL = "https://policies.google.com/privacy" -private const val LICENSES_URL = - "https://github.com/android/nowinandroid/blob/main/app/LICENSES.md#open-source-licenses-and-copyright-notices" -private const val BRAND_GUIDELINES_URL = - "https://developer.android.com/distribute/marketing-tools/brand-guidelines" +private const val BRAND_GUIDELINES_URL = "https://developer.android.com/distribute/marketing-tools/brand-guidelines" private const val FEEDBACK_URL = "https://goo.gle/nia-app-feedback" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5705059b0..653350f7d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -33,6 +33,8 @@ firebaseBom = "31.2.0" firebaseCrashlyticsPlugin = "2.9.2" firebasePerfPlugin = "1.4.2" gmsPlugin = "4.3.14" +googleOss = "17.0.1" +googleOssPlugin = "0.10.6" hilt = "2.44.2" hiltExt = "1.0.0" jacoco = "0.8.7" @@ -44,8 +46,8 @@ 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.9.1" +protobuf = "3.23.0" +protobufPlugin = "0.9.3" retrofit = "2.9.0" retrofitKotlinxSerializationJson = "1.0.0" room = "2.5.0" @@ -53,9 +55,9 @@ secrets = "2.0.1" turbine = "0.12.1" [libraries] +accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", 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" } -accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" } androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivity" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "androidxAppCompat" } @@ -99,11 +101,14 @@ androidx-work-testing = { group = "androidx.work", name = "work-testing", versio coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil" } coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } -firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" } +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-cloud-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx" } firebase-performance = { group = "com.google.firebase", name = "firebase-perf-ktx" } +firebase-performance-gradle = { group = "com.google.firebase", name = "perf-plugin", version.ref = "firebasePerfPlugin" } +google-oss-licenses = { group = "com.google.android.gms", name = "play-services-oss-licenses", version.ref = "googleOss" } +google-oss-licenses-plugin = { group = "com.google.android.gms", name = "oss-licenses-plugin", version.ref = "googleOssPlugin" } 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" } From c7c2bb04aa51ff1bdf5adde188a2544f451f9c55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Moczkowski?= Date: Tue, 16 May 2023 16:46:03 +0200 Subject: [PATCH 081/107] Spotless apply Change-Id: I773059a755bebf99af30a71fd44aa12e58f46b23 --- .../nowinandroid/feature/settings/SettingsDialog.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 c31bcbce5..bdcb01ed2 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 @@ -250,11 +250,11 @@ private fun LinksPanel() { space = 16.dp, alignment = Alignment.CenterHorizontally, ), - modifier = Modifier.fillMaxWidth() + modifier = Modifier.fillMaxWidth(), ) { val uriHandler = LocalUriHandler.current NiaTextButton( - onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) } + onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }, ) { Text(text = stringResource(string.privacy_policy)) } @@ -262,17 +262,17 @@ private fun LinksPanel() { NiaTextButton( onClick = { context.startActivity(Intent(context, OssLicensesMenuActivity::class.java)) - } + }, ) { Text(text = stringResource(string.licenses)) } NiaTextButton( - onClick = { uriHandler.openUri(BRAND_GUIDELINES_URL) } + onClick = { uriHandler.openUri(BRAND_GUIDELINES_URL) }, ) { Text(text = stringResource(string.brand_guidelines)) } NiaTextButton( - onClick = { uriHandler.openUri(FEEDBACK_URL) } + onClick = { uriHandler.openUri(FEEDBACK_URL) }, ) { Text(text = stringResource(string.feedback)) } From 13f1f8d0963496c1d6dd54ae43d742920b461888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Moczkowski?= Date: Wed, 17 May 2023 11:27:56 +0200 Subject: [PATCH 082/107] Exclude transient appcompat dependency Change-Id: Ifdd7e34192c5db8598c42f67088ac292b38ee3e2 --- feature/settings/build.gradle.kts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 67aca2fdd..349eff3e5 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -27,5 +27,7 @@ android { } dependencies { - implementation(libs.google.oss.licenses) + implementation(libs.google.oss.licenses) { + exclude(group = "androidx.appcompat") + } } From f809142cb238ef46ddb777375d5865f73998f223 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Moczkowski?= Date: Wed, 17 May 2023 12:05:07 +0200 Subject: [PATCH 083/107] Add missing appcompat dependency Change-Id: I6192623d21986962c17204720fa1a1778b773b56 --- feature/settings/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 349eff3e5..3229c350f 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -27,6 +27,7 @@ android { } dependencies { + implementation(libs.androidx.appcompat) implementation(libs.google.oss.licenses) { exclude(group = "androidx.appcompat") } From 627ccd939d7814f3c3bd8882c809e831886c4fbf Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Fri, 19 May 2023 10:34:50 +0100 Subject: [PATCH 084/107] chore(deps): update android-gradleplugin to v8.0.1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 653350f7d..0be000440 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] accompanist = "0.28.0" androidDesugarJdkLibs = "1.2.2" -androidGradlePlugin = "8.0.0" +androidGradlePlugin = "8.0.1" androidxActivity = "1.7.0" androidxAppCompat = "1.5.1" androidxBrowser = "1.4.0" From d6ba65c77f60a240eaaf6fda2603362ed8b59cc3 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Fri, 19 May 2023 10:45:02 +0100 Subject: [PATCH 085/107] Update `BookmarksGrid` horizontal spacing to fix #732 --- .../apps/nowinandroid/feature/bookmarks/BookmarksScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e2eb4524b..25412e851 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 @@ -181,7 +181,7 @@ private fun BookmarksGrid( LazyVerticalGrid( columns = Adaptive(300.dp), contentPadding = PaddingValues(16.dp), - horizontalArrangement = Arrangement.spacedBy(32.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(24.dp), state = scrollableState, modifier = modifier From 331e8cffaca5f7fae93267c9d25a7231106fb6bf Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Fri, 19 May 2023 11:45:38 +0100 Subject: [PATCH 086/107] Grant `POST_NOTIFICATIONS` permission in instrumented tests --- .../apps/nowinandroid/feature/foryou/ForYouScreenTest.kt | 7 +++++++ 1 file changed, 7 insertions(+) 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 eb27473bb..404ad55b9 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 @@ -16,6 +16,7 @@ package com.google.samples.apps.nowinandroid.feature.foryou +import android.Manifest import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.ui.test.assertHasClickAction @@ -28,6 +29,8 @@ import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performScrollToNode +import androidx.test.rule.GrantPermissionRule +import androidx.test.rule.GrantPermissionRule.grant import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState @@ -35,6 +38,10 @@ import org.junit.Rule import org.junit.Test class ForYouScreenTest { + + @get:Rule + val permissionTestRule: GrantPermissionRule = grant(Manifest.permission.POST_NOTIFICATIONS) + @get:Rule val composeTestRule = createAndroidComposeRule() From a605ab1727eba343a6ec44f2529f7c8c05b43cbc Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Fri, 19 May 2023 11:52:21 +0100 Subject: [PATCH 087/107] Update benchmarks module minSdk to 28 Call requires API level 24: - `CompilationMode` sealed class Calls requires API level 28: - `BaselineProfileRule()` - `baselineProfileRule.collectBaselineProfile(PACKAGE_NAME)` --- benchmarks/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index fb46ae63f..320d66647 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -25,7 +25,7 @@ android { namespace = "com.google.samples.apps.nowinandroid.benchmarks" defaultConfig { - minSdk = 23 + minSdk = 28 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" buildConfigField("String", "APP_BUILD_TYPE_SUFFIX", "\"\"") From 69879ff874161253b255de4e5c7bdf5fb024f228 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Fri, 19 May 2023 12:28:01 +0100 Subject: [PATCH 088/107] Grant permission only if TIRAMISU or above --- .../apps/nowinandroid/feature/foryou/ForYouScreenTest.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 404ad55b9..7a750a13d 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 @@ -17,6 +17,8 @@ package com.google.samples.apps.nowinandroid.feature.foryou import android.Manifest +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.TIRAMISU import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.ui.test.assertHasClickAction @@ -40,7 +42,9 @@ import org.junit.Test class ForYouScreenTest { @get:Rule - val permissionTestRule: GrantPermissionRule = grant(Manifest.permission.POST_NOTIFICATIONS) + val permissionTestRule: GrantPermissionRule = + if (SDK_INT < TIRAMISU) grant() + else grant(Manifest.permission.POST_NOTIFICATIONS) @get:Rule val composeTestRule = createAndroidComposeRule() From a93cce92f712a8d067751f751e7ffce0de544c2f Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Fri, 19 May 2023 12:51:35 +0100 Subject: [PATCH 089/107] Update ForYouScreenTest.kt --- .../apps/nowinandroid/feature/foryou/ForYouScreenTest.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 7a750a13d..b138cba06 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 @@ -43,8 +43,11 @@ class ForYouScreenTest { @get:Rule val permissionTestRule: GrantPermissionRule = - if (SDK_INT < TIRAMISU) grant() - else grant(Manifest.permission.POST_NOTIFICATIONS) + if (SDK_INT >= TIRAMISU) { + grant(Manifest.permission.POST_NOTIFICATIONS) + } else { + grant() + } @get:Rule val composeTestRule = createAndroidComposeRule() From ae50f3d332d5049843bc0458c743dce46fd697fc Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Wed, 24 May 2023 18:46:33 +0100 Subject: [PATCH 090/107] Remove benchmark androidTest task exclusion Task `:benchmark:connectedDemoBenchmarkAndroidTest` is not part of the `connectedDemoDebugAndroidTest` task graph and can therefore safely be removed. --- .github/workflows/Build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Build.yaml b/.github/workflows/Build.yaml index 0389dcf56..a3b328dcb 100644 --- a/.github/workflows/Build.yaml +++ b/.github/workflows/Build.yaml @@ -100,7 +100,7 @@ jobs: disable-animations: true disk-size: 6000M heap-size: 600M - script: ./gradlew connectedDemoDebugAndroidTest -x :benchmark:connectedDemoBenchmarkAndroidTest --daemon + script: ./gradlew connectedDemoDebugAndroidTest --daemon - name: Upload test reports if: always() From 01e5a68a8d9e9fc65619f2e82e94b66eac92b0b0 Mon Sep 17 00:00:00 2001 From: Vixb Date: Thu, 25 May 2023 08:11:51 +0800 Subject: [PATCH 091/107] Replace NiaTopLevelNavigation with TopLevelDestination based on #186 (#723) * Replace NiaTopLevelNavigation with TopLevelDestination based on #186 * Update ModularizationLearningJourney.md --- docs/ModularizationLearningJourney.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ModularizationLearningJourney.md b/docs/ModularizationLearningJourney.md index 32f1c2249..81e35c436 100644 --- a/docs/ModularizationLearningJourney.md +++ b/docs/ModularizationLearningJourney.md @@ -95,7 +95,7 @@ The Now in Android app contains the following types of modules: * The `app` module - contains app level and scaffolding classes that bind the rest of the codebase, such as `MainActivity`, `NiaApp` and app-level controlled navigation. A good example of this is the navigation setup through `NiaNavHost` and the bottom navigation bar setup - through `NiaTopLevelNavigation`. The `app` module depends on all `feature` modules and + through `TopLevelDestination`. The `app` module depends on all `feature` modules and required `core` modules. * `feature:` modules - feature specific modules which are scoped to handle a single responsibility @@ -132,7 +132,7 @@ Using the above modularization strategy, the Now in Android app has the followin Brings everything together required for the app to function correctly. This includes UI scaffolding and navigation. NiaApp, MainActivity
- App-level controlled navigation via NiaNavHost, NiaTopLevelNavigation + App-level controlled navigation via NiaNavHost, NiaAppState, TopLevelDestination From cd6ad7d575a82c6a88c9c8e0e13841743d3031e7 Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Thu, 25 May 2023 08:32:26 +0100 Subject: [PATCH 092/107] Ensure DAO exposes only reactive types Change-Id: I8992ccf6525cd4c5ea9503d532b500e4d310b7fb --- .../data/repository/DefaultSearchContentsRepository.kt | 9 ++++++++- .../nowinandroid/core/database/dao/NewsResourceDao.kt | 4 ---- 2 files changed, 8 insertions(+), 5 deletions(-) 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 index 40b170cbe..dc3caa143 100644 --- 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 @@ -20,6 +20,7 @@ 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.PopulatedNewsResource 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 @@ -29,6 +30,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.withContext @@ -45,7 +47,12 @@ class DefaultSearchContentsRepository @Inject constructor( override suspend fun populateFtsData() { withContext(ioDispatcher) { newsResourceFtsDao.insertAll( - newsResourceDao.getOneOffNewsResources().map { it.asFtsEntity() }, + newsResourceDao.getNewsResources( + useFilterTopicIds = false, + useFilterNewsIds = false, + ) + .first() + .map(PopulatedNewsResource::asFtsEntity), ) topicFtsDao.insertAll(topicDao.getOneOffTopicEntities().map { it.asFtsEntity() }) } 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 b5949c6d2..a05507a8b 100644 --- a/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt +++ b/core/database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/NewsResourceDao.kt @@ -65,10 +65,6 @@ interface NewsResourceDao { filterNewsIds: Set = emptySet(), ): Flow> - @Transaction - @Query(value = "SELECT * FROM news_resources ORDER BY publish_date DESC") - suspend fun getOneOffNewsResources(): List - /** * Inserts [entities] into the db if they don't exist, and ignores those that do */ From 4cdb5963a0af0ee50213b9238130f39d15d3ffba Mon Sep 17 00:00:00 2001 From: Don Turner Date: Thu, 25 May 2023 10:00:55 +0100 Subject: [PATCH 093/107] Add top level application scope, use IO dispatcher for DataStore Change-Id: I1512b1665587c73abd5e8a78aa9abd3eed24ab79 --- .../core/network/di/CoroutineScopesModule.kt | 15 ++++++++++----- .../core/datastore/di/DataStoreModule.kt | 7 +++++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt b/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt index b73c63568..c265394a8 100644 --- a/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt +++ b/core/common/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/CoroutineScopesModule.kt @@ -17,7 +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.IO +import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.Default import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -25,15 +25,20 @@ import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import javax.inject.Qualifier import javax.inject.Singleton +@Retention(AnnotationRetention.RUNTIME) +@Qualifier +annotation class ApplicationScope + @Module @InstallIn(SingletonComponent::class) object CoroutineScopesModule { @Provides @Singleton - @Dispatcher(IO) - fun providesIOCoroutineScope( - @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, - ): CoroutineScope = CoroutineScope(SupervisorJob() + ioDispatcher) + @ApplicationScope + fun providesCoroutineScope( + @Dispatcher(Default) dispatcher: CoroutineDispatcher, + ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher) } diff --git a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/di/DataStoreModule.kt index 89a6eb734..ac9eaf767 100644 --- a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/di/DataStoreModule.kt @@ -25,11 +25,13 @@ 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.network.Dispatcher import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO +import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import javax.inject.Singleton @@ -41,12 +43,13 @@ object DataStoreModule { @Singleton fun providesUserPreferencesDataStore( @ApplicationContext context: Context, - @Dispatcher(IO) ioScope: CoroutineScope, + @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, + @ApplicationScope scope: CoroutineScope, userPreferencesSerializer: UserPreferencesSerializer, ): DataStore = DataStoreFactory.create( serializer = userPreferencesSerializer, - scope = ioScope, + scope = CoroutineScope(scope.coroutineContext + ioDispatcher), migrations = listOf( IntToStringIdsMigration, ), From dcc36b0228cc0e99dda9983a661e2879bcd21e68 Mon Sep 17 00:00:00 2001 From: TJ Dahunsi Date: Thu, 25 May 2023 11:46:59 +0100 Subject: [PATCH 094/107] Fix tests Change-Id: Ie6b62071d2023e3a7ffe1553ab606e3f6b386a5e --- .../nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt index 09af77213..d5d8932e7 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/testdoubles/TestNewsResourceDao.kt @@ -67,8 +67,6 @@ class TestNewsResourceDao : NewsResourceDao { result } - override suspend fun getOneOffNewsResources(): List = emptyList() - override suspend fun insertOrIgnoreNewsResources( entities: List, ): List { From b245334a1811f89141bcacb5060fb2bcf2a48e8e Mon Sep 17 00:00:00 2001 From: Don Turner Date: Thu, 25 May 2023 13:11:14 +0100 Subject: [PATCH 095/107] Updating TestDataStoreModule to use ApplicationScope --- .../core/datastore/test/TestDataStoreModule.kt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/core/datastore-test/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt b/core/datastore-test/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/test/TestDataStoreModule.kt index cb7e38db7..b86003e83 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,8 +21,7 @@ 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 com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope import dagger.Module import dagger.Provides import dagger.hilt.components.SingletonComponent @@ -41,12 +40,12 @@ object TestDataStoreModule { @Provides @Singleton fun providesUserPreferencesDataStore( - @Dispatcher(IO) ioScope: CoroutineScope, + @ApplicationScope scope: CoroutineScope, userPreferencesSerializer: UserPreferencesSerializer, tmpFolder: TemporaryFolder, ): DataStore = tmpFolder.testUserPreferencesDataStore( - coroutineScope = ioScope, + coroutineScope = scope, userPreferencesSerializer = userPreferencesSerializer, ) } From 49c8ce700e6cfb6447e97421d7b2492a4cbdbac2 Mon Sep 17 00:00:00 2001 From: Alejandra Stamato Date: Wed, 17 May 2023 18:28:31 +0100 Subject: [PATCH 096/107] Fixing ScrollForYouFeedBenchmark Co-authored-by: Simon Marquis --- .../apps/nowinandroid/GeneralActions.kt | 44 +++++++++++++++++++ .../foryou/ScrollForYouFeedBenchmark.kt | 2 + .../TopicsScreenRecompositionBenchmark.kt | 3 +- 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 benchmarks/src/main/java/com/google/samples/apps/nowinandroid/GeneralActions.kt diff --git a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/GeneralActions.kt b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/GeneralActions.kt new file mode 100644 index 000000000..48472e523 --- /dev/null +++ b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/GeneralActions.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 + +import android.Manifest.permission +import android.os.Build.VERSION.SDK_INT +import android.os.Build.VERSION_CODES.TIRAMISU +import androidx.benchmark.macro.MacrobenchmarkScope + +/** + * Because the app under test is different from the one running the instrumentation test, + * the permission has to be granted manually by either: + * + * - tapping the Allow button + * ```kotlin + * val obj = By.text("Allow") + * val dialog = device.wait(Until.findObject(obj), TIMEOUT) + * dialog?.let { + * it.click() + * device.wait(Until.gone(obj), 5_000) + * } + * ``` + * - or (preferred) executing the grant command on the target package. + */ +fun MacrobenchmarkScope.allowNotifications() { + if (SDK_INT >= TIRAMISU) { + val command = "pm grant $packageName ${permission.POST_NOTIFICATIONS}" + device.executeShellCommand(command) + } +} diff --git a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt index f8945a31c..3008fdc0d 100644 --- a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt +++ b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/foryou/ScrollForYouFeedBenchmark.kt @@ -22,6 +22,7 @@ import androidx.benchmark.macro.StartupMode import androidx.benchmark.macro.junit4.MacrobenchmarkRule import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner import com.google.samples.apps.nowinandroid.PACKAGE_NAME +import com.google.samples.apps.nowinandroid.allowNotifications import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -47,6 +48,7 @@ class ScrollForYouFeedBenchmark { // Start the app pressHome() startActivityAndWait() + allowNotifications() }, ) { forYouWaitForContent() diff --git a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/interests/TopicsScreenRecompositionBenchmark.kt b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/interests/TopicsScreenRecompositionBenchmark.kt index 24bd233ea..0030386b7 100644 --- a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/interests/TopicsScreenRecompositionBenchmark.kt +++ b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/interests/TopicsScreenRecompositionBenchmark.kt @@ -23,6 +23,7 @@ import androidx.benchmark.macro.junit4.MacrobenchmarkRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.uiautomator.By import com.google.samples.apps.nowinandroid.PACKAGE_NAME +import com.google.samples.apps.nowinandroid.allowNotifications import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -47,7 +48,7 @@ class TopicsScreenRecompositionBenchmark { // Start the app pressHome() startActivityAndWait() - + allowNotifications() // Navigate to interests screen device.findObject(By.text("Interests")).click() device.waitForIdle() From c64cf13b4ecae1af81bb3657245fb9b78d028fa1 Mon Sep 17 00:00:00 2001 From: Alejandra Stamato Date: Thu, 25 May 2023 16:10:40 +0100 Subject: [PATCH 097/107] Added Compose compiler metrics generation command to README.md --- README.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cd699caa4..dec8ec961 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,14 @@ The app uses adaptive layouts to Find out more about the [UI architecture here](docs/ArchitectureLearningJourney.md#ui-layer). -# Baseline profiles +# Performance + +## Benchmarks + +Find all tests written using [`Macrobenchmark`](https://developer.android.com/topic/performance/benchmarking/macrobenchmark-overview) +in the `benchmarks` module. This module also contains the test to generate the Baseline profile. + +## Baseline profiles The baseline profile for this app is located at [`app/src/main/baseline-prof.txt`](app/src/main/baseline-prof.txt). It contains rules that enable AOT compilation of the critical user path taken during app launch. @@ -144,6 +151,16 @@ To generate the baseline profile, select the `benchmark` build variant and run t `BaselineProfileGenerator` benchmark test on an AOSP Android Emulator. Then copy the resulting baseline profile from the emulator to [`app/src/main/baseline-prof.txt`](app/src/main/baseline-prof.txt). +## Compose compiler metrics + +Run the following command to get and analyse compose compiler metrics: + +``` +./gradlew assembleRelease -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true +``` + +For more information on Compose compiler metrics, see [this blog post](https://medium.com/androiddevelopers/jetpack-compose-stability-explained-79c10db270c8). + # License **Now in Android** is distributed under the terms of the Apache License (Version 2.0). See the From 96c7ada651cd22be6b5250cd1784eda664267cef Mon Sep 17 00:00:00 2001 From: Alejandra Stamato Date: Thu, 25 May 2023 16:21:13 +0100 Subject: [PATCH 098/107] Added Compose compiler metrics generation command to README.md --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index dec8ec961..9aca22cbd 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,9 @@ Run the following command to get and analyse compose compiler metrics: ./gradlew assembleRelease -PenableComposeCompilerMetrics=true -PenableComposeCompilerReports=true ``` +The reports files will be added to build/compose-reports in each module. The metrics files will be +added to build/compose-metrics in each module. + For more information on Compose compiler metrics, see [this blog post](https://medium.com/androiddevelopers/jetpack-compose-stability-explained-79c10db270c8). # License From d319264ef061dd52dbbcc3b739d670eeb7d3d4df Mon Sep 17 00:00:00 2001 From: Don Turner Date: Thu, 25 May 2023 17:08:44 +0100 Subject: [PATCH 099/107] Provide Default dispatcher for instrumented tests Change-Id: I488a9b9d9d3864ce9496614ab35332ec19d06bcc --- core/data-test/build.gradle.kts | 1 + core/datastore-test/build.gradle.kts | 1 + .../nowinandroid/core/testing/di/TestDispatchersModule.kt | 7 +++++++ 3 files changed, 9 insertions(+) diff --git a/core/data-test/build.gradle.kts b/core/data-test/build.gradle.kts index f50e7b4b8..dfc224e19 100644 --- a/core/data-test/build.gradle.kts +++ b/core/data-test/build.gradle.kts @@ -25,4 +25,5 @@ android { dependencies { api(project(":core:data")) implementation(project(":core:testing")) + implementation(project(":core:common")) } diff --git a/core/datastore-test/build.gradle.kts b/core/datastore-test/build.gradle.kts index c7c423c25..193c49da7 100644 --- a/core/datastore-test/build.gradle.kts +++ b/core/datastore-test/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { api(project(":core:datastore")) api(libs.androidx.dataStore.core) + implementation(libs.protobuf.kotlin.lite) implementation(project(":core:common")) implementation(project(":core:testing")) } diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/di/TestDispatchersModule.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/di/TestDispatchersModule.kt index a5eb506ae..f2134105a 100644 --- a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/di/TestDispatchersModule.kt +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/di/TestDispatchersModule.kt @@ -17,6 +17,7 @@ package com.google.samples.apps.nowinandroid.core.testing.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 com.google.samples.apps.nowinandroid.core.network.di.DispatchersModule import dagger.Module @@ -35,4 +36,10 @@ object TestDispatchersModule { @Provides @Dispatcher(IO) fun providesIODispatcher(testDispatcher: TestDispatcher): CoroutineDispatcher = testDispatcher + + @Provides + @Dispatcher(Default) + fun providesDefaultDispatcher( + testDispatcher: TestDispatcher, + ): CoroutineDispatcher = testDispatcher } From 502f9ed1108ed35344bafe128b5b88e99f4a7b17 Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sat, 27 May 2023 13:07:58 +0100 Subject: [PATCH 100/107] Remove `ExperimentalCoroutinesApi` and `FlowPreview` opt-in ``` > Task :core:model:compileKotlin w: Opt-in requirement marker kotlinx.coroutines.ExperimentalCoroutinesApi is unresolved. Please make sure it's present in the module dependencies w: Opt-in requirement marker kotlinx.coroutines.FlowPreview is unresolved. Please make sure it's present in the module dependencies ``` --- .../com/google/samples/apps/nowinandroid/KotlinAndroid.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index 43edd53ec..bf0bc5e72 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 @@ -92,9 +92,6 @@ private fun Project.configureKotlin() { 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", ) } } From b4533863274cc3510861e9867781d22b3ca7752d Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sun, 28 May 2023 12:15:36 +0200 Subject: [PATCH 101/107] Ellipsis string can be replaced with ellipsis character MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lint: Replace `...` with ellipsis character `…` --- feature/settings/src/main/res/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index cbd4df8ed..ad56f6b08 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -18,7 +18,7 @@ Settings Search Settings - Loading... + Loading… Privacy policy Licenses Brand Guidelines From 9aba5cc0fe43f2e7459cfdec3f71527367ccf98d Mon Sep 17 00:00:00 2001 From: Simon Marquis Date: Sun, 28 May 2023 12:09:20 +0200 Subject: [PATCH 102/107] Cleanup unused string resources --- core/notifications/src/main/res/values/strings.xml | 1 - feature/bookmarks/src/main/res/values/strings.xml | 3 --- feature/foryou/src/main/res/values/strings.xml | 8 -------- feature/interests/src/main/res/values/strings.xml | 3 --- feature/topic/src/main/res/values/strings.xml | 1 - 5 files changed, 16 deletions(-) diff --git a/core/notifications/src/main/res/values/strings.xml b/core/notifications/src/main/res/values/strings.xml index a3f8a4e61..5bb37b23a 100644 --- a/core/notifications/src/main/res/values/strings.xml +++ b/core/notifications/src/main/res/values/strings.xml @@ -15,7 +15,6 @@ limitations under the License. --> - Now in Android News updates The latest updates on what\'s new in Android %1$d news updates diff --git a/feature/bookmarks/src/main/res/values/strings.xml b/feature/bookmarks/src/main/res/values/strings.xml index 2dd36659e..875a90a0b 100644 --- a/feature/bookmarks/src/main/res/values/strings.xml +++ b/feature/bookmarks/src/main/res/values/strings.xml @@ -17,9 +17,6 @@ Saved Loading saved… - Saved - Search - Menu No saved updates Updates you save will be stored here\nto read later Bookmark removed diff --git a/feature/foryou/src/main/res/values/strings.xml b/feature/foryou/src/main/res/values/strings.xml index 1880ab953..5a33bc9c8 100644 --- a/feature/foryou/src/main/res/values/strings.xml +++ b/feature/foryou/src/main/res/values/strings.xml @@ -21,13 +21,5 @@ Navigate up What are you interested in? Updates from topics you follow will appear here. Follow some things to get started. - Now in Android - Search - - - You are following - You are not following - Follow - Unfollow diff --git a/feature/interests/src/main/res/values/strings.xml b/feature/interests/src/main/res/values/strings.xml index 68deb933e..384cb1deb 100644 --- a/feature/interests/src/main/res/values/strings.xml +++ b/feature/interests/src/main/res/values/strings.xml @@ -20,7 +20,4 @@ "No available data" Follow interest Unfollow interest - Interests - Menu - Search diff --git a/feature/topic/src/main/res/values/strings.xml b/feature/topic/src/main/res/values/strings.xml index 21e3ec246..284f2f7b2 100644 --- a/feature/topic/src/main/res/values/strings.xml +++ b/feature/topic/src/main/res/values/strings.xml @@ -15,6 +15,5 @@ limitations under the License. --> - Topic Loading topic From ded8b109b923feeafcbc602055a9db9e1c619d03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Moczkowski?= Date: Mon, 29 May 2023 12:29:50 +0200 Subject: [PATCH 103/107] Refactor custom notificationDot modifier Change-Id: Ife24492a495b111f111a8bd16f21b7ebd469e5aa --- .../samples/apps/nowinandroid/ui/NiaApp.kt | 46 +++++++++++-------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/ui/NiaApp.kt index 6f6ab0603..de321db52 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 @@ -15,6 +15,7 @@ */ package com.google.samples.apps.nowinandroid.ui + import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.Row @@ -46,6 +47,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color @@ -129,6 +131,8 @@ fun NiaApp( ) } + val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle() + Scaffold( modifier = Modifier.semantics { testTagsAsResourceId = true @@ -139,7 +143,6 @@ fun NiaApp( snackbarHost = { SnackbarHost(snackbarHostState) }, bottomBar = { if (appState.shouldShowBottomBar) { - val unreadDestinations by appState.topLevelDestinationsWithUnreadResources.collectAsStateWithLifecycle() NiaBottomBar( destinations = appState.topLevelDestinations, destinationsWithUnreadResources = unreadDestinations, @@ -164,6 +167,7 @@ fun NiaApp( if (appState.shouldShowNavRail) { NiaNavRail( destinations = appState.topLevelDestinations, + destinationsWithUnreadResources = unreadDestinations, onNavigateToDestination = appState::navigateToTopLevelDestination, currentDestination = appState.currentDestination, modifier = Modifier @@ -208,6 +212,7 @@ fun NiaApp( @Composable private fun NiaNavRail( destinations: List, + destinationsWithUnreadResources: Set, onNavigateToDestination: (TopLevelDestination) -> Unit, currentDestination: NavDestination?, modifier: Modifier = Modifier, @@ -215,6 +220,7 @@ private fun NiaNavRail( NiaNavigationRail(modifier = modifier) { destinations.forEach { destination -> val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) + val hasUnread = destinationsWithUnreadResources.contains(destination) NiaNavigationRailItem( selected = selected, onClick = { onNavigateToDestination(destination) }, @@ -237,7 +243,7 @@ private fun NiaNavRail( } }, label = { Text(stringResource(destination.iconTextId)) }, - + modifier = if (hasUnread) Modifier.notificationDot() else Modifier, ) } } @@ -279,30 +285,30 @@ private fun NiaBottomBar( } }, label = { Text(stringResource(destination.iconTextId)) }, - modifier = if (hasUnread) notificationDot() else Modifier, + modifier = if (hasUnread) Modifier.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 Modifier.notificationDot(): Modifier = + composed { + val tertiaryColor = MaterialTheme.colorScheme.tertiary + 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 { From 5bf66739bd043c61980d588a6f264852227d7a63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Moczkowski?= Date: Mon, 29 May 2023 15:21:53 +0200 Subject: [PATCH 104/107] Remove redundant statements Change-Id: Iaa25b7ba033d5e9c85cc2dc433197ed7b8b6cb89 --- benchmarks/build.gradle.kts | 1 - .../baselineprofile/BaselineProfileGenerator.kt | 2 -- .../nowinandroid/bookmarks/BookmarksActions.kt | 6 ------ .../samples/apps/nowinandroid/KotlinAndroid.kt | 4 ---- .../samples/apps/nowinandroid/NiaFlavor.kt | 7 +++---- .../core/analytics/AnalyticsEvent.kt | 2 -- .../core/database/model/NewsResourceFtsEntity.kt | 6 ------ .../core/datastore/NiaPreferencesDataSource.kt | 10 +++++----- .../core/designsystem/icon/NiaIcons.kt | 12 ------------ .../core/designsystem/theme/Color.kt | 3 --- .../testing/repository/TestUserDataRepository.kt | 16 ---------------- .../nowinandroid/core/ui/NewsResourceCard.kt | 8 -------- feature/foryou/build.gradle.kts | 2 -- .../feature/foryou/ForYouScreenTest.kt | 2 +- .../feature/foryou/ForYouViewModelTest.kt | 2 -- feature/interests/build.gradle.kts | 2 -- feature/settings/build.gradle.kts | 2 -- feature/topic/build.gradle.kts | 2 -- .../nowinandroid/feature/topic/TopicScreen.kt | 3 +-- 19 files changed, 10 insertions(+), 82 deletions(-) diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index 320d66647..48a6687e4 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import com.android.build.api.dsl.ManagedVirtualDevice import com.google.samples.apps.nowinandroid.NiaBuildType import com.google.samples.apps.nowinandroid.configureFlavors diff --git a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/baselineprofile/BaselineProfileGenerator.kt b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/baselineprofile/BaselineProfileGenerator.kt index 3dfafd647..5abf7db4a 100644 --- a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/baselineprofile/BaselineProfileGenerator.kt +++ b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/baselineprofile/BaselineProfileGenerator.kt @@ -52,8 +52,6 @@ class BaselineProfileGenerator { // Navigate to saved screen goToBookmarksScreen() - // TODO: we need to implement adding stuff to bookmarks before able to scroll it - // bookmarksScrollFeedDownUp() // Navigate to interests screen goToInterestsScreen() diff --git a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/bookmarks/BookmarksActions.kt b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/bookmarks/BookmarksActions.kt index 3dce5b313..f66fa27a2 100644 --- a/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/bookmarks/BookmarksActions.kt +++ b/benchmarks/src/main/java/com/google/samples/apps/nowinandroid/bookmarks/BookmarksActions.kt @@ -19,7 +19,6 @@ package com.google.samples.apps.nowinandroid.bookmarks import androidx.benchmark.macro.MacrobenchmarkScope import androidx.test.uiautomator.By import androidx.test.uiautomator.Until -import com.google.samples.apps.nowinandroid.flingElementDownUp fun MacrobenchmarkScope.goToBookmarksScreen() { device.findObject(By.text("Saved")).click() @@ -29,8 +28,3 @@ fun MacrobenchmarkScope.goToBookmarksScreen() { val topAppBar = device.findObject(By.res("niaTopAppBar")) topAppBar.wait(Until.hasObject(By.text("Saved")), 2_000) } - -fun MacrobenchmarkScope.bookmarksScrollFeedDownUp() { - val feedList = device.findObject(By.res("bookmarks:feed")) - device.flingElementDownUp(feedList) -} 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 43edd53ec..976183aa4 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 @@ -16,20 +16,16 @@ package com.google.samples.apps.nowinandroid -import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.CommonExtension import org.gradle.api.JavaVersion import org.gradle.api.Project import org.gradle.api.artifacts.VersionCatalogsExtension -import org.gradle.api.plugins.ExtensionAware import org.gradle.api.plugins.JavaPluginExtension 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 /** 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 dec592542..ef55024e2 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 @@ -4,7 +4,6 @@ import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.ApplicationProductFlavor import com.android.build.api.dsl.CommonExtension import com.android.build.api.dsl.ProductFlavor -import org.gradle.api.Project @Suppress("EnumEntryName") enum class FlavorDimension { @@ -17,10 +16,10 @@ enum class FlavorDimension { @Suppress("EnumEntryName") enum class NiaFlavor(val dimension: FlavorDimension, val applicationIdSuffix: String? = null) { demo(FlavorDimension.contentType, applicationIdSuffix = ".demo"), - prod(FlavorDimension.contentType, ) + prod(FlavorDimension.contentType) } -fun Project.configureFlavors( +fun configureFlavors( commonExtension: CommonExtension<*, *, *, *>, flavorConfigurationBlock: ProductFlavor.(flavor: NiaFlavor) -> Unit = {} ) { @@ -33,7 +32,7 @@ fun Project.configureFlavors( flavorConfigurationBlock(this, it) if (this@apply is ApplicationExtension && this is ApplicationProductFlavor) { if (it.applicationIdSuffix != null) { - this.applicationIdSuffix = it.applicationIdSuffix + applicationIdSuffix = it.applicationIdSuffix } } } 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 index 3e0650eed..97ae76b56 100644 --- 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 @@ -34,7 +34,6 @@ data class AnalyticsEvent( class Types { companion object { const val SCREEN_VIEW = "screen_view" // (extras: SCREEN_NAME) - const val VIEW_SEARCH_RESULTS = "view_search_results" // (extras: SEARCH_TERM) } } @@ -54,7 +53,6 @@ data class AnalyticsEvent( class ParamKeys { companion object { const val SCREEN_NAME = "screen_name" - const val SEARCH_TERM = "search_term" } } } 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 index 0ef9333c1..0ba625024 100644 --- 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 @@ -36,9 +36,3 @@ data class NewsResourceFtsEntity( @ColumnInfo(name = "content") val content: String, ) - -fun NewsResourceEntity.asFtsEntity() = NewsResourceFtsEntity( - newsResourceId = id, - title = title, - content = content, -) 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 6d585ebd4..6e2be2808 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 @@ -143,13 +143,13 @@ class NiaPreferencesDataSource @Inject constructor( } suspend fun setNewsResourcesViewed(newsResourceIds: List, viewed: Boolean) { - userPreferences.updateData { - it.copy { - newsResourceIds.forEach { + userPreferences.updateData { prefs -> + prefs.copy { + newsResourceIds.forEach { id -> if (viewed) { - viewedNewsResourceIds.put(it, true) + viewedNewsResourceIds.put(id, true) } else { - viewedNewsResourceIds.remove(it) + viewedNewsResourceIds.remove(id) } } } diff --git a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt index 5646f088a..ac31fbcdb 100644 --- a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt +++ b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt @@ -18,19 +18,13 @@ package com.google.samples.apps.nowinandroid.core.designsystem.icon import androidx.annotation.DrawableRes import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.ArrowDropUp import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.outlined.AccountCircle import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.ArrowBack import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.ExpandLess -import androidx.compose.material.icons.rounded.Fullscreen import androidx.compose.material.icons.rounded.Grid3x3 import androidx.compose.material.icons.rounded.Person -import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.ShortText @@ -45,25 +39,19 @@ import com.google.samples.apps.nowinandroid.core.designsystem.R * Now in Android icons. Material icons are [ImageVector]s, custom icons are drawable resource IDs. */ object NiaIcons { - val AccountCircle = Icons.Outlined.AccountCircle val Add = Icons.Rounded.Add val ArrowBack = Icons.Rounded.ArrowBack - val ArrowDropDown = Icons.Default.ArrowDropDown - val ArrowDropUp = Icons.Default.ArrowDropUp val Bookmark = R.drawable.ic_bookmark val BookmarkBorder = R.drawable.ic_bookmark_border val Bookmarks = R.drawable.ic_bookmarks val BookmarksBorder = R.drawable.ic_bookmarks_border val Check = Icons.Rounded.Check val Close = Icons.Rounded.Close - val ExpandLess = Icons.Rounded.ExpandLess - val Fullscreen = Icons.Rounded.Fullscreen val Grid3x3 = Icons.Rounded.Grid3x3 val MenuBook = R.drawable.ic_menu_book val MenuBookBorder = R.drawable.ic_menu_book_border val MoreVert = Icons.Default.MoreVert val Person = Icons.Rounded.Person - val PlayArrow = Icons.Rounded.PlayArrow val Search = Icons.Rounded.Search val Settings = Icons.Rounded.Settings val ShortText = Icons.Rounded.ShortText diff --git a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Color.kt b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Color.kt index ec4fa76b7..103457b08 100644 --- a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Color.kt +++ b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/theme/Color.kt @@ -27,7 +27,6 @@ internal val Blue30 = Color(0xFF004D61) internal val Blue40 = Color(0xFF006780) internal val Blue80 = Color(0xFF5DD5FC) internal val Blue90 = Color(0xFFB8EAFF) -internal val Blue95 = Color(0xFFDDF4FF) internal val DarkGreen10 = Color(0xFF0D1F12) internal val DarkGreen20 = Color(0xFF223526) internal val DarkGreen30 = Color(0xFF394B3C) @@ -61,14 +60,12 @@ internal val Orange30 = Color(0xFF812800) internal val Orange40 = Color(0xFFA23F16) internal val Orange80 = Color(0xFFFFB59B) internal val Orange90 = Color(0xFFFFDBCF) -internal val Orange95 = Color(0xFFFFEDE8) internal val Purple10 = Color(0xFF36003C) internal val Purple20 = Color(0xFF560A5D) internal val Purple30 = Color(0xFF702776) internal val Purple40 = Color(0xFF8B418F) internal val Purple80 = Color(0xFFFFA9FE) internal val Purple90 = Color(0xFFFFD6FA) -internal val Purple95 = Color(0xFFFFEBFA) internal val PurpleGray30 = Color(0xFF4D444C) internal val PurpleGray50 = Color(0xFF7F747C) internal val PurpleGray60 = Color(0xFF998D96) 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 66ac80868..9d1650c98 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 @@ -112,22 +112,6 @@ class TestUserDataRepository : UserDataRepository { } } - /** - * A test-only API to allow setting/unsetting of bookmarks. - * - */ - fun setNewsResourceBookmarks(newsResourceIds: Set) { - currentUserData.let { current -> - _userData.tryEmit(current.copy(bookmarkedNewsResources = newsResourceIds)) - } - } - - /** - * A test-only API to allow querying the current followed topics. - */ - fun getCurrentFollowedTopics(): Set? = - _userData.replayCache.firstOrNull()?.followedTopics - /** * A test-only API to allow setting of user data directly. */ 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 a6a7aafc9..46521a320 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 @@ -250,14 +250,6 @@ fun NewsResourceMetaData( ) } -@Composable -fun NewsResourceLink( - @Suppress("UNUSED_PARAMETER") - newsResource: NewsResource, -) { - TODO() -} - @Composable fun NewsResourceShortDescription( newsResourceShortDescription: String, diff --git a/feature/foryou/build.gradle.kts b/feature/foryou/build.gradle.kts index 6cd5216d6..bd633e3d2 100644 --- a/feature/foryou/build.gradle.kts +++ b/feature/foryou/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") 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 b138cba06..38961a918 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 @@ -112,7 +112,7 @@ class ForYouScreenTest { @Test fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() { - val testData = followableTopicTestData.map { it -> it.copy(isFollowed = false) } + val testData = followableTopicTestData.map { it.copy(isFollowed = false) } composeTestRule.setContent { BoxWithConstraints { 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 e99cfb74d..6a2ea4a02 100644 --- a/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt +++ b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt @@ -30,7 +30,6 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRe 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.TestSyncManager import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.feature.foryou.navigation.LINKED_NEWS_RESOURCE_ID @@ -54,7 +53,6 @@ class ForYouViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() - private val networkMonitor = TestNetworkMonitor() private val syncManager = TestSyncManager() private val userDataRepository = TestUserDataRepository() private val topicsRepository = TestTopicsRepository() diff --git a/feature/interests/build.gradle.kts b/feature/interests/build.gradle.kts index 12b3074e4..5c4b0360a 100644 --- a/feature/interests/build.gradle.kts +++ b/feature/interests/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") diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index 3229c350f..ef367d612 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/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") diff --git a/feature/topic/build.gradle.kts b/feature/topic/build.gradle.kts index 6bacd8343..ecb0630ce 100644 --- a/feature/topic/build.gradle.kts +++ b/feature/topic/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") 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 fd408f9cf..b987a2752 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 @@ -59,7 +59,6 @@ import com.google.samples.apps.nowinandroid.core.ui.TrackScrollJank import com.google.samples.apps.nowinandroid.core.ui.UserNewsResourcePreviewParameterProvider import com.google.samples.apps.nowinandroid.core.ui.userNewsResourceCardItems import com.google.samples.apps.nowinandroid.feature.topic.R.string -import com.google.samples.apps.nowinandroid.feature.topic.TopicUiState.Loading @Composable internal fun TopicRoute( @@ -107,7 +106,7 @@ internal fun TopicScreen( Spacer(Modifier.windowInsetsTopHeight(WindowInsets.safeDrawing)) } when (topicUiState) { - Loading -> item { + TopicUiState.Loading -> item { NiaLoadingWheel( modifier = modifier, contentDesc = stringResource(id = string.topic_loading), From 99094199bc14800d12b559fd6d1cac75e179f343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Moczkowski?= Date: Mon, 29 May 2023 16:08:44 +0200 Subject: [PATCH 105/107] Remove redundant suppressions Change-Id: Iae08b6f284cf7a387922fdec34c8a0aa0da809ef --- .../nowinandroid/core/datastore/UserPreferencesSerializer.kt | 2 -- .../apps/nowinandroid/feature/settings/SettingsDialog.kt | 1 - .../apps/nowinandroid/lint/designsystem/DesignSystemDetector.kt | 1 - .../nowinandroid/lint/designsystem/DesignSystemIssueRegistry.kt | 1 - 4 files changed, 5 deletions(-) diff --git a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/UserPreferencesSerializer.kt b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/UserPreferencesSerializer.kt index 6d1a4ab8b..40c1e210f 100644 --- a/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/UserPreferencesSerializer.kt +++ b/core/datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/UserPreferencesSerializer.kt @@ -32,7 +32,6 @@ class UserPreferencesSerializer @Inject constructor() : Serializer> { diff --git a/lint/src/main/java/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemIssueRegistry.kt b/lint/src/main/java/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemIssueRegistry.kt index d951151bb..bb7e971e3 100644 --- a/lint/src/main/java/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemIssueRegistry.kt +++ b/lint/src/main/java/com/google/samples/apps/nowinandroid/lint/designsystem/DesignSystemIssueRegistry.kt @@ -24,7 +24,6 @@ import com.android.tools.lint.detector.api.CURRENT_API * An issue registry that checks for incorrect usages of Compose Material APIs over equivalents in * the Now in Android design system module. */ -@Suppress("UnstableApiUsage") class DesignSystemIssueRegistry : IssueRegistry() { override val issues = listOf(DesignSystemDetector.ISSUE) From 83fa8c887f0381f87bfe03afa4ee640f64449392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Moczkowski?= Date: Tue, 30 May 2023 09:42:31 +0200 Subject: [PATCH 106/107] Spotless apply Change-Id: I80457acc6a49fe9755240a3d52ff96a8fdfd6b92 --- .../samples/apps/nowinandroid/feature/settings/SettingsDialog.kt | 1 - 1 file changed, 1 deletion(-) 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 98e63af56..d8411113d 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 @@ -40,7 +40,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext From 479a2f00b0979cb26a33ec4629048314abccadda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mi=C5=82osz=20Moczkowski?= Date: Tue, 30 May 2023 12:21:16 +0200 Subject: [PATCH 107/107] Refactor icons --- .../samples/apps/niacatalog/ui/Catalog.kt | 48 +++++++--------- .../navigation/TopLevelDestination.kt | 20 +++---- .../samples/apps/nowinandroid/ui/NiaApp.kt | 55 +++++++------------ .../core/designsystem/icon/NiaIcons.kt | 36 ++++-------- .../src/main/res/drawable/ic_bookmark.xml | 25 --------- .../main/res/drawable/ic_bookmark_border.xml | 25 --------- .../src/main/res/drawable/ic_bookmarks.xml | 28 ---------- .../main/res/drawable/ic_bookmarks_border.xml | 29 ---------- .../src/main/res/drawable/ic_menu_book.xml | 35 ------------ .../main/res/drawable/ic_menu_book_border.xml | 35 ------------ .../src/main/res/drawable/ic_upcoming.xml | 34 ------------ .../main/res/drawable/ic_upcoming_border.xml | 35 ------------ .../nowinandroid/core/ui/NewsResourceCard.kt | 4 +- 13 files changed, 62 insertions(+), 347 deletions(-) delete mode 100644 core/designsystem/src/main/res/drawable/ic_bookmark.xml delete mode 100644 core/designsystem/src/main/res/drawable/ic_bookmark_border.xml delete mode 100644 core/designsystem/src/main/res/drawable/ic_bookmarks.xml delete mode 100644 core/designsystem/src/main/res/drawable/ic_bookmarks_border.xml delete mode 100644 core/designsystem/src/main/res/drawable/ic_menu_book.xml delete mode 100644 core/designsystem/src/main/res/drawable/ic_menu_book_border.xml delete mode 100644 core/designsystem/src/main/res/drawable/ic_upcoming.xml delete mode 100644 core/designsystem/src/main/res/drawable/ic_upcoming_border.xml 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 54e4264fa..2624262ad 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 @@ -36,7 +36,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.samples.apps.nowinandroid.core.designsystem.component.NiaButton import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaFilterChip @@ -206,13 +205,13 @@ fun NiaCatalog() { onCheckedChange = { checked -> firstChecked = checked }, icon = { Icon( - painter = painterResource(id = NiaIcons.BookmarkBorder), + imageVector = NiaIcons.BookmarkBorder, contentDescription = null, ) }, checkedIcon = { Icon( - painter = painterResource(id = NiaIcons.Bookmark), + imageVector = NiaIcons.Bookmark, contentDescription = null, ) }, @@ -223,13 +222,13 @@ fun NiaCatalog() { onCheckedChange = { checked -> secondChecked = checked }, icon = { Icon( - painter = painterResource(id = NiaIcons.BookmarkBorder), + imageVector = NiaIcons.BookmarkBorder, contentDescription = null, ) }, checkedIcon = { Icon( - painter = painterResource(id = NiaIcons.Bookmark), + imageVector = NiaIcons.Bookmark, contentDescription = null, ) }, @@ -239,13 +238,13 @@ fun NiaCatalog() { onCheckedChange = {}, icon = { Icon( - painter = painterResource(id = NiaIcons.BookmarkBorder), + imageVector = NiaIcons.BookmarkBorder, contentDescription = null, ) }, checkedIcon = { Icon( - painter = painterResource(id = NiaIcons.Bookmark), + imageVector = NiaIcons.Bookmark, contentDescription = null, ) }, @@ -256,13 +255,13 @@ fun NiaCatalog() { onCheckedChange = {}, icon = { Icon( - painter = painterResource(id = NiaIcons.BookmarkBorder), + imageVector = NiaIcons.BookmarkBorder, contentDescription = null, ) }, checkedIcon = { Icon( - painter = painterResource(id = NiaIcons.Bookmark), + imageVector = NiaIcons.Bookmark, contentDescription = null, ) }, @@ -334,40 +333,31 @@ fun NiaCatalog() { item { Text("Navigation", Modifier.padding(top = 16.dp)) } item { var selectedItem by remember { mutableStateOf(0) } - val items = listOf("For you", "Episodes", "Saved", "Interests") + val items = listOf("For you", "Saved", "Interests") val icons = listOf( NiaIcons.UpcomingBorder, - NiaIcons.MenuBookBorder, NiaIcons.BookmarksBorder, + NiaIcons.Grid3x3, ) val selectedIcons = listOf( NiaIcons.Upcoming, - NiaIcons.MenuBook, NiaIcons.Bookmarks, + NiaIcons.Grid3x3, ) - val tagIcon = NiaIcons.Tag NiaNavigationBar { items.forEachIndexed { index, item -> NiaNavigationBarItem( icon = { - if (index == 3) { - Icon(imageVector = tagIcon, contentDescription = null) - } else { - Icon( - painter = painterResource(id = icons[index]), - contentDescription = item, - ) - } + Icon( + imageVector = icons[index], + contentDescription = item, + ) }, selectedIcon = { - if (index == 3) { - Icon(imageVector = tagIcon, contentDescription = null) - } else { - Icon( - painter = painterResource(id = selectedIcons[index]), - contentDescription = item, - ) - } + Icon( + imageVector = selectedIcons[index], + contentDescription = item, + ) }, label = { Text(item) }, selected = selectedItem == index, diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt index 396ab8b7b..8dbd0fcb6 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/navigation/TopLevelDestination.kt @@ -16,10 +16,8 @@ package com.google.samples.apps.nowinandroid.navigation +import androidx.compose.ui.graphics.vector.ImageVector import com.google.samples.apps.nowinandroid.R -import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon -import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon -import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.feature.bookmarks.R as bookmarksR import com.google.samples.apps.nowinandroid.feature.foryou.R as forYouR @@ -31,26 +29,26 @@ import com.google.samples.apps.nowinandroid.feature.interests.R as interestsR * next within a single destination will be handled directly in composables. */ enum class TopLevelDestination( - val selectedIcon: Icon, - val unselectedIcon: Icon, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, val iconTextId: Int, val titleTextId: Int, ) { FOR_YOU( - selectedIcon = DrawableResourceIcon(NiaIcons.Upcoming), - unselectedIcon = DrawableResourceIcon(NiaIcons.UpcomingBorder), + selectedIcon = NiaIcons.Upcoming, + unselectedIcon = NiaIcons.UpcomingBorder, iconTextId = forYouR.string.for_you, titleTextId = R.string.app_name, ), BOOKMARKS( - selectedIcon = DrawableResourceIcon(NiaIcons.Bookmarks), - unselectedIcon = DrawableResourceIcon(NiaIcons.BookmarksBorder), + selectedIcon = NiaIcons.Bookmarks, + unselectedIcon = NiaIcons.BookmarksBorder, iconTextId = bookmarksR.string.saved, titleTextId = bookmarksR.string.saved, ), INTERESTS( - selectedIcon = ImageVectorIcon(NiaIcons.Grid3x3), - unselectedIcon = ImageVectorIcon(NiaIcons.Grid3x3), + selectedIcon = NiaIcons.Grid3x3, + unselectedIcon = NiaIcons.Grid3x3, iconTextId = interestsR.string.interests, titleTextId = interestsR.string.interests, ), 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 de321db52..01726c909 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 @@ -52,7 +52,6 @@ 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 @@ -70,8 +69,6 @@ import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavig import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationRail import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaNavigationRailItem import com.google.samples.apps.nowinandroid.core.designsystem.component.NiaTopAppBar -import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.DrawableResourceIcon -import com.google.samples.apps.nowinandroid.core.designsystem.icon.Icon.ImageVectorIcon import com.google.samples.apps.nowinandroid.core.designsystem.icon.NiaIcons import com.google.samples.apps.nowinandroid.core.designsystem.theme.GradientColors import com.google.samples.apps.nowinandroid.core.designsystem.theme.LocalGradientColors @@ -225,22 +222,16 @@ private fun NiaNavRail( selected = selected, onClick = { onNavigateToDestination(destination) }, icon = { - val icon = if (selected) { - destination.selectedIcon - } else { - destination.unselectedIcon - } - when (icon) { - is ImageVectorIcon -> Icon( - imageVector = icon.imageVector, - contentDescription = null, - ) - - is DrawableResourceIcon -> Icon( - painter = painterResource(id = icon.id), - contentDescription = null, - ) - } + Icon( + imageVector = destination.unselectedIcon, + contentDescription = null, + ) + }, + selectedIcon = { + Icon( + imageVector = destination.selectedIcon, + contentDescription = null, + ) }, label = { Text(stringResource(destination.iconTextId)) }, modifier = if (hasUnread) Modifier.notificationDot() else Modifier, @@ -267,22 +258,16 @@ private fun NiaBottomBar( selected = selected, onClick = { onNavigateToDestination(destination) }, icon = { - val icon = if (selected) { - destination.selectedIcon - } else { - destination.unselectedIcon - } - when (icon) { - is ImageVectorIcon -> Icon( - imageVector = icon.imageVector, - contentDescription = null, - ) - - is DrawableResourceIcon -> Icon( - painter = painterResource(id = icon.id), - contentDescription = null, - ) - } + Icon( + imageVector = destination.unselectedIcon, + contentDescription = null, + ) + }, + selectedIcon = { + Icon( + imageVector = destination.selectedIcon, + contentDescription = null, + ) }, label = { Text(stringResource(destination.iconTextId)) }, modifier = if (hasUnread) Modifier.notificationDot() else Modifier, diff --git a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt index ac31fbcdb..8db20689f 100644 --- a/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt +++ b/core/designsystem/src/main/java/com/google/samples/apps/nowinandroid/core/designsystem/icon/NiaIcons.kt @@ -16,11 +16,15 @@ package com.google.samples.apps.nowinandroid.core.designsystem.icon -import androidx.annotation.DrawableRes import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.outlined.Bookmarks +import androidx.compose.material.icons.outlined.Upcoming import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Bookmark +import androidx.compose.material.icons.rounded.BookmarkBorder +import androidx.compose.material.icons.rounded.Bookmarks import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Grid3x3 @@ -28,12 +32,9 @@ import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.ShortText -import androidx.compose.material.icons.rounded.Tag +import androidx.compose.material.icons.rounded.Upcoming import androidx.compose.material.icons.rounded.ViewDay -import androidx.compose.material.icons.rounded.VolumeOff -import androidx.compose.material.icons.rounded.VolumeUp import androidx.compose.ui.graphics.vector.ImageVector -import com.google.samples.apps.nowinandroid.core.designsystem.R /** * Now in Android icons. Material icons are [ImageVector]s, custom icons are drawable resource IDs. @@ -41,32 +42,19 @@ import com.google.samples.apps.nowinandroid.core.designsystem.R object NiaIcons { val Add = Icons.Rounded.Add val ArrowBack = Icons.Rounded.ArrowBack - val Bookmark = R.drawable.ic_bookmark - val BookmarkBorder = R.drawable.ic_bookmark_border - val Bookmarks = R.drawable.ic_bookmarks - val BookmarksBorder = R.drawable.ic_bookmarks_border + val Bookmark = Icons.Rounded.Bookmark + val BookmarkBorder = Icons.Rounded.BookmarkBorder + val Bookmarks = Icons.Rounded.Bookmarks + val BookmarksBorder = Icons.Outlined.Bookmarks val Check = Icons.Rounded.Check val Close = Icons.Rounded.Close val Grid3x3 = Icons.Rounded.Grid3x3 - val MenuBook = R.drawable.ic_menu_book - val MenuBookBorder = R.drawable.ic_menu_book_border val MoreVert = Icons.Default.MoreVert val Person = Icons.Rounded.Person val Search = Icons.Rounded.Search val Settings = Icons.Rounded.Settings val ShortText = Icons.Rounded.ShortText - val Tag = Icons.Rounded.Tag - val Upcoming = R.drawable.ic_upcoming - val UpcomingBorder = R.drawable.ic_upcoming_border + val Upcoming = Icons.Rounded.Upcoming + val UpcomingBorder = Icons.Outlined.Upcoming val ViewDay = Icons.Rounded.ViewDay - val VolumeOff = Icons.Rounded.VolumeOff - val VolumeUp = Icons.Rounded.VolumeUp -} - -/** - * A sealed class to make dealing with [ImageVector] and [DrawableRes] icons easier. - */ -sealed class Icon { - data class ImageVectorIcon(val imageVector: ImageVector) : Icon() - data class DrawableResourceIcon(@DrawableRes val id: Int) : Icon() } diff --git a/core/designsystem/src/main/res/drawable/ic_bookmark.xml b/core/designsystem/src/main/res/drawable/ic_bookmark.xml deleted file mode 100644 index 29b7e40a7..000000000 --- a/core/designsystem/src/main/res/drawable/ic_bookmark.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - diff --git a/core/designsystem/src/main/res/drawable/ic_bookmark_border.xml b/core/designsystem/src/main/res/drawable/ic_bookmark_border.xml deleted file mode 100644 index 1d4b4aca9..000000000 --- a/core/designsystem/src/main/res/drawable/ic_bookmark_border.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - diff --git a/core/designsystem/src/main/res/drawable/ic_bookmarks.xml b/core/designsystem/src/main/res/drawable/ic_bookmarks.xml deleted file mode 100644 index ed6e84f81..000000000 --- a/core/designsystem/src/main/res/drawable/ic_bookmarks.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - diff --git a/core/designsystem/src/main/res/drawable/ic_bookmarks_border.xml b/core/designsystem/src/main/res/drawable/ic_bookmarks_border.xml deleted file mode 100644 index 64f0b5159..000000000 --- a/core/designsystem/src/main/res/drawable/ic_bookmarks_border.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - diff --git a/core/designsystem/src/main/res/drawable/ic_menu_book.xml b/core/designsystem/src/main/res/drawable/ic_menu_book.xml deleted file mode 100644 index e81276888..000000000 --- a/core/designsystem/src/main/res/drawable/ic_menu_book.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - diff --git a/core/designsystem/src/main/res/drawable/ic_menu_book_border.xml b/core/designsystem/src/main/res/drawable/ic_menu_book_border.xml deleted file mode 100644 index 04ec651f6..000000000 --- a/core/designsystem/src/main/res/drawable/ic_menu_book_border.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - diff --git a/core/designsystem/src/main/res/drawable/ic_upcoming.xml b/core/designsystem/src/main/res/drawable/ic_upcoming.xml deleted file mode 100644 index a05017e74..000000000 --- a/core/designsystem/src/main/res/drawable/ic_upcoming.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - diff --git a/core/designsystem/src/main/res/drawable/ic_upcoming_border.xml b/core/designsystem/src/main/res/drawable/ic_upcoming_border.xml deleted file mode 100644 index 5f3151232..000000000 --- a/core/designsystem/src/main/res/drawable/ic_upcoming_border.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - 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 46521a320..009fb1249 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 @@ -183,13 +183,13 @@ fun BookmarkButton( modifier = modifier, icon = { Icon( - painter = painterResource(NiaIcons.BookmarkBorder), + imageVector = NiaIcons.BookmarkBorder, contentDescription = stringResource(R.string.bookmark), ) }, checkedIcon = { Icon( - painter = painterResource(NiaIcons.Bookmark), + imageVector = NiaIcons.Bookmark, contentDescription = stringResource(R.string.unbookmark), ) },