diff --git a/.github/workflows/AndroidCIWithGmd.yaml b/.github/workflows/AndroidCIWithGmd.yaml index bae399f47..44f8141f6 100644 --- a/.github/workflows/AndroidCIWithGmd.yaml +++ b/.github/workflows/AndroidCIWithGmd.yaml @@ -27,7 +27,7 @@ jobs: - name: Run instrumented tests with GMD run: ./gradlew cleanManagedDevices --unused-only && ./gradlew ${{ matrix.device-config }}DemoDebugAndroidTest -Dorg.gradle.workers.max=1 - -Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true --info + -Pandroid.testoptions.manageddevices.emulator.gpu="swiftshader_indirect" -Pandroid.experimental.testOptions.managedDevices.emulator.showKernelLogging=true - name: Upload test reports if: success() || failure() diff --git a/app-nia-catalog/build.gradle.kts b/app-nia-catalog/build.gradle.kts index 8232350d9..bf0695fd3 100644 --- a/app-nia-catalog/build.gradle.kts +++ b/app-nia-catalog/build.gradle.kts @@ -65,9 +65,8 @@ android { } dependencies { - implementation(project(":core:ui")) implementation(project(":core:designsystem")) - - implementation(libs.androidx.activity.compose) + implementation(project(":core:ui")) implementation(libs.accompanist.flowlayout) + implementation(libs.androidx.activity.compose) } diff --git a/benchmarks/build.gradle.kts b/benchmarks/build.gradle.kts index 9af89d98d..fa8aeefb0 100644 --- a/benchmarks/build.gradle.kts +++ b/benchmarks/build.gradle.kts @@ -68,13 +68,13 @@ android { } dependencies { + implementation(libs.androidx.benchmark.macro) implementation(libs.androidx.test.core) implementation(libs.androidx.test.espresso.core) implementation(libs.androidx.test.ext) - implementation(libs.androidx.test.runner) implementation(libs.androidx.test.rules) + implementation(libs.androidx.test.runner) implementation(libs.androidx.test.uiautomator) - implementation(libs.androidx.benchmark.macro) } androidComponents { diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 0b929d4f7..281434b87 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -27,9 +27,9 @@ java { dependencies { compileOnly(libs.android.gradlePlugin) - compileOnly(libs.kotlin.gradlePlugin) - compileOnly(libs.firebase.performance.gradle) compileOnly(libs.firebase.crashlytics.gradle) + compileOnly(libs.firebase.performance.gradle) + compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.ksp.gradlePlugin) } diff --git a/build-logic/gradle/wrapper/gradle-wrapper.jar b/build-logic/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 41d9927a4..000000000 Binary files a/build-logic/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/build-logic/gradle/wrapper/gradle-wrapper.properties b/build-logic/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index ae04661ee..000000000 --- a/build-logic/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/core/analytics/build.gradle.kts b/core/analytics/build.gradle.kts index e42499769..8c573b854 100644 --- a/core/analytics/build.gradle.kts +++ b/core/analytics/build.gradle.kts @@ -24,10 +24,9 @@ android { } dependencies { - implementation(libs.kotlinx.coroutines.android) + implementation(platform(libs.firebase.bom)) implementation(libs.androidx.compose.runtime) implementation(libs.androidx.core.ktx) - - implementation(platform(libs.firebase.bom)) implementation(libs.firebase.analytics) + implementation(libs.kotlinx.coroutines.android) } diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 5b468c43e..51dfb5393 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -25,24 +25,24 @@ android { testOptions { unitTests { isIncludeAndroidResources = true + isReturnDefaultValues = true } } } dependencies { + implementation(project(":core:analytics")) implementation(project(":core:common")) - implementation(project(":core:model")) implementation(project(":core:database")) implementation(project(":core:datastore")) + implementation(project(":core:model")) implementation(project(":core:network")) - implementation(project(":core:analytics")) - - testImplementation(project(":core:testing")) - testImplementation(project(":core:datastore-test")) - + implementation(project(":core:notifications")) implementation(libs.androidx.core.ktx) - - implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.coroutines.android) + implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) -} \ No newline at end of file + + testImplementation(project(":core:datastore-test")) + testImplementation(project(":core:testing")) +} diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt index c16355d69..02c58d855 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepository.kt @@ -30,7 +30,9 @@ import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource +import com.google.samples.apps.nowinandroid.core.notifications.Notifier import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import javax.inject.Inject @@ -46,6 +48,7 @@ class OfflineFirstNewsRepository @Inject constructor( private val newsResourceDao: NewsResourceDao, private val topicDao: TopicDao, private val network: NiaNetworkDataSource, + private val notifier: Notifier, ) : NewsRepository { override fun getNewsResources( @@ -69,6 +72,16 @@ class OfflineFirstNewsRepository @Inject constructor( }, modelDeleter = newsResourceDao::deleteNewsResources, modelUpdater = { changedIds -> + // TODO: Make this more efficient, there is no need to retrieve populated + // news resources when all that's needed are the ids + val existingNewsResourceIds = newsResourceDao.getNewsResources( + useFilterNewsIds = true, + filterNewsIds = changedIds.toSet(), + ) + .first() + .map { it.entity.id } + .toSet() + changedIds.chunked(SYNC_BATCH_SIZE).forEach { chunkedIds -> val networkNewsResources = network.getNewsResources(ids = chunkedIds) @@ -92,6 +105,20 @@ class OfflineFirstNewsRepository @Inject constructor( .flatten(), ) } + + val addedNewsResources = newsResourceDao.getNewsResources( + useFilterNewsIds = true, + filterNewsIds = changedIds.toSet(), + ) + .first() + .filter { !existingNewsResourceIds.contains(it.entity.id) } + .map(PopulatedNewsResource::asExternalModel) + + // TODO: Define business logic for notifications on first time sync. + // we probably do not want to send notifications on first install. + // We can easily check if the change list version is 0 and not send notifications + // if it is. + if (addedNewsResources.isNotEmpty()) notifier.onNewsAdded(addedNewsResources) }, ) } diff --git a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/SyncStatusMonitor.kt b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/SyncManager.kt similarity index 94% rename from core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/SyncStatusMonitor.kt rename to core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/SyncManager.kt index 14823ed0e..d72fa27a6 100644 --- a/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/SyncStatusMonitor.kt +++ b/core/data/src/main/java/com/google/samples/apps/nowinandroid/core/data/util/SyncManager.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.Flow /** * Reports on if synchronization is in progress */ -interface SyncStatusMonitor { +interface SyncManager { val isSyncing: Flow + fun requestSync() } diff --git a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt index 6b424e69e..6cdbf67d0 100644 --- a/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt +++ b/core/data/src/test/java/com/google/samples/apps/nowinandroid/core/data/repository/OfflineFirstNewsRepositoryTest.kt @@ -36,6 +36,7 @@ import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferen import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource +import com.google.samples.apps.nowinandroid.core.testing.notifications.TestNotifier import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -58,6 +59,8 @@ class OfflineFirstNewsRepositoryTest { private lateinit var network: TestNiaNetworkDataSource + private lateinit var notifier: TestNotifier + private lateinit var synchronizer: Synchronizer @get:Rule @@ -68,6 +71,7 @@ class OfflineFirstNewsRepositoryTest { newsResourceDao = TestNewsResourceDao() topicDao = TestTopicDao() network = TestNiaNetworkDataSource() + notifier = TestNotifier() synchronizer = TestSynchronizer( NiaPreferencesDataSource( tmpFolder.testUserPreferencesDataStore(testScope), @@ -78,6 +82,7 @@ class OfflineFirstNewsRepositoryTest { newsResourceDao = newsResourceDao, topicDao = topicDao, network = network, + notifier = notifier, ) } @@ -145,6 +150,12 @@ class OfflineFirstNewsRepositoryTest { expected = network.latestChangeListVersion(CollectionType.NewsResources), actual = synchronizer.getChangeListVersions().newsResourceVersion, ) + + // Notifier should have been called with new news resources + assertEquals( + expected = newsResourcesFromDb.map(NewsResource::id).sorted(), + actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(), + ) } @Test @@ -186,6 +197,13 @@ class OfflineFirstNewsRepositoryTest { expected = network.latestChangeListVersion(CollectionType.NewsResources), actual = synchronizer.getChangeListVersions().newsResourceVersion, ) + + // Notifier should have been called with news resources from network that are not + // deleted + assertEquals( + expected = (newsResourcesFromNetwork.map(NewsResource::id) - deletedItems).sorted(), + actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(), + ) } @Test @@ -225,6 +243,12 @@ class OfflineFirstNewsRepositoryTest { expected = changeList.last().changeListVersion, actual = synchronizer.getChangeListVersions().newsResourceVersion, ) + + // Notifier should have been called with only added news resources from network + assertEquals( + expected = newsResourcesFromNetwork.map(NewsResource::id).sorted(), + actual = notifier.addedNewsResources.first().map(NewsResource::id).sorted(), + ) } @Test diff --git a/core/datastore-test/build.gradle.kts b/core/datastore-test/build.gradle.kts index d8223c3f3..c7c423c25 100644 --- a/core/datastore-test/build.gradle.kts +++ b/core/datastore-test/build.gradle.kts @@ -24,8 +24,8 @@ android { dependencies { api(project(":core:datastore")) + api(libs.androidx.dataStore.core) + implementation(project(":core:common")) implementation(project(":core:testing")) - - api(libs.androidx.dataStore.core) } diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 8f3d7ece6..1787a5b8f 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -33,6 +33,11 @@ android { consumerProguardFiles("consumer-proguard-rules.pro") } namespace = "com.google.samples.apps.nowinandroid.core.datastore" + testOptions { + unitTests { + isReturnDefaultValues = true + } + } } // Setup protobuf configuration, generating lite Java and Kotlin classes @@ -57,12 +62,10 @@ protobuf { dependencies { implementation(project(":core:common")) implementation(project(":core:model")) - - testImplementation(project(":core:testing")) - testImplementation(project(":core:datastore-test")) - - implementation(libs.kotlinx.coroutines.android) - implementation(libs.androidx.dataStore.core) + implementation(libs.kotlinx.coroutines.android) implementation(libs.protobuf.kotlin.lite) + + testImplementation(project(":core:datastore-test")) + testImplementation(project(":core:testing")) } diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts index 1bcc9d65c..a40926383 100644 --- a/core/designsystem/build.gradle.kts +++ b/core/designsystem/build.gradle.kts @@ -30,16 +30,20 @@ android { } dependencies { - implementation(libs.androidx.core.ktx) - implementation(libs.coil.kt.compose) + lintPublish(project(":lint")) + api(libs.androidx.compose.foundation) api(libs.androidx.compose.foundation.layout) api(libs.androidx.compose.material.iconsExtended) api(libs.androidx.compose.material3) - debugApi(libs.androidx.compose.ui.tooling) + api(libs.androidx.compose.runtime) api(libs.androidx.compose.ui.tooling.preview) api(libs.androidx.compose.ui.util) - api(libs.androidx.compose.runtime) - lintPublish(project(":lint")) + + debugApi(libs.androidx.compose.ui.tooling) + + implementation(libs.androidx.core.ktx) + implementation(libs.coil.kt.compose) + androidTestImplementation(project(":core:testing")) } diff --git a/core/domain/build.gradle.kts b/core/domain/build.gradle.kts index 8483d890c..0e3949aa3 100644 --- a/core/domain/build.gradle.kts +++ b/core/domain/build.gradle.kts @@ -24,15 +24,13 @@ android { } dependencies { - implementation(project(":core:data")) implementation(project(":core:model")) - - testImplementation(project(":core:testing")) - + implementation(libs.hilt.android) implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.datetime) - implementation(libs.hilt.android) kapt(libs.hilt.compiler) + + testImplementation(project(":core:testing")) } \ No newline at end of file diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 97a68b3a5..633e2573d 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -41,17 +41,14 @@ secrets { dependencies { implementation(project(":core:common")) implementation(project(":core:model")) - - testImplementation(project(":core:testing")) - + implementation(libs.coil.kt) + implementation(libs.coil.kt.svg) implementation(libs.kotlinx.coroutines.android) - implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.datetime) - + implementation(libs.kotlinx.serialization.json) implementation(libs.okhttp.logging) implementation(libs.retrofit.core) implementation(libs.retrofit.kotlin.serialization) - implementation(libs.coil.kt) - implementation(libs.coil.kt.svg) + testImplementation(project(":core:testing")) } diff --git a/core/notifications/.gitignore b/core/notifications/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/core/notifications/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/notifications/build.gradle.kts b/core/notifications/build.gradle.kts new file mode 100644 index 000000000..608e59a38 --- /dev/null +++ b/core/notifications/build.gradle.kts @@ -0,0 +1,35 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +plugins { + id("nowinandroid.android.library") + id("nowinandroid.android.library.compose") + id("nowinandroid.android.hilt") +} + +android { + namespace = "com.google.samples.apps.nowinandroid.core.notifications" +} + +dependencies { + implementation(project(":core:model")) + + implementation(libs.kotlinx.coroutines.android) + implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.core.ktx) + + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.cloud.messaging) +} diff --git a/core/notifications/src/demo/java/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt b/core/notifications/src/demo/java/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt new file mode 100644 index 000000000..9bb2b3fb9 --- /dev/null +++ b/core/notifications/src/demo/java/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.notifications + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class NotificationsModule { + @Binds + abstract fun bindNotifier( + notifier: NoOpNotifier, + ): Notifier +} diff --git a/core/notifications/src/main/AndroidManifest.xml b/core/notifications/src/main/AndroidManifest.xml new file mode 100644 index 000000000..31c889874 --- /dev/null +++ b/core/notifications/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + diff --git a/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/AndroidSystemNotifier.kt b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/AndroidSystemNotifier.kt new file mode 100644 index 000000000..00d97fcb3 --- /dev/null +++ b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/AndroidSystemNotifier.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.notifications + +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Implementation of [Notifier] that displays notifications in the system tray. + */ +@Singleton +class AndroidSystemNotifier @Inject constructor() : Notifier { + + override fun onNewsAdded(newsResources: List) { + // TODO, create notification and display to the user + } +} diff --git a/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/NoOpNotifier.kt b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/NoOpNotifier.kt new file mode 100644 index 000000000..5a8141e91 --- /dev/null +++ b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/NoOpNotifier.kt @@ -0,0 +1,27 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.notifications + +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import javax.inject.Inject + +/** + * Implementation of [Notifier] which does nothing. Useful for tests and previews. + */ +class NoOpNotifier @Inject constructor() : Notifier { + override fun onNewsAdded(newsResources: List) = Unit +} diff --git a/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/Notifier.kt b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/Notifier.kt new file mode 100644 index 000000000..3084dcb75 --- /dev/null +++ b/core/notifications/src/main/java/com/google/samples/apps/nowinandroid/core/notifications/Notifier.kt @@ -0,0 +1,26 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.notifications + +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource + +/** + * Interface for creating notifications in the app + */ +interface Notifier { + fun onNewsAdded(newsResources: List) +} diff --git a/core/notifications/src/main/res/values/strings.xml b/core/notifications/src/main/res/values/strings.xml new file mode 100644 index 000000000..e3fd73ff8 --- /dev/null +++ b/core/notifications/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + + Now in Android + Sync + Background tasks for Now in Android + + diff --git a/core/notifications/src/prod/java/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt b/core/notifications/src/prod/java/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt new file mode 100644 index 000000000..0b4bd6bae --- /dev/null +++ b/core/notifications/src/prod/java/com/google/samples/apps/nowinandroid/core/notifications/NotificationsModule.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.notifications + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +abstract class NotificationsModule { + @Binds + abstract fun bindNotifier( + notifier: AndroidSystemNotifier, + ): Notifier +} diff --git a/core/testing/build.gradle.kts b/core/testing/build.gradle.kts index 5e0c3e409..2fea5beb3 100644 --- a/core/testing/build.gradle.kts +++ b/core/testing/build.gradle.kts @@ -24,23 +24,22 @@ android { } dependencies { - implementation(project(":core:common")) - implementation(project(":core:data")) - implementation(project(":core:domain")) - implementation(project(":core:model")) - - implementation(libs.kotlinx.datetime) - - api(libs.junit4) + api(libs.androidx.compose.ui.test) api(libs.androidx.test.core) - api(libs.kotlinx.coroutines.test) - api(libs.turbine) - api(libs.androidx.test.espresso.core) - api(libs.androidx.test.runner) api(libs.androidx.test.rules) - api(libs.androidx.compose.ui.test) + api(libs.androidx.test.runner) api(libs.hilt.android.testing) + api(libs.junit4) + api(libs.kotlinx.coroutines.test) + api(libs.turbine) debugApi(libs.androidx.compose.ui.testManifest) + + implementation(project(":core:common")) + implementation(project(":core:data")) + implementation(project(":core:domain")) + implementation(project(":core:model")) + implementation(project(":core:notifications")) + implementation(libs.kotlinx.datetime) } diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/notifications/TestNotifier.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/notifications/TestNotifier.kt new file mode 100644 index 000000000..669d2e6c4 --- /dev/null +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/notifications/TestNotifier.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.testing.notifications + +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import com.google.samples.apps.nowinandroid.core.notifications.Notifier + +/** + * Aggregates news resources that have been notified for addition + */ +class TestNotifier : Notifier { + + private val mutableAddedNewResources = mutableListOf>() + + val addedNewsResources: List> = mutableAddedNewResources + + override fun onNewsAdded(newsResources: List) { + mutableAddedNewResources.add(newsResources) + } +} diff --git a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncStatusMonitor.kt b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncManager.kt similarity index 85% rename from core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncStatusMonitor.kt rename to core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncManager.kt index a2edc89ff..999b67195 100644 --- a/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncStatusMonitor.kt +++ b/core/testing/src/main/java/com/google/samples/apps/nowinandroid/core/testing/util/TestSyncManager.kt @@ -16,16 +16,20 @@ package com.google.samples.apps.nowinandroid.core.testing.util -import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor +import com.google.samples.apps.nowinandroid.core.data.util.SyncManager import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -class TestSyncStatusMonitor : SyncStatusMonitor { +class TestSyncManager : SyncManager { private val syncStatusFlow = MutableStateFlow(false) override val isSyncing: Flow = syncStatusFlow + override fun requestSync() { + TODO("Not yet implemented") + } + /** * A test-only API to set the sync status from tests. */ diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 0438b8f36..b7280e757 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -27,28 +27,28 @@ android { } dependencies { - implementation(project(":core:designsystem")) - implementation(project(":core:model")) - implementation(project(":core:domain")) - implementation(project(":core:analytics")) - - implementation(libs.androidx.browser) - implementation(libs.androidx.core.ktx) - implementation(libs.coil.kt) - implementation(libs.coil.kt.compose) - implementation(libs.kotlinx.datetime) - api(libs.androidx.compose.foundation) api(libs.androidx.compose.foundation.layout) api(libs.androidx.compose.material.iconsExtended) api(libs.androidx.compose.material3) - debugApi(libs.androidx.compose.ui.tooling) - api(libs.androidx.compose.ui.tooling.preview) - api(libs.androidx.compose.ui.util) api(libs.androidx.compose.runtime) api(libs.androidx.compose.runtime.livedata) + api(libs.androidx.compose.ui.tooling.preview) + api(libs.androidx.compose.ui.util) api(libs.androidx.metrics) api(libs.androidx.tracing.ktx) + debugApi(libs.androidx.compose.ui.tooling) + + implementation(project(":core:analytics")) + implementation(project(":core:designsystem")) + implementation(project(":core:domain")) + implementation(project(":core:model")) + implementation(libs.androidx.browser) + implementation(libs.androidx.core.ktx) + implementation(libs.coil.kt) + implementation(libs.coil.kt.compose) + implementation(libs.kotlinx.datetime) + androidTestImplementation(project(":core:testing")) } \ No newline at end of file diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AnalyticsExtensions.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AnalyticsExtensions.kt index 38bce838a..bebaa4711 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AnalyticsExtensions.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/AnalyticsExtensions.kt @@ -39,7 +39,7 @@ fun AnalyticsHelper.logScreenView(screenName: String) { ) } -fun AnalyticsHelper.logNewsResourceOpened(newsResourceId: String, newsResourceTitle: String) { +fun AnalyticsHelper.logNewsResourceOpened(newsResourceId: String) { logEvent( event = AnalyticsEvent( type = "news_resource_opened", diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt index 412266034..981de26f9 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsFeed.kt @@ -21,6 +21,7 @@ import android.net.Uri import androidx.annotation.ColorInt import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabsIntent +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyGridScope @@ -31,6 +32,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Devices @@ -68,7 +70,6 @@ fun LazyGridScope.newsFeed( onClick = { analyticsHelper.logNewsResourceOpened( newsResourceId = userNewsResource.id, - newsResourceTitle = userNewsResource.title, ) launchCustomChromeTab(context, resourceUrl, backgroundColor) onNewsResourceViewed(userNewsResource.id) @@ -81,6 +82,7 @@ fun LazyGridScope.newsFeed( ) }, onTopicClick = onTopicClick, + modifier = Modifier.padding(horizontal = 8.dp), ) } } 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 f74fb48ca..a6a7aafc9 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCard.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -328,16 +329,20 @@ private fun ExpandedNewsResourcePreview( @PreviewParameter(UserNewsResourcePreviewParameterProvider::class) userNewsResources: List, ) { - NiaTheme { - Surface { - NewsResourceCardExpanded( - userNewsResource = userNewsResources[0], - isBookmarked = true, - hasBeenViewed = false, - onToggleBookmark = {}, - onClick = {}, - onTopicClick = {}, - ) + CompositionLocalProvider( + LocalInspectionMode provides true, + ) { + NiaTheme { + Surface { + NewsResourceCardExpanded( + userNewsResource = userNewsResources[0], + isBookmarked = true, + hasBeenViewed = false, + onToggleBookmark = {}, + onClick = {}, + onTopicClick = {}, + ) + } } } } diff --git a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt index 884da93b5..5cf7d7313 100644 --- a/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt +++ b/core/ui/src/main/java/com/google/samples/apps/nowinandroid/core/ui/NewsResourceCardList.kt @@ -58,7 +58,6 @@ fun LazyListScope.userNewsResourceCardItems( onClick = { analyticsHelper.logNewsResourceOpened( newsResourceId = userNewsResource.id, - newsResourceTitle = userNewsResource.title, ) when (onItemClick) { null -> launchCustomChromeTab(context, resourceUrl, backgroundColor) 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 a9ef26f64..2ed6a76b3 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 @@ -163,7 +163,7 @@ private fun EmptyState(modifier: Modifier = Modifier) { contentDescription = null, ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(48.dp)) Text( text = stringResource(id = R.string.bookmarks_empty_error), diff --git a/feature/foryou/build.gradle.kts b/feature/foryou/build.gradle.kts index ed7be27dc..ad50e531b 100644 --- a/feature/foryou/build.gradle.kts +++ b/feature/foryou/build.gradle.kts @@ -27,7 +27,7 @@ android { } dependencies { - implementation(libs.kotlinx.datetime) - implementation(libs.accompanist.flowlayout) + implementation(libs.kotlinx.datetime) + implementation(libs.androidx.activity.compose) } diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt index 961046538..06c73c971 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouScreen.kt @@ -16,7 +16,7 @@ package com.google.samples.apps.nowinandroid.feature.foryou -import android.app.Activity +import androidx.activity.compose.ReportDrawnWhen import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -38,6 +38,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsBottomHeight import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.grid.GridCells @@ -56,13 +57,11 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -72,7 +71,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import androidx.compose.ui.unit.sp import androidx.compose.ui.util.trace -import androidx.core.view.doOnPreDraw import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.samples.apps.nowinandroid.core.designsystem.component.DynamicAsyncImage @@ -127,23 +125,8 @@ internal fun ForYouScreen( val isOnboardingLoading = onboardingUiState is OnboardingUiState.Loading val isFeedLoading = feedState is NewsFeedUiState.Loading - // Workaround to call Activity.reportFullyDrawn from Jetpack Compose. - // This code should be called when the UI is ready for use - // and relates to Time To Full Display. - // TODO replace with ReportDrawnWhen { } once androidx.activity-compose 1.7.0 is used (currently alpha) - if (!isSyncing && !isOnboardingLoading && !isFeedLoading) { - val localView = LocalView.current - // We use Unit to call reportFullyDrawn only on the first recomposition, - // however it will be called again if this composable goes out of scope. - // Activity.reportFullyDrawn() has its own check for this - // and is safe to call multiple times though. - LaunchedEffect(Unit) { - // We're leveraging the fact, that the current view is directly set as content of Activity. - val activity = localView.context as? Activity ?: return@LaunchedEffect - // To be sure not to call in the middle of a frame draw. - localView.doOnPreDraw { activity.reportFullyDrawn() } - } - } + // This code should be called when the UI is ready for use and relates to Time To Full Display. + ReportDrawnWhen { !isSyncing && !isOnboardingLoading && !isFeedLoading } val state = rememberLazyGridState() TrackScrollJank(scrollableState = state, stateName = "forYou:feed") @@ -250,7 +233,7 @@ private fun LazyGridScope.onboarding( text = stringResource(R.string.onboarding_guidance_subtitle), modifier = Modifier .fillMaxWidth() - .padding(top = 8.dp, start = 16.dp, end = 16.dp), + .padding(top = 8.dp, start = 24.dp, end = 24.dp), textAlign = TextAlign.Center, style = MaterialTheme.typography.bodyMedium, ) @@ -268,8 +251,9 @@ private fun LazyGridScope.onboarding( onClick = saveFollowedTopics, enabled = onboardingUiState.isDismissable, modifier = Modifier - .padding(horizontal = 40.dp) - .width(364.dp), + .padding(horizontal = 24.dp) + .widthIn(364.dp) + .fillMaxWidth(), ) { Text( text = stringResource(R.string.done), @@ -458,7 +442,8 @@ fun ForYouScreenTopicSelection( ForYouScreen( isSyncing = false, onboardingUiState = OnboardingUiState.Shown( - topics = userNewsResources.flatMap { news -> news.followableTopics }, + topics = userNewsResources.flatMap { news -> news.followableTopics } + .distinctBy { it.topic.id }, ), feedState = NewsFeedUiState.Success( feed = userNewsResources, diff --git a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt index 84638c55e..18d24118b 100644 --- a/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt +++ b/feature/foryou/src/main/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModel.kt @@ -20,7 +20,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository import com.google.samples.apps.nowinandroid.core.data.repository.UserNewsResourceRepository -import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor +import com.google.samples.apps.nowinandroid.core.data.util.SyncManager import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCase import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import dagger.hilt.android.lifecycle.HiltViewModel @@ -35,7 +35,7 @@ import javax.inject.Inject @HiltViewModel class ForYouViewModel @Inject constructor( - syncStatusMonitor: SyncStatusMonitor, + syncManager: SyncManager, private val userDataRepository: UserDataRepository, userNewsResourceRepository: UserNewsResourceRepository, getFollowableTopics: GetFollowableTopicsUseCase, @@ -44,7 +44,7 @@ class ForYouViewModel @Inject constructor( private val shouldShowOnboarding: Flow = userDataRepository.userData.map { !it.shouldHideOnboarding } - val isSyncing = syncStatusMonitor.isSyncing + val isSyncing = syncManager.isSyncing .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), @@ -52,7 +52,7 @@ class ForYouViewModel @Inject constructor( ) val feedState: StateFlow = - userNewsResourceRepository.getUserNewsResourcesForFollowedTopics() + userNewsResourceRepository.observeAllForFollowedTopics() .map(NewsFeedUiState::Success) .stateIn( scope = viewModelScope, 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 16c593aa0..62993dc9f 100644 --- a/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt +++ b/feature/foryou/src/test/java/com/google/samples/apps/nowinandroid/feature/foryou/ForYouViewModelTest.kt @@ -30,7 +30,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserData import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.TestNetworkMonitor -import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncStatusMonitor +import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncManager import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch @@ -52,7 +52,7 @@ class ForYouViewModelTest { val mainDispatcherRule = MainDispatcherRule() private val networkMonitor = TestNetworkMonitor() - private val syncStatusMonitor = TestSyncStatusMonitor() + private val syncManager = TestSyncManager() private val userDataRepository = TestUserDataRepository() private val topicsRepository = TestTopicsRepository() private val newsRepository = TestNewsRepository() @@ -70,7 +70,7 @@ class ForYouViewModelTest { @Before fun setup() { viewModel = ForYouViewModel( - syncStatusMonitor = syncStatusMonitor, + syncManager = syncManager, userDataRepository = userDataRepository, userNewsResourceRepository = userNewsResourceRepository, getFollowableTopics = getFollowableTopicsUseCase, @@ -106,7 +106,7 @@ class ForYouViewModelTest { @Test fun stateIsLoadingWhenAppIsSyncingWithNoInterests() = runTest { - syncStatusMonitor.setSyncing(true) + syncManager.setSyncing(true) val collectJob = launch(UnconfinedTestDispatcher()) { viewModel.isSyncing.collect() } diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt index f8a7a8d90..ec9fd8f10 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/InterestsItem.kt @@ -65,7 +65,7 @@ fun InterestsItem( .padding(vertical = itemSeparation), ) { InterestsIcon(topicImageUrl, iconModifier.size(64.dp)) - Spacer(modifier = Modifier.width(16.dp)) + Spacer(modifier = Modifier.width(24.dp)) InterestContent(name, description) } NiaIconToggleButton( diff --git a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt index 457014cc2..289776164 100644 --- a/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt +++ b/feature/interests/src/main/java/com/google/samples/apps/nowinandroid/feature/interests/TabContent.kt @@ -38,9 +38,9 @@ fun TopicsTabContent( ) { LazyColumn( modifier = modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 24.dp) .testTag("interests:topics"), - contentPadding = PaddingValues(top = 8.dp), + contentPadding = PaddingValues(vertical = 16.dp), ) { topics.forEach { followableTopic -> val topicId = followableTopic.topic.id diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 737ad43a0..677438412 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ accompanist = "0.28.0" androidDesugarJdkLibs = "1.2.2" androidGradlePlugin = "7.4.1" -androidxActivity = "1.6.1" +androidxActivity = "1.7.0" androidxAppCompat = "1.5.1" androidxBrowser = "1.4.0" androidxComposeBom = "2023.01.00" @@ -87,26 +87,26 @@ androidx-metrics = { group = "androidx.metrics", name = "metrics-performance", v androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" } androidx-navigation-testing = { group = "androidx.navigation", name = "navigation-testing", version.ref = "androidxNavigation" } androidx-profileinstaller = { group = "androidx.profileinstaller", name = "profileinstaller", version.ref = "androidxProfileinstaller" } -androidx-startup = { group = "androidx.startup", name = "startup-runtime", version.ref = "androidxStartup" } androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTestCore" } androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "androidxEspresso" } androidx-test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxTestExt" } androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTestRunner" } androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "androidxUiAutomator" } -androidx-tracing-ktx = { group = "androidx.tracing", name="tracing-ktx", version.ref = "androidxTracing" } +androidx-tracing-ktx = { group = "androidx.tracing", name = "tracing-ktx", version.ref = "androidxTracing" } androidx-window-manager = { module = "androidx.window:window", version.ref = "androidxWindowManager" } androidx-work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidxWork" } androidx-work-testing = { group = "androidx.work", name = "work-testing", version.ref = "androidxWork" } coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil" } coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } -firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref="firebaseBom"} -firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx"} -firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx"} -firebase-crashlytics-gradle = { group = "com.google.firebase", name="firebase-crashlytics-gradle", version.ref = "firebaseCrashlyticsPlugin"} -firebase-performance = { group = "com.google.firebase", name = "firebase-perf-ktx"} -firebase-performance-gradle = { group = "com.google.firebase", name = "perf-plugin", version.ref = "firebasePerfPlugin"} +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } +firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" } +firebase-cloud-messaging = { group = "com.google.firebase", name = "firebase-messaging-ktx" } +firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx" } +firebase-crashlytics-gradle = { group = "com.google.firebase", name = "firebase-crashlytics-gradle", version.ref = "firebaseCrashlyticsPlugin" } +firebase-performance = { group = "com.google.firebase", name = "firebase-perf-ktx" } +firebase-performance-gradle = { group = "com.google.firebase", name = "perf-plugin", version.ref = "firebasePerfPlugin" } hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } @@ -138,9 +138,9 @@ ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devto android-application = { id = "com.android.application", version.ref = "androidGradlePlugin" } android-library = { id = "com.android.library", version.ref = "androidGradlePlugin" } android-test = { id = "com.android.test", version.ref = "androidGradlePlugin" } -firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin"} -firebase-perf = { id = "com.google.firebase.firebase-perf", version.ref = "firebasePerfPlugin"} -gms = { id = "com.google.gms.google-services", version.ref = "gmsPlugin"} +firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin" } +firebase-perf = { id = "com.google.firebase.firebase-perf", version.ref = "firebasePerfPlugin" } +gms = { id = "com.google.gms.google-services", version.ref = "gmsPlugin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 857f9d56c..2af582a7b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -47,6 +47,7 @@ include(":core:network") include(":core:ui") include(":core:testing") include(":core:analytics") +include(":core:notifications") include(":feature:foryou") include(":feature:interests") diff --git a/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncStatusMonitor.kt b/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncManager.kt similarity index 82% rename from sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncStatusMonitor.kt rename to sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncManager.kt index 647dd864e..2b0b4fb6a 100644 --- a/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncStatusMonitor.kt +++ b/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/NeverSyncingSyncManager.kt @@ -16,11 +16,12 @@ package com.google.samples.apps.nowinandroid.core.sync.test -import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor +import com.google.samples.apps.nowinandroid.core.data.util.SyncManager import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import javax.inject.Inject -class NeverSyncingSyncStatusMonitor @Inject constructor() : SyncStatusMonitor { +class NeverSyncingSyncManager @Inject constructor() : SyncManager { override val isSyncing: Flow = flowOf(false) + override fun requestSync() = Unit } diff --git a/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/TestSyncModule.kt b/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/TestSyncModule.kt index 323704b5a..0089450b5 100644 --- a/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/TestSyncModule.kt +++ b/sync/sync-test/src/main/java/com/google/samples/apps/nowinandroid/core/sync/test/TestSyncModule.kt @@ -16,7 +16,7 @@ package com.google.samples.apps.nowinandroid.core.sync.test -import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor +import com.google.samples.apps.nowinandroid.core.data.util.SyncManager import com.google.samples.apps.nowinandroid.sync.di.SyncModule import dagger.Binds import dagger.Module @@ -31,6 +31,6 @@ import dagger.hilt.testing.TestInstallIn interface TestSyncModule { @Binds fun bindsSyncStatusMonitor( - syncStatusMonitor: NeverSyncingSyncStatusMonitor, - ): SyncStatusMonitor + syncStatusMonitor: NeverSyncingSyncManager, + ): SyncManager } diff --git a/sync/work/build.gradle.kts b/sync/work/build.gradle.kts index a3b589db3..79902e486 100644 --- a/sync/work/build.gradle.kts +++ b/sync/work/build.gradle.kts @@ -27,24 +27,22 @@ android { } dependencies { + implementation(project(":core:analytics")) implementation(project(":core:common")) - implementation(project(":core:model")) implementation(project(":core:data")) implementation(project(":core:datastore")) - implementation(project(":core:analytics")) - - implementation(libs.kotlinx.coroutines.android) - + implementation(project(":core:model")) implementation(libs.androidx.lifecycle.livedata.ktx) implementation(libs.androidx.tracing.ktx) - implementation(libs.androidx.startup) implementation(libs.androidx.work.ktx) + implementation(libs.firebase.cloud.messaging) implementation(libs.hilt.ext.work) - - testImplementation(project(":core:testing")) - androidTestImplementation(project(":core:testing")) + implementation(libs.kotlinx.coroutines.android) kapt(libs.hilt.ext.compiler) + testImplementation(project(":core:testing")) + + androidTestImplementation(project(":core:testing")) androidTestImplementation(libs.androidx.work.testing) } diff --git a/sync/work/src/demo/AndroidManifest.xml b/sync/work/src/demo/AndroidManifest.xml new file mode 100644 index 000000000..8dc32c86f --- /dev/null +++ b/sync/work/src/demo/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/sync/work/src/main/AndroidManifest.xml b/sync/work/src/main/AndroidManifest.xml index 2487eb105..fed519807 100644 --- a/sync/work/src/main/AndroidManifest.xml +++ b/sync/work/src/main/AndroidManifest.xml @@ -18,18 +18,13 @@ xmlns:tools="http://schemas.android.com/tools"> - - - - - + + + + + diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt index 68f9eee93..bbc45dc42 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt @@ -16,8 +16,8 @@ package com.google.samples.apps.nowinandroid.sync.di -import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor -import com.google.samples.apps.nowinandroid.sync.status.WorkManagerSyncStatusMonitor +import com.google.samples.apps.nowinandroid.core.data.util.SyncManager +import com.google.samples.apps.nowinandroid.sync.status.WorkManagerSyncManager import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -28,6 +28,6 @@ import dagger.hilt.components.SingletonComponent interface SyncModule { @Binds fun bindsSyncStatusMonitor( - syncStatusMonitor: WorkManagerSyncStatusMonitor, - ): SyncStatusMonitor + syncStatusMonitor: WorkManagerSyncManager, + ): SyncManager } diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt index 837eb9a20..00f61f17d 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt @@ -17,31 +17,14 @@ package com.google.samples.apps.nowinandroid.sync.initializers import android.content.Context -import androidx.startup.AppInitializer -import androidx.startup.Initializer import androidx.work.ExistingWorkPolicy import androidx.work.WorkManager -import androidx.work.WorkManagerInitializer import com.google.samples.apps.nowinandroid.sync.workers.SyncWorker object Sync { - // This method is a workaround to manually initialize the sync process instead of relying on - // automatic initialization with Androidx Startup. It is called from the app module's - // Application.onCreate() and should be only done once. + // This method is initializes sync, the process that keeps the app's data current. + // It is called from the app module's Application.onCreate() and should be only done once. fun initialize(context: Context) { - AppInitializer.getInstance(context) - .initializeComponent(SyncInitializer::class.java) - } -} - -// This name should not be changed otherwise the app may have concurrent sync requests running -internal const val SyncWorkName = "SyncWorkName" - -/** - * Registers work to sync the data layer periodically on app startup. - */ -class SyncInitializer : Initializer { - override fun create(context: Context): Sync { WorkManager.getInstance(context).apply { // Run sync on app startup and ensure only one sync worker runs at any time enqueueUniqueWork( @@ -50,10 +33,8 @@ class SyncInitializer : Initializer { SyncWorker.startUpSyncWork(), ) } - - return Sync } - - override fun dependencies(): List>> = - listOf(WorkManagerInitializer::class.java) } + +// This name should not be changed otherwise the app may have concurrent sync requests running +internal const val SyncWorkName = "SyncWorkName" diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt new file mode 100644 index 000000000..ab318776a --- /dev/null +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/services/SyncNotificationsService.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.sync.services + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.google.samples.apps.nowinandroid.core.data.util.SyncManager +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +private const val SYNC_TOPIC = "sync" + +@AndroidEntryPoint +class SyncNotificationsService : FirebaseMessagingService() { + + @Inject + lateinit var syncManager: SyncManager + + override fun onMessageReceived(message: RemoteMessage) { + if (SYNC_TOPIC == message.from) { + syncManager.requestSync() + } + } +} diff --git a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncStatusMonitor.kt b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncManager.kt similarity index 67% rename from sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncStatusMonitor.kt rename to sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncManager.kt index f4f9d02cb..9bb57ccf0 100644 --- a/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncStatusMonitor.kt +++ b/sync/work/src/main/java/com/google/samples/apps/nowinandroid/sync/status/WorkManagerSyncManager.kt @@ -19,27 +19,39 @@ package com.google.samples.apps.nowinandroid.sync.status import android.content.Context import androidx.lifecycle.asFlow import androidx.lifecycle.map +import androidx.work.ExistingWorkPolicy import androidx.work.WorkInfo import androidx.work.WorkInfo.State import androidx.work.WorkManager -import com.google.samples.apps.nowinandroid.core.data.util.SyncStatusMonitor +import com.google.samples.apps.nowinandroid.core.data.util.SyncManager import com.google.samples.apps.nowinandroid.sync.initializers.SyncWorkName +import com.google.samples.apps.nowinandroid.sync.workers.SyncWorker import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.conflate import javax.inject.Inject /** - * [SyncStatusMonitor] backed by [WorkInfo] from [WorkManager] + * [SyncManager] backed by [WorkInfo] from [WorkManager] */ -class WorkManagerSyncStatusMonitor @Inject constructor( - @ApplicationContext context: Context, -) : SyncStatusMonitor { +class WorkManagerSyncManager @Inject constructor( + @ApplicationContext private val context: Context, +) : SyncManager { override val isSyncing: Flow = WorkManager.getInstance(context).getWorkInfosForUniqueWorkLiveData(SyncWorkName) .map(MutableList::anyRunning) .asFlow() .conflate() + + override fun requestSync() { + val workManager = WorkManager.getInstance(context) + // Run sync on app startup and ensure only one sync worker runs at any time + workManager.enqueueUniqueWork( + SyncWorkName, + ExistingWorkPolicy.KEEP, + SyncWorker.startUpSyncWork(), + ) + } } private val List.anyRunning get() = any { it.state == State.RUNNING }