Integrate WorkManager (WIP)

Change-Id: Iedf81220336911ab3ed6ea4ca71b10f07e645bc9
pull/2/head
Adetunji Dahunsi 3 years ago committed by Don Turner
parent c417672c85
commit ccb822286f

@ -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

@ -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)
}
}

@ -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"
}

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group android:scaleX="0.92"
android:scaleY="0.92"
android:translateX="0.96"
android:translateY="0.96">
<path
android:pathData="M17.6,11.48 L19.44,8.3a0.63,0.63 0,0 0,-1.09 -0.63l-1.88,3.24a11.43,11.43 0,0 0,-8.94 0L5.65,7.67a0.63,0.63 0,0 0,-1.09 0.63L6.4,11.48A10.81,10.81 0,0 0,1 20L23,20A10.81,10.81 0,0 0,17.6 11.48ZM7,17.25A1.25,1.25 0,1 1,8.25 16,1.25 1.25,0 0,1 7,17.25ZM17,17.25A1.25,1.25 0,1 1,18.25 16,1.25 1.25,0 0,1 17,17.25Z"
android:fillColor="#FF000000"/>
</group>
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 673 B

@ -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<List<PopulatedEpisode>>
@Insert
// TODO: Perform a proper upsert. See: b/226916817
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveEpisodeEntities(entities: List<EpisodeEntity>)
}

@ -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<List<PopulatedNewsResource>>
@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<Int>): Flow<List<PopulatedNewsResource>>
@Insert
// TODO: Perform a proper upsert. See: b/226916817
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveNewsResourceEntities(entities: List<NewsResourceEntity>)
// TODO: Perform a proper upsert. See: b/226916817
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveTopicCrossRefEntities(entities: List<NewsResourceTopicCrossRef>)
}

@ -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<Int>): Flow<List<TopicEntity>>
@Insert
// TODO: Perform a proper upsert. See: b/226916817
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveTopics(entities: List<TopicEntity>)
}

@ -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,
)

@ -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'
}
}
}
}

@ -40,11 +40,14 @@ object TestDataStoreModule {
fun providesUserPreferencesDataStore(
userPreferencesSerializer: UserPreferencesSerializer,
tmpFolder: TemporaryFolder
): DataStore<UserPreferences> {
return DataStoreFactory.create(
serializer = userPreferencesSerializer,
) {
tmpFolder.newFile("user_preferences_test.pb")
}
}
): DataStore<UserPreferences> =
tmpFolder.testUserPreferencesDataStore(userPreferencesSerializer)
}
fun TemporaryFolder.testUserPreferencesDataStore(
userPreferencesSerializer: UserPreferencesSerializer = UserPreferencesSerializer()
) = DataStoreFactory.create(
serializer = userPreferencesSerializer,
) {
newFile("user_preferences_test.pb")
}

@ -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
}
}

@ -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)
}
}
}

@ -21,4 +21,5 @@ option java_multiple_files = true;
message UserPreferences {
repeated int32 followed_topic_ids = 1;
bool has_run_first_time_sync = 2;
}

@ -41,6 +41,7 @@ class UserPreferencesSerializerTest {
val expectedUserPreferences = userPreferences {
followedTopicIds.add(0)
followedTopicIds.add(1)
hasRunFirstTimeSync = true
}
val outputStream = ByteArrayOutputStream()

@ -0,0 +1 @@
/build

@ -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'
}
}
}

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.google.samples.apps.nowinandroid.core.domain.test">
</manifest>

@ -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
}

@ -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
}
}

@ -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 <T> suspendRunCatching(block: suspend () -> T): Result<T> = 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)
}

@ -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
}

@ -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<NewsResourceTopicCrossRef> =
topics.map { topicId ->
NewsResourceTopicCrossRef(
newsResourceId = id,
topicId = topicId
)
}

@ -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<List<NewsResource>> =
newsResourceDao.getNewsResourcesStream()
.map { it.map(PopulatedNewsResource::asExternalModel) }
override fun getNewsResourcesStream(filterTopicIds: Set<Int>): Flow<List<NewsResource>> =
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
}

@ -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
}

@ -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<NewsResource>(),
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
)
}
}

@ -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()
)
}
}

@ -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<List<PopulatedEpisode>> =
flowOf(entities.map(EpisodeEntity::asPopulatedEpisode))
override suspend fun saveEpisodeEntities(entities: List<EpisodeEntity>) {
this.entities = entities
}
}
private fun EpisodeEntity.asPopulatedEpisode() = PopulatedEpisode(
entity = this,
newsResources = emptyList(),
authors = emptyList(),
)

@ -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<NewsResourceTopicCrossRef> = listOf()
override fun getNewsResourcesStream(): Flow<List<PopulatedNewsResource>> =
flowOf(entities.map(NewsResourceEntity::asPopulatedNewsResource))
override fun getNewsResourcesStream(
filterTopicIds: Set<Int>
): Flow<List<PopulatedNewsResource>> =
getNewsResourcesStream()
.map { resources ->
resources.filter { resource ->
resource.topics.any { it.id in filterTopicIds }
}
}
override suspend fun saveNewsResourceEntities(entities: List<NewsResourceEntity>) {
this.entities = entities
}
override suspend fun saveTopicCrossRefEntities(entities: List<NewsResourceTopicCrossRef>) {
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",
)
),
)

@ -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<NetworkTopic> =
networkJson.decodeFromString(FakeDataSource.topicsData)
override suspend fun getNewsResources(itemsPerPage: Int): List<NetworkNewsResource> =
networkJson.decodeFromString(FakeDataSource.data)
}

@ -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<List<TopicEntity>> =
flowOf(entities)
override fun getTopicEntitiesStream(ids: Set<Int>): Flow<List<TopicEntity>> =
getTopicEntitiesStream()
.map { topics -> topics.filter { it.id in ids } }
override suspend fun saveTopics(entities: List<TopicEntity>) {
this.entities = entities
}
}

@ -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

@ -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<NetworkTopic>
suspend fun getTopics(itemsPerPage: Int = 200): List<NetworkTopic>
suspend fun getNewsResources(): List<NetworkNewsResource>
suspend fun getNewsResources(itemsPerPage: Int = 200): List<NetworkNewsResource>
}

@ -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 {

@ -34,12 +34,12 @@ class FakeNiANetwork @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
private val networkJson: Json
) : NiANetwork {
override suspend fun getTopics(): List<NetworkTopic> =
override suspend fun getTopics(itemsPerPage: Int): List<NetworkTopic> =
withContext(ioDispatcher) {
networkJson.decodeFromString(FakeDataSource.topicsData)
}
override suspend fun getNewsResources(): List<NetworkNewsResource> =
override suspend fun getNewsResources(itemsPerPage: Int): List<NetworkNewsResource> =
withContext(ioDispatcher) {
networkJson.decodeFromString(FakeDataSource.data)
}

@ -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<NetworkTopic>
/**
* Retrofit API declaration for NIA Network API
*/
private interface RetrofitNiANetworkApi {
@GET(value = "topics")
suspend fun getTopics(
@Query("pageSize") itemsPerPage: Int,
): NetworkResponse<List<NetworkTopic>>
@GET(value = "newsresources")
suspend fun getNewsResources(
@Query("pageSize") itemsPerPage: Int,
): NetworkResponse<List<NetworkNewsResource>>
}
private const val NiABaseUrl = "https://staging-url.com/"
/**
* Wrapper for data provided from the [NiABaseUrl]
*/
@Serializable
private data class NetworkResponse<T>(
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<NetworkTopic> =
networkApi.getTopics(itemsPerPage = itemsPerPage).data
@GET(value = "/newsresources")
suspend fun getNewsResources(): List<NetworkNewsResource>
override suspend fun getNewsResources(itemsPerPage: Int): List<NetworkNewsResource> =
networkApi.getNewsResources(itemsPerPage = itemsPerPage).data
}

@ -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" }

@ -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'

1
sync/.gitignore vendored

@ -0,0 +1 @@
/build

@ -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
}

@ -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<SyncWorker>(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)
}
}

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.google.samples.apps.nowinandroid.sync">
<application>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<!-- TODO: b/2173216 Disable auto sync startup till it works well with instrumented tests -->
<meta-data
android:name="com.google.samples.apps.nowinandroid.sync.initializers.SyncInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>
</manifest>

@ -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()
}
}

@ -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
}

@ -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<Sync> {
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<Class<out Initializer<*>>> =
listOf(WorkManagerInitializer::class.java)
}

@ -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()
}

@ -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<out CoroutineWorker>.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<HiltWorkerFactoryEntryPoint>(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()
}

@ -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<DelegatingWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setConstraints(SyncConstraints)
.setInputData(FirstTimeSyncCheckWorker::class.delegatedData())
.build()
}
}

@ -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<DelegatingWorker>()
.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<DelegatingWorker>(
SyncInterval,
SyncIntervalTimeUnit
)
.setConstraints(SyncConstraints)
.setInputData(SyncWorker::class.delegatedData())
.build()
}
}

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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.
-->
<resources>
<string name="sync_notification_title">Now in Android</string>
<string name="sync_notification_channel_name">Sync</string>
<string name="sync_notification_channel_description">Background tasks for Now in Android</string>
</resources>
Loading…
Cancel
Save