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