From ccb822286f99c0d5a969a3db90eb4e2a72254bf8 Mon Sep 17 00:00:00 2001 From: Adetunji Dahunsi Date: Wed, 9 Mar 2022 14:23:34 -0500 Subject: [PATCH] Integrate WorkManager (WIP) Change-Id: Iedf81220336911ab3ed6ea4ca71b10f07e645bc9 --- app/build.gradle | 3 + .../samples/apps/nowinandroid/NiAApp.kt | 9 +- build.gradle | 3 + .../ic_nia_notification.xml | 15 ++ .../res/drawable-hdpi/ic_nia_notification.png | Bin 0 -> 373 bytes .../res/drawable-mdpi/ic_nia_notification.png | Bin 0 -> 265 bytes .../drawable-xhdpi/ic_nia_notification.png | Bin 0 -> 478 bytes .../drawable-xxhdpi/ic_nia_notification.png | Bin 0 -> 673 bytes .../core/database/dao/EpisodeDao.kt | 4 +- .../core/database/dao/NewsResourceDao.kt | 23 ++- .../core/database/dao/TopicDao.kt | 4 +- .../core/database/model/NewsResourceEntity.kt | 12 ++ core-datastore-test/build.gradle | 6 +- .../datastore/test/TestDataStoreModule.kt | 17 +- core-datastore/build.gradle | 4 +- .../core/datastore/NiaPreferences.kt | 16 ++ .../nowinandroid/data/user_preferences.proto | 1 + .../UserPreferencesSerializerTest.kt | 1 + core-domain-test/.gitignore | 1 + core-domain-test/build.gradle | 55 +++++++ core-domain-test/src/main/AndroidManifest.xml | 5 + .../core/domain/test/TestDomainModule.kt | 44 +++++ core-domain/build.gradle | 3 +- .../nowinandroid/core/domain/Utilities.kt | 37 +++++ .../core/domain/di/DomainModule.kt | 8 +- .../core/domain/model/NewsResource.kt | 10 ++ .../domain/repository/LocalNewsRepository.kt | 84 ++++++++++ ...Repository.kt => LocalTopicsRepository.kt} | 19 +-- .../repository/LocalNewsRepositoryTest.kt | 141 ++++++++++++++++ .../repository/LocalTopicsRepositoryTest.kt | 150 ++++++++++++++++++ .../core/domain/testdoubles/TestEpisodeDao.kt | 53 +++++++ .../domain/testdoubles/TestNewsResourceDao.kt | 100 ++++++++++++ .../core/domain/testdoubles/TestNiaNetwork.kt | 38 +++++ .../core/domain/testdoubles/TestTopicDao.kt | 48 ++++++ core-network/build.gradle | 3 +- .../nowinandroid/core/network/NiANetwork.kt | 4 +- .../core/network/di/NetworkModule.kt | 4 +- .../core/network/fake/FakeNiANetwork.kt | 4 +- .../network/retrofit/RetrofitNiANetwork.kt | 69 +++++++- gradle/libs.versions.toml | 15 +- settings.gradle | 2 + sync/.gitignore | 1 + sync/build.gradle | 58 +++++++ .../sync/workers/SyncWorkerTest.kt | 71 +++++++++ sync/src/main/AndroidManifest.xml | 21 +++ .../apps/nowinandroid/sync/SyncRepository.kt | 49 ++++++ .../apps/nowinandroid/sync/di/SyncModule.kt | 33 ++++ .../sync/initializers/SyncInitializer.kt | 61 +++++++ .../sync/initializers/SyncWorkHelpers.kt | 76 +++++++++ .../sync/workers/DelegatingWorker.kt | 80 ++++++++++ .../sync/workers/FirstTimeSyncCheckWorker.kt | 66 ++++++++ .../nowinandroid/sync/workers/SyncWorker.kt | 88 ++++++++++ sync/src/main/res/values/strings.xml | 22 +++ 53 files changed, 1591 insertions(+), 50 deletions(-) create mode 100644 core-common/src/main/res/drawable-anydpi-v24/ic_nia_notification.xml create mode 100644 core-common/src/main/res/drawable-hdpi/ic_nia_notification.png create mode 100644 core-common/src/main/res/drawable-mdpi/ic_nia_notification.png create mode 100644 core-common/src/main/res/drawable-xhdpi/ic_nia_notification.png create mode 100644 core-common/src/main/res/drawable-xxhdpi/ic_nia_notification.png create mode 100644 core-domain-test/.gitignore create mode 100644 core-domain-test/build.gradle create mode 100644 core-domain-test/src/main/AndroidManifest.xml create mode 100644 core-domain-test/src/main/java/com/google/samples/apps/nowinandroid/core/domain/test/TestDomainModule.kt create mode 100644 core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/Utilities.kt create mode 100644 core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepository.kt rename core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/{RoomTopicsRepository.kt => LocalTopicsRepository.kt} (80%) create mode 100644 core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepositoryTest.kt create mode 100644 core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepositoryTest.kt create mode 100644 core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestEpisodeDao.kt create mode 100644 core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNewsResourceDao.kt create mode 100644 core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNiaNetwork.kt create mode 100644 core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestTopicDao.kt create mode 100644 sync/.gitignore create mode 100644 sync/build.gradle create mode 100644 sync/src/androidTest/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorkerTest.kt create mode 100644 sync/src/main/AndroidManifest.xml create mode 100644 sync/src/main/java/com/google/samples/apps/nowinandroid/sync/SyncRepository.kt create mode 100644 sync/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt create mode 100644 sync/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt create mode 100644 sync/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt create mode 100644 sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/DelegatingWorker.kt create mode 100644 sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/FirstTimeSyncCheckWorker.kt create mode 100644 sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt create mode 100644 sync/src/main/res/values/strings.xml diff --git a/app/build.gradle b/app/build.gradle index 7468966c0..87ffa57c7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -121,8 +121,11 @@ dependencies { implementation project(':core-ui') + implementation project(':sync') + androidTestImplementation project(':core-testing') androidTestImplementation project(':core-datastore-test') + androidTestImplementation project(':core-domain-test') coreLibraryDesugaring libs.android.desugarJdkLibs diff --git a/app/src/main/java/com/google/samples/apps/nowinandroid/NiAApp.kt b/app/src/main/java/com/google/samples/apps/nowinandroid/NiAApp.kt index bed079d62..f0e9c3102 100644 --- a/app/src/main/java/com/google/samples/apps/nowinandroid/NiAApp.kt +++ b/app/src/main/java/com/google/samples/apps/nowinandroid/NiAApp.kt @@ -17,10 +17,17 @@ package com.google.samples.apps.nowinandroid import android.app.Application +import com.google.samples.apps.nowinandroid.sync.initializers.Sync import dagger.hilt.android.HiltAndroidApp /** * [Application] class for NiA */ @HiltAndroidApp -class NiAApp : Application() +class NiAApp : Application() { + override fun onCreate() { + super.onCreate() + // Initialize Sync; the system responsible for keeping data in the app up to date. + Sync.initialize(context = this) + } +} diff --git a/build.gradle b/build.gradle index 5149acc5e..e3679a438 100644 --- a/build.gradle +++ b/build.gradle @@ -68,6 +68,9 @@ subprojects { freeCompilerArgs += '-Xopt-in=kotlinx.coroutines.FlowPreview' freeCompilerArgs += '-Xopt-in=kotlin.Experimental' + // Enable experimental kotlinx serialization APIs + freeCompilerArgs += '-Xopt-in=kotlinx.serialization.ExperimentalSerializationApi' + // Set JVM target to 1.8 jvmTarget = "1.8" } diff --git a/core-common/src/main/res/drawable-anydpi-v24/ic_nia_notification.xml b/core-common/src/main/res/drawable-anydpi-v24/ic_nia_notification.xml new file mode 100644 index 000000000..6f92d40c0 --- /dev/null +++ b/core-common/src/main/res/drawable-anydpi-v24/ic_nia_notification.xml @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/core-common/src/main/res/drawable-hdpi/ic_nia_notification.png b/core-common/src/main/res/drawable-hdpi/ic_nia_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..7df77a01e46dcc9b14a027b774df44037dfd58c7 GIT binary patch literal 373 zcmV-*0gC>KP)MqGPP4bg0rT5R@ zjuYj&**K2lIL^8d(E-=yve=6a0wr41Y7r0h3aAys$y<&Oj!%vT^-?5Pn-Q-q>Q%78 zUd_&^mBP6f_1L2}3pVQ1Fz9cY+!MzO$C=}*A=k1d+HDdvKK9OfE`rT=YWhy8XeqK+ za)cfF&PL2Q;!viZNNY93Mn^DT!ZZ3;LF3D~CU01$2u?hq(~8tJMfu0{rBTaN-T~`o zq46VT>5N`8xs%E3KOL5)*C(NT!Pe*j#RmkF*P=+K;VhJ&j&L=5UQv8NYw~gv5}JO` z9hAR|9y!k;> zTsxQS=k#Ep(S!^(n)4J3SN@cp=Jl}tvv8AI3!j~WJ$mSktTfkSk8Ozft*{V~qP=^# zbrEI^x6g*}et>G|B=s)r)fT*68qMbcPE6n6G@l1distix#gx+g!tb~N@7k@>y;)n# P00000NkvXXu0mjf)RA=_ literal 0 HcmV?d00001 diff --git a/core-common/src/main/res/drawable-xhdpi/ic_nia_notification.png b/core-common/src/main/res/drawable-xhdpi/ic_nia_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..6b6ba812c29de3b0b2fb37ae169e5e34b801e3c3 GIT binary patch literal 478 zcmV<40U`d0P)FeAJdOqvPy(f3pI@N2lX=-nE{8MTpVE?V^|f-|bz zII4RFs9C9BR^Tu3j~M5K+O_(w2L7TosaH`N; zz2-Fwr1f6m4`Vgr8?=+y61bKc)Vj7=JI!wroO=`HB=h1XY$^IyKIw$OvipGRyFX~B zb;4XmbpG1uF?KrtBDlQ!BWHeyRj8bVxeM?T}Z7lA*IQ?RVQVtEgw^za(gnp3JP z&36FO=IYUyu1F};sO6&7dpXOE1?OuV0L7 zzU>7uKe%RTmVq*Dxn?o0c|~D9xMpdVfihKGvnUr}O@Y>2vy_su{0BcF{_TbOgAD&Q z>v`ziu}t|U=sp)@)eB~JT$7shqzo&r$;t(Kr+_urFjw>BmTzwP>6RB->~FU2iML~} zrpC~oYqA>&8Qk{aPsj%0L>l7*e%s)LVR&0NHP;jYBbT{bzPqKtaDKR!?W=*`clO~O z4Su5RPuNSd-WBI63jE`JF0Y)Y81Pq5OM!D}QQB1mf7dCA%~3C$b8+~5Yc5rTTn~%z zL+aNsPmwHR#rczhf1a81ww>B?uRrj^>K3jXW|`9tH*0WcbgR;yB$PcN$4c|8gz%C6 znBQ*M!@qX$3v3G3!+Kdy>m6go$9ENk-&&5bpo2>o;Y;kCYU*WEfMu96@1%{mre<_} zBYyjH?6{ACKS?omyzNEcZ|O1KdD{-cPoZ;o<~|+75AER7$=NXcY3GzZZ`*O}p1F7i zr%uqGi;uYOCn1EtT1+`M#I{^GgVCRJ5I@8^74IV&&L0|^k$u!$G^2W?--e4Ota%^N zaQUav2L27d?fpHK7QR=53-j`EM?w7Ld=Dx<{z*tPv6gDZ$3F@2cdvV}> - @Insert + // TODO: Perform a proper upsert. See: b/226916817 + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun saveEpisodeEntities(entities: List) } 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 15f21f6e9..1bea8c6cc 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 @@ -18,8 +18,10 @@ 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.NewsResourceEntity +import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import kotlinx.coroutines.flow.Flow @@ -29,17 +31,32 @@ import kotlinx.coroutines.flow.Flow */ @Dao interface NewsResourceDao { - @Query(value = "SELECT * FROM news_resources") + @Query( + value = """ + SELECT * FROM news_resources + ORDER BY publish_date + """ + ) fun getNewsResourcesStream(): Flow> @Query( value = """ SELECT * FROM news_resources - WHERE id IN (:filterTopicIds) + WHERE id in + ( + SELECT news_resource_id FROM news_resources_topics + WHERE topic_id IN (:filterTopicIds) + ) + ORDER BY publish_date """ ) fun getNewsResourcesStream(filterTopicIds: Set): Flow> - @Insert + // TODO: Perform a proper upsert. See: b/226916817 + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun saveNewsResourceEntities(entities: List) + + // TODO: Perform a proper upsert. See: b/226916817 + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun saveTopicCrossRefEntities(entities: List) } 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 c710b4b75..17182ec62 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 @@ -18,6 +18,7 @@ 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.TopicEntity import kotlinx.coroutines.flow.Flow @@ -38,6 +39,7 @@ interface TopicDao { ) fun getTopicEntitiesStream(ids: Set): Flow> - @Insert + // TODO: Perform a proper upsert. See: b/226916817 + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun saveTopics(entities: List) } diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceEntity.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceEntity.kt index 28d8d858e..06267551f 100644 --- a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceEntity.kt +++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/model/NewsResourceEntity.kt @@ -66,3 +66,15 @@ fun NewsResourceEntity.asExternalModel() = NewsResource( authors = listOf(), topics = listOf() ) + +/** + * A shell [EpisodeEntity] to fulfill the foreign key constraint when inserting + * a [NewsResourceEntity] into the DB + */ +fun NewsResourceEntity.episodeEntityShell() = EpisodeEntity( + id = episodeId, + name = "", + publishDate = Instant.fromEpochMilliseconds(0), + alternateVideo = null, + alternateAudio = null, +) diff --git a/core-datastore-test/build.gradle b/core-datastore-test/build.gradle index 9ad105776..686b4a322 100644 --- a/core-datastore-test/build.gradle +++ b/core-datastore-test/build.gradle @@ -39,10 +39,10 @@ android { } dependencies { - implementation project(':core-datastore') + api project(':core-datastore') implementation project(':core-testing') - implementation libs.androidx.dataStore + api libs.androidx.dataStore.core implementation libs.hilt.android kapt libs.hilt.compiler @@ -54,4 +54,4 @@ dependencies { force 'org.objenesis:objenesis:2.6' } } -} \ No newline at end of file +} 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 567686fa9..62b3abc34 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 @@ -40,11 +40,14 @@ object TestDataStoreModule { fun providesUserPreferencesDataStore( userPreferencesSerializer: UserPreferencesSerializer, tmpFolder: TemporaryFolder - ): DataStore { - return DataStoreFactory.create( - serializer = userPreferencesSerializer, - ) { - tmpFolder.newFile("user_preferences_test.pb") - } - } + ): DataStore = + tmpFolder.testUserPreferencesDataStore(userPreferencesSerializer) +} + +fun TemporaryFolder.testUserPreferencesDataStore( + userPreferencesSerializer: UserPreferencesSerializer = UserPreferencesSerializer() +) = DataStoreFactory.create( + serializer = userPreferencesSerializer, +) { + newFile("user_preferences_test.pb") } diff --git a/core-datastore/build.gradle b/core-datastore/build.gradle index 86041ab07..4547ea557 100644 --- a/core-datastore/build.gradle +++ b/core-datastore/build.gradle @@ -63,7 +63,7 @@ dependencies { implementation libs.kotlinx.coroutines.android - implementation libs.androidx.dataStore + implementation libs.androidx.dataStore.core implementation(libs.protobuf.kotlin.lite) { // TODO: https://github.com/protocolbuffers/protobuf/issues/9517 exclude group: "org.jetbrains.kotlin", module: "kotlin-test" @@ -72,4 +72,4 @@ dependencies { implementation libs.hilt.android kapt libs.hilt.compiler kaptAndroidTest libs.hilt.compiler -} \ No newline at end of file +} diff --git a/core-datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferences.kt b/core-datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferences.kt index ab4111ca1..7c275da57 100644 --- a/core-datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferences.kt +++ b/core-datastore/src/main/java/com/google/samples/apps/nowinandroid/core/datastore/NiaPreferences.kt @@ -21,6 +21,7 @@ import androidx.datastore.core.DataStore import java.io.IOException import javax.inject.Inject import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.retry @@ -65,4 +66,19 @@ class NiaPreferences @Inject constructor( true } .map { it.followedTopicIdsList.toSet() } + + suspend fun hasRunFirstTimeSync() = userPreferences.data + .map { it.hasRunFirstTimeSync }.firstOrNull() ?: false + + suspend fun markFirstTimeSyncDone() { + try { + userPreferences.updateData { + it.copy { + hasRunFirstTimeSync = true + } + } + } catch (ioException: IOException) { + Log.e("NiaPreferences", "Failed to update user preferences", ioException) + } + } } diff --git a/core-datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto b/core-datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto index fc42652fe..f9ab22ab4 100644 --- a/core-datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto +++ b/core-datastore/src/main/proto/com/google/samples/apps/nowinandroid/data/user_preferences.proto @@ -21,4 +21,5 @@ option java_multiple_files = true; message UserPreferences { repeated int32 followed_topic_ids = 1; + bool has_run_first_time_sync = 2; } diff --git a/core-datastore/src/test/java/com/google/samples/apps/nowinandroid/core/datastore/UserPreferencesSerializerTest.kt b/core-datastore/src/test/java/com/google/samples/apps/nowinandroid/core/datastore/UserPreferencesSerializerTest.kt index b0906f7db..6c7248837 100644 --- a/core-datastore/src/test/java/com/google/samples/apps/nowinandroid/core/datastore/UserPreferencesSerializerTest.kt +++ b/core-datastore/src/test/java/com/google/samples/apps/nowinandroid/core/datastore/UserPreferencesSerializerTest.kt @@ -41,6 +41,7 @@ class UserPreferencesSerializerTest { val expectedUserPreferences = userPreferences { followedTopicIds.add(0) followedTopicIds.add(1) + hasRunFirstTimeSync = true } val outputStream = ByteArrayOutputStream() diff --git a/core-domain-test/.gitignore b/core-domain-test/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/core-domain-test/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core-domain-test/build.gradle b/core-domain-test/build.gradle new file mode 100644 index 000000000..21d8bd359 --- /dev/null +++ b/core-domain-test/build.gradle @@ -0,0 +1,55 @@ +/* + * 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. + */ +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'kotlin-kapt' + id 'dagger.hilt.android.plugin' +} + +android { + compileSdk buildConfig.compileSdk + + defaultConfig { + minSdk buildConfig.minSdk + targetSdk buildConfig.targetSdk + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + api project(':core-domain') + implementation project(':core-testing') + + implementation libs.hilt.android + kapt libs.hilt.compiler + kaptAndroidTest libs.hilt.compiler + + configurations.configureEach { + resolutionStrategy { + // Temporary workaround for https://issuetracker.google.com/174733673 + force 'org.objenesis:objenesis:2.6' + } + } +} diff --git a/core-domain-test/src/main/AndroidManifest.xml b/core-domain-test/src/main/AndroidManifest.xml new file mode 100644 index 000000000..c4553d7d8 --- /dev/null +++ b/core-domain-test/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + diff --git a/core-domain-test/src/main/java/com/google/samples/apps/nowinandroid/core/domain/test/TestDomainModule.kt b/core-domain-test/src/main/java/com/google/samples/apps/nowinandroid/core/domain/test/TestDomainModule.kt new file mode 100644 index 000000000..08f514562 --- /dev/null +++ b/core-domain-test/src/main/java/com/google/samples/apps/nowinandroid/core/domain/test/TestDomainModule.kt @@ -0,0 +1,44 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain.test + +import com.google.samples.apps.nowinandroid.core.domain.di.DomainModule +import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository +import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository +import com.google.samples.apps.nowinandroid.core.domain.repository.fake.FakeNewsRepository +import com.google.samples.apps.nowinandroid.core.domain.repository.fake.FakeTopicsRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.components.SingletonComponent +import dagger.hilt.testing.TestInstallIn + +@Module +@TestInstallIn( + components = [SingletonComponent::class], + replaces = [DomainModule::class] +) +interface TestDomainModule { + @Binds + fun bindsTopicRepository( + fakeTopicsRepository: FakeTopicsRepository + ): TopicsRepository + + @Binds + fun bindsNewsResourceRepository( + fakeNewsRepository: FakeNewsRepository + ): NewsRepository +} diff --git a/core-domain/build.gradle b/core-domain/build.gradle index 80edd7603..fca6d8424 100644 --- a/core-domain/build.gradle +++ b/core-domain/build.gradle @@ -45,6 +45,7 @@ dependencies { implementation project(':core-network') testImplementation project(':core-testing') + testImplementation project(':core-datastore-test') implementation libs.kotlinx.datetime implementation libs.kotlinx.coroutines.android @@ -52,4 +53,4 @@ dependencies { implementation libs.hilt.android kapt libs.hilt.compiler -} \ No newline at end of file +} diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/Utilities.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/Utilities.kt new file mode 100644 index 000000000..612c0ea96 --- /dev/null +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/Utilities.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain + +import android.util.Log +import kotlin.coroutines.cancellation.CancellationException + +/** + * Attempts [block], returning a successful [Result] if it succeeds, otherwise a [Result.Failure] + * taking care not to break structured concurrency + */ +suspend fun suspendRunCatching(block: suspend () -> T): Result = try { + Result.success(block()) +} catch (cancellationException: CancellationException) { + throw cancellationException +} catch (exception: Exception) { + Log.i( + "suspendRunCatching", + "Failed to evaluate a suspendRunCatchingBlock. Returning failure Result", + exception + ) + Result.failure(exception) +} diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/DomainModule.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/DomainModule.kt index 3db5bab1b..8a549ca9f 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/DomainModule.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/di/DomainModule.kt @@ -16,10 +16,10 @@ package com.google.samples.apps.nowinandroid.core.domain.di +import com.google.samples.apps.nowinandroid.core.domain.repository.LocalNewsRepository +import com.google.samples.apps.nowinandroid.core.domain.repository.LocalTopicsRepository import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository -import com.google.samples.apps.nowinandroid.core.domain.repository.fake.FakeNewsRepository -import com.google.samples.apps.nowinandroid.core.domain.repository.fake.FakeTopicsRepository import dagger.Binds import dagger.Module import dagger.hilt.InstallIn @@ -31,11 +31,11 @@ interface DomainModule { @Binds fun bindsTopicRepository( - fakeTopicsRepository: FakeTopicsRepository + topicsRepository: LocalTopicsRepository ): TopicsRepository @Binds fun bindsNewsResourceRepository( - fakeNewsRepository: FakeNewsRepository + newsRepository: LocalNewsRepository ): NewsRepository } diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/NewsResource.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/NewsResource.kt index ffb5be168..a83768db6 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/NewsResource.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/model/NewsResource.kt @@ -17,6 +17,7 @@ package com.google.samples.apps.nowinandroid.core.domain.model import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity +import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded @@ -41,3 +42,12 @@ fun NetworkNewsResourceExpanded.asEntity() = NewsResourceEntity( publishDate = publishDate, type = type, ) + +fun NetworkNewsResource.topicCrossReferences(): List = + topics.map { topicId -> + NewsResourceTopicCrossRef( + newsResourceId = id, + topicId = topicId + + ) + } diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepository.kt new file mode 100644 index 000000000..a28b776c6 --- /dev/null +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepository.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain.repository + +import com.google.samples.apps.nowinandroid.core.database.dao.EpisodeDao +import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao +import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity +import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity +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.episodeEntityShell +import com.google.samples.apps.nowinandroid.core.domain.model.asEntity +import com.google.samples.apps.nowinandroid.core.domain.model.topicCrossReferences +import com.google.samples.apps.nowinandroid.core.domain.suspendRunCatching +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import com.google.samples.apps.nowinandroid.core.network.NiANetwork +import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** + * Room database backed implementation of the [NewsRepository]. + */ +class LocalNewsRepository @Inject constructor( + private val newsResourceDao: NewsResourceDao, + private val episodeDao: EpisodeDao, + private val network: NiANetwork, +) : NewsRepository { + + override fun getNewsResourcesStream(): Flow> = + newsResourceDao.getNewsResourcesStream() + .map { it.map(PopulatedNewsResource::asExternalModel) } + + override fun getNewsResourcesStream(filterTopicIds: Set): Flow> = + newsResourceDao.getNewsResourcesStream(filterTopicIds = filterTopicIds) + .map { it.map(PopulatedNewsResource::asExternalModel) } + + override suspend fun sync() = suspendRunCatching { + val networkNewsResources = network.getNewsResources() + + val newsResourceEntities = networkNewsResources + .map(NetworkNewsResource::asEntity) + + val episodeEntityShells = newsResourceEntities + .map(NewsResourceEntity::episodeEntityShell) + .distinctBy(EpisodeEntity::id) + + val topicCrossReferences = networkNewsResources + .map(NetworkNewsResource::topicCrossReferences) + .distinct() + .flatten() + + // Order of invocation matters to satisfy id and foreign key constraints! + + // TODO: Create a separate method for saving shells with proper conflict resolution + // See: b/226919874 + episodeDao.saveEpisodeEntities( + episodeEntityShells + ) + newsResourceDao.saveNewsResourceEntities( + newsResourceEntities + ) + newsResourceDao.saveTopicCrossRefEntities( + topicCrossReferences + ) + + // TODO: Save author as well + }.isSuccess +} diff --git a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/RoomTopicsRepository.kt b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepository.kt similarity index 80% rename from core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/RoomTopicsRepository.kt rename to core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepository.kt index 7b020846d..322c9d873 100644 --- a/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/RoomTopicsRepository.kt +++ b/core-domain/src/main/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepository.kt @@ -21,20 +21,18 @@ 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.NiaPreferences import com.google.samples.apps.nowinandroid.core.domain.model.asEntity +import com.google.samples.apps.nowinandroid.core.domain.suspendRunCatching import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.network.NiANetwork -import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import javax.inject.Inject -import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map /** * Room database backed implementation of the [TopicsRepository]. */ -class RoomTopicsRepository @Inject constructor( - private val dispatchers: NiaDispatchers, +class LocalTopicsRepository @Inject constructor( private val topicDao: TopicDao, private val network: NiANetwork, private val niaPreferences: NiaPreferences @@ -52,15 +50,10 @@ class RoomTopicsRepository @Inject constructor( override fun getFollowedTopicIdsStream() = niaPreferences.followedTopicIds - override suspend fun sync(): Boolean = try { + override suspend fun sync(): Boolean = suspendRunCatching { + val networkTopics = network.getTopics() topicDao.saveTopics( - network.getTopics() - .map(NetworkTopic::asEntity) + networkTopics.map(NetworkTopic::asEntity) ) - true - } catch (cancellationException: CancellationException) { - throw cancellationException - } catch (exception: Exception) { - false - } + }.isSuccess } diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepositoryTest.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepositoryTest.kt new file mode 100644 index 000000000..53a1b6310 --- /dev/null +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalNewsRepositoryTest.kt @@ -0,0 +1,141 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain.repository + +import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity +import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity +import com.google.samples.apps.nowinandroid.core.database.model.PopulatedEpisode +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.episodeEntityShell +import com.google.samples.apps.nowinandroid.core.domain.model.asEntity +import com.google.samples.apps.nowinandroid.core.domain.model.topicCrossReferences +import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestEpisodeDao +import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestNewsResourceDao +import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestNiaNetwork +import com.google.samples.apps.nowinandroid.core.domain.testdoubles.filteredTopicIds +import com.google.samples.apps.nowinandroid.core.domain.testdoubles.nonPresentTopicIds +import com.google.samples.apps.nowinandroid.core.model.data.NewsResource +import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class LocalNewsRepositoryTest { + + private lateinit var subject: LocalNewsRepository + + private lateinit var newsResourceDao: TestNewsResourceDao + + private lateinit var episodeDao: TestEpisodeDao + + private lateinit var network: TestNiaNetwork + + @Before + fun setup() { + newsResourceDao = TestNewsResourceDao() + episodeDao = TestEpisodeDao() + network = TestNiaNetwork() + + subject = LocalNewsRepository( + newsResourceDao = newsResourceDao, + episodeDao = episodeDao, + network = network + ) + } + + @Test + fun localNewsRepository_news_resources_stream_is_backed_by_news_resource_dao() = + runTest { + assertEquals( + newsResourceDao.getNewsResourcesStream() + .first() + .map(PopulatedNewsResource::asExternalModel), + subject.getNewsResourcesStream() + .first() + ) + } + + @Test + fun localNewsRepository_news_resources_filtered_stream_is_backed_by_news_resource_dao() = + runTest { + assertEquals( + newsResourceDao.getNewsResourcesStream(filterTopicIds = filteredTopicIds) + .first() + .map(PopulatedNewsResource::asExternalModel), + subject.getNewsResourcesStream(filterTopicIds = filteredTopicIds) + .first() + ) + + assertEquals( + emptyList(), + subject.getNewsResourcesStream(filterTopicIds = nonPresentTopicIds) + .first() + ) + } + + @Test + fun localNewsRepository_sync_pulls_from_network() = + runTest { + subject.sync() + + val newsResourcesFromNetwork = network.getNewsResources() + .map(NetworkNewsResource::asEntity) + .map(NewsResourceEntity::asExternalModel) + + val newsResourcesFromDb = newsResourceDao.getNewsResourcesStream() + .first() + .map(PopulatedNewsResource::asExternalModel) + + assertEquals( + newsResourcesFromNetwork.map(NewsResource::id), + newsResourcesFromDb.map(NewsResource::id) + ) + } + + @Test + fun localNewsRepository_sync_saves_shell_episode_entities() = + runTest { + subject.sync() + + assertEquals( + network.getNewsResources() + .map(NetworkNewsResource::asEntity) + .map(NewsResourceEntity::episodeEntityShell) + .distinctBy(EpisodeEntity::id), + episodeDao.getEpisodesStream() + .first() + .map(PopulatedEpisode::entity) + ) + } + + @Test + fun localNewsRepository_sync_saves_topic_cross_references() = + runTest { + subject.sync() + + assertEquals( + network.getNewsResources() + .map(NetworkNewsResource::topicCrossReferences) + .distinct() + .flatten(), + newsResourceDao.topicCrossReferences + ) + } +} diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepositoryTest.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepositoryTest.kt new file mode 100644 index 000000000..25fa03b88 --- /dev/null +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/repository/LocalTopicsRepositoryTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain.repository + +import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao +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.NiaPreferences +import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore +import com.google.samples.apps.nowinandroid.core.domain.model.asEntity +import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestNiaNetwork +import com.google.samples.apps.nowinandroid.core.domain.testdoubles.TestTopicDao +import com.google.samples.apps.nowinandroid.core.network.NiANetwork +import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class LocalTopicsRepositoryTest { + + private lateinit var subject: LocalTopicsRepository + + private lateinit var topicDao: TopicDao + + private lateinit var network: NiANetwork + + private lateinit var niaPreferences: NiaPreferences + + @get:Rule + val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() + + @Before + fun setup() { + topicDao = TestTopicDao() + network = TestNiaNetwork() + niaPreferences = NiaPreferences( + tmpFolder.testUserPreferencesDataStore() + ) + + subject = LocalTopicsRepository( + topicDao = topicDao, + network = network, + niaPreferences = niaPreferences, + ) + } + + @Test + fun localTopicsRepository_topics_stream_is_backed_by_topics_dao() = + runTest { + Assert.assertEquals( + topicDao.getTopicEntitiesStream() + .first() + .map(TopicEntity::asExternalModel), + subject.getTopicsStream() + .first() + ) + } + + @Test + fun localTopicsRepository_news_resources_filtered_stream_is_backed_by_news_resource_dao() = + runTest { + Assert.assertEquals( + niaPreferences.followedTopicIds + .first(), + subject.getFollowedTopicIdsStream() + .first() + ) + } + + @Test + fun localTopicsRepository_sync_pulls_from_network() = + runTest { + subject.sync() + + val network = network.getTopics() + .map(NetworkTopic::asEntity) + + val db = topicDao.getTopicEntitiesStream() + .first() + + Assert.assertEquals( + network.map(TopicEntity::id), + db.map(TopicEntity::id) + ) + } + + @Test + fun localTopicsRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() = + runTest { + subject.toggleFollowedTopicId(followedTopicId = 0, followed = true) + + Assert.assertEquals( + setOf(0), + subject.getFollowedTopicIdsStream() + .first() + ) + + subject.toggleFollowedTopicId(followedTopicId = 1, followed = true) + + Assert.assertEquals( + setOf(0, 1), + subject.getFollowedTopicIdsStream() + .first() + ) + + Assert.assertEquals( + niaPreferences.followedTopicIds + .first(), + subject.getFollowedTopicIdsStream() + .first() + ) + } + + @Test + fun localTopicsRepository_set_followed_topics_logic_delegates_to_nia_preferences() = + runTest { + subject.setFollowedTopicIds(followedTopicIds = setOf(1, 2)) + + Assert.assertEquals( + setOf(1, 2), + subject.getFollowedTopicIdsStream() + .first() + ) + + Assert.assertEquals( + niaPreferences.followedTopicIds + .first(), + subject.getFollowedTopicIdsStream() + .first() + ) + } +} diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestEpisodeDao.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestEpisodeDao.kt new file mode 100644 index 000000000..11bbe8179 --- /dev/null +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestEpisodeDao.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain.testdoubles + +import com.google.samples.apps.nowinandroid.core.database.dao.EpisodeDao +import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity +import com.google.samples.apps.nowinandroid.core.database.model.PopulatedEpisode +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.datetime.Instant + +/** + * Test double for [EpisodeDao] + */ +class TestEpisodeDao : EpisodeDao { + + private var entities = listOf( + EpisodeEntity( + id = 1, + name = "Topic", + publishDate = Instant.fromEpochMilliseconds(0), + alternateVideo = null, + alternateAudio = null, + ) + ) + + override fun getEpisodesStream(): Flow> = + flowOf(entities.map(EpisodeEntity::asPopulatedEpisode)) + + override suspend fun saveEpisodeEntities(entities: List) { + this.entities = entities + } +} + +private fun EpisodeEntity.asPopulatedEpisode() = PopulatedEpisode( + entity = this, + newsResources = emptyList(), + authors = emptyList(), +) diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNewsResourceDao.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNewsResourceDao.kt new file mode 100644 index 000000000..a790fecc0 --- /dev/null +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNewsResourceDao.kt @@ -0,0 +1,100 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain.testdoubles + +import com.google.samples.apps.nowinandroid.core.database.dao.NewsResourceDao +import com.google.samples.apps.nowinandroid.core.database.model.AuthorEntity +import com.google.samples.apps.nowinandroid.core.database.model.EpisodeEntity +import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEntity +import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef +import com.google.samples.apps.nowinandroid.core.database.model.PopulatedNewsResource +import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity +import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.datetime.Instant + +val filteredTopicIds = setOf(1) +val nonPresentTopicIds = setOf(2) + +/** + * Test double for [NewsResourceDao] + */ +class TestNewsResourceDao : NewsResourceDao { + + private var entities = listOf( + NewsResourceEntity( + id = 1, + episodeId = 0, + title = "news", + content = "Hilt", + url = "url", + headerImageUrl = "headerImageUrl", + type = Video, + publishDate = Instant.fromEpochMilliseconds(1), + ) + ) + + internal var topicCrossReferences: List = listOf() + + override fun getNewsResourcesStream(): Flow> = + flowOf(entities.map(NewsResourceEntity::asPopulatedNewsResource)) + + override fun getNewsResourcesStream( + filterTopicIds: Set + ): Flow> = + getNewsResourcesStream() + .map { resources -> + resources.filter { resource -> + resource.topics.any { it.id in filterTopicIds } + } + } + + override suspend fun saveNewsResourceEntities(entities: List) { + this.entities = entities + } + + override suspend fun saveTopicCrossRefEntities(entities: List) { + topicCrossReferences = entities + } +} + +private fun NewsResourceEntity.asPopulatedNewsResource() = PopulatedNewsResource( + entity = this, + episode = EpisodeEntity( + id = this.episodeId, + name = "episode 4", + publishDate = Instant.fromEpochMilliseconds(2), + alternateAudio = "audio", + alternateVideo = "video", + ), + authors = listOf( + AuthorEntity( + id = 2, + name = "name", + imageUrl = "imageUrl" + ) + ), + topics = listOf( + TopicEntity( + id = filteredTopicIds.random(), + name = "name", + description = "description", + ) + ), +) diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNiaNetwork.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNiaNetwork.kt new file mode 100644 index 000000000..96334e11c --- /dev/null +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestNiaNetwork.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain.testdoubles + +import com.google.samples.apps.nowinandroid.core.network.NiANetwork +import com.google.samples.apps.nowinandroid.core.network.fake.FakeDataSource +import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource +import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json + +/** + * Test double for [NiANetwork] + */ +class TestNiaNetwork : NiANetwork { + + private val networkJson = Json + + override suspend fun getTopics(itemsPerPage: Int): List = + networkJson.decodeFromString(FakeDataSource.topicsData) + + override suspend fun getNewsResources(itemsPerPage: Int): List = + networkJson.decodeFromString(FakeDataSource.data) +} diff --git a/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestTopicDao.kt b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestTopicDao.kt new file mode 100644 index 000000000..f4ec8346b --- /dev/null +++ b/core-domain/src/test/java/com/google/samples/apps/nowinandroid/core/domain/testdoubles/TestTopicDao.kt @@ -0,0 +1,48 @@ +/* + * Copyright 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.samples.apps.nowinandroid.core.domain.testdoubles + +import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao +import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +/** + * Test double for [TopicDao] + */ +class TestTopicDao : TopicDao { + + private var entities = listOf( + TopicEntity( + id = 1, + name = "Topic", + description = "A topic", + ) + ) + + override fun getTopicEntitiesStream(): Flow> = + flowOf(entities) + + override fun getTopicEntitiesStream(ids: Set): Flow> = + getTopicEntitiesStream() + .map { topics -> topics.filter { it.id in ids } } + + override suspend fun saveTopics(entities: List) { + this.entities = entities + } +} diff --git a/core-network/build.gradle b/core-network/build.gradle index b0e9697ad..c715a6879 100644 --- a/core-network/build.gradle +++ b/core-network/build.gradle @@ -48,7 +48,8 @@ dependencies { implementation libs.kotlinx.datetime implementation libs.okhttp.logging - implementation libs.retrofit + implementation libs.retrofit.core + implementation libs.retrofit.kotlin.serialization implementation libs.hilt.android kapt libs.hilt.compiler diff --git a/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/NiANetwork.kt b/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/NiANetwork.kt index 32599344c..16cc47a44 100644 --- a/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/NiANetwork.kt +++ b/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/NiANetwork.kt @@ -23,7 +23,7 @@ import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic * Interface representing network calls to the NIA backend */ interface NiANetwork { - suspend fun getTopics(): List + suspend fun getTopics(itemsPerPage: Int = 200): List - suspend fun getNewsResources(): List + suspend fun getNewsResources(itemsPerPage: Int = 200): List } diff --git a/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/NetworkModule.kt b/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/NetworkModule.kt index 521c6109e..b57201bea 100644 --- a/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/NetworkModule.kt +++ b/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/di/NetworkModule.kt @@ -17,7 +17,7 @@ package com.google.samples.apps.nowinandroid.core.network.di import com.google.samples.apps.nowinandroid.core.network.NiANetwork -import com.google.samples.apps.nowinandroid.core.network.fake.FakeNiANetwork +import com.google.samples.apps.nowinandroid.core.network.retrofit.RetrofitNiANetwork import dagger.Binds import dagger.Module import dagger.Provides @@ -32,7 +32,7 @@ interface NetworkModule { @Binds fun bindsNiANetwork( - fakeNiANetwork: FakeNiANetwork + niANetwork: RetrofitNiANetwork ): NiANetwork companion object { diff --git a/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiANetwork.kt b/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiANetwork.kt index a89f151df..087a08fcf 100644 --- a/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiANetwork.kt +++ b/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/fake/FakeNiANetwork.kt @@ -34,12 +34,12 @@ class FakeNiANetwork @Inject constructor( @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, private val networkJson: Json ) : NiANetwork { - override suspend fun getTopics(): List = + override suspend fun getTopics(itemsPerPage: Int): List = withContext(ioDispatcher) { networkJson.decodeFromString(FakeDataSource.topicsData) } - override suspend fun getNewsResources(): List = + override suspend fun getNewsResources(itemsPerPage: Int): List = withContext(ioDispatcher) { networkJson.decodeFromString(FakeDataSource.data) } diff --git a/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiANetwork.kt b/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiANetwork.kt index 6e855c55a..54def1763 100644 --- a/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiANetwork.kt +++ b/core-network/src/main/java/com/google/samples/apps/nowinandroid/core/network/retrofit/RetrofitNiANetwork.kt @@ -16,14 +16,73 @@ package com.google.samples.apps.nowinandroid.core.network.retrofit +import com.google.samples.apps.nowinandroid.core.network.NiANetwork import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit import retrofit2.http.GET +import retrofit2.http.Query -interface RetrofitNiANetwork { - @GET(value = "/topics") - suspend fun getTopics(): List +/** + * Retrofit API declaration for NIA Network API + */ +private interface RetrofitNiANetworkApi { + @GET(value = "topics") + suspend fun getTopics( + @Query("pageSize") itemsPerPage: Int, + ): NetworkResponse> + + @GET(value = "newsresources") + suspend fun getNewsResources( + @Query("pageSize") itemsPerPage: Int, + ): NetworkResponse> +} + +private const val NiABaseUrl = "https://staging-url.com/" + +/** + * Wrapper for data provided from the [NiABaseUrl] + */ +@Serializable +private data class NetworkResponse( + val data: T +) + +/** + * [Retrofit] backed [NiANetwork] + */ +@Singleton +class RetrofitNiANetwork @Inject constructor( + networkJson: Json +) : NiANetwork { + + private val networkApi = Retrofit.Builder() + .baseUrl(NiABaseUrl) + .client( + OkHttpClient.Builder() + .addInterceptor( + // TODO: Decide logging logic + HttpLoggingInterceptor().apply { + setLevel(HttpLoggingInterceptor.Level.BODY) + } + ) + .build() + ) + .addConverterFactory(networkJson.asConverterFactory("application/json".toMediaType())) + .build() + .create(RetrofitNiANetworkApi::class.java) + + override suspend fun getTopics(itemsPerPage: Int): List = + networkApi.getTopics(itemsPerPage = itemsPerPage).data - @GET(value = "/newsresources") - suspend fun getNewsResources(): List + override suspend fun getNewsResources(itemsPerPage: Int): List = + networkApi.getNewsResources(itemsPerPage = itemsPerPage).data } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 04e1d9e10..92ecac243 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,12 +15,15 @@ androidxLifecycle = "2.4.0" androidxMacroBenchmark = "1.1.0-beta04" androidxNavigation = "2.4.0-rc01" androidxProfileinstaller = "1.2.0-alpha02" +androidxStartup = "1.1.1" androidxWindowManager = "1.0.0" androidxTest = "1.4.0" androidxTestExt = "1.1.2" androidxUiAutomator = "2.2.0" +androidxWork = "2.7.1" coil = "2.0.0-rc01" hilt = "2.41" +hiltExt = "1.0.0" jacoco = "0.8.7" junit4 = "4.13" kotlin = "1.6.10" @@ -36,6 +39,7 @@ okhttp = "4.9.3" protobuf = "3.19.1" protobufPlugin = "0.8.18" retrofit = "2.9.0" +retrofitKotlinxSerializationJson = "0.8.0" room = "2.4.1" spotless = "6.0.0" turbine = "0.7.0" @@ -60,11 +64,13 @@ androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-toolin androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview", version.ref = "androidxCompose" } androidx-compose-ui-util = { group = "androidx.compose.ui", name = "ui-util", version.ref = "androidxCompose" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" } -androidx-dataStore = { group = "androidx.datastore", name = "datastore", version.ref = "androidxDataStore" } +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-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", 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-window-manager = {module = "androidx.window:window", version.ref = "androidxWindowManager"} androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTest" } androidx-test-ext = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "androidxTestExt" } @@ -72,9 +78,13 @@ androidx-test-espresso-core = { group = "androidx.test.espresso", name = "espres androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "androidxTest" } androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTest" } androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "androidxTestExt" } +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"} hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-ext-work = { group = "androidx.hilt", name = "hilt-work", version.ref = "hiltExt" } +hilt-ext-compiler = { group = "androidx.hilt", name = "hilt-compiler", version.ref = "hiltExt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" } hilt-gradlePlugin = { group = "com.google.dagger", name = "hilt-android-gradle-plugin", version.ref = "hilt" } @@ -91,7 +101,8 @@ okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } -retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-kotlin-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationJson" } room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } diff --git a/settings.gradle b/settings.gradle index 00e535e69..7f4c5a29c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -32,6 +32,7 @@ include ':app' include ':benchmark' include ':core-common' include ':core-domain' +include ':core-domain-test' include ':core-database' include ':core-datastore' include ':core-datastore-test' @@ -41,3 +42,4 @@ include ':core-ui' include ':core-testing' include ':feature-following' include ':feature-foryou' +include ':sync' diff --git a/sync/.gitignore b/sync/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/sync/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/sync/build.gradle b/sync/build.gradle new file mode 100644 index 000000000..4840816d1 --- /dev/null +++ b/sync/build.gradle @@ -0,0 +1,58 @@ +/* + * 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. + */ +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'kotlin-kapt' + id 'dagger.hilt.android.plugin' +} + +android { + compileSdk buildConfig.compileSdk + + defaultConfig { + minSdk buildConfig.minSdk + targetSdk buildConfig.targetSdk + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + implementation project(':core-model') + implementation project(':core-domain') + implementation project(':core-datastore') + + implementation libs.kotlinx.coroutines.android + + implementation libs.androidx.startup + implementation libs.androidx.work.ktx + implementation libs.hilt.ext.work + + testImplementation project(':core-testing') + androidTestImplementation project(':core-testing') + + implementation libs.hilt.android + kapt libs.hilt.compiler + kapt libs.hilt.ext.compiler + + androidTestImplementation libs.androidx.work.testing +} diff --git a/sync/src/androidTest/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorkerTest.kt b/sync/src/androidTest/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorkerTest.kt new file mode 100644 index 000000000..e3ecc6944 --- /dev/null +++ b/sync/src/androidTest/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorkerTest.kt @@ -0,0 +1,71 @@ +/* + * 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.workers + +import android.util.Log +import androidx.test.platform.app.InstrumentationRegistry +import androidx.work.Configuration +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.testing.SynchronousExecutor +import androidx.work.testing.WorkManagerTestInitHelper +import androidx.work.workDataOf +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class SyncWorkerTest { + + private val context get() = InstrumentationRegistry.getInstrumentation().context + + @Before + fun setup() { + val config = Configuration.Builder() + .setMinimumLoggingLevel(Log.DEBUG) + .setExecutor(SynchronousExecutor()) + .build() + + // Initialize WorkManager for instrumentation tests. + WorkManagerTestInitHelper.initializeTestWorkManager(context, config) + } + + @Test + fun testSyncPeriodicWork() { + // Define input data + val input = workDataOf() + + // Create request + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setInputData(input) + .build() + + val workManager = WorkManager.getInstance(context) + val testDriver = WorkManagerTestInitHelper.getTestDriver(context)!! + + // Enqueue and wait for result. + workManager.enqueue(request).result.get() + // Tells the testing framework the period delay is met + testDriver.setPeriodDelayMet(request.id) + // Get WorkInfo and outputData + val workInfo = workManager.getWorkInfoById(request.id).get() + + // Assert + assertEquals(workInfo.state, WorkInfo.State.ENQUEUED) + } +} diff --git a/sync/src/main/AndroidManifest.xml b/sync/src/main/AndroidManifest.xml new file mode 100644 index 000000000..5627bb1b5 --- /dev/null +++ b/sync/src/main/AndroidManifest.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + diff --git a/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/SyncRepository.kt b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/SyncRepository.kt new file mode 100644 index 000000000..12512a29c --- /dev/null +++ b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/SyncRepository.kt @@ -0,0 +1,49 @@ +/* + * 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 + +import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferences +import javax.inject.Inject + +/** + * Source of truth for status of sync. + */ +interface SyncRepository { + /** + * returns whether we should run a first time sync job + */ + suspend fun shouldRunFirstTimeSync(): Boolean + + /** + * Notify the repository that first time sync has run successfully + */ + suspend fun notifyFirstTimeSyncRun() +} + +/** + * Uses a [NiaPreferences] to manage sync status + */ +class LocalSyncRepository @Inject constructor( + private val preferences: NiaPreferences +) : SyncRepository { + + override suspend fun shouldRunFirstTimeSync(): Boolean = !preferences.hasRunFirstTimeSync() + + override suspend fun notifyFirstTimeSyncRun() { + preferences.markFirstTimeSyncDone() + } +} diff --git a/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt new file mode 100644 index 000000000..9f8762d23 --- /dev/null +++ b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/di/SyncModule.kt @@ -0,0 +1,33 @@ +/* + * 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.samples.apps.nowinandroid.sync.LocalSyncRepository +import com.google.samples.apps.nowinandroid.sync.SyncRepository +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface SyncModule { + @Binds + fun bindsSyncStatusRepository( + localSyncRepository: LocalSyncRepository + ): SyncRepository +} diff --git a/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt new file mode 100644 index 000000000..42432ce69 --- /dev/null +++ b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncInitializer.kt @@ -0,0 +1,61 @@ +/* + * 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.initializers + +import android.content.Context +import androidx.startup.AppInitializer +import androidx.startup.Initializer +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.WorkManager +import androidx.work.WorkManagerInitializer +import com.google.samples.apps.nowinandroid.sync.workers.FirstTimeSyncCheckWorker +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. + 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 +private const val SyncWorkName = "SyncWorkName" + +/** + * Registers sync to sync the data layer periodically [SyncInterval] on app startup. + */ +class SyncInitializer : Initializer { + override fun create(context: Context): Sync { + + val workManager = WorkManager.getInstance(context) + + workManager.enqueueUniquePeriodicWork( + SyncWorkName, + ExistingPeriodicWorkPolicy.KEEP, + SyncWorker.periodicSyncWork() + ) + workManager.enqueue(FirstTimeSyncCheckWorker.firstTimeSyncCheckWork()) + + return Sync + } + + override fun dependencies(): List>> = + listOf(WorkManagerInitializer::class.java) +} diff --git a/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt new file mode 100644 index 000000000..6a58ebe19 --- /dev/null +++ b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/initializers/SyncWorkHelpers.kt @@ -0,0 +1,76 @@ +/* + * 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.initializers + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.Constraints +import androidx.work.ForegroundInfo +import androidx.work.NetworkType +import com.google.samples.apps.nowinandroid.sync.R + +private const val SyncNotificationId = 0 +private const val SyncNotificationChannelID = "SyncNotificationChannel" + +// All sync work needs an internet connectionS +val SyncConstraints + get() = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + +/** + * Foreground information for sync on lower API levels when sync workers are being + * run with a foreground service + */ +fun Context.syncForegroundInfo() = ForegroundInfo( + SyncNotificationId, + syncWorkNotification() +) + +/** + * Notification displayed on lower API levels when sync workers are being + * run with a foreground service + */ +private fun Context.syncWorkNotification(): Notification { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + SyncNotificationChannelID, + getString(R.string.sync_notification_channel_name), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = getString(R.string.sync_notification_channel_description) + } + // Register the channel with the system + val notificationManager: NotificationManager? = + getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager + + notificationManager?.createNotificationChannel(channel) + } + + return NotificationCompat.Builder( + this, + SyncNotificationChannelID + ) + .setSmallIcon(R.drawable.ic_nia_notification) + .setContentTitle(getString(R.string.sync_notification_title)) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build() +} diff --git a/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/DelegatingWorker.kt b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/DelegatingWorker.kt new file mode 100644 index 000000000..0114ad6ec --- /dev/null +++ b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/DelegatingWorker.kt @@ -0,0 +1,80 @@ +/* + * 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.workers + +import android.content.Context +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import kotlin.reflect.KClass + +/** + * An entry point to retrieve the [HiltWorkerFactory] at runtime + */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface HiltWorkerFactoryEntryPoint { + fun hiltWorkerFactory(): HiltWorkerFactory +} + +private const val WORKER_CLASS_NAME = "RouterWorkerDelegateClassName" + +/** + * Adds metadata to a WorkRequest to identify what [CoroutineWorker] the [DelegatingWorker] should + * delegate to + */ +internal fun KClass.delegatedData() = + Data.Builder() + .putString(WORKER_CLASS_NAME, qualifiedName) + .build() + +/** + * A worker that delegates sync to another [CoroutineWorker] constructed with a [HiltWorkerFactory]. + * + * This allows for creating and using [CoroutineWorker] instances with extended arguments + * without having to provide a custom WorkManager configuration that the app module needs to utilize. + * + * In other words, it allows for custom workers in a library module without having to own + * configuration of the WorkManager singleton. + */ +class DelegatingWorker( + appContext: Context, + workerParams: WorkerParameters, +) : CoroutineWorker(appContext, workerParams) { + + private val workerClassName = + workerParams.inputData.getString(WORKER_CLASS_NAME) ?: "" + + private val delegateWorker = + EntryPointAccessors.fromApplication(appContext) + .hiltWorkerFactory() + .createWorker(appContext, workerClassName, workerParams) + as? CoroutineWorker + ?: throw IllegalArgumentException("Unable to find appropriate worker") + + override suspend fun getForegroundInfo(): ForegroundInfo = + delegateWorker.getForegroundInfo() + + override suspend fun doWork(): Result = + delegateWorker.doWork() +} diff --git a/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/FirstTimeSyncCheckWorker.kt b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/FirstTimeSyncCheckWorker.kt new file mode 100644 index 000000000..a1919b39a --- /dev/null +++ b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/FirstTimeSyncCheckWorker.kt @@ -0,0 +1,66 @@ +/* + * 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.workers + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.google.samples.apps.nowinandroid.sync.SyncRepository +import com.google.samples.apps.nowinandroid.sync.initializers.SyncConstraints +import com.google.samples.apps.nowinandroid.sync.initializers.syncForegroundInfo +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +/** + * Checks if the [SyncWorker] has ever run. If it hasn't, it requests one time sync to sync as + * quickly as possible, otherwise it does nothing. + */ +@HiltWorker +class FirstTimeSyncCheckWorker @AssistedInject constructor( + @Assisted private val appContext: Context, + @Assisted workerParams: WorkerParameters, + private val syncRepository: SyncRepository, +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun getForegroundInfo(): ForegroundInfo = + appContext.syncForegroundInfo() + + override suspend fun doWork(): Result { + if (!syncRepository.shouldRunFirstTimeSync()) return Result.success() + + WorkManager.getInstance(appContext) + .enqueue(SyncWorker.firstTimeSyncWork()) + + return Result.success() + } + + companion object { + /** + * Work that runs to enqueue an immediate first time sync if the app has yet to sync at all + */ + fun firstTimeSyncCheckWork() = OneTimeWorkRequestBuilder() + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setConstraints(SyncConstraints) + .setInputData(FirstTimeSyncCheckWorker::class.delegatedData()) + .build() + } +} diff --git a/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt new file mode 100644 index 000000000..8ecf6c48a --- /dev/null +++ b/sync/src/main/java/com/google/samples/apps/nowinandroid/sync/workers/SyncWorker.kt @@ -0,0 +1,88 @@ +/* + * 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.workers + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkerParameters +import com.google.samples.apps.nowinandroid.core.domain.repository.NewsRepository +import com.google.samples.apps.nowinandroid.core.domain.repository.TopicsRepository +import com.google.samples.apps.nowinandroid.sync.SyncRepository +import com.google.samples.apps.nowinandroid.sync.initializers.SyncConstraints +import com.google.samples.apps.nowinandroid.sync.initializers.syncForegroundInfo +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import java.util.concurrent.TimeUnit + +/** + * Syncs the data layer by delegating to the appropriate repository instances with + * sync functionality. + */ +@HiltWorker +class SyncWorker @AssistedInject constructor( + @Assisted private val appContext: Context, + @Assisted workerParams: WorkerParameters, + private val syncRepository: SyncRepository, + private val topicRepository: TopicsRepository, + private val newsRepository: NewsRepository, +) : CoroutineWorker(appContext, workerParams) { + + override suspend fun getForegroundInfo(): ForegroundInfo = + appContext.syncForegroundInfo() + + override suspend fun doWork(): Result = + // First sync the repositories + when (topicRepository.sync() && newsRepository.sync()) { + // Sync ran successfully, notify the SyncRepository that sync has been run + true -> { + syncRepository.notifyFirstTimeSyncRun() + Result.success() + } + false -> Result.retry() + } + + companion object { + private const val SyncInterval = 1L + private val SyncIntervalTimeUnit = TimeUnit.DAYS + + /** + * Expedited one time work to sync data as quickly as possible because the app has + * either launched for the first time, or hasn't had the opportunity to sync at all + */ + fun firstTimeSyncWork() = OneTimeWorkRequestBuilder() + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .setConstraints(SyncConstraints) + .setInputData(SyncWorker::class.delegatedData()) + .build() + + /** + * Periodic sync work to routinely keep the app up to date + */ + fun periodicSyncWork() = PeriodicWorkRequestBuilder( + SyncInterval, + SyncIntervalTimeUnit + ) + .setConstraints(SyncConstraints) + .setInputData(SyncWorker::class.delegatedData()) + .build() + } +} diff --git a/sync/src/main/res/values/strings.xml b/sync/src/main/res/values/strings.xml new file mode 100644 index 000000000..3ad1cc990 --- /dev/null +++ b/sync/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + + + Now in Android + Sync + Background tasks for Now in Android + +