Fix spotless.

Change-Id: Id0c411f2f592ba0f30bd4c10370129ecc10b4c43
pull/1350/head
Jaehwa Noh 2 years ago
parent 00fc41ae0b
commit 8593927aa0

@ -30,11 +30,10 @@ class BookmarksBaselineProfile {
@get:Rule val baselineProfileRule = BaselineProfileRule() @get:Rule val baselineProfileRule = BaselineProfileRule()
@Test @Test
fun generate() = fun generate() = baselineProfileRule.collect(PACKAGE_NAME) {
baselineProfileRule.collect(PACKAGE_NAME) { startActivityAndAllowNotifications()
startActivityAndAllowNotifications()
// Navigate to saved screen // Navigate to saved screen
goToBookmarksScreen() goToBookmarksScreen()
} }
} }

@ -32,13 +32,12 @@ class ForYouBaselineProfile {
@get:Rule val baselineProfileRule = BaselineProfileRule() @get:Rule val baselineProfileRule = BaselineProfileRule()
@Test @Test
fun generate() = fun generate() = baselineProfileRule.collect(PACKAGE_NAME) {
baselineProfileRule.collect(PACKAGE_NAME) { startActivityAndAllowNotifications()
startActivityAndAllowNotifications()
// Scroll the feed critical user journey // Scroll the feed critical user journey
forYouWaitForContent() forYouWaitForContent()
forYouSelectTopics(true) forYouSelectTopics(true)
forYouScrollFeedDownUp() forYouScrollFeedDownUp()
} }
} }

@ -31,12 +31,11 @@ class InterestsBaselineProfile {
@get:Rule val baselineProfileRule = BaselineProfileRule() @get:Rule val baselineProfileRule = BaselineProfileRule()
@Test @Test
fun generate() = fun generate() = baselineProfileRule.collect(PACKAGE_NAME) {
baselineProfileRule.collect(PACKAGE_NAME) { startActivityAndAllowNotifications()
startActivityAndAllowNotifications()
// Navigate to interests screen // Navigate to interests screen
goToInterestsScreen() goToInterestsScreen()
interestsScrollTopicsDownUp() interestsScrollTopicsDownUp()
} }
} }

@ -22,11 +22,11 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Qualifier
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import javax.inject.Qualifier
import javax.inject.Singleton
@Retention(AnnotationRetention.RUNTIME) @Retention(AnnotationRetention.RUNTIME)
@Qualifier @Qualifier

@ -17,10 +17,10 @@
package com.google.samples.apps.nowinandroid.core.result package com.google.samples.apps.nowinandroid.core.result
import app.cash.turbine.test import app.cash.turbine.test
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class ResultKtTest { class ResultKtTest {

@ -17,9 +17,9 @@
package com.google.samples.apps.nowinandroid.core.data.test package com.google.samples.apps.nowinandroid.core.data.test
import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor import com.google.samples.apps.nowinandroid.core.data.util.NetworkMonitor
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
class AlwaysOnlineNetworkMonitor @Inject constructor() : NetworkMonitor { class AlwaysOnlineNetworkMonitor @Inject constructor() : NetworkMonitor {
override val isOnline: Flow<Boolean> = flowOf(true) override val isOnline: Flow<Boolean> = flowOf(true)

@ -17,10 +17,10 @@
package com.google.samples.apps.nowinandroid.core.data.test package com.google.samples.apps.nowinandroid.core.data.test
import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor import com.google.samples.apps.nowinandroid.core.data.util.TimeZoneMonitor
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import javax.inject.Inject
class DefaultZoneIdTimeZoneMonitor @Inject constructor() : TimeZoneMonitor { class DefaultZoneIdTimeZoneMonitor @Inject constructor() : TimeZoneMonitor {
override val currentTimeZone: Flow<TimeZone> = flowOf(TimeZone.of("Europe/Warsaw")) override val currentTimeZone: Flow<TimeZone> = flowOf(TimeZone.of("Europe/Warsaw"))

@ -41,19 +41,13 @@ import dagger.hilt.testing.TestInstallIn
) )
internal interface TestDataModule { internal interface TestDataModule {
@Binds @Binds
fun bindsTopicRepository( fun bindsTopicRepository(fakeTopicsRepository: FakeTopicsRepository): TopicsRepository
fakeTopicsRepository: FakeTopicsRepository,
): TopicsRepository
@Binds @Binds
fun bindsNewsResourceRepository( fun bindsNewsResourceRepository(fakeNewsRepository: FakeNewsRepository): NewsRepository
fakeNewsRepository: FakeNewsRepository,
): NewsRepository
@Binds @Binds
fun bindsUserDataRepository( fun bindsUserDataRepository(userDataRepository: FakeUserDataRepository): UserDataRepository
userDataRepository: FakeUserDataRepository,
): UserDataRepository
@Binds @Binds
fun bindsRecentSearchRepository( fun bindsRecentSearchRepository(
@ -66,9 +60,7 @@ internal interface TestDataModule {
): SearchContentsRepository ): SearchContentsRepository
@Binds @Binds
fun bindsNetworkMonitor( fun bindsNetworkMonitor(networkMonitor: AlwaysOnlineNetworkMonitor): NetworkMonitor
networkMonitor: AlwaysOnlineNetworkMonitor,
): NetworkMonitor
@Binds @Binds
fun binds(impl: DefaultZoneIdTimeZoneMonitor): TimeZoneMonitor fun binds(impl: DefaultZoneIdTimeZoneMonitor): TimeZoneMonitor

@ -27,11 +27,11 @@ import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.demo.DemoNiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.demo.DemoNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import javax.inject.Inject
/** /**
* Fake implementation of the [NewsRepository] that retrieves the news resources from a JSON String. * Fake implementation of the [NewsRepository] that retrieves the news resources from a JSON String.
@ -44,30 +44,27 @@ internal class FakeNewsRepository @Inject constructor(
private val datasource: DemoNiaNetworkDataSource, private val datasource: DemoNiaNetworkDataSource,
) : NewsRepository { ) : NewsRepository {
override fun getNewsResources( override fun getNewsResources(query: NewsResourceQuery): Flow<List<NewsResource>> = flow {
query: NewsResourceQuery, emit(
): Flow<List<NewsResource>> = datasource
flow { .getNewsResources()
emit( .filter { networkNewsResource ->
datasource // Filter out any news resources which don't match the current query.
.getNewsResources() // If no query parameters (filterTopicIds or filterNewsIds) are specified
.filter { networkNewsResource -> // then the news resource is returned.
// Filter out any news resources which don't match the current query. listOfNotNull(
// If no query parameters (filterTopicIds or filterNewsIds) are specified true,
// then the news resource is returned. query.filterNewsIds?.contains(networkNewsResource.id),
listOfNotNull( query.filterTopicIds?.let { filterTopicIds ->
true, networkNewsResource.topics.intersect(filterTopicIds).isNotEmpty()
query.filterNewsIds?.contains(networkNewsResource.id), },
query.filterTopicIds?.let { filterTopicIds -> )
networkNewsResource.topics.intersect(filterTopicIds).isNotEmpty() .all(true::equals)
}, }
) .map(NetworkNewsResource::asEntity)
.all(true::equals) .map(NewsResourceEntity::asExternalModel),
} )
.map(NetworkNewsResource::asEntity) }.flowOn(ioDispatcher)
.map(NewsResourceEntity::asExternalModel),
)
}.flowOn(ioDispatcher)
override suspend fun syncWith(synchronizer: Synchronizer) = true override suspend fun syncWith(synchronizer: Synchronizer) = true
} }

@ -18,9 +18,9 @@ package com.google.samples.apps.nowinandroid.core.data.test.repository
import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
/** /**
* Fake implementation of the [RecentSearchRepository] * Fake implementation of the [RecentSearchRepository]

@ -18,9 +18,9 @@ package com.google.samples.apps.nowinandroid.core.data.test.repository
import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository import com.google.samples.apps.nowinandroid.core.data.repository.SearchContentsRepository
import com.google.samples.apps.nowinandroid.core.model.data.SearchResult import com.google.samples.apps.nowinandroid.core.model.data.SearchResult
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
/** /**
* Fake implementation of the [SearchContentsRepository] * Fake implementation of the [SearchContentsRepository]

@ -22,12 +22,12 @@ import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.Dispatcher import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.demo.DemoNiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.demo.DemoNiaNetworkDataSource
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject
/** /**
* Fake implementation of the [TopicsRepository] that retrieves the topics from a JSON String, and * Fake implementation of the [TopicsRepository] that retrieves the topics from a JSON String, and

@ -21,8 +21,8 @@ import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSou
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
/** /**
* Fake implementation of the [UserDataRepository] that returns hardcoded user data. * Fake implementation of the [UserDataRepository] that returns hardcoded user data.

@ -46,17 +46,16 @@ fun NetworkNewsResourceExpanded.asEntity() = NewsResourceEntity(
* A shell [TopicEntity] to fulfill the foreign key constraint when inserting * A shell [TopicEntity] to fulfill the foreign key constraint when inserting
* a [NewsResourceEntity] into the DB * a [NewsResourceEntity] into the DB
*/ */
fun NetworkNewsResource.topicEntityShells() = fun NetworkNewsResource.topicEntityShells() = topics.map { topicId ->
topics.map { topicId -> TopicEntity(
TopicEntity( id = topicId,
id = topicId, name = "",
name = "", url = "",
url = "", imageUrl = "",
imageUrl = "", shortDescription = "",
shortDescription = "", longDescription = "",
longDescription = "", )
) }
}
fun NetworkNewsResource.topicCrossReferences(): List<NewsResourceTopicCrossRef> = fun NetworkNewsResource.topicCrossReferences(): List<NewsResourceTopicCrossRef> =
topics.map { topicId -> topics.map { topicId ->

@ -20,7 +20,10 @@ import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.Param import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsEvent.Param
import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper import com.google.samples.apps.nowinandroid.core.analytics.AnalyticsHelper
internal fun AnalyticsHelper.logNewsResourceBookmarkToggled(newsResourceId: String, isBookmarked: Boolean) { internal fun AnalyticsHelper.logNewsResourceBookmarkToggled(
newsResourceId: String,
isBookmarked: Boolean,
) {
val eventType = if (isBookmarked) "news_resource_saved" else "news_resource_unsaved" val eventType = if (isBookmarked) "news_resource_saved" else "news_resource_unsaved"
val paramKey = if (isBookmarked) "saved_news_resource_id" else "unsaved_news_resource_id" val paramKey = if (isBookmarked) "saved_news_resource_id" else "unsaved_news_resource_id"
logEvent( logEvent(
@ -46,35 +49,32 @@ internal fun AnalyticsHelper.logTopicFollowToggled(followedTopicId: String, isFo
) )
} }
internal fun AnalyticsHelper.logThemeChanged(themeName: String) = internal fun AnalyticsHelper.logThemeChanged(themeName: String) = logEvent(
logEvent( AnalyticsEvent(
AnalyticsEvent( type = "theme_changed",
type = "theme_changed", extras = listOf(
extras = listOf( Param(key = "theme_name", value = themeName),
Param(key = "theme_name", value = themeName),
),
), ),
) ),
)
internal fun AnalyticsHelper.logDarkThemeConfigChanged(darkThemeConfigName: String) = internal fun AnalyticsHelper.logDarkThemeConfigChanged(darkThemeConfigName: String) = logEvent(
logEvent( AnalyticsEvent(
AnalyticsEvent( type = "dark_theme_config_changed",
type = "dark_theme_config_changed", extras = listOf(
extras = listOf( Param(key = "dark_theme_config", value = darkThemeConfigName),
Param(key = "dark_theme_config", value = darkThemeConfigName),
),
), ),
) ),
)
internal fun AnalyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor: Boolean) = internal fun AnalyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor: Boolean) = logEvent(
logEvent( AnalyticsEvent(
AnalyticsEvent( type = "dynamic_color_preference_changed",
type = "dynamic_color_preference_changed", extras = listOf(
extras = listOf( Param(key = "dynamic_color_preference", value = useDynamicColor.toString()),
Param(key = "dynamic_color_preference", value = useDynamicColor.toString()),
),
), ),
) ),
)
internal fun AnalyticsHelper.logOnboardingStateChanged(shouldHideOnboarding: Boolean) { internal fun AnalyticsHelper.logOnboardingStateChanged(shouldHideOnboarding: Boolean) {
val eventType = if (shouldHideOnboarding) "onboarding_complete" else "onboarding_reset" val eventType = if (shouldHideOnboarding) "onboarding_complete" else "onboarding_reset"

@ -18,13 +18,13 @@ package com.google.samples.apps.nowinandroid.core.data.repository
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResources
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject
/** /**
* Implements a [UserNewsResourceRepository] by combining a [NewsRepository] with a * Implements a [UserNewsResourceRepository] by combining a [NewsRepository] with a
@ -38,9 +38,7 @@ class CompositeUserNewsResourceRepository @Inject constructor(
/** /**
* Returns available news resources (joined with user data) matching the given query. * Returns available news resources (joined with user data) matching the given query.
*/ */
override fun observeAll( override fun observeAll(query: NewsResourceQuery): Flow<List<UserNewsResource>> =
query: NewsResourceQuery,
): Flow<List<UserNewsResource>> =
newsRepository.getNewsResources(query) newsRepository.getNewsResources(query)
.combine(userDataRepository.userData) { newsResources, userData -> .combine(userDataRepository.userData) { newsResources, userData ->
newsResources.mapToUserNewsResources(userData) newsResources.mapToUserNewsResources(userData)

@ -20,10 +20,10 @@ import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
import com.google.samples.apps.nowinandroid.core.data.model.asExternalModel import com.google.samples.apps.nowinandroid.core.data.model.asExternalModel
import com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao import com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryDao
import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import javax.inject.Inject
internal class DefaultRecentSearchRepository @Inject constructor( internal class DefaultRecentSearchRepository @Inject constructor(
private val recentSearchQueryDao: RecentSearchQueryDao, private val recentSearchQueryDao: RecentSearchQueryDao,

@ -26,6 +26,7 @@ import com.google.samples.apps.nowinandroid.core.database.model.asFtsEntity
import com.google.samples.apps.nowinandroid.core.model.data.SearchResult import com.google.samples.apps.nowinandroid.core.model.data.SearchResult
import com.google.samples.apps.nowinandroid.core.network.Dispatcher import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -34,7 +35,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class DefaultSearchContentsRepository @Inject constructor( internal class DefaultSearchContentsRepository @Inject constructor(
private val newsResourceDao: NewsResourceDao, private val newsResourceDao: NewsResourceDao,
@ -82,11 +82,10 @@ internal class DefaultSearchContentsRepository @Inject constructor(
} }
} }
override fun getSearchContentsCount(): Flow<Int> = override fun getSearchContentsCount(): Flow<Int> = combine(
combine( newsResourceFtsDao.getCount(),
newsResourceFtsDao.getCount(), topicFtsDao.getCount(),
topicFtsDao.getCount(), ) { newsResourceCount, topicsCount ->
) { newsResourceCount, topicsCount -> newsResourceCount + topicsCount
newsResourceCount + topicsCount }
}
} }

@ -32,10 +32,10 @@ import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.notifications.Notifier import com.google.samples.apps.nowinandroid.core.notifications.Notifier
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject
// Heuristic value to optimize for serialization and deserialization cost on client and server // Heuristic value to optimize for serialization and deserialization cost on client and server
// for each news resource batch. // for each news resource batch.
@ -53,15 +53,14 @@ internal class OfflineFirstNewsRepository @Inject constructor(
private val notifier: Notifier, private val notifier: Notifier,
) : NewsRepository { ) : NewsRepository {
override fun getNewsResources( override fun getNewsResources(query: NewsResourceQuery): Flow<List<NewsResource>> =
query: NewsResourceQuery, newsResourceDao.getNewsResources(
): Flow<List<NewsResource>> = newsResourceDao.getNewsResources( useFilterTopicIds = query.filterTopicIds != null,
useFilterTopicIds = query.filterTopicIds != null, filterTopicIds = query.filterTopicIds ?: emptySet(),
filterTopicIds = query.filterTopicIds ?: emptySet(), useFilterNewsIds = query.filterNewsIds != null,
useFilterNewsIds = query.filterNewsIds != null, filterNewsIds = query.filterNewsIds ?: emptySet(),
filterNewsIds = query.filterNewsIds ?: emptySet(), )
) .map { it.map(PopulatedNewsResource::asExternalModel) }
.map { it.map(PopulatedNewsResource::asExternalModel) }
override suspend fun syncWith(synchronizer: Synchronizer): Boolean { override suspend fun syncWith(synchronizer: Synchronizer): Boolean {
var isFirstSync = false var isFirstSync = false

@ -26,9 +26,9 @@ import com.google.samples.apps.nowinandroid.core.datastore.ChangeListVersions
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject
/** /**
* Disk storage backed implementation of the [TopicsRepository]. * Disk storage backed implementation of the [TopicsRepository].
@ -39,9 +39,8 @@ internal class OfflineFirstTopicsRepository @Inject constructor(
private val network: NiaNetworkDataSource, private val network: NiaNetworkDataSource,
) : TopicsRepository { ) : TopicsRepository {
override fun getTopics(): Flow<List<Topic>> = override fun getTopics(): Flow<List<Topic>> = topicDao.getTopicEntities()
topicDao.getTopicEntities() .map { it.map(TopicEntity::asExternalModel) }
.map { it.map(TopicEntity::asExternalModel) }
override fun getTopic(id: String): Flow<Topic> = override fun getTopic(id: String): Flow<Topic> =
topicDao.getTopicEntity(id).map { it.asExternalModel() } topicDao.getTopicEntity(id).map { it.asExternalModel() }

@ -22,8 +22,8 @@ import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSou
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
internal class OfflineFirstUserDataRepository @Inject constructor( internal class OfflineFirstUserDataRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource, private val niaPreferencesDataSource: NiaPreferencesDataSource,

@ -27,11 +27,11 @@ import android.os.Build.VERSION
import android.os.Build.VERSION_CODES import android.os.Build.VERSION_CODES
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.conflate
import javax.inject.Inject
internal class ConnectivityManagerNetworkMonitor @Inject constructor( internal class ConnectivityManagerNetworkMonitor @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,

@ -27,6 +27,9 @@ import com.google.samples.apps.nowinandroid.core.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope import com.google.samples.apps.nowinandroid.core.network.di.ApplicationScope
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
@ -40,9 +43,6 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.shareIn
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.datetime.toKotlinTimeZone import kotlinx.datetime.toKotlinTimeZone
import java.time.ZoneId
import javax.inject.Inject
import javax.inject.Singleton
/** /**
* Utility for reporting current timezone the device has set. * Utility for reporting current timezone the device has set.

@ -24,11 +24,11 @@ import com.google.samples.apps.nowinandroid.core.model.data.mapToUserNewsResourc
import com.google.samples.apps.nowinandroid.core.testing.repository.TestNewsRepository 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.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class CompositeUserNewsResourceRepositoryTest { class CompositeUserNewsResourceRepositoryTest {

@ -19,9 +19,9 @@ package com.google.samples.apps.nowinandroid.core.data.model
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResourceExpanded
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlin.test.assertEquals
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class NetworkEntityKtTest { class NetworkEntityKtTest {

@ -38,6 +38,8 @@ import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.testing.notifications.TestNotifier import com.google.samples.apps.nowinandroid.core.testing.notifications.TestNotifier
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -46,8 +48,6 @@ import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class OfflineFirstNewsRepositoryTest { class OfflineFirstNewsRepositoryTest {
@ -134,34 +134,33 @@ class OfflineFirstNewsRepositoryTest {
} }
@Test @Test
fun offlineFirstNewsRepository_sync_pulls_from_network() = fun offlineFirstNewsRepository_sync_pulls_from_network() = testScope.runTest {
testScope.runTest { // User has not onboarded
// User has not onboarded niaPreferencesDataSource.setShouldHideOnboarding(false)
niaPreferencesDataSource.setShouldHideOnboarding(false) subject.syncWith(synchronizer)
subject.syncWith(synchronizer)
val newsResourcesFromNetwork = network.getNewsResources()
val newsResourcesFromNetwork = network.getNewsResources() .map(NetworkNewsResource::asEntity)
.map(NetworkNewsResource::asEntity) .map(NewsResourceEntity::asExternalModel)
.map(NewsResourceEntity::asExternalModel)
val newsResourcesFromDb = newsResourceDao.getNewsResources()
val newsResourcesFromDb = newsResourceDao.getNewsResources() .first()
.first() .map(PopulatedNewsResource::asExternalModel)
.map(PopulatedNewsResource::asExternalModel)
assertEquals(
assertEquals( newsResourcesFromNetwork.map(NewsResource::id).sorted(),
newsResourcesFromNetwork.map(NewsResource::id).sorted(), newsResourcesFromDb.map(NewsResource::id).sorted(),
newsResourcesFromDb.map(NewsResource::id).sorted(), )
)
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
expected = network.latestChangeListVersion(CollectionType.NewsResources), expected = network.latestChangeListVersion(CollectionType.NewsResources),
actual = synchronizer.getChangeListVersions().newsResourceVersion, actual = synchronizer.getChangeListVersions().newsResourceVersion,
) )
// Notifier should not have been called // Notifier should not have been called
assertTrue(notifier.addedNewsResources.isEmpty()) assertTrue(notifier.addedNewsResources.isEmpty())
} }
@Test @Test
fun offlineFirstNewsRepository_sync_deletes_items_marked_deleted_on_network() = fun offlineFirstNewsRepository_sync_deletes_items_marked_deleted_on_network() =
@ -211,93 +210,89 @@ class OfflineFirstNewsRepositoryTest {
} }
@Test @Test
fun offlineFirstNewsRepository_incremental_sync_pulls_from_network() = fun offlineFirstNewsRepository_incremental_sync_pulls_from_network() = testScope.runTest {
testScope.runTest { // User has not onboarded
// User has not onboarded niaPreferencesDataSource.setShouldHideOnboarding(false)
niaPreferencesDataSource.setShouldHideOnboarding(false)
// Set news version to 7
synchronizer.updateChangeListVersions {
copy(newsResourceVersion = 7)
}
subject.syncWith(synchronizer) // Set news version to 7
synchronizer.updateChangeListVersions {
val changeList = network.changeListsAfter( copy(newsResourceVersion = 7)
CollectionType.NewsResources, }
version = 7,
)
val changeListIds = changeList
.map(NetworkChangeList::id)
.toSet()
val newsResourcesFromNetwork = network.getNewsResources()
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel)
.filter { it.id in changeListIds }
val newsResourcesFromDb = newsResourceDao.getNewsResources() subject.syncWith(synchronizer)
.first()
.map(PopulatedNewsResource::asExternalModel)
assertEquals( val changeList = network.changeListsAfter(
expected = newsResourcesFromNetwork.map(NewsResource::id).sorted(), CollectionType.NewsResources,
actual = newsResourcesFromDb.map(NewsResource::id).sorted(), version = 7,
) )
val changeListIds = changeList
.map(NetworkChangeList::id)
.toSet()
val newsResourcesFromNetwork = network.getNewsResources()
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel)
.filter { it.id in changeListIds }
val newsResourcesFromDb = newsResourceDao.getNewsResources()
.first()
.map(PopulatedNewsResource::asExternalModel)
assertEquals(
expected = newsResourcesFromNetwork.map(NewsResource::id).sorted(),
actual = newsResourcesFromDb.map(NewsResource::id).sorted(),
)
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
expected = changeList.last().changeListVersion, expected = changeList.last().changeListVersion,
actual = synchronizer.getChangeListVersions().newsResourceVersion, actual = synchronizer.getChangeListVersions().newsResourceVersion,
) )
// Notifier should not have been called // Notifier should not have been called
assertTrue(notifier.addedNewsResources.isEmpty()) assertTrue(notifier.addedNewsResources.isEmpty())
} }
@Test @Test
fun offlineFirstNewsRepository_sync_saves_shell_topic_entities() = fun offlineFirstNewsRepository_sync_saves_shell_topic_entities() = testScope.runTest {
testScope.runTest { subject.syncWith(synchronizer)
subject.syncWith(synchronizer)
assertEquals(
assertEquals( expected = network.getNewsResources()
expected = network.getNewsResources() .map(NetworkNewsResource::topicEntityShells)
.map(NetworkNewsResource::topicEntityShells) .flatten()
.flatten() .distinctBy(TopicEntity::id)
.distinctBy(TopicEntity::id) .sortedBy(TopicEntity::toString),
.sortedBy(TopicEntity::toString), actual = topicDao.getTopicEntities()
actual = topicDao.getTopicEntities() .first()
.first() .sortedBy(TopicEntity::toString),
.sortedBy(TopicEntity::toString), )
) }
}
@Test @Test
fun offlineFirstNewsRepository_sync_saves_topic_cross_references() = fun offlineFirstNewsRepository_sync_saves_topic_cross_references() = testScope.runTest {
testScope.runTest { subject.syncWith(synchronizer)
subject.syncWith(synchronizer)
assertEquals(
assertEquals( expected = network.getNewsResources()
expected = network.getNewsResources() .map(NetworkNewsResource::topicCrossReferences)
.map(NetworkNewsResource::topicCrossReferences) .flatten()
.flatten() .distinct()
.distinct() .sortedBy(NewsResourceTopicCrossRef::toString),
.sortedBy(NewsResourceTopicCrossRef::toString), actual = newsResourceDao.topicCrossReferences
actual = newsResourceDao.topicCrossReferences .sortedBy(NewsResourceTopicCrossRef::toString),
.sortedBy(NewsResourceTopicCrossRef::toString), )
) }
}
@Test @Test
fun offlineFirstNewsRepository_sync_marks_as_read_on_first_run() = fun offlineFirstNewsRepository_sync_marks_as_read_on_first_run() = testScope.runTest {
testScope.runTest { subject.syncWith(synchronizer)
subject.syncWith(synchronizer)
assertEquals( assertEquals(
network.getNewsResources().map { it.id }.toSet(), network.getNewsResources().map { it.id }.toSet(),
niaPreferencesDataSource.userData.first().viewedNewsResources, niaPreferencesDataSource.userData.first().viewedNewsResources,
) )
} }
@Test @Test
fun offlineFirstNewsRepository_sync_does_not_mark_as_read_on_subsequent_run() = fun offlineFirstNewsRepository_sync_does_not_mark_as_read_on_subsequent_run() =

@ -28,6 +28,7 @@ import com.google.samples.apps.nowinandroid.core.datastore.NiaPreferencesDataSou
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -36,7 +37,6 @@ import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
class OfflineFirstTopicsRepositoryTest { class OfflineFirstTopicsRepositoryTest {
@ -71,69 +71,66 @@ class OfflineFirstTopicsRepositoryTest {
} }
@Test @Test
fun offlineFirstTopicsRepository_topics_stream_is_backed_by_topics_dao() = fun offlineFirstTopicsRepository_topics_stream_is_backed_by_topics_dao() = testScope.runTest {
testScope.runTest { assertEquals(
assertEquals( topicDao.getTopicEntities()
topicDao.getTopicEntities() .first()
.first() .map(TopicEntity::asExternalModel),
.map(TopicEntity::asExternalModel), subject.getTopics()
subject.getTopics() .first(),
.first(), )
) }
}
@Test @Test
fun offlineFirstTopicsRepository_sync_pulls_from_network() = fun offlineFirstTopicsRepository_sync_pulls_from_network() = testScope.runTest {
testScope.runTest { subject.syncWith(synchronizer)
subject.syncWith(synchronizer)
val networkTopics = network.getTopics() val networkTopics = network.getTopics()
.map(NetworkTopic::asEntity) .map(NetworkTopic::asEntity)
val dbTopics = topicDao.getTopicEntities() val dbTopics = topicDao.getTopicEntities()
.first() .first()
assertEquals( assertEquals(
networkTopics.map(TopicEntity::id), networkTopics.map(TopicEntity::id),
dbTopics.map(TopicEntity::id), dbTopics.map(TopicEntity::id),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
network.latestChangeListVersion(CollectionType.Topics), network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion, synchronizer.getChangeListVersions().topicVersion,
) )
} }
@Test @Test
fun offlineFirstTopicsRepository_incremental_sync_pulls_from_network() = fun offlineFirstTopicsRepository_incremental_sync_pulls_from_network() = testScope.runTest {
testScope.runTest { // Set topics version to 10
// Set topics version to 10 synchronizer.updateChangeListVersions {
synchronizer.updateChangeListVersions { copy(topicVersion = 10)
copy(topicVersion = 10) }
}
subject.syncWith(synchronizer) subject.syncWith(synchronizer)
val networkTopics = network.getTopics() val networkTopics = network.getTopics()
.map(NetworkTopic::asEntity) .map(NetworkTopic::asEntity)
// Drop 10 to simulate the first 10 items being unchanged // Drop 10 to simulate the first 10 items being unchanged
.drop(10) .drop(10)
val dbTopics = topicDao.getTopicEntities() val dbTopics = topicDao.getTopicEntities()
.first() .first()
assertEquals( assertEquals(
networkTopics.map(TopicEntity::id), networkTopics.map(TopicEntity::id),
dbTopics.map(TopicEntity::id), dbTopics.map(TopicEntity::id),
) )
// After sync version should be updated // After sync version should be updated
assertEquals( assertEquals(
network.latestChangeListVersion(CollectionType.Topics), network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion, synchronizer.getChangeListVersions().topicVersion,
) )
} }
@Test @Test
fun offlineFirstTopicsRepository_sync_deletes_items_marked_deleted_on_network() = fun offlineFirstTopicsRepository_sync_deletes_items_marked_deleted_on_network() =

@ -22,6 +22,9 @@ import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferen
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@ -31,9 +34,6 @@ import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class OfflineFirstUserDataRepositoryTest { class OfflineFirstUserDataRepositoryTest {
@ -61,21 +61,20 @@ class OfflineFirstUserDataRepositoryTest {
} }
@Test @Test
fun offlineFirstUserDataRepository_default_user_data_is_correct() = fun offlineFirstUserDataRepository_default_user_data_is_correct() = testScope.runTest {
testScope.runTest { assertEquals(
assertEquals( UserData(
UserData( bookmarkedNewsResources = emptySet(),
bookmarkedNewsResources = emptySet(), viewedNewsResources = emptySet(),
viewedNewsResources = emptySet(), followedTopics = emptySet(),
followedTopics = emptySet(), themeBrand = ThemeBrand.DEFAULT,
themeBrand = ThemeBrand.DEFAULT, darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, useDynamicColor = false,
useDynamicColor = false, shouldHideOnboarding = false,
shouldHideOnboarding = false, ),
), subject.userData.first(),
subject.userData.first(), )
) }
}
@Test @Test
fun offlineFirstUserDataRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() = fun offlineFirstUserDataRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() =

@ -43,54 +43,52 @@ class TestNewsResourceDao : NewsResourceDao {
filterTopicIds: Set<String>, filterTopicIds: Set<String>,
useFilterNewsIds: Boolean, useFilterNewsIds: Boolean,
filterNewsIds: Set<String>, filterNewsIds: Set<String>,
): Flow<List<PopulatedNewsResource>> = ): Flow<List<PopulatedNewsResource>> = entitiesStateFlow
entitiesStateFlow .map { newsResourceEntities ->
.map { newsResourceEntities -> newsResourceEntities.map { entity ->
newsResourceEntities.map { entity -> entity.asPopulatedNewsResource(topicCrossReferences)
entity.asPopulatedNewsResource(topicCrossReferences)
}
} }
.map { resources -> }
var result = resources .map { resources ->
if (useFilterTopicIds) { var result = resources
result = result.filter { resource -> if (useFilterTopicIds) {
resource.topics.any { it.id in filterTopicIds } result = result.filter { resource ->
} resource.topics.any { it.id in filterTopicIds }
} }
if (useFilterNewsIds) { }
result = result.filter { resource -> if (useFilterNewsIds) {
resource.entity.id in filterNewsIds result = result.filter { resource ->
} resource.entity.id in filterNewsIds
} }
result
} }
result
}
override fun getNewsResourceIds( override fun getNewsResourceIds(
useFilterTopicIds: Boolean, useFilterTopicIds: Boolean,
filterTopicIds: Set<String>, filterTopicIds: Set<String>,
useFilterNewsIds: Boolean, useFilterNewsIds: Boolean,
filterNewsIds: Set<String>, filterNewsIds: Set<String>,
): Flow<List<String>> = ): Flow<List<String>> = entitiesStateFlow
entitiesStateFlow .map { newsResourceEntities ->
.map { newsResourceEntities -> newsResourceEntities.map { entity ->
newsResourceEntities.map { entity -> entity.asPopulatedNewsResource(topicCrossReferences)
entity.asPopulatedNewsResource(topicCrossReferences)
}
} }
.map { resources -> }
var result = resources .map { resources ->
if (useFilterTopicIds) { var result = resources
result = result.filter { resource -> if (useFilterTopicIds) {
resource.topics.any { it.id in filterTopicIds } result = result.filter { resource ->
} resource.topics.any { it.id in filterTopicIds }
} }
if (useFilterNewsIds) { }
result = result.filter { resource -> if (useFilterNewsIds) {
resource.entity.id in filterNewsIds result = result.filter { resource ->
} resource.entity.id in filterNewsIds
} }
result.map { it.entity.id }
} }
result.map { it.entity.id }
}
override suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>) { override suspend fun upsertNewsResources(newsResourceEntities: List<NewsResourceEntity>) {
entitiesStateFlow.update { oldValues -> entitiesStateFlow.update { oldValues ->

@ -51,11 +51,10 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
.mapToChangeList(idGetter = NetworkNewsResource::id), .mapToChangeList(idGetter = NetworkNewsResource::id),
) )
override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> = override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> = allTopics.matchIds(
allTopics.matchIds( ids = ids,
ids = ids, idGetter = NetworkTopic::id,
idGetter = NetworkTopic::id, )
)
override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> = override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> =
allNewsResources.matchIds( allNewsResources.matchIds(
@ -99,10 +98,7 @@ fun List<NetworkChangeList>.after(version: Int?): List<NetworkChangeList> = when
/** /**
* Return items from [this] whose id defined by [idGetter] is in [ids] if [ids] is not null * Return items from [this] whose id defined by [idGetter] is in [ids] if [ids] is not null
*/ */
private fun <T> List<T>.matchIds( private fun <T> List<T>.matchIds(ids: List<String>?, idGetter: (T) -> String) = when (ids) {
ids: List<String>?,
idGetter: (T) -> String,
) = when (ids) {
null -> this null -> this
else -> ids.toSet().let { idSet -> filter { idGetter(it) in idSet } } else -> ids.toSet().let { idSet -> filter { idGetter(it) in idSet } }
} }
@ -111,9 +107,7 @@ private fun <T> List<T>.matchIds(
* Maps items to a change list where the change list version is denoted by the index of each item. * Maps items to a change list where the change list version is denoted by the index of each item.
* [after] simulates which models have changed by excluding items before it * [after] simulates which models have changed by excluding items before it
*/ */
private fun <T> List<T>.mapToChangeList( private fun <T> List<T>.mapToChangeList(idGetter: (T) -> String) = mapIndexed { index, item ->
idGetter: (T) -> String,
) = mapIndexed { index, item ->
NetworkChangeList( NetworkChangeList(
id = idGetter(item), id = idGetter(item),
changeListVersion = index + 1, changeListVersion = index + 1,

@ -18,9 +18,9 @@ package com.google.samples.apps.nowinandroid.core.database.model
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.Topic import com.google.samples.apps.nowinandroid.core.model.data.Topic
import kotlin.test.assertEquals
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class PopulatedNewsResourceKtTest { class PopulatedNewsResourceKtTest {
@Test @Test

@ -24,13 +24,13 @@ import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceEnti
import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef import com.google.samples.apps.nowinandroid.core.database.model.NewsResourceTopicCrossRef
import com.google.samples.apps.nowinandroid.core.database.model.TopicEntity 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.database.model.asExternalModel
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import org.junit.After import org.junit.After
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class NewsResourceDaoTest { class NewsResourceDaoTest {
@ -248,48 +248,44 @@ class NewsResourceDaoTest {
} }
@Test @Test
fun newsResourceDao_deletes_items_by_ids() = fun newsResourceDao_deletes_items_by_ids() = runTest {
runTest { val newsResourceEntities = listOf(
val newsResourceEntities = listOf( testNewsResource(
testNewsResource( id = "0",
id = "0", millisSinceEpoch = 0,
millisSinceEpoch = 0, ),
), testNewsResource(
testNewsResource( id = "1",
id = "1", millisSinceEpoch = 3,
millisSinceEpoch = 3, ),
), testNewsResource(
testNewsResource( id = "2",
id = "2", millisSinceEpoch = 1,
millisSinceEpoch = 1, ),
), testNewsResource(
testNewsResource( id = "3",
id = "3", millisSinceEpoch = 2,
millisSinceEpoch = 2, ),
), )
) newsResourceDao.upsertNewsResources(newsResourceEntities)
newsResourceDao.upsertNewsResources(newsResourceEntities)
val (toDelete, toKeep) = newsResourceEntities.partition { it.id.toInt() % 2 == 0 } val (toDelete, toKeep) = newsResourceEntities.partition { it.id.toInt() % 2 == 0 }
newsResourceDao.deleteNewsResources( newsResourceDao.deleteNewsResources(
toDelete.map(NewsResourceEntity::id), toDelete.map(NewsResourceEntity::id),
) )
assertEquals( assertEquals(
toKeep.map(NewsResourceEntity::id) toKeep.map(NewsResourceEntity::id)
.toSet(), .toSet(),
newsResourceDao.getNewsResources().first() newsResourceDao.getNewsResources().first()
.map { it.entity.id } .map { it.entity.id }
.toSet(), .toSet(),
) )
} }
} }
private fun testTopicEntity( private fun testTopicEntity(id: String = "0", name: String) = TopicEntity(
id: String = "0",
name: String,
) = TopicEntity(
id = id, id = id,
name = name, name = name,
shortDescription = "", shortDescription = "",
@ -298,10 +294,7 @@ private fun testTopicEntity(
imageUrl = "", imageUrl = "",
) )
private fun testNewsResource( private fun testNewsResource(id: String = "0", millisSinceEpoch: Long = 0) = NewsResourceEntity(
id: String = "0",
millisSinceEpoch: Long = 0,
) = NewsResourceEntity(
id = id, id = id,
title = "", title = "",
content = "", content = "",

@ -31,27 +31,19 @@ import dagger.hilt.components.SingletonComponent
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
internal object DaosModule { internal object DaosModule {
@Provides @Provides
fun providesTopicsDao( fun providesTopicsDao(database: NiaDatabase): TopicDao = database.topicDao()
database: NiaDatabase,
): TopicDao = database.topicDao()
@Provides @Provides
fun providesNewsResourceDao( fun providesNewsResourceDao(database: NiaDatabase): NewsResourceDao = database.newsResourceDao()
database: NiaDatabase,
): NewsResourceDao = database.newsResourceDao()
@Provides @Provides
fun providesTopicFtsDao( fun providesTopicFtsDao(database: NiaDatabase): TopicFtsDao = database.topicFtsDao()
database: NiaDatabase,
): TopicFtsDao = database.topicFtsDao()
@Provides @Provides
fun providesNewsResourceFtsDao( fun providesNewsResourceFtsDao(database: NiaDatabase): NewsResourceFtsDao =
database: NiaDatabase, database.newsResourceFtsDao()
): NewsResourceFtsDao = database.newsResourceFtsDao()
@Provides @Provides
fun providesRecentSearchQueryDao( fun providesRecentSearchQueryDao(database: NiaDatabase): RecentSearchQueryDao =
database: NiaDatabase, database.recentSearchQueryDao()
): RecentSearchQueryDao = database.recentSearchQueryDao()
} }

@ -31,11 +31,10 @@ import javax.inject.Singleton
internal object DatabaseModule { internal object DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun providesNiaDatabase( fun providesNiaDatabase(@ApplicationContext context: Context): NiaDatabase =
@ApplicationContext context: Context, Room.databaseBuilder(
): NiaDatabase = Room.databaseBuilder( context,
context, NiaDatabase::class.java,
NiaDatabase::class.java, "nia-database",
"nia-database", ).build()
).build()
} }

@ -21,10 +21,8 @@ import kotlinx.datetime.Instant
internal class InstantConverter { internal class InstantConverter {
@TypeConverter @TypeConverter
fun longToInstant(value: Long?): Instant? = fun longToInstant(value: Long?): Instant? = value?.let(Instant::fromEpochMilliseconds)
value?.let(Instant::fromEpochMilliseconds)
@TypeConverter @TypeConverter
fun instantToLong(instant: Instant?): Long? = fun instantToLong(instant: Instant?): Long? = instant?.toEpochMilliseconds()
instant?.toEpochMilliseconds()
} }

@ -26,9 +26,9 @@ import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn import dagger.hilt.testing.TestInstallIn
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import javax.inject.Singleton
@Module @Module
@TestInstallIn( @TestInstallIn(
@ -43,11 +43,10 @@ internal object TestDataStoreModule {
@ApplicationScope scope: CoroutineScope, @ApplicationScope scope: CoroutineScope,
userPreferencesSerializer: UserPreferencesSerializer, userPreferencesSerializer: UserPreferencesSerializer,
tmpFolder: TemporaryFolder, tmpFolder: TemporaryFolder,
): DataStore<UserPreferences> = ): DataStore<UserPreferences> = tmpFolder.testUserPreferencesDataStore(
tmpFolder.testUserPreferencesDataStore( coroutineScope = scope,
coroutineScope = scope, userPreferencesSerializer = userPreferencesSerializer,
userPreferencesSerializer = userPreferencesSerializer, )
)
} }
fun TemporaryFolder.testUserPreferencesDataStore( fun TemporaryFolder.testUserPreferencesDataStore(

@ -25,25 +25,24 @@ internal object IntToStringIdsMigration : DataMigration<UserPreferences> {
override suspend fun cleanUp() = Unit override suspend fun cleanUp() = Unit
override suspend fun migrate(currentData: UserPreferences): UserPreferences = override suspend fun migrate(currentData: UserPreferences): UserPreferences = currentData.copy {
currentData.copy { // Migrate topic ids
// Migrate topic ids deprecatedFollowedTopicIds.clear()
deprecatedFollowedTopicIds.clear() deprecatedFollowedTopicIds.addAll(
deprecatedFollowedTopicIds.addAll( currentData.deprecatedIntFollowedTopicIdsList.map(Int::toString),
currentData.deprecatedIntFollowedTopicIdsList.map(Int::toString), )
) deprecatedIntFollowedTopicIds.clear()
deprecatedIntFollowedTopicIds.clear()
// Migrate author ids
// Migrate author ids deprecatedFollowedAuthorIds.clear()
deprecatedFollowedAuthorIds.clear() deprecatedFollowedAuthorIds.addAll(
deprecatedFollowedAuthorIds.addAll( currentData.deprecatedIntFollowedAuthorIdsList.map(Int::toString),
currentData.deprecatedIntFollowedAuthorIdsList.map(Int::toString), )
) deprecatedIntFollowedAuthorIds.clear()
deprecatedIntFollowedAuthorIds.clear()
// Mark migration as complete
// Mark migration as complete hasDoneIntToStringIdMigration = true
hasDoneIntToStringIdMigration = true }
}
override suspend fun shouldMigrate(currentData: UserPreferences): Boolean = override suspend fun shouldMigrate(currentData: UserPreferences): Boolean =
!currentData.hasDoneIntToStringIdMigration !currentData.hasDoneIntToStringIdMigration

@ -25,32 +25,31 @@ internal object ListToMapMigration : DataMigration<UserPreferences> {
override suspend fun cleanUp() = Unit override suspend fun cleanUp() = Unit
override suspend fun migrate(currentData: UserPreferences): UserPreferences = override suspend fun migrate(currentData: UserPreferences): UserPreferences = currentData.copy {
currentData.copy { // Migrate topic id lists
// Migrate topic id lists followedTopicIds.clear()
followedTopicIds.clear() followedTopicIds.putAll(
followedTopicIds.putAll( currentData.deprecatedFollowedTopicIdsList.associateWith { true },
currentData.deprecatedFollowedTopicIdsList.associateWith { true }, )
) deprecatedFollowedTopicIds.clear()
deprecatedFollowedTopicIds.clear()
// Migrate author ids
// Migrate author ids followedAuthorIds.clear()
followedAuthorIds.clear() followedAuthorIds.putAll(
followedAuthorIds.putAll( currentData.deprecatedFollowedAuthorIdsList.associateWith { true },
currentData.deprecatedFollowedAuthorIdsList.associateWith { true }, )
) deprecatedFollowedAuthorIds.clear()
deprecatedFollowedAuthorIds.clear()
// Migrate bookmarks
// Migrate bookmarks bookmarkedNewsResourceIds.clear()
bookmarkedNewsResourceIds.clear() bookmarkedNewsResourceIds.putAll(
bookmarkedNewsResourceIds.putAll( currentData.deprecatedBookmarkedNewsResourceIdsList.associateWith { true },
currentData.deprecatedBookmarkedNewsResourceIdsList.associateWith { true }, )
) deprecatedBookmarkedNewsResourceIds.clear()
deprecatedBookmarkedNewsResourceIds.clear()
// Mark migration as complete
// Mark migration as complete hasDoneListToMapMigration = true
hasDoneListToMapMigration = true }
}
override suspend fun shouldMigrate(currentData: UserPreferences): Boolean = override suspend fun shouldMigrate(currentData: UserPreferences): Boolean =
!currentData.hasDoneListToMapMigration !currentData.hasDoneListToMapMigration

@ -21,10 +21,10 @@ import androidx.datastore.core.DataStore
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import java.io.IOException import java.io.IOException
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
class NiaPreferencesDataSource @Inject constructor( class NiaPreferencesDataSource @Inject constructor(
private val userPreferences: DataStore<UserPreferences>, private val userPreferences: DataStore<UserPreferences>,

@ -29,13 +29,12 @@ import javax.inject.Inject
class UserPreferencesSerializer @Inject constructor() : Serializer<UserPreferences> { class UserPreferencesSerializer @Inject constructor() : Serializer<UserPreferences> {
override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance() override val defaultValue: UserPreferences = UserPreferences.getDefaultInstance()
override suspend fun readFrom(input: InputStream): UserPreferences = override suspend fun readFrom(input: InputStream): UserPreferences = try {
try { // readFrom is already called on the data store background thread
// readFrom is already called on the data store background thread UserPreferences.parseFrom(input)
UserPreferences.parseFrom(input) } catch (exception: InvalidProtocolBufferException) {
} catch (exception: InvalidProtocolBufferException) { throw CorruptionException("Cannot read proto.", exception)
throw CorruptionException("Cannot read proto.", exception) }
}
override suspend fun writeTo(t: UserPreferences, output: OutputStream) { override suspend fun writeTo(t: UserPreferences, output: OutputStream) {
// writeTo is already called on the data store background thread // writeTo is already called on the data store background thread

@ -31,9 +31,9 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@ -46,14 +46,13 @@ object DataStoreModule {
@Dispatcher(IO) ioDispatcher: CoroutineDispatcher, @Dispatcher(IO) ioDispatcher: CoroutineDispatcher,
@ApplicationScope scope: CoroutineScope, @ApplicationScope scope: CoroutineScope,
userPreferencesSerializer: UserPreferencesSerializer, userPreferencesSerializer: UserPreferencesSerializer,
): DataStore<UserPreferences> = ): DataStore<UserPreferences> = DataStoreFactory.create(
DataStoreFactory.create( serializer = userPreferencesSerializer,
serializer = userPreferencesSerializer, scope = CoroutineScope(scope.coroutineContext + ioDispatcher),
scope = CoroutineScope(scope.coroutineContext + ioDispatcher), migrations = listOf(
migrations = listOf( IntToStringIdsMigration,
IntToStringIdsMigration, ),
), ) {
) { context.dataStoreFile("user_preferences.pb")
context.dataStoreFile("user_preferences.pb") }
}
} }

@ -16,10 +16,10 @@
package com.google.samples.apps.nowinandroid.core.datastore package com.google.samples.apps.nowinandroid.core.datastore
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlinx.coroutines.test.runTest
import org.junit.Test
/** /**
* Unit test for [IntToStringIdsMigration] * Unit test for [IntToStringIdsMigration]

@ -16,10 +16,10 @@
package com.google.samples.apps.nowinandroid.core.datastore package com.google.samples.apps.nowinandroid.core.datastore
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
import kotlinx.coroutines.test.runTest
import org.junit.Test
class ListToMapMigrationTest { class ListToMapMigrationTest {

@ -17,6 +17,8 @@
package com.google.samples.apps.nowinandroid.core.datastore package com.google.samples.apps.nowinandroid.core.datastore
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore
import kotlin.test.assertFalse
import kotlin.test.assertTrue
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -25,8 +27,6 @@ import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.rules.TemporaryFolder import org.junit.rules.TemporaryFolder
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NiaPreferencesDataSourceTest { class NiaPreferencesDataSourceTest {

@ -17,11 +17,11 @@
package com.google.samples.apps.nowinandroid.core.datastore package com.google.samples.apps.nowinandroid.core.datastore
import androidx.datastore.core.CorruptionException import androidx.datastore.core.CorruptionException
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlinx.coroutines.test.runTest
import org.junit.Test
class UserPreferencesSerializerTest { class UserPreferencesSerializerTest {
private val userPreferencesSerializer = UserPreferencesSerializer() private val userPreferencesSerializer = UserPreferencesSerializer()

@ -21,9 +21,9 @@ import com.google.samples.apps.nowinandroid.core.data.repository.UserDataReposit
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NAME import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NAME
import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NONE import com.google.samples.apps.nowinandroid.core.domain.TopicSortField.NONE
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import javax.inject.Inject
/** /**
* A use case which obtains a list of topics with their followed state. * A use case which obtains a list of topics with their followed state.

@ -18,8 +18,8 @@ package com.google.samples.apps.nowinandroid.core.domain
import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery import com.google.samples.apps.nowinandroid.core.data.model.RecentSearchQuery
import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository import com.google.samples.apps.nowinandroid.core.data.repository.RecentSearchRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
/** /**
* A use case which returns the recent search queries. * A use case which returns the recent search queries.

@ -23,9 +23,9 @@ import com.google.samples.apps.nowinandroid.core.model.data.SearchResult
import com.google.samples.apps.nowinandroid.core.model.data.UserData import com.google.samples.apps.nowinandroid.core.model.data.UserData
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.UserSearchResult import com.google.samples.apps.nowinandroid.core.model.data.UserSearchResult
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import javax.inject.Inject
/** /**
* A use case which returns the searched contents matched with the search query. * A use case which returns the searched contents matched with the search query.
@ -35,27 +35,26 @@ class GetSearchContentsUseCase @Inject constructor(
private val userDataRepository: UserDataRepository, private val userDataRepository: UserDataRepository,
) { ) {
operator fun invoke( operator fun invoke(searchQuery: String): Flow<UserSearchResult> =
searchQuery: String,
): Flow<UserSearchResult> =
searchContentsRepository.searchContents(searchQuery) searchContentsRepository.searchContents(searchQuery)
.mapToUserSearchResult(userDataRepository.userData) .mapToUserSearchResult(userDataRepository.userData)
} }
private fun Flow<SearchResult>.mapToUserSearchResult(userDataStream: Flow<UserData>): Flow<UserSearchResult> = private fun Flow<SearchResult>.mapToUserSearchResult(
combine(userDataStream) { searchResult, userData -> userDataStream: Flow<UserData>,
UserSearchResult( ): Flow<UserSearchResult> = combine(userDataStream) { searchResult, userData ->
topics = searchResult.topics.map { topic -> UserSearchResult(
FollowableTopic( topics = searchResult.topics.map { topic ->
topic = topic, FollowableTopic(
isFollowed = topic.id in userData.followedTopics, topic = topic,
) isFollowed = topic.id in userData.followedTopics,
}, )
newsResources = searchResult.newsResources.map { news -> },
UserNewsResource( newsResources = searchResult.newsResources.map { news ->
newsResource = news, UserNewsResource(
userData = userData, newsResource = news,
) userData = userData,
}, )
) },
} )
}

@ -22,11 +22,11 @@ import com.google.samples.apps.nowinandroid.core.model.data.Topic
import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class GetFollowableTopicsUseCaseTest { class GetFollowableTopicsUseCaseTest {

@ -23,12 +23,12 @@ import com.google.samples.apps.nowinandroid.core.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.decodeFromStream
import javax.inject.Inject
/** /**
* [NiaNetworkDataSource] implementation that provides static news resources to aid development * [NiaNetworkDataSource] implementation that provides static news resources to aid development
@ -67,9 +67,7 @@ class DemoNiaNetworkDataSource @Inject constructor(
* Converts a list of [T] to change list of all the items in it where [idGetter] defines the * Converts a list of [T] to change list of all the items in it where [idGetter] defines the
* [NetworkChangeList.id] * [NetworkChangeList.id]
*/ */
private fun <T> List<T>.mapToChangeList( private fun <T> List<T>.mapToChangeList(idGetter: (T) -> String) = mapIndexed { index, item ->
idGetter: (T) -> String,
) = mapIndexed { index, item ->
NetworkChangeList( NetworkChangeList(
id = idGetter(item), id = idGetter(item),
changeListVersion = index, changeListVersion = index,

@ -28,11 +28,11 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Call import okhttp3.Call
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@ -46,9 +46,8 @@ internal object NetworkModule {
@Provides @Provides
@Singleton @Singleton
fun providesDemoAssetManager( fun providesDemoAssetManager(@ApplicationContext context: Context): DemoAssetManager =
@ApplicationContext context: Context, DemoAssetManager(context.assets::open)
): DemoAssetManager = DemoAssetManager(context.assets::open)
@Provides @Provides
@Singleton @Singleton

@ -23,6 +23,8 @@ import com.google.samples.apps.nowinandroid.core.network.model.NetworkChangeList
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Call import okhttp3.Call
@ -30,17 +32,13 @@ import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Query import retrofit2.http.Query
import javax.inject.Inject
import javax.inject.Singleton
/** /**
* Retrofit API declaration for NIA Network API * Retrofit API declaration for NIA Network API
*/ */
private interface RetrofitNiaNetworkApi { private interface RetrofitNiaNetworkApi {
@GET(value = "topics") @GET(value = "topics")
suspend fun getTopics( suspend fun getTopics(@Query("id") ids: List<String>?): NetworkResponse<List<NetworkTopic>>
@Query("id") ids: List<String>?,
): NetworkResponse<List<NetworkTopic>>
@GET(value = "newsresources") @GET(value = "newsresources")
suspend fun getNewsResources( suspend fun getNewsResources(
@ -48,14 +46,10 @@ private interface RetrofitNiaNetworkApi {
): NetworkResponse<List<NetworkNewsResource>> ): NetworkResponse<List<NetworkNewsResource>>
@GET(value = "changelists/topics") @GET(value = "changelists/topics")
suspend fun getTopicChangeList( suspend fun getTopicChangeList(@Query("after") after: Int?): List<NetworkChangeList>
@Query("after") after: Int?,
): List<NetworkChangeList>
@GET(value = "changelists/newsresources") @GET(value = "changelists/newsresources")
suspend fun getNewsResourcesChangeList( suspend fun getNewsResourcesChangeList(@Query("after") after: Int?): List<NetworkChangeList>
@Query("after") after: Int?,
): List<NetworkChangeList>
} }
private const val NIA_BASE_URL = BuildConfig.BACKEND_URL private const val NIA_BASE_URL = BuildConfig.BACKEND_URL

@ -19,6 +19,7 @@ package com.google.samples.apps.nowinandroid.core.network.demo
import JvmUnitTestDemoAssetManager import JvmUnitTestDemoAssetManager
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlin.test.assertEquals
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import kotlinx.datetime.LocalDateTime import kotlinx.datetime.LocalDateTime
@ -27,7 +28,6 @@ import kotlinx.datetime.toInstant
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class DemoNiaNetworkDataSourceTest { class DemoNiaNetworkDataSourceTest {

@ -65,6 +65,7 @@ internal class SystemTrayNotifier @Inject constructor(
val newsNotifications = truncatedNewsResources.map { newsResource -> val newsNotifications = truncatedNewsResources.map { newsResource ->
createNewsNotification { createNewsNotification {
setSmallIcon( setSmallIcon(
com.google.samples.apps.nowinandroid.core.common.R.drawable.core_common_ic_nia_notification, com.google.samples.apps.nowinandroid.core.common.R.drawable.core_common_ic_nia_notification,
) )

@ -49,10 +49,7 @@ fun rememberMetricsStateHolder(): Holder {
* @see TrackDisposableJank if you need to work with DisposableEffect to cleanup added state. * @see TrackDisposableJank if you need to work with DisposableEffect to cleanup added state.
*/ */
@Composable @Composable
fun TrackJank( fun TrackJank(vararg keys: Any, reportMetric: suspend CoroutineScope.(state: Holder) -> Unit) {
vararg keys: Any,
reportMetric: suspend CoroutineScope.(state: Holder) -> Unit,
) {
val metrics = rememberMetricsStateHolder() val metrics = rememberMetricsStateHolder()
LaunchedEffect(metrics, *keys) { LaunchedEffect(metrics, *keys) {
reportMetric(metrics) reportMetric(metrics)

@ -73,7 +73,11 @@ fun LazyStaggeredGridScope.newsFeed(
analyticsHelper.logNewsResourceOpened( analyticsHelper.logNewsResourceOpened(
newsResourceId = userNewsResource.id, newsResourceId = userNewsResource.id,
) )
launchCustomChromeTab(context, Uri.parse(userNewsResource.url), backgroundColor) launchCustomChromeTab(
context,
Uri.parse(userNewsResource.url),
backgroundColor,
)
onNewsResourceViewed(userNewsResource.id) onNewsResourceViewed(userNewsResource.id)
}, },

@ -66,12 +66,12 @@ import com.google.samples.apps.nowinandroid.core.designsystem.theme.NiaTheme
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.core.model.data.NewsResource import com.google.samples.apps.nowinandroid.core.model.data.NewsResource
import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import kotlinx.datetime.Instant
import kotlinx.datetime.toJavaInstant
import kotlinx.datetime.toJavaZoneId
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle import java.time.format.FormatStyle
import java.util.Locale import java.util.Locale
import kotlinx.datetime.Instant
import kotlinx.datetime.toJavaInstant
import kotlinx.datetime.toJavaZoneId
/** /**
* [NewsResource] card used on the following screens: For You, Saved * [NewsResource] card used on the following screens: For You, Saved
@ -142,9 +142,7 @@ fun NewsResourceCardExpanded(
} }
@Composable @Composable
fun NewsResourceHeaderImage( fun NewsResourceHeaderImage(headerImageUrl: String?) {
headerImageUrl: String?,
) {
var isLoading by remember { mutableStateOf(true) } var isLoading by remember { mutableStateOf(true) }
var isError by remember { mutableStateOf(false) } var isError by remember { mutableStateOf(false) }
val imageLoader = rememberAsyncImagePainter( val imageLoader = rememberAsyncImagePainter(
@ -189,19 +187,12 @@ fun NewsResourceHeaderImage(
} }
@Composable @Composable
fun NewsResourceTitle( fun NewsResourceTitle(newsResourceTitle: String, modifier: Modifier = Modifier) {
newsResourceTitle: String,
modifier: Modifier = Modifier,
) {
Text(newsResourceTitle, style = MaterialTheme.typography.headlineSmall, modifier = modifier) Text(newsResourceTitle, style = MaterialTheme.typography.headlineSmall, modifier = modifier)
} }
@Composable @Composable
fun BookmarkButton( fun BookmarkButton(isBookmarked: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
isBookmarked: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
NiaIconToggleButton( NiaIconToggleButton(
checked = isBookmarked, checked = isBookmarked,
onCheckedChange = { onClick() }, onCheckedChange = { onClick() },
@ -222,10 +213,7 @@ fun BookmarkButton(
} }
@Composable @Composable
fun NotificationDot( fun NotificationDot(color: Color, modifier: Modifier = Modifier) {
color: Color,
modifier: Modifier = Modifier,
) {
val description = stringResource(R.string.core_ui_unread_resource_dot_content_description) val description = stringResource(R.string.core_ui_unread_resource_dot_content_description)
Canvas( Canvas(
modifier = modifier modifier = modifier
@ -247,10 +235,7 @@ fun dateFormatted(publishDate: Instant): String = DateTimeFormatter
.format(publishDate.toJavaInstant()) .format(publishDate.toJavaInstant())
@Composable @Composable
fun NewsResourceMetaData( fun NewsResourceMetaData(publishDate: Instant, resourceType: String) {
publishDate: Instant,
resourceType: String,
) {
val formattedDate = dateFormatted(publishDate) val formattedDate = dateFormatted(publishDate)
Text( Text(
if (resourceType.isNotBlank()) { if (resourceType.isNotBlank()) {
@ -263,9 +248,7 @@ fun NewsResourceMetaData(
} }
@Composable @Composable
fun NewsResourceShortDescription( fun NewsResourceShortDescription(newsResourceShortDescription: String) {
newsResourceShortDescription: String,
) {
Text(newsResourceShortDescription, style = MaterialTheme.typography.bodyLarge) Text(newsResourceShortDescription, style = MaterialTheme.typography.bodyLarge)
} }

@ -36,11 +36,11 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.testing.TestLifecycleOwner import androidx.lifecycle.testing.TestLifecycleOwner
import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/** /**
* UI tests for [BookmarksScreen] composable. * UI tests for [BookmarksScreen] composable.

@ -27,13 +27,13 @@ import com.google.samples.apps.nowinandroid.core.model.data.UserNewsResource
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class BookmarksViewModel @Inject constructor( class BookmarksViewModel @Inject constructor(

@ -24,7 +24,10 @@ import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksRoute
const val BOOKMARKS_ROUTE = "bookmarks_route" const val BOOKMARKS_ROUTE = "bookmarks_route"
fun NavController.navigateToBookmarks(navOptions: NavOptions) = navigate(BOOKMARKS_ROUTE, navOptions) fun NavController.navigateToBookmarks(navOptions: NavOptions) = navigate(
BOOKMARKS_ROUTE,
navOptions,
)
fun NavGraphBuilder.bookmarksScreen( fun NavGraphBuilder.bookmarksScreen(
onTopicClick: (String) -> Unit, onTopicClick: (String) -> Unit,

@ -23,6 +23,8 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Loading
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState.Success
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -30,8 +32,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
/** /**
* To learn more about how this test handles Flows created with stateIn, see * To learn more about how this test handles Flows created with stateIn, see

@ -429,10 +429,7 @@ private fun SingleTopicButton(
} }
@Composable @Composable
fun TopicIcon( fun TopicIcon(imageUrl: String, modifier: Modifier = Modifier) {
imageUrl: String,
modifier: Modifier = Modifier,
) {
DynamicAsyncImage( DynamicAsyncImage(
placeholder = painterResource(R.drawable.feature_foryou_ic_icon_placeholder), placeholder = painterResource(R.drawable.feature_foryou_ic_icon_placeholder),
imageUrl = imageUrl, imageUrl = imageUrl,
@ -482,10 +479,7 @@ private fun DeepLinkEffect(
} }
} }
private fun feedItemsSize( private fun feedItemsSize(feedState: NewsFeedUiState, onboardingUiState: OnboardingUiState): Int {
feedState: NewsFeedUiState,
onboardingUiState: OnboardingUiState,
): Int {
val feedSize = when (feedState) { val feedSize = when (feedState) {
NewsFeedUiState.Loading -> 0 NewsFeedUiState.Loading -> 0
is NewsFeedUiState.Success -> feedState.feed.size is NewsFeedUiState.Success -> feedState.feed.size

@ -30,6 +30,7 @@ import com.google.samples.apps.nowinandroid.core.domain.GetFollowableTopicsUseCa
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.LINKED_NEWS_RESOURCE_ID import com.google.samples.apps.nowinandroid.feature.foryou.navigation.LINKED_NEWS_RESOURCE_ID
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -39,7 +40,6 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ForYouViewModel @Inject constructor( class ForYouViewModel @Inject constructor(
@ -147,15 +147,14 @@ class ForYouViewModel @Inject constructor(
} }
} }
private fun AnalyticsHelper.logNewsDeepLinkOpen(newsResourceId: String) = private fun AnalyticsHelper.logNewsDeepLinkOpen(newsResourceId: String) = logEvent(
logEvent( AnalyticsEvent(
AnalyticsEvent( type = "news_deep_link_opened",
type = "news_deep_link_opened", extras = listOf(
extras = listOf( Param(
Param( key = LINKED_NEWS_RESOURCE_ID,
key = LINKED_NEWS_RESOURCE_ID, value = newsResourceId,
value = newsResourceId,
),
), ),
), ),
) ),
)

@ -31,6 +31,7 @@ import com.google.samples.apps.nowinandroid.feature.foryou.OnboardingUiState.Loa
import com.google.samples.apps.nowinandroid.feature.foryou.OnboardingUiState.NotShown import com.google.samples.apps.nowinandroid.feature.foryou.OnboardingUiState.NotShown
import com.google.samples.apps.nowinandroid.feature.foryou.OnboardingUiState.Shown import com.google.samples.apps.nowinandroid.feature.foryou.OnboardingUiState.Shown
import dagger.hilt.android.testing.HiltTestApplication import dagger.hilt.android.testing.HiltTestApplication
import java.util.TimeZone
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -39,7 +40,6 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode import org.robolectric.annotation.GraphicsMode
import org.robolectric.annotation.LooperMode import org.robolectric.annotation.LooperMode
import java.util.TimeZone
/** /**
* Screenshot tests for the [ForYouScreen]. * Screenshot tests for the [ForYouScreen].

@ -35,6 +35,9 @@ import com.google.samples.apps.nowinandroid.core.testing.util.TestAnalyticsHelpe
import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncManager import com.google.samples.apps.nowinandroid.core.testing.util.TestSyncManager
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import com.google.samples.apps.nowinandroid.feature.foryou.navigation.LINKED_NEWS_RESOURCE_ID import com.google.samples.apps.nowinandroid.feature.foryou.navigation.LINKED_NEWS_RESOURCE_ID
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -45,9 +48,6 @@ import kotlinx.datetime.Instant
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** /**
* To learn more about how this test handles Flows created with stateIn, see * To learn more about how this test handles Flows created with stateIn, see

@ -25,13 +25,13 @@ import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData import com.google.samples.apps.nowinandroid.core.testing.data.followableTopicTestData
import com.google.samples.apps.nowinandroid.core.ui.R as CoreUiR
import com.google.samples.apps.nowinandroid.feature.interests.InterestsScreen import com.google.samples.apps.nowinandroid.feature.interests.InterestsScreen
import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState
import com.google.samples.apps.nowinandroid.feature.interests.R as InterestsR
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import com.google.samples.apps.nowinandroid.core.ui.R as CoreUiR
import com.google.samples.apps.nowinandroid.feature.interests.R as InterestsR
/** /**
* UI test for checking the correct behaviour of the Interests screen; * UI test for checking the correct behaviour of the Interests screen;

@ -25,12 +25,12 @@ import com.google.samples.apps.nowinandroid.core.domain.TopicSortField
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class InterestsViewModel @Inject constructor( class InterestsViewModel @Inject constructor(

@ -37,9 +37,7 @@ fun NavController.navigateToInterests(topicId: String? = null, navOptions: NavOp
navigate(route, navOptions) navigate(route, navOptions)
} }
fun NavGraphBuilder.interestsScreen( fun NavGraphBuilder.interestsScreen(onTopicClick: (String) -> Unit) {
onTopicClick: (String) -> Unit,
) {
composable( composable(
route = INTERESTS_ROUTE, route = INTERESTS_ROUTE,
arguments = listOf( arguments = listOf(

@ -26,6 +26,7 @@ import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState import com.google.samples.apps.nowinandroid.feature.interests.InterestsUiState
import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel import com.google.samples.apps.nowinandroid.feature.interests.InterestsViewModel
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -33,7 +34,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
/** /**
* To learn more about how this test handles Flows created with stateIn, see * To learn more about how this test handles Flows created with stateIn, see

@ -71,17 +71,29 @@ class SettingsDialogTest {
} }
// Check that all the possible settings are displayed. // Check that all the possible settings are displayed.
composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_default)).assertExists() composeTestRule.onNodeWithText(
composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_android)).assertExists() getString(R.string.feature_settings_brand_default),
).assertExists()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_brand_android),
).assertExists()
composeTestRule.onNodeWithText( composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dark_mode_config_system_default), getString(R.string.feature_settings_dark_mode_config_system_default),
).assertExists() ).assertExists()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dark_mode_config_light)).assertExists() composeTestRule.onNodeWithText(
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dark_mode_config_dark)).assertExists() getString(R.string.feature_settings_dark_mode_config_light),
).assertExists()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dark_mode_config_dark),
).assertExists()
// Check that the correct settings are selected. // Check that the correct settings are selected.
composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_android)).assertIsSelected() composeTestRule.onNodeWithText(
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dark_mode_config_dark)).assertIsSelected() getString(R.string.feature_settings_brand_android),
).assertIsSelected()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dark_mode_config_dark),
).assertIsSelected()
} }
@Test @Test
@ -103,12 +115,20 @@ class SettingsDialogTest {
) )
} }
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_preference)).assertExists() composeTestRule.onNodeWithText(
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_yes)).assertExists() getString(R.string.feature_settings_dynamic_color_preference),
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertExists() ).assertExists()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dynamic_color_yes),
).assertExists()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dynamic_color_no),
).assertExists()
// Check that the correct default dynamic color setting is selected. // Check that the correct default dynamic color setting is selected.
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertIsSelected() composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dynamic_color_no),
).assertIsSelected()
} }
@Test @Test
@ -129,10 +149,16 @@ class SettingsDialogTest {
) )
} }
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_preference)) composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dynamic_color_preference),
)
.assertDoesNotExist() .assertDoesNotExist()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_yes)).assertDoesNotExist() composeTestRule.onNodeWithText(
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertDoesNotExist() getString(R.string.feature_settings_dynamic_color_yes),
).assertDoesNotExist()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dynamic_color_no),
).assertDoesNotExist()
} }
@Test @Test
@ -153,10 +179,16 @@ class SettingsDialogTest {
) )
} }
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_preference)) composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dynamic_color_preference),
)
.assertDoesNotExist() .assertDoesNotExist()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_yes)).assertDoesNotExist() composeTestRule.onNodeWithText(
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertDoesNotExist() getString(R.string.feature_settings_dynamic_color_yes),
).assertDoesNotExist()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dynamic_color_no),
).assertDoesNotExist()
} }
@Test @Test
@ -177,9 +209,13 @@ class SettingsDialogTest {
) )
} }
composeTestRule.onNodeWithText(getString(R.string.feature_settings_privacy_policy)).assertExists() composeTestRule.onNodeWithText(
getString(R.string.feature_settings_privacy_policy),
).assertExists()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_licenses)).assertExists() composeTestRule.onNodeWithText(getString(R.string.feature_settings_licenses)).assertExists()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_guidelines)).assertExists() composeTestRule.onNodeWithText(
getString(R.string.feature_settings_brand_guidelines),
).assertExists()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_feedback)).assertExists() composeTestRule.onNodeWithText(getString(R.string.feature_settings_feedback)).assertExists()
} }
} }

@ -72,10 +72,7 @@ import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Loa
import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success
@Composable @Composable
fun SettingsDialog( fun SettingsDialog(onDismiss: () -> Unit, viewModel: SettingsViewModel = hiltViewModel()) {
onDismiss: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel(),
) {
val settingsUiState by viewModel.settingsUiState.collectAsStateWithLifecycle() val settingsUiState by viewModel.settingsUiState.collectAsStateWithLifecycle()
SettingsDialog( SettingsDialog(
onDismiss = onDismiss, onDismiss = onDismiss,
@ -177,7 +174,9 @@ private fun ColumnScope.SettingsPanel(
} }
AnimatedVisibility(visible = settings.brand == DEFAULT && supportDynamicColor) { AnimatedVisibility(visible = settings.brand == DEFAULT && supportDynamicColor) {
Column { Column {
SettingsDialogSectionTitle(text = stringResource(string.feature_settings_dynamic_color_preference)) SettingsDialogSectionTitle(
text = stringResource(string.feature_settings_dynamic_color_preference),
)
Column(Modifier.selectableGroup()) { Column(Modifier.selectableGroup()) {
SettingsDialogThemeChooserRow( SettingsDialogThemeChooserRow(
text = stringResource(string.feature_settings_dynamic_color_yes), text = stringResource(string.feature_settings_dynamic_color_yes),
@ -222,11 +221,7 @@ private fun SettingsDialogSectionTitle(text: String) {
} }
@Composable @Composable
fun SettingsDialogThemeChooserRow( fun SettingsDialogThemeChooserRow(text: String, selected: Boolean, onClick: () -> Unit) {
text: String,
selected: Boolean,
onClick: () -> Unit,
) {
Row( Row(
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()

@ -24,13 +24,13 @@ import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Loading import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Loading
import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor( class SettingsViewModel @Inject constructor(

@ -22,6 +22,7 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserData
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Loading import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Loading
import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -29,7 +30,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
class SettingsViewModelTest { class SettingsViewModelTest {

@ -172,18 +172,16 @@ internal fun TopicScreen(
} }
} }
private fun topicItemsSize( private fun topicItemsSize(topicUiState: TopicUiState, newsUiState: NewsUiState) =
topicUiState: TopicUiState, when (topicUiState) {
newsUiState: NewsUiState, TopicUiState.Error -> 0 // Nothing
) = when (topicUiState) { TopicUiState.Loading -> 1 // Loading bar
TopicUiState.Error -> 0 // Nothing is TopicUiState.Success -> when (newsUiState) {
TopicUiState.Loading -> 1 // Loading bar NewsUiState.Error -> 0 // Nothing
is TopicUiState.Success -> when (newsUiState) { NewsUiState.Loading -> 1 // Loading bar
NewsUiState.Error -> 0 // Nothing is NewsUiState.Success -> 2 + newsUiState.news.size // Toolbar, header
NewsUiState.Loading -> 1 // Loading bar }
is NewsUiState.Success -> 2 + newsUiState.news.size // Toolbar, header
} }
}
private fun LazyListScope.topicBody( private fun LazyListScope.topicBody(
name: String, name: String,

@ -30,6 +30,7 @@ import com.google.samples.apps.nowinandroid.core.result.Result
import com.google.samples.apps.nowinandroid.core.result.asResult import com.google.samples.apps.nowinandroid.core.result.asResult
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicArgs import com.google.samples.apps.nowinandroid.feature.topic.navigation.TopicArgs
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -37,7 +38,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class TopicViewModel @Inject constructor( class TopicViewModel @Inject constructor(

@ -37,7 +37,9 @@ const val TOPIC_ROUTE = "topic_route"
internal class TopicArgs(val topicId: String) { internal class TopicArgs(val topicId: String) {
constructor(savedStateHandle: SavedStateHandle) : constructor(savedStateHandle: SavedStateHandle) :
this(URLDecoder.decode(checkNotNull(savedStateHandle[TOPIC_ID_ARG]), URL_CHARACTER_ENCODING)) this(
URLDecoder.decode(checkNotNull(savedStateHandle[TOPIC_ID_ARG]), URL_CHARACTER_ENCODING),
)
} }
fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) { fun NavController.navigateToTopic(topicId: String, navOptions: NavOptionsBuilder.() -> Unit = {}) {

@ -26,6 +26,8 @@ import com.google.samples.apps.nowinandroid.core.testing.repository.TestTopicsRe
import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository import com.google.samples.apps.nowinandroid.core.testing.repository.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import com.google.samples.apps.nowinandroid.feature.topic.navigation.TOPIC_ID_ARG import com.google.samples.apps.nowinandroid.feature.topic.navigation.TOPIC_ID_ARG
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -36,8 +38,6 @@ import kotlinx.datetime.Instant
import org.junit.Before import org.junit.Before
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
/** /**
* To learn more about how this test handles Flows created with stateIn, see * To learn more about how this test handles Flows created with stateIn, see

@ -17,9 +17,9 @@
package com.google.samples.apps.nowinandroid.core.sync.test package com.google.samples.apps.nowinandroid.core.sync.test
import com.google.samples.apps.nowinandroid.core.data.util.SyncManager import com.google.samples.apps.nowinandroid.core.data.util.SyncManager
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
internal class NeverSyncingSyncManager @Inject constructor() : SyncManager { internal class NeverSyncingSyncManager @Inject constructor() : SyncManager {
override val isSyncing: Flow<Boolean> = flowOf(false) override val isSyncing: Flow<Boolean> = flowOf(false)

@ -30,7 +30,5 @@ import dagger.hilt.testing.TestInstallIn
) )
internal interface TestSyncModule { internal interface TestSyncModule {
@Binds @Binds
fun bindsSyncStatusMonitor( fun bindsSyncStatusMonitor(syncStatusMonitor: NeverSyncingSyncManager): SyncManager
syncStatusMonitor: NeverSyncingSyncManager,
): SyncManager
} }

Loading…
Cancel
Save