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 000000000..7df77a01e
Binary files /dev/null and b/core-common/src/main/res/drawable-hdpi/ic_nia_notification.png differ
diff --git a/core-common/src/main/res/drawable-mdpi/ic_nia_notification.png b/core-common/src/main/res/drawable-mdpi/ic_nia_notification.png
new file mode 100644
index 000000000..092834949
Binary files /dev/null and b/core-common/src/main/res/drawable-mdpi/ic_nia_notification.png differ
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 000000000..6b6ba812c
Binary files /dev/null and b/core-common/src/main/res/drawable-xhdpi/ic_nia_notification.png differ
diff --git a/core-common/src/main/res/drawable-xxhdpi/ic_nia_notification.png b/core-common/src/main/res/drawable-xxhdpi/ic_nia_notification.png
new file mode 100644
index 000000000..cc58ec035
Binary files /dev/null and b/core-common/src/main/res/drawable-xxhdpi/ic_nia_notification.png differ
diff --git a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/EpisodeDao.kt b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/EpisodeDao.kt
index 17aadc6e0..267bba130 100644
--- a/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/EpisodeDao.kt
+++ b/core-database/src/main/java/com/google/samples/apps/nowinandroid/core/database/dao/EpisodeDao.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.EpisodeEntity
import com.google.samples.apps.nowinandroid.core.database.model.PopulatedEpisode
@@ -32,6 +33,7 @@ interface EpisodeDao {
@Query(value = "SELECT * FROM episodes")
fun getEpisodesStream(): Flow>
- @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
+
+