Skip to content

Commit

Permalink
Replace GetUserNewsResourcesUseCase with UserNewsResourceRepository
Browse files Browse the repository at this point in the history
This moves the responsibility for joining the UserData and the
NewsResources to UserNewsResourceRepository. This way, the work can be
done once and shared with all consumers in a SharedFlow, rather than
having each consumer perform the join itself by invoking the UseCase.
  • Loading branch information
rosejr committed Feb 27, 2023
1 parent 9ee2acf commit bd45009
Show file tree
Hide file tree
Showing 14 changed files with 262 additions and 101 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ import kotlin.annotation.AnnotationRetention.RUNTIME
annotation class Dispatcher(val niaDispatcher: NiaDispatchers)

enum class NiaDispatchers {
Default,
IO,
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.google.samples.apps.nowinandroid.core.network.di

import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.Default
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import dagger.Module
import dagger.Provides
Expand All @@ -31,4 +32,8 @@ object DispatchersModule {
@Provides
@Dispatcher(IO)
fun providesIODispatcher(): CoroutineDispatcher = Dispatchers.IO

@Provides
@Dispatcher(Default)
fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright 2023 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.di

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import javax.inject.Qualifier
import javax.inject.Singleton

@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class ApplicationScope

@InstallIn(SingletonComponent::class)
@Module
object CoroutinesScopesModule {

@Singleton
@ApplicationScope
@Provides
fun providesCoroutineScope(): CoroutineScope =
CoroutineScope(SupervisorJob() + Dispatchers.Default)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright 2023 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.di

import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent

@Module
@InstallIn(SingletonComponent::class)
interface UserNewsResourceRepositoryModule {
@Binds
fun bindsUserNewsResourceRepository(
userDataRepository: CompositeUserNewsResourceRepository,
): UserNewsResourceRepository
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Copyright 2023 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.data.repository.NewsRepository
import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.di.ApplicationScope
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.shareIn
import javax.inject.Inject

/**
* Implements a [UserNewsResourceRepository] by combining a [NewsRepository] with a
* [UserDataRepository].
*/
class CompositeUserNewsResourceRepository @Inject constructor(
@ApplicationScope private val coroutineScope: CoroutineScope,
val newsRepository: NewsRepository,
val userDataRepository: UserDataRepository,
) : UserNewsResourceRepository {

private val userNewsResources =
newsRepository.getNewsResources().mapToUserNewsResources(userDataRepository.userData)
.shareIn(coroutineScope, started = WhileSubscribed(5000), replay = 1)

override fun getUserNewsResources(
query: NewsResourceQuery,
): Flow<List<UserNewsResource>> =
userNewsResources.map { resources ->
resources.filter { resource ->
query.filterTopicIds?.let { topics -> resource.hasTopic(topics) } ?: true &&
query.filterNewsIds?.contains(resource.id) ?: true
}
}

override fun getUserNewsResourcesForFollowedTopics(): Flow<List<UserNewsResource>> =
userDataRepository.userData.flatMapLatest { getUserNewsResources(NewsResourceQuery(filterTopicIds = it.followedTopics)) }

private fun UserNewsResource.hasTopic(filterTopicIds: Set<String>) =
followableTopics.any { filterTopicIds.contains(it.topic.id) }
}

private fun Flow<List<NewsResource>>.mapToUserNewsResources(
userDataStream: Flow<UserData>,
): Flow<List<UserNewsResource>> =
filterNot { it.isEmpty() }
.combine(userDataStream) { newsResources, userData ->
newsResources.mapToUserNewsResources(userData)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright 2023 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.data.repository.NewsResourceQuery
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import kotlinx.coroutines.flow.Flow

/**
* Data layer implementation for [UserNewsResource]
*/
interface UserNewsResourceRepository {
/**
* Returns available news resources as a stream.
*/
fun getUserNewsResources(
query: NewsResourceQuery = NewsResourceQuery(
filterTopicIds = null,
filterNewsIds = null,
),
): Flow<List<UserNewsResource>>

/**
* Returns available news resources for the user's followed topics as a stream.
*/
fun getUserNewsResourcesForFollowedTopics(): Flow<List<UserNewsResource>>
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2022 The Android Open Source Project
* Copyright 2023 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.
Expand All @@ -18,34 +18,36 @@ package com.google.samples.apps.nowinandroid.core.domain

import com.google.samples.apps.nowinandroid.core.data.repository.NewsResourceQuery
import com.google.samples.apps.nowinandroid.core.domain.model.mapToUserNewsResources
import com.google.samples.apps.nowinandroid.core.domain.repository.CompositeUserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.NewsResourceType.Video
import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals

class GetUserNewsResourcesUseCaseTest {

@get:Rule
val mainDispatcherRule = MainDispatcherRule()
class CompositeUserNewsResourceRepositoryTest {

private val newsRepository = TestNewsRepository()
private val userDataRepository = TestUserDataRepository()

val useCase = GetUserNewsResourcesUseCase(newsRepository, userDataRepository)
private val userNewsResourceRepository = CompositeUserNewsResourceRepository(
coroutineScope = TestScope(UnconfinedTestDispatcher()),
newsRepository = newsRepository,
userDataRepository = userDataRepository,
)

@Test
fun whenNoFilters_allNewsResourcesAreReturned() = runTest {
// Obtain the user news resources stream.
val userNewsResources = useCase()
// Obtain the user news resources flow.
val userNewsResources = userNewsResourceRepository.getUserNewsResources()

// Send some news resources and user data into the data repositories.
newsRepository.sendNewsResources(sampleNewsResources)
Expand All @@ -68,11 +70,8 @@ class GetUserNewsResourcesUseCaseTest {
@Test
fun whenFilteredByTopicId_matchingNewsResourcesAreReturned() = runTest {
// Obtain a stream of user news resources for the given topic id.
val userNewsResources = useCase(
NewsResourceQuery(
filterTopicIds = setOf(sampleTopic1.id),
),
)
val userNewsResources =
userNewsResourceRepository.getUserNewsResources(NewsResourceQuery(filterTopicIds = setOf(sampleTopic1.id)))

// Send test data into the repositories.
newsRepository.sendNewsResources(sampleNewsResources)
Expand All @@ -86,6 +85,28 @@ class GetUserNewsResourcesUseCaseTest {
userNewsResources.first(),
)
}

@Test
fun whenFilteredByFollowedTopics_matchingNewsResourcesAreReturned() = runTest {
// Obtain a stream of user news resources for the given topic id.
val userNewsResources =
userNewsResourceRepository.getUserNewsResourcesForFollowedTopics()

// Send test data into the repositories.
val userData = emptyUserData.copy(
followedTopics = setOf(sampleTopic1.id),
)
newsRepository.sendNewsResources(sampleNewsResources)
userDataRepository.setUserData(userData)

// Check that only news resources with the given topic id are returned.
assertEquals(
sampleNewsResources
.filter { it.topics.contains(sampleTopic1) }
.mapToUserNewsResources(userData),
userNewsResources.first(),
)
}
}

private val sampleTopic1 = Topic(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ package com.google.samples.apps.nowinandroid.feature.bookmarks
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.core.data.repository.UserDataRepository
import com.google.samples.apps.nowinandroid.core.domain.GetUserNewsResourcesUseCase
import com.google.samples.apps.nowinandroid.core.domain.model.UserNewsResource
import com.google.samples.apps.nowinandroid.core.domain.repository.UserNewsResourceRepository
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
import dagger.hilt.android.lifecycle.HiltViewModel
Expand All @@ -36,10 +36,10 @@ import javax.inject.Inject
@HiltViewModel
class BookmarksViewModel @Inject constructor(
private val userDataRepository: UserDataRepository,
getSaveableNewsResources: GetUserNewsResourcesUseCase,
userNewsResourceRepository: UserNewsResourceRepository,
) : ViewModel() {

val feedUiState: StateFlow<NewsFeedUiState> = getSaveableNewsResources()
val feedUiState: StateFlow<NewsFeedUiState> = userNewsResourceRepository.getUserNewsResources()
.filterNot { it.isEmpty() }
.map { newsResources -> newsResources.filter(UserNewsResource::isSaved) } // Only show bookmarked news resources.
.map<List<UserNewsResource>, NewsFeedUiState>(NewsFeedUiState::Success)
Expand Down
Loading

0 comments on commit bd45009

Please sign in to comment.