parent
c417672c85
commit
ccb822286f
@ -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>
|
After Width: | Height: | Size: 373 B |
After Width: | Height: | Size: 265 B |
After Width: | Height: | Size: 478 B |
After Width: | Height: | Size: 673 B |
@ -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
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
@ -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…
Reference in new issue