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()
@Test
fun generate() =
baselineProfileRule.collect(PACKAGE_NAME) {
startActivityAndAllowNotifications()
fun generate() = baselineProfileRule.collect(PACKAGE_NAME) {
startActivityAndAllowNotifications()
// Navigate to saved screen
goToBookmarksScreen()
}
// Navigate to saved screen
goToBookmarksScreen()
}
}

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

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

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

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

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

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

@ -41,19 +41,13 @@ import dagger.hilt.testing.TestInstallIn
)
internal interface TestDataModule {
@Binds
fun bindsTopicRepository(
fakeTopicsRepository: FakeTopicsRepository,
): TopicsRepository
fun bindsTopicRepository(fakeTopicsRepository: FakeTopicsRepository): TopicsRepository
@Binds
fun bindsNewsResourceRepository(
fakeNewsRepository: FakeNewsRepository,
): NewsRepository
fun bindsNewsResourceRepository(fakeNewsRepository: FakeNewsRepository): NewsRepository
@Binds
fun bindsUserDataRepository(
userDataRepository: FakeUserDataRepository,
): UserDataRepository
fun bindsUserDataRepository(userDataRepository: FakeUserDataRepository): UserDataRepository
@Binds
fun bindsRecentSearchRepository(
@ -66,9 +60,7 @@ internal interface TestDataModule {
): SearchContentsRepository
@Binds
fun bindsNetworkMonitor(
networkMonitor: AlwaysOnlineNetworkMonitor,
): NetworkMonitor
fun bindsNetworkMonitor(networkMonitor: AlwaysOnlineNetworkMonitor): NetworkMonitor
@Binds
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.demo.DemoNiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import javax.inject.Inject
/**
* 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,
) : NewsRepository {
override fun getNewsResources(
query: NewsResourceQuery,
): Flow<List<NewsResource>> =
flow {
emit(
datasource
.getNewsResources()
.filter { networkNewsResource ->
// Filter out any news resources which don't match the current query.
// If no query parameters (filterTopicIds or filterNewsIds) are specified
// then the news resource is returned.
listOfNotNull(
true,
query.filterNewsIds?.contains(networkNewsResource.id),
query.filterTopicIds?.let { filterTopicIds ->
networkNewsResource.topics.intersect(filterTopicIds).isNotEmpty()
},
)
.all(true::equals)
}
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel),
)
}.flowOn(ioDispatcher)
override fun getNewsResources(query: NewsResourceQuery): Flow<List<NewsResource>> = flow {
emit(
datasource
.getNewsResources()
.filter { networkNewsResource ->
// Filter out any news resources which don't match the current query.
// If no query parameters (filterTopicIds or filterNewsIds) are specified
// then the news resource is returned.
listOfNotNull(
true,
query.filterNewsIds?.contains(networkNewsResource.id),
query.filterTopicIds?.let { filterTopicIds ->
networkNewsResource.topics.intersect(filterTopicIds).isNotEmpty()
},
)
.all(true::equals)
}
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel),
)
}.flowOn(ioDispatcher)
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.repository.RecentSearchRepository
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
/**
* 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.model.data.SearchResult
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import javax.inject.Inject
/**
* 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.NiaDispatchers.IO
import com.google.samples.apps.nowinandroid.core.network.demo.DemoNiaNetworkDataSource
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import javax.inject.Inject
/**
* 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.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
/**
* 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 [NewsResourceEntity] into the DB
*/
fun NetworkNewsResource.topicEntityShells() =
topics.map { topicId ->
TopicEntity(
id = topicId,
name = "",
url = "",
imageUrl = "",
shortDescription = "",
longDescription = "",
)
}
fun NetworkNewsResource.topicEntityShells() = topics.map { topicId ->
TopicEntity(
id = topicId,
name = "",
url = "",
imageUrl = "",
shortDescription = "",
longDescription = "",
)
}
fun NetworkNewsResource.topicCrossReferences(): List<NewsResourceTopicCrossRef> =
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.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 paramKey = if (isBookmarked) "saved_news_resource_id" else "unsaved_news_resource_id"
logEvent(
@ -46,35 +49,32 @@ internal fun AnalyticsHelper.logTopicFollowToggled(followedTopicId: String, isFo
)
}
internal fun AnalyticsHelper.logThemeChanged(themeName: String) =
logEvent(
AnalyticsEvent(
type = "theme_changed",
extras = listOf(
Param(key = "theme_name", value = themeName),
),
internal fun AnalyticsHelper.logThemeChanged(themeName: String) = logEvent(
AnalyticsEvent(
type = "theme_changed",
extras = listOf(
Param(key = "theme_name", value = themeName),
),
)
),
)
internal fun AnalyticsHelper.logDarkThemeConfigChanged(darkThemeConfigName: String) =
logEvent(
AnalyticsEvent(
type = "dark_theme_config_changed",
extras = listOf(
Param(key = "dark_theme_config", value = darkThemeConfigName),
),
internal fun AnalyticsHelper.logDarkThemeConfigChanged(darkThemeConfigName: String) = logEvent(
AnalyticsEvent(
type = "dark_theme_config_changed",
extras = listOf(
Param(key = "dark_theme_config", value = darkThemeConfigName),
),
)
),
)
internal fun AnalyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor: Boolean) =
logEvent(
AnalyticsEvent(
type = "dynamic_color_preference_changed",
extras = listOf(
Param(key = "dynamic_color_preference", value = useDynamicColor.toString()),
),
internal fun AnalyticsHelper.logDynamicColorPreferenceChanged(useDynamicColor: Boolean) = logEvent(
AnalyticsEvent(
type = "dynamic_color_preference_changed",
extras = listOf(
Param(key = "dynamic_color_preference", value = useDynamicColor.toString()),
),
)
),
)
internal fun AnalyticsHelper.logOnboardingStateChanged(shouldHideOnboarding: Boolean) {
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.mapToUserNewsResources
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import javax.inject.Inject
/**
* 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.
*/
override fun observeAll(
query: NewsResourceQuery,
): Flow<List<UserNewsResource>> =
override fun observeAll(query: NewsResourceQuery): Flow<List<UserNewsResource>> =
newsRepository.getNewsResources(query)
.combine(userDataRepository.userData) { newsResources, 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.database.dao.RecentSearchQueryDao
import com.google.samples.apps.nowinandroid.core.database.model.RecentSearchQueryEntity
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.datetime.Clock
import javax.inject.Inject
internal class DefaultRecentSearchRepository @Inject constructor(
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.network.Dispatcher
import com.google.samples.apps.nowinandroid.core.network.NiaDispatchers.IO
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
@ -34,7 +35,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import javax.inject.Inject
internal class DefaultSearchContentsRepository @Inject constructor(
private val newsResourceDao: NewsResourceDao,
@ -82,11 +82,10 @@ internal class DefaultSearchContentsRepository @Inject constructor(
}
}
override fun getSearchContentsCount(): Flow<Int> =
combine(
newsResourceFtsDao.getCount(),
topicFtsDao.getCount(),
) { newsResourceCount, topicsCount ->
newsResourceCount + topicsCount
}
override fun getSearchContentsCount(): Flow<Int> = combine(
newsResourceFtsDao.getCount(),
topicFtsDao.getCount(),
) { 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.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.notifications.Notifier
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import javax.inject.Inject
// Heuristic value to optimize for serialization and deserialization cost on client and server
// for each news resource batch.
@ -53,15 +53,14 @@ internal class OfflineFirstNewsRepository @Inject constructor(
private val notifier: Notifier,
) : NewsRepository {
override fun getNewsResources(
query: NewsResourceQuery,
): Flow<List<NewsResource>> = newsResourceDao.getNewsResources(
useFilterTopicIds = query.filterTopicIds != null,
filterTopicIds = query.filterTopicIds ?: emptySet(),
useFilterNewsIds = query.filterNewsIds != null,
filterNewsIds = query.filterNewsIds ?: emptySet(),
)
.map { it.map(PopulatedNewsResource::asExternalModel) }
override fun getNewsResources(query: NewsResourceQuery): Flow<List<NewsResource>> =
newsResourceDao.getNewsResources(
useFilterTopicIds = query.filterTopicIds != null,
filterTopicIds = query.filterTopicIds ?: emptySet(),
useFilterNewsIds = query.filterNewsIds != null,
filterNewsIds = query.filterNewsIds ?: emptySet(),
)
.map { it.map(PopulatedNewsResource::asExternalModel) }
override suspend fun syncWith(synchronizer: Synchronizer): Boolean {
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.network.NiaNetworkDataSource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
/**
* Disk storage backed implementation of the [TopicsRepository].
@ -39,9 +39,8 @@ internal class OfflineFirstTopicsRepository @Inject constructor(
private val network: NiaNetworkDataSource,
) : TopicsRepository {
override fun getTopics(): Flow<List<Topic>> =
topicDao.getTopicEntities()
.map { it.map(TopicEntity::asExternalModel) }
override fun getTopics(): Flow<List<Topic>> = topicDao.getTopicEntities()
.map { it.map(TopicEntity::asExternalModel) }
override fun getTopic(id: String): Flow<Topic> =
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.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
internal class OfflineFirstUserDataRepository @Inject constructor(
private val niaPreferencesDataSource: NiaPreferencesDataSource,

@ -27,11 +27,11 @@ import android.os.Build.VERSION
import android.os.Build.VERSION_CODES
import androidx.core.content.getSystemService
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import javax.inject.Inject
internal class ConnectivityManagerNetworkMonitor @Inject constructor(
@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.di.ApplicationScope
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.CoroutineScope
import kotlinx.coroutines.channels.awaitClose
@ -40,9 +43,6 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.shareIn
import kotlinx.datetime.TimeZone
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.

@ -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.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.repository.emptyUserData
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.Test
import kotlin.test.assertEquals
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.NetworkNewsResourceExpanded
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlin.test.assertEquals
import kotlinx.datetime.Instant
import org.junit.Test
import kotlin.test.assertEquals
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.NetworkNewsResource
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.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -46,8 +48,6 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class OfflineFirstNewsRepositoryTest {
@ -134,34 +134,33 @@ class OfflineFirstNewsRepositoryTest {
}
@Test
fun offlineFirstNewsRepository_sync_pulls_from_network() =
testScope.runTest {
// User has not onboarded
niaPreferencesDataSource.setShouldHideOnboarding(false)
subject.syncWith(synchronizer)
val newsResourcesFromNetwork = network.getNewsResources()
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel)
val newsResourcesFromDb = newsResourceDao.getNewsResources()
.first()
.map(PopulatedNewsResource::asExternalModel)
assertEquals(
newsResourcesFromNetwork.map(NewsResource::id).sorted(),
newsResourcesFromDb.map(NewsResource::id).sorted(),
)
fun offlineFirstNewsRepository_sync_pulls_from_network() = testScope.runTest {
// User has not onboarded
niaPreferencesDataSource.setShouldHideOnboarding(false)
subject.syncWith(synchronizer)
val newsResourcesFromNetwork = network.getNewsResources()
.map(NetworkNewsResource::asEntity)
.map(NewsResourceEntity::asExternalModel)
val newsResourcesFromDb = newsResourceDao.getNewsResources()
.first()
.map(PopulatedNewsResource::asExternalModel)
assertEquals(
newsResourcesFromNetwork.map(NewsResource::id).sorted(),
newsResourcesFromDb.map(NewsResource::id).sorted(),
)
// After sync version should be updated
assertEquals(
expected = network.latestChangeListVersion(CollectionType.NewsResources),
actual = synchronizer.getChangeListVersions().newsResourceVersion,
)
// After sync version should be updated
assertEquals(
expected = network.latestChangeListVersion(CollectionType.NewsResources),
actual = synchronizer.getChangeListVersions().newsResourceVersion,
)
// Notifier should not have been called
assertTrue(notifier.addedNewsResources.isEmpty())
}
// Notifier should not have been called
assertTrue(notifier.addedNewsResources.isEmpty())
}
@Test
fun offlineFirstNewsRepository_sync_deletes_items_marked_deleted_on_network() =
@ -211,93 +210,89 @@ class OfflineFirstNewsRepositoryTest {
}
@Test
fun offlineFirstNewsRepository_incremental_sync_pulls_from_network() =
testScope.runTest {
// User has not onboarded
niaPreferencesDataSource.setShouldHideOnboarding(false)
// Set news version to 7
synchronizer.updateChangeListVersions {
copy(newsResourceVersion = 7)
}
fun offlineFirstNewsRepository_incremental_sync_pulls_from_network() = testScope.runTest {
// User has not onboarded
niaPreferencesDataSource.setShouldHideOnboarding(false)
subject.syncWith(synchronizer)
val changeList = network.changeListsAfter(
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 }
// Set news version to 7
synchronizer.updateChangeListVersions {
copy(newsResourceVersion = 7)
}
val newsResourcesFromDb = newsResourceDao.getNewsResources()
.first()
.map(PopulatedNewsResource::asExternalModel)
subject.syncWith(synchronizer)
assertEquals(
expected = newsResourcesFromNetwork.map(NewsResource::id).sorted(),
actual = newsResourcesFromDb.map(NewsResource::id).sorted(),
)
val changeList = network.changeListsAfter(
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()
.first()
.map(PopulatedNewsResource::asExternalModel)
assertEquals(
expected = newsResourcesFromNetwork.map(NewsResource::id).sorted(),
actual = newsResourcesFromDb.map(NewsResource::id).sorted(),
)
// After sync version should be updated
assertEquals(
expected = changeList.last().changeListVersion,
actual = synchronizer.getChangeListVersions().newsResourceVersion,
)
// After sync version should be updated
assertEquals(
expected = changeList.last().changeListVersion,
actual = synchronizer.getChangeListVersions().newsResourceVersion,
)
// Notifier should not have been called
assertTrue(notifier.addedNewsResources.isEmpty())
}
// Notifier should not have been called
assertTrue(notifier.addedNewsResources.isEmpty())
}
@Test
fun offlineFirstNewsRepository_sync_saves_shell_topic_entities() =
testScope.runTest {
subject.syncWith(synchronizer)
assertEquals(
expected = network.getNewsResources()
.map(NetworkNewsResource::topicEntityShells)
.flatten()
.distinctBy(TopicEntity::id)
.sortedBy(TopicEntity::toString),
actual = topicDao.getTopicEntities()
.first()
.sortedBy(TopicEntity::toString),
)
}
fun offlineFirstNewsRepository_sync_saves_shell_topic_entities() = testScope.runTest {
subject.syncWith(synchronizer)
assertEquals(
expected = network.getNewsResources()
.map(NetworkNewsResource::topicEntityShells)
.flatten()
.distinctBy(TopicEntity::id)
.sortedBy(TopicEntity::toString),
actual = topicDao.getTopicEntities()
.first()
.sortedBy(TopicEntity::toString),
)
}
@Test
fun offlineFirstNewsRepository_sync_saves_topic_cross_references() =
testScope.runTest {
subject.syncWith(synchronizer)
assertEquals(
expected = network.getNewsResources()
.map(NetworkNewsResource::topicCrossReferences)
.flatten()
.distinct()
.sortedBy(NewsResourceTopicCrossRef::toString),
actual = newsResourceDao.topicCrossReferences
.sortedBy(NewsResourceTopicCrossRef::toString),
)
}
fun offlineFirstNewsRepository_sync_saves_topic_cross_references() = testScope.runTest {
subject.syncWith(synchronizer)
assertEquals(
expected = network.getNewsResources()
.map(NetworkNewsResource::topicCrossReferences)
.flatten()
.distinct()
.sortedBy(NewsResourceTopicCrossRef::toString),
actual = newsResourceDao.topicCrossReferences
.sortedBy(NewsResourceTopicCrossRef::toString),
)
}
@Test
fun offlineFirstNewsRepository_sync_marks_as_read_on_first_run() =
testScope.runTest {
subject.syncWith(synchronizer)
fun offlineFirstNewsRepository_sync_marks_as_read_on_first_run() = testScope.runTest {
subject.syncWith(synchronizer)
assertEquals(
network.getNewsResources().map { it.id }.toSet(),
niaPreferencesDataSource.userData.first().viewedNewsResources,
)
}
assertEquals(
network.getNewsResources().map { it.id }.toSet(),
niaPreferencesDataSource.userData.first().viewedNewsResources,
)
}
@Test
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.model.data.Topic
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -36,7 +37,6 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
class OfflineFirstTopicsRepositoryTest {
@ -71,69 +71,66 @@ class OfflineFirstTopicsRepositoryTest {
}
@Test
fun offlineFirstTopicsRepository_topics_stream_is_backed_by_topics_dao() =
testScope.runTest {
assertEquals(
topicDao.getTopicEntities()
.first()
.map(TopicEntity::asExternalModel),
subject.getTopics()
.first(),
)
}
fun offlineFirstTopicsRepository_topics_stream_is_backed_by_topics_dao() = testScope.runTest {
assertEquals(
topicDao.getTopicEntities()
.first()
.map(TopicEntity::asExternalModel),
subject.getTopics()
.first(),
)
}
@Test
fun offlineFirstTopicsRepository_sync_pulls_from_network() =
testScope.runTest {
subject.syncWith(synchronizer)
fun offlineFirstTopicsRepository_sync_pulls_from_network() = testScope.runTest {
subject.syncWith(synchronizer)
val networkTopics = network.getTopics()
.map(NetworkTopic::asEntity)
val networkTopics = network.getTopics()
.map(NetworkTopic::asEntity)
val dbTopics = topicDao.getTopicEntities()
.first()
val dbTopics = topicDao.getTopicEntities()
.first()
assertEquals(
networkTopics.map(TopicEntity::id),
dbTopics.map(TopicEntity::id),
)
assertEquals(
networkTopics.map(TopicEntity::id),
dbTopics.map(TopicEntity::id),
)
// After sync version should be updated
assertEquals(
network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion,
)
}
// After sync version should be updated
assertEquals(
network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion,
)
}
@Test
fun offlineFirstTopicsRepository_incremental_sync_pulls_from_network() =
testScope.runTest {
// Set topics version to 10
synchronizer.updateChangeListVersions {
copy(topicVersion = 10)
}
fun offlineFirstTopicsRepository_incremental_sync_pulls_from_network() = testScope.runTest {
// Set topics version to 10
synchronizer.updateChangeListVersions {
copy(topicVersion = 10)
}
subject.syncWith(synchronizer)
subject.syncWith(synchronizer)
val networkTopics = network.getTopics()
.map(NetworkTopic::asEntity)
// Drop 10 to simulate the first 10 items being unchanged
.drop(10)
val networkTopics = network.getTopics()
.map(NetworkTopic::asEntity)
// Drop 10 to simulate the first 10 items being unchanged
.drop(10)
val dbTopics = topicDao.getTopicEntities()
.first()
val dbTopics = topicDao.getTopicEntities()
.first()
assertEquals(
networkTopics.map(TopicEntity::id),
dbTopics.map(TopicEntity::id),
)
assertEquals(
networkTopics.map(TopicEntity::id),
dbTopics.map(TopicEntity::id),
)
// After sync version should be updated
assertEquals(
network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion,
)
}
// After sync version should be updated
assertEquals(
network.latestChangeListVersion(CollectionType.Topics),
synchronizer.getChangeListVersions().topicVersion,
)
}
@Test
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.ThemeBrand
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.map
import kotlinx.coroutines.test.TestScope
@ -31,9 +34,6 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class OfflineFirstUserDataRepositoryTest {
@ -61,21 +61,20 @@ class OfflineFirstUserDataRepositoryTest {
}
@Test
fun offlineFirstUserDataRepository_default_user_data_is_correct() =
testScope.runTest {
assertEquals(
UserData(
bookmarkedNewsResources = emptySet(),
viewedNewsResources = emptySet(),
followedTopics = emptySet(),
themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
useDynamicColor = false,
shouldHideOnboarding = false,
),
subject.userData.first(),
)
}
fun offlineFirstUserDataRepository_default_user_data_is_correct() = testScope.runTest {
assertEquals(
UserData(
bookmarkedNewsResources = emptySet(),
viewedNewsResources = emptySet(),
followedTopics = emptySet(),
themeBrand = ThemeBrand.DEFAULT,
darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM,
useDynamicColor = false,
shouldHideOnboarding = false,
),
subject.userData.first(),
)
}
@Test
fun offlineFirstUserDataRepository_toggle_followed_topics_logic_delegates_to_nia_preferences() =

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

@ -51,11 +51,10 @@ class TestNiaNetworkDataSource : NiaNetworkDataSource {
.mapToChangeList(idGetter = NetworkNewsResource::id),
)
override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> =
allTopics.matchIds(
ids = ids,
idGetter = NetworkTopic::id,
)
override suspend fun getTopics(ids: List<String>?): List<NetworkTopic> = allTopics.matchIds(
ids = ids,
idGetter = NetworkTopic::id,
)
override suspend fun getNewsResources(ids: List<String>?): List<NetworkNewsResource> =
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
*/
private fun <T> List<T>.matchIds(
ids: List<String>?,
idGetter: (T) -> String,
) = when (ids) {
private fun <T> List<T>.matchIds(ids: List<String>?, idGetter: (T) -> String) = when (ids) {
null -> this
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.
* [after] simulates which models have changed by excluding items before it
*/
private fun <T> List<T>.mapToChangeList(
idGetter: (T) -> String,
) = mapIndexed { index, item ->
private fun <T> List<T>.mapToChangeList(idGetter: (T) -> String) = mapIndexed { index, item ->
NetworkChangeList(
id = idGetter(item),
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.Topic
import kotlin.test.assertEquals
import kotlinx.datetime.Instant
import org.junit.Test
import kotlin.test.assertEquals
class PopulatedNewsResourceKtTest {
@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.TopicEntity
import com.google.samples.apps.nowinandroid.core.database.model.asExternalModel
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.Instant
import org.junit.After
import org.junit.Before
import org.junit.Test
import kotlin.test.assertEquals
class NewsResourceDaoTest {
@ -248,48 +248,44 @@ class NewsResourceDaoTest {
}
@Test
fun newsResourceDao_deletes_items_by_ids() =
runTest {
val newsResourceEntities = listOf(
testNewsResource(
id = "0",
millisSinceEpoch = 0,
),
testNewsResource(
id = "1",
millisSinceEpoch = 3,
),
testNewsResource(
id = "2",
millisSinceEpoch = 1,
),
testNewsResource(
id = "3",
millisSinceEpoch = 2,
),
)
newsResourceDao.upsertNewsResources(newsResourceEntities)
fun newsResourceDao_deletes_items_by_ids() = runTest {
val newsResourceEntities = listOf(
testNewsResource(
id = "0",
millisSinceEpoch = 0,
),
testNewsResource(
id = "1",
millisSinceEpoch = 3,
),
testNewsResource(
id = "2",
millisSinceEpoch = 1,
),
testNewsResource(
id = "3",
millisSinceEpoch = 2,
),
)
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(
toDelete.map(NewsResourceEntity::id),
)
newsResourceDao.deleteNewsResources(
toDelete.map(NewsResourceEntity::id),
)
assertEquals(
toKeep.map(NewsResourceEntity::id)
.toSet(),
newsResourceDao.getNewsResources().first()
.map { it.entity.id }
.toSet(),
)
}
assertEquals(
toKeep.map(NewsResourceEntity::id)
.toSet(),
newsResourceDao.getNewsResources().first()
.map { it.entity.id }
.toSet(),
)
}
}
private fun testTopicEntity(
id: String = "0",
name: String,
) = TopicEntity(
private fun testTopicEntity(id: String = "0", name: String) = TopicEntity(
id = id,
name = name,
shortDescription = "",
@ -298,10 +294,7 @@ private fun testTopicEntity(
imageUrl = "",
)
private fun testNewsResource(
id: String = "0",
millisSinceEpoch: Long = 0,
) = NewsResourceEntity(
private fun testNewsResource(id: String = "0", millisSinceEpoch: Long = 0) = NewsResourceEntity(
id = id,
title = "",
content = "",

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

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

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

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

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

@ -25,32 +25,31 @@ internal object ListToMapMigration : DataMigration<UserPreferences> {
override suspend fun cleanUp() = Unit
override suspend fun migrate(currentData: UserPreferences): UserPreferences =
currentData.copy {
// Migrate topic id lists
followedTopicIds.clear()
followedTopicIds.putAll(
currentData.deprecatedFollowedTopicIdsList.associateWith { true },
)
deprecatedFollowedTopicIds.clear()
// Migrate author ids
followedAuthorIds.clear()
followedAuthorIds.putAll(
currentData.deprecatedFollowedAuthorIdsList.associateWith { true },
)
deprecatedFollowedAuthorIds.clear()
// Migrate bookmarks
bookmarkedNewsResourceIds.clear()
bookmarkedNewsResourceIds.putAll(
currentData.deprecatedBookmarkedNewsResourceIdsList.associateWith { true },
)
deprecatedBookmarkedNewsResourceIds.clear()
// Mark migration as complete
hasDoneListToMapMigration = true
}
override suspend fun migrate(currentData: UserPreferences): UserPreferences = currentData.copy {
// Migrate topic id lists
followedTopicIds.clear()
followedTopicIds.putAll(
currentData.deprecatedFollowedTopicIdsList.associateWith { true },
)
deprecatedFollowedTopicIds.clear()
// Migrate author ids
followedAuthorIds.clear()
followedAuthorIds.putAll(
currentData.deprecatedFollowedAuthorIdsList.associateWith { true },
)
deprecatedFollowedAuthorIds.clear()
// Migrate bookmarks
bookmarkedNewsResourceIds.clear()
bookmarkedNewsResourceIds.putAll(
currentData.deprecatedBookmarkedNewsResourceIdsList.associateWith { true },
)
deprecatedBookmarkedNewsResourceIds.clear()
// Mark migration as complete
hasDoneListToMapMigration = true
}
override suspend fun shouldMigrate(currentData: UserPreferences): Boolean =
!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.ThemeBrand
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 javax.inject.Inject
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
class NiaPreferencesDataSource @Inject constructor(
private val userPreferences: DataStore<UserPreferences>,

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

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

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

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

@ -17,6 +17,8 @@
package com.google.samples.apps.nowinandroid.core.datastore
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.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -25,8 +27,6 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NiaPreferencesDataSourceTest {

@ -17,11 +17,11 @@
package com.google.samples.apps.nowinandroid.core.datastore
import androidx.datastore.core.CorruptionException
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import kotlin.test.assertEquals
import kotlinx.coroutines.test.runTest
import org.junit.Test
class UserPreferencesSerializerTest {
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.NONE
import com.google.samples.apps.nowinandroid.core.model.data.FollowableTopic
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
/**
* 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.repository.RecentSearchRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
/**
* 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.UserNewsResource
import com.google.samples.apps.nowinandroid.core.model.data.UserSearchResult
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import javax.inject.Inject
/**
* 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,
) {
operator fun invoke(
searchQuery: String,
): Flow<UserSearchResult> =
operator fun invoke(searchQuery: String): Flow<UserSearchResult> =
searchContentsRepository.searchContents(searchQuery)
.mapToUserSearchResult(userDataRepository.userData)
}
private fun Flow<SearchResult>.mapToUserSearchResult(userDataStream: Flow<UserData>): Flow<UserSearchResult> =
combine(userDataStream) { searchResult, userData ->
UserSearchResult(
topics = searchResult.topics.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = topic.id in userData.followedTopics,
)
},
newsResources = searchResult.newsResources.map { news ->
UserNewsResource(
newsResource = news,
userData = userData,
)
},
)
}
private fun Flow<SearchResult>.mapToUserSearchResult(
userDataStream: Flow<UserData>,
): Flow<UserSearchResult> = combine(userDataStream) { searchResult, userData ->
UserSearchResult(
topics = searchResult.topics.map { topic ->
FollowableTopic(
topic = topic,
isFollowed = topic.id in userData.followedTopics,
)
},
newsResources = searchResult.newsResources.map { news ->
UserNewsResource(
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.TestUserDataRepository
import com.google.samples.apps.nowinandroid.core.testing.util.MainDispatcherRule
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
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.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import javax.inject.Inject
/**
* [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
* [NetworkChangeList.id]
*/
private fun <T> List<T>.mapToChangeList(
idGetter: (T) -> String,
) = mapIndexed { index, item ->
private fun <T> List<T>.mapToChangeList(idGetter: (T) -> String) = mapIndexed { index, item ->
NetworkChangeList(
id = idGetter(item),
changeListVersion = index,

@ -28,11 +28,11 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import kotlinx.serialization.json.Json
import okhttp3.Call
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
@ -46,9 +46,8 @@ internal object NetworkModule {
@Provides
@Singleton
fun providesDemoAssetManager(
@ApplicationContext context: Context,
): DemoAssetManager = DemoAssetManager(context.assets::open)
fun providesDemoAssetManager(@ApplicationContext context: Context): DemoAssetManager =
DemoAssetManager(context.assets::open)
@Provides
@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.NetworkTopic
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Call
@ -30,17 +32,13 @@ import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.http.GET
import retrofit2.http.Query
import javax.inject.Inject
import javax.inject.Singleton
/**
* Retrofit API declaration for NIA Network API
*/
private interface RetrofitNiaNetworkApi {
@GET(value = "topics")
suspend fun getTopics(
@Query("id") ids: List<String>?,
): NetworkResponse<List<NetworkTopic>>
suspend fun getTopics(@Query("id") ids: List<String>?): NetworkResponse<List<NetworkTopic>>
@GET(value = "newsresources")
suspend fun getNewsResources(
@ -48,14 +46,10 @@ private interface RetrofitNiaNetworkApi {
): NetworkResponse<List<NetworkNewsResource>>
@GET(value = "changelists/topics")
suspend fun getTopicChangeList(
@Query("after") after: Int?,
): List<NetworkChangeList>
suspend fun getTopicChangeList(@Query("after") after: Int?): List<NetworkChangeList>
@GET(value = "changelists/newsresources")
suspend fun getNewsResourcesChangeList(
@Query("after") after: Int?,
): List<NetworkChangeList>
suspend fun getNewsResourcesChangeList(@Query("after") after: Int?): List<NetworkChangeList>
}
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 com.google.samples.apps.nowinandroid.core.network.model.NetworkNewsResource
import com.google.samples.apps.nowinandroid.core.network.model.NetworkTopic
import kotlin.test.assertEquals
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.datetime.LocalDateTime
@ -27,7 +28,6 @@ import kotlinx.datetime.toInstant
import kotlinx.serialization.json.Json
import org.junit.Before
import org.junit.Test
import kotlin.test.assertEquals
class DemoNiaNetworkDataSourceTest {

@ -65,6 +65,7 @@ internal class SystemTrayNotifier @Inject constructor(
val newsNotifications = truncatedNewsResources.map { newsResource ->
createNewsNotification {
setSmallIcon(
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.
*/
@Composable
fun TrackJank(
vararg keys: Any,
reportMetric: suspend CoroutineScope.(state: Holder) -> Unit,
) {
fun TrackJank(vararg keys: Any, reportMetric: suspend CoroutineScope.(state: Holder) -> Unit) {
val metrics = rememberMetricsStateHolder()
LaunchedEffect(metrics, *keys) {
reportMetric(metrics)

@ -73,7 +73,11 @@ fun LazyStaggeredGridScope.newsFeed(
analyticsHelper.logNewsResourceOpened(
newsResourceId = userNewsResource.id,
)
launchCustomChromeTab(context, Uri.parse(userNewsResource.url), backgroundColor)
launchCustomChromeTab(
context,
Uri.parse(userNewsResource.url),
backgroundColor,
)
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.NewsResource
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.FormatStyle
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
@ -142,9 +142,7 @@ fun NewsResourceCardExpanded(
}
@Composable
fun NewsResourceHeaderImage(
headerImageUrl: String?,
) {
fun NewsResourceHeaderImage(headerImageUrl: String?) {
var isLoading by remember { mutableStateOf(true) }
var isError by remember { mutableStateOf(false) }
val imageLoader = rememberAsyncImagePainter(
@ -189,19 +187,12 @@ fun NewsResourceHeaderImage(
}
@Composable
fun NewsResourceTitle(
newsResourceTitle: String,
modifier: Modifier = Modifier,
) {
fun NewsResourceTitle(newsResourceTitle: String, modifier: Modifier = Modifier) {
Text(newsResourceTitle, style = MaterialTheme.typography.headlineSmall, modifier = modifier)
}
@Composable
fun BookmarkButton(
isBookmarked: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
fun BookmarkButton(isBookmarked: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) {
NiaIconToggleButton(
checked = isBookmarked,
onCheckedChange = { onClick() },
@ -222,10 +213,7 @@ fun BookmarkButton(
}
@Composable
fun NotificationDot(
color: Color,
modifier: Modifier = Modifier,
) {
fun NotificationDot(color: Color, modifier: Modifier = Modifier) {
val description = stringResource(R.string.core_ui_unread_resource_dot_content_description)
Canvas(
modifier = modifier
@ -247,10 +235,7 @@ fun dateFormatted(publishDate: Instant): String = DateTimeFormatter
.format(publishDate.toJavaInstant())
@Composable
fun NewsResourceMetaData(
publishDate: Instant,
resourceType: String,
) {
fun NewsResourceMetaData(publishDate: Instant, resourceType: String) {
val formattedDate = dateFormatted(publishDate)
Text(
if (resourceType.isNotBlank()) {
@ -263,9 +248,7 @@ fun NewsResourceMetaData(
}
@Composable
fun NewsResourceShortDescription(
newsResourceShortDescription: String,
) {
fun NewsResourceShortDescription(newsResourceShortDescription: String) {
Text(newsResourceShortDescription, style = MaterialTheme.typography.bodyLarge)
}

@ -36,11 +36,11 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.testing.TestLifecycleOwner
import com.google.samples.apps.nowinandroid.core.testing.data.userNewsResourcesTestData
import com.google.samples.apps.nowinandroid.core.ui.NewsFeedUiState
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* 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.Loading
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class BookmarksViewModel @Inject constructor(

@ -24,7 +24,10 @@ import com.google.samples.apps.nowinandroid.feature.bookmarks.BookmarksRoute
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(
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.ui.NewsFeedUiState.Loading
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.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -30,8 +32,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
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

@ -429,10 +429,7 @@ private fun SingleTopicButton(
}
@Composable
fun TopicIcon(
imageUrl: String,
modifier: Modifier = Modifier,
) {
fun TopicIcon(imageUrl: String, modifier: Modifier = Modifier) {
DynamicAsyncImage(
placeholder = painterResource(R.drawable.feature_foryou_ic_icon_placeholder),
imageUrl = imageUrl,
@ -482,10 +479,7 @@ private fun DeepLinkEffect(
}
}
private fun feedItemsSize(
feedState: NewsFeedUiState,
onboardingUiState: OnboardingUiState,
): Int {
private fun feedItemsSize(feedState: NewsFeedUiState, onboardingUiState: OnboardingUiState): Int {
val feedSize = when (feedState) {
NewsFeedUiState.Loading -> 0
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.feature.foryou.navigation.LINKED_NEWS_RESOURCE_ID
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -39,7 +40,6 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ForYouViewModel @Inject constructor(
@ -147,15 +147,14 @@ class ForYouViewModel @Inject constructor(
}
}
private fun AnalyticsHelper.logNewsDeepLinkOpen(newsResourceId: String) =
logEvent(
AnalyticsEvent(
type = "news_deep_link_opened",
extras = listOf(
Param(
key = LINKED_NEWS_RESOURCE_ID,
value = newsResourceId,
),
private fun AnalyticsHelper.logNewsDeepLinkOpen(newsResourceId: String) = logEvent(
AnalyticsEvent(
type = "news_deep_link_opened",
extras = listOf(
Param(
key = LINKED_NEWS_RESOURCE_ID,
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.Shown
import dagger.hilt.android.testing.HiltTestApplication
import java.util.TimeZone
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@ -39,7 +40,6 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
import org.robolectric.annotation.LooperMode
import java.util.TimeZone
/**
* 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.ui.NewsFeedUiState
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.first
import kotlinx.coroutines.launch
@ -45,9 +48,6 @@ import kotlinx.datetime.Instant
import org.junit.Before
import org.junit.Rule
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

@ -25,13 +25,13 @@ import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
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.InterestsUiState
import com.google.samples.apps.nowinandroid.feature.interests.R as InterestsR
import org.junit.Before
import org.junit.Rule
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;

@ -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.feature.interests.navigation.TOPIC_ID_ARG
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class InterestsViewModel @Inject constructor(

@ -37,9 +37,7 @@ fun NavController.navigateToInterests(topicId: String? = null, navOptions: NavOp
navigate(route, navOptions)
}
fun NavGraphBuilder.interestsScreen(
onTopicClick: (String) -> Unit,
) {
fun NavGraphBuilder.interestsScreen(onTopicClick: (String) -> Unit) {
composable(
route = INTERESTS_ROUTE,
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.InterestsViewModel
import com.google.samples.apps.nowinandroid.feature.interests.navigation.TOPIC_ID_ARG
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -33,7 +34,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
/**
* 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.
composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_default)).assertExists()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_android)).assertExists()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_brand_default),
).assertExists()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_brand_android),
).assertExists()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dark_mode_config_system_default),
).assertExists()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dark_mode_config_light)).assertExists()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dark_mode_config_dark)).assertExists()
composeTestRule.onNodeWithText(
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.
composeTestRule.onNodeWithText(getString(R.string.feature_settings_brand_android)).assertIsSelected()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dark_mode_config_dark)).assertIsSelected()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_brand_android),
).assertIsSelected()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dark_mode_config_dark),
).assertIsSelected()
}
@Test
@ -103,12 +115,20 @@ class SettingsDialogTest {
)
}
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_preference)).assertExists()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_yes)).assertExists()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertExists()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dynamic_color_preference),
).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.
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertIsSelected()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dynamic_color_no),
).assertIsSelected()
}
@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()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_yes)).assertDoesNotExist()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertDoesNotExist()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dynamic_color_yes),
).assertDoesNotExist()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dynamic_color_no),
).assertDoesNotExist()
}
@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()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_yes)).assertDoesNotExist()
composeTestRule.onNodeWithText(getString(R.string.feature_settings_dynamic_color_no)).assertDoesNotExist()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dynamic_color_yes),
).assertDoesNotExist()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_dynamic_color_no),
).assertDoesNotExist()
}
@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_brand_guidelines)).assertExists()
composeTestRule.onNodeWithText(
getString(R.string.feature_settings_brand_guidelines),
).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
@Composable
fun SettingsDialog(
onDismiss: () -> Unit,
viewModel: SettingsViewModel = hiltViewModel(),
) {
fun SettingsDialog(onDismiss: () -> Unit, viewModel: SettingsViewModel = hiltViewModel()) {
val settingsUiState by viewModel.settingsUiState.collectAsStateWithLifecycle()
SettingsDialog(
onDismiss = onDismiss,
@ -177,7 +174,9 @@ private fun ColumnScope.SettingsPanel(
}
AnimatedVisibility(visible = settings.brand == DEFAULT && supportDynamicColor) {
Column {
SettingsDialogSectionTitle(text = stringResource(string.feature_settings_dynamic_color_preference))
SettingsDialogSectionTitle(
text = stringResource(string.feature_settings_dynamic_color_preference),
)
Column(Modifier.selectableGroup()) {
SettingsDialogThemeChooserRow(
text = stringResource(string.feature_settings_dynamic_color_yes),
@ -222,11 +221,7 @@ private fun SettingsDialogSectionTitle(text: String) {
}
@Composable
fun SettingsDialogThemeChooserRow(
text: String,
selected: Boolean,
onClick: () -> Unit,
) {
fun SettingsDialogThemeChooserRow(text: String, selected: Boolean, onClick: () -> Unit) {
Row(
Modifier
.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.Success
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.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@HiltViewModel
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.feature.settings.SettingsUiState.Loading
import com.google.samples.apps.nowinandroid.feature.settings.SettingsUiState.Success
import kotlin.test.assertEquals
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
@ -29,7 +30,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
class SettingsViewModelTest {

@ -172,18 +172,16 @@ internal fun TopicScreen(
}
}
private fun topicItemsSize(
topicUiState: TopicUiState,
newsUiState: NewsUiState,
) = when (topicUiState) {
TopicUiState.Error -> 0 // Nothing
TopicUiState.Loading -> 1 // Loading bar
is TopicUiState.Success -> when (newsUiState) {
NewsUiState.Error -> 0 // Nothing
NewsUiState.Loading -> 1 // Loading bar
is NewsUiState.Success -> 2 + newsUiState.news.size // Toolbar, header
private fun topicItemsSize(topicUiState: TopicUiState, newsUiState: NewsUiState) =
when (topicUiState) {
TopicUiState.Error -> 0 // Nothing
TopicUiState.Loading -> 1 // Loading bar
is TopicUiState.Success -> when (newsUiState) {
NewsUiState.Error -> 0 // Nothing
NewsUiState.Loading -> 1 // Loading bar
is NewsUiState.Success -> 2 + newsUiState.news.size // Toolbar, header
}
}
}
private fun LazyListScope.topicBody(
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.feature.topic.navigation.TopicArgs
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
@ -37,7 +38,6 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class TopicViewModel @Inject constructor(

@ -37,7 +37,9 @@ const val TOPIC_ROUTE = "topic_route"
internal class TopicArgs(val topicId: String) {
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 = {}) {

@ -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.util.MainDispatcherRule
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.combine
import kotlinx.coroutines.flow.first
@ -36,8 +38,6 @@ import kotlinx.datetime.Instant
import org.junit.Before
import org.junit.Rule
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

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

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

Loading…
Cancel
Save