For You initial screen

Change-Id: Ic1975802934e64b59ef151c0e2063ddeb9645690
pull/2/head
Alex Vanyo 2 years ago
parent 41874d73ef
commit 5b89ea4fe5

@ -20,6 +20,7 @@ plugins {
id 'kotlinx-serialization'
id 'jacoco'
id 'dagger.hilt.android.plugin'
alias(libs.plugins.protobuf)
}
def jacocoTestReport = tasks.create("jacocoTestReport")
@ -109,6 +110,25 @@ android {
}
}
// Setup protobuf configuration, generating lite Java and Kotlin classes
protobuf {
protoc {
artifact = libs.protobuf.protoc.get()
}
generateProtoTasks {
all().each { task ->
task.builtins {
java {
option "lite"
}
kotlin {
option "lite"
}
}
}
}
}
dependencies {
coreLibraryDesugaring libs.android.desugarJdkLibs
@ -118,11 +138,14 @@ dependencies {
implementation libs.androidx.activity.compose
implementation libs.androidx.core.ktx
implementation libs.androidx.dataStore
implementation libs.androidx.appcompat
implementation libs.androidx.hilt.navigation.compose
implementation libs.androidx.lifecycle.viewModelCompose
implementation libs.androidx.navigation.compose
implementation libs.material3
implementation libs.accompanist.flowlayout
implementation libs.accompanist.insets
implementation libs.androidx.compose.foundation.layout
@ -138,12 +161,15 @@ dependencies {
implementation libs.hilt.android
kapt libs.hilt.compiler
implementation libs.protobuf.kotlin.lite
debugImplementation libs.androidx.compose.ui.testManifest
testImplementation libs.junit4
testImplementation libs.mockk
testImplementation libs.androidx.test.core
testImplementation libs.kotlinx.coroutines.test
testImplementation libs.turbine
androidTestImplementation libs.androidx.test.espresso.core
androidTestImplementation libs.androidx.test.runner

@ -60,3 +60,6 @@
#-keepnames class <1>$$serializer { # -keepnames suffices; class is kept when serializer() is kept.
# static <1>$$serializer INSTANCE;
#}
# Enable protobuf-related optimizations.
-shrinkunusedprotofields

@ -16,15 +16,15 @@
package com.google.samples.apps.nowinandroid.ui
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.ComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.espresso.Espresso
import androidx.test.espresso.NoActivityResumedException
import org.junit.Before
import com.google.samples.apps.nowinandroid.MainActivity
import org.junit.Rule
import org.junit.Test
@ -34,18 +34,12 @@ import org.junit.Test
class NavigationTest {
/**
* Using an empty activity to have control of the content that is set.
* Use the primary activity to initialize the app normally.
*
* TODO: Bind fakes as needed to the Dagger graph to allow for easier testing
*/
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Before
fun setUp() {
// Using targetContext as the Context of the instrumentation code
composeTestRule.setContent {
NiaApp()
}
}
val composeTestRule = createAndroidComposeRule<MainActivity>()
@Test
fun firstScreenIsForYou() {
@ -166,7 +160,7 @@ class NavigationTest {
* Matches an element at the top of the For You destination. Should be updated when the
* destination is implemented.
*/
private fun ComposeTestRule.forYouDestinationTopMatcher() = onNodeWithText("FOR YOU")
private fun ComposeTestRule.forYouDestinationTopMatcher() = onNodeWithTag("FOR YOU")
/*
* Matches an element at the top of the Topics destination. Should be updated when the

@ -0,0 +1,160 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.ui.foryou
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.data.news.Topic
import org.junit.Rule
import org.junit.Test
class ForYouScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun circularProgressIndicator_whenScreenIsLoading_exists() {
composeTestRule.setContent {
ForYouScreen(
uiState = ForYouFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}
)
}
composeTestRule
.onNodeWithContentDescription(
composeTestRule.activity.resources.getString(R.string.for_you_loading)
)
.assertExists()
}
@Test
fun topicSelector_whenNoTopicsSelected_showsTopicChipsAndDisabledDoneButton() {
composeTestRule.setContent {
ForYouScreen(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
selectedTopics = listOf(
Topic(
id = 0,
name = "Headlines",
description = ""
) to false,
Topic(
id = 1,
name = "UI",
description = ""
) to false,
Topic(
id = 2,
name = "Tools",
description = ""
) to false
),
feed = emptyList()
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}
)
}
composeTestRule
.onNodeWithText("HEADLINES")
.assertIsDisplayed()
// .assertIsOff()
.assertHasClickAction()
composeTestRule
.onNodeWithText("UI")
.assertIsDisplayed()
// .assertIsOff()
.assertHasClickAction()
composeTestRule
.onNodeWithText("TOOLS")
.assertIsDisplayed()
// .assertIsOff()
.assertHasClickAction()
composeTestRule
.onNodeWithText(composeTestRule.activity.resources.getString(R.string.done))
.assertIsDisplayed()
.assertIsNotEnabled()
.assertHasClickAction()
}
@Test
fun topicSelector_whenSomeTopicsSelected_showsTopicChipsAndEnabledDoneButton() {
composeTestRule.setContent {
ForYouScreen(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
selectedTopics = listOf(
Topic(
id = 0,
name = "Headlines",
description = ""
) to false,
Topic(
id = 1,
name = "UI",
description = ""
) to true,
Topic(
id = 2,
name = "Tools",
description = ""
) to false
),
feed = emptyList()
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}
)
}
composeTestRule
.onNodeWithText("HEADLINES")
.assertIsDisplayed()
// .assertIsOff()
.assertHasClickAction()
composeTestRule
.onNodeWithText("UI")
.assertIsDisplayed()
// .assertIsOn()
.assertHasClickAction()
composeTestRule
.onNodeWithText("TOOLS")
.assertIsDisplayed()
// .assertIsOff()
.assertHasClickAction()
composeTestRule
.onNodeWithText(composeTestRule.activity.resources.getString(R.string.done))
.assertIsDisplayed()
.assertIsEnabled()
.assertHasClickAction()
}
}

@ -21,7 +21,9 @@ import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.core.view.WindowCompat
import com.google.samples.apps.nowinandroid.ui.NiaApp
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

@ -0,0 +1,49 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.data
import android.util.Log
import androidx.datastore.core.DataStore
import java.io.IOException
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.retry
class NiaPreferences @Inject constructor(
private val userPreferences: DataStore<UserPreferences>
) {
suspend fun setFollowedTopicIds(followedTopicIds: Set<Int>) {
try {
userPreferences.updateData {
it.copy {
this.followedTopicIds.clear()
this.followedTopicIds.addAll(followedTopicIds)
}
}
} catch (ioException: IOException) {
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
}
}
val followedTopicIds: Flow<Set<Int>> = userPreferences.data
.retry {
Log.e("NiaPreferences", "Failed to read user preferences", it)
true
}
.map { it.followedTopicIdsList.toSet() }
}

@ -0,0 +1,46 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.data
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.google.protobuf.InvalidProtocolBufferException
import java.io.InputStream
import java.io.OutputStream
import javax.inject.Inject
/**
* An [androidx.datastore.core.Serializer] for the [UserPreferences] proto.
*/
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
@Suppress("BlockingMethodInNonBlockingContext")
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
@Suppress("BlockingMethodInNonBlockingContext")
t.writeTo(output)
}
}

@ -21,9 +21,14 @@ import kotlinx.coroutines.flow.Flow
/**
* Data layer implementation for [NewsResource]
*/
interface NewsResourceRepository {
interface NewsRepository {
/**
* Fetches available news resources
* Returns available news resources as a stream.
*/
fun monitor(): Flow<List<NewsResource>>
fun getNewsResourcesStream(): Flow<List<NewsResource>>
/**
* Returns available news resources as a stream filtered by the topic.
*/
fun getNewsResourcesStream(filterTopicIds: Set<Int>): Flow<List<NewsResource>>
}

@ -32,16 +32,16 @@ import kotlinx.serialization.encoding.Encoder
*/
@Serializable
data class NewsResource(
val episode: Int,
val id: Int,
val episodeId: Int,
val title: String,
val content: String,
@SerialName("URL")
val url: String,
val authorName: String,
val authors: List<Int>,
@Serializable(InstantSerializer::class)
val publishDate: Instant,
val type: String,
val topics: List<String>,
val topics: List<Int>,
val alternateVideo: VideoInfo?
)

@ -0,0 +1,26 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.data.news
import kotlinx.serialization.Serializable
@Serializable
data class Topic(
val id: Int,
val name: String,
val description: String
)

@ -0,0 +1,36 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.data.news
import kotlinx.coroutines.flow.Flow
interface TopicsRepository {
/**
* Gets the available topics as a stream
*/
fun getTopicsStream(): Flow<List<Topic>>
/**
* Sets the user's currently followed topics
*/
suspend fun setFollowedTopicIds(followedTopicIds: Set<Int>)
/**
* Returns the users currently followed topics
*/
fun getFollowedTopicIdsStream(): Flow<Set<Int>>
}

@ -16,29 +16,35 @@
package com.google.samples.apps.nowinandroid.data.news.fake
import com.google.samples.apps.nowinandroid.data.news.NewsRepository
import com.google.samples.apps.nowinandroid.data.news.NewsResource
import com.google.samples.apps.nowinandroid.data.news.NewsResourceRepository
import kotlinx.coroutines.CoroutineDispatcher
import com.google.samples.apps.nowinandroid.di.NiaDispatchers
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
/**
* [NewsResourceRepository] implementation that provides static news resources to aid development
* [NewsRepository] implementation that provides static news resources to aid development
*/
class FakeNewsResourceRepository(
private val ioDispatcher: CoroutineDispatcher
) : NewsResourceRepository {
private val deserializer = Json { ignoreUnknownKeys = true }
override fun monitor(): Flow<List<NewsResource>> = flow {
emit(deserializer.decodeFromString<ResourceData>(FakeDataSource.data).resources)
class FakeNewsRepository @Inject constructor(
private val dispatchers: NiaDispatchers,
private val networkJson: Json
) : NewsRepository {
override fun getNewsResourcesStream(): Flow<List<NewsResource>> = flow {
emit(networkJson.decodeFromString<ResourceData>(FakeDataSource.data).resources)
}
.flowOn(ioDispatcher)
.flowOn(dispatchers.IO)
override fun getNewsResourcesStream(filterTopicIds: Set<Int>): Flow<List<NewsResource>> =
getNewsResourcesStream().map { newsResources ->
newsResources.filter { it.topics.intersect(filterTopicIds.toSet()).isNotEmpty() }
}
}
/**

@ -0,0 +1,44 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.data.news.fake
import com.google.samples.apps.nowinandroid.data.NiaPreferences
import com.google.samples.apps.nowinandroid.data.news.Topic
import com.google.samples.apps.nowinandroid.data.news.TopicsRepository
import com.google.samples.apps.nowinandroid.di.NiaDispatchers
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
class FakeTopicsRepository @Inject constructor(
private val dispatchers: NiaDispatchers,
private val networkJson: Json,
private val niaPreferences: NiaPreferences
) : TopicsRepository {
override fun getTopicsStream(): Flow<List<Topic>> = flow<List<Topic>> {
emit(networkJson.decodeFromString(FakeDataSource.topicsData))
}
.flowOn(dispatchers.IO)
override suspend fun setFollowedTopicIds(followedTopicIds: Set<Int>) =
niaPreferences.setFollowedTopicIds(followedTopicIds)
override fun getFollowedTopicIdsStream() = niaPreferences.followedTopicIds
}

@ -16,10 +16,57 @@
package com.google.samples.apps.nowinandroid.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.dataStoreFile
import com.google.samples.apps.nowinandroid.data.UserPreferences
import com.google.samples.apps.nowinandroid.data.UserPreferencesSerializer
import com.google.samples.apps.nowinandroid.data.news.NewsRepository
import com.google.samples.apps.nowinandroid.data.news.TopicsRepository
import com.google.samples.apps.nowinandroid.data.news.fake.FakeNewsRepository
import com.google.samples.apps.nowinandroid.data.news.fake.FakeTopicsRepository
import dagger.Binds
import dagger.Module
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
@Module
@InstallIn(SingletonComponent::class)
class AppModule
interface AppModule {
@Binds
fun bindsTopicRepository(fakeTopicsRepository: FakeTopicsRepository): TopicsRepository
@Binds
fun bindsNewsResourceRepository(
fakeNewsRepository: FakeNewsRepository
): NewsRepository
@Binds
fun bindsNiaDispatchers(defaultNiaDispatchers: DefaultNiaDispatchers): NiaDispatchers
companion object {
@Provides
@Singleton
fun providesUserPreferencesDataStore(
@ApplicationContext context: Context,
userPreferencesSerializer: UserPreferencesSerializer
): DataStore<UserPreferences> =
DataStoreFactory.create(
serializer = userPreferencesSerializer
) {
context.dataStoreFile("user_preferences.pb")
}
@Provides
@Singleton
fun providesNetworkJson(): Json = Json {
ignoreUnknownKeys = true
}
}
}

@ -0,0 +1,39 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.di
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainCoroutineDispatcher
interface NiaDispatchers {
val IO: CoroutineDispatcher
val Default: CoroutineDispatcher
val Main: MainCoroutineDispatcher
val Unconfined: CoroutineDispatcher
}
class DefaultNiaDispatchers @Inject constructor() : NiaDispatchers {
override val IO: CoroutineDispatcher = Dispatchers.IO
override val Default: CoroutineDispatcher = Dispatchers.Default
override val Main: MainCoroutineDispatcher = Dispatchers.Main
override val Unconfined: CoroutineDispatcher = Dispatchers.Unconfined
}

@ -19,10 +19,12 @@ package com.google.samples.apps.nowinandroid.ui
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.google.samples.apps.nowinandroid.ui.foryou.ForYouRoute
/**
* Top-level navigation graph. Navigation is organized as explained at
@ -42,7 +44,7 @@ fun NiaNavGraph(
startDestination = startDestination,
) {
composable(NiaDestinations.FOR_YOU_ROUTE) {
Text("FOR YOU", modifier)
ForYouRoute(modifier = modifier.testTag("FOR YOU"))
}
composable(NiaDestinations.EPISODES_ROUTE) {
Text("EPISODES", modifier)

@ -0,0 +1,229 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.ui
import android.os.Binder
import android.os.Bundle
import android.os.Parcelable
import android.util.Size
import android.util.SizeF
import android.util.SparseArray
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.SnapshotMutationPolicy
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.neverEqualPolicy
import androidx.compose.runtime.referentialEqualityPolicy
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
import androidx.compose.runtime.saveable.autoSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotMutableState
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.core.os.bundleOf
import androidx.lifecycle.SavedStateHandle
import java.io.Serializable
import kotlin.properties.PropertyDelegateProvider
import kotlin.properties.ReadOnlyProperty
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
// These are placeholder solutions for https://issuetracker.google.com/issues/195689777
// With the following, it is possible to use the Compose Saver APIs to back values in the
// SavedStateHandle to persist through process death, in a similar form as rememberSaveable
/**
* A [PropertyDelegateProvider] allowing the use of [saveable] with the key provided by the name
* of the property.
*/
fun <T : Any> SavedStateHandle.saveable(
saver: Saver<T, out Any> = autoSaver(),
init: () -> T,
): PropertyDelegateProvider<Any, ReadOnlyProperty<Any, T>> =
PropertyDelegateProvider { _, property ->
val value = saveable(
key = property.name,
saver = saver,
init = init
)
ReadOnlyProperty { _, _ -> value }
}
/**
* A nested [PropertyDelegateProvider] allowing the use of [saveable] with the key provided by the
* name, as well as direct access to the value contained with [MutableState].
*
* This allows the main usage to look almost identical to
* `rememberSaveable { mutableStateOf(...) }`:
*
* ```
* val value by savedStateHandle.saveable { mutableStateOf("initialValue") }
* ```
*/
@JvmName("saveableMutableState")
fun <T : Any> SavedStateHandle.saveable(
stateSaver: Saver<T, out Any> = autoSaver(),
init: () -> MutableState<T>,
): PropertyDelegateProvider<Any, ReadWriteProperty<Any, T>> =
PropertyDelegateProvider<Any, ReadWriteProperty<Any, T>> { _, property ->
val mutableState = saveable(
key = property.name,
stateSaver = stateSaver,
init = init
)
object : ReadWriteProperty<Any, T> {
override fun getValue(thisRef: Any, property: KProperty<*>): T =
mutableState.getValue(thisRef, property)
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) =
mutableState.setValue(thisRef, property, value)
}
}
/**
* A basic interop between [SavedStateHandle] and [Saver], so the latter can be used to save
* state holders into the [SavedStateHandle].
*
* This implementation is based on [rememberSaveable], [SaveableStateRegistry] and
* [DisposableSaveableStateRegistry], with some simplifications since there will be exactly one
* state provider storing exactly one value.
*
* This implementation makes use of [SavedStateHandle.setSavedStateProvider], so this
* state will not be kept in sync with any other way to change the internal state
* of the [SavedStateHandle].
*/
fun <T : Any> SavedStateHandle.saveable(
key: String,
saver: Saver<T, out Any> = autoSaver(),
init: () -> T,
): T {
@Suppress("UNCHECKED_CAST")
saver as Saver<T, Any>
// value is restored using the SavedStateHandle or created via [init] lambda
val value = get<Bundle?>(key)?.get("value")?.let(saver::restore) ?: init()
// Hook up saving the state to the SavedStateHandle
setSavedStateProvider(key) {
bundleOf("value" to with(saver) { SaverScope(::canBeSavedToBundle).save(value) })
}
return value
}
/**
* A basic interop between [SavedStateHandle] and [Saver], so the latter can be used to save
* state holders into the [SavedStateHandle].
*
* This implementation is based on [rememberSaveable], [SaveableStateRegistry] and
* [DisposableSaveableStateRegistry], with some simplifications since there will be exactly one
* state provider storing exactly one value.
*
* This implementation makes use of [SavedStateHandle.setSavedStateProvider], so this
* state will not be kept in sync with any other way to change the internal state
* of the [SavedStateHandle].
*
* Use this overload if you remember a mutable state with a type which can't be stored in the
* Bundle so you have to provide a custom saver object.
*/
fun <T> SavedStateHandle.saveable(
key: String,
stateSaver: Saver<T, out Any>,
init: () -> MutableState<T>
): MutableState<T> = saveable(
saver = mutableStateSaver(stateSaver),
key = key,
init = init
)
/**
* Copied from RememberSaveable.kt
*/
@Suppress("UNCHECKED_CAST")
private fun <T> mutableStateSaver(inner: Saver<T, out Any>) = with(inner as Saver<T, Any>) {
Saver<MutableState<T>, MutableState<Any?>>(
save = { state ->
require(state is SnapshotMutableState<T>) {
"If you use a custom MutableState implementation you have to write a custom " +
"Saver and pass it as a saver param to saveable()"
}
mutableStateOf(save(state.value), state.policy as SnapshotMutationPolicy<Any?>)
},
restore = @Suppress("UNCHECKED_CAST") {
require(it is SnapshotMutableState<Any?>)
mutableStateOf(
if (it.value != null) restore(it.value!!) else null,
it.policy as SnapshotMutationPolicy<T?>
) as MutableState<T>
}
)
}
/**
* Checks that [value] can be stored inside [Bundle].
*
* Copied from DisposableSaveableStateRegistry.android.kt
*/
private fun canBeSavedToBundle(value: Any): Boolean {
// SnapshotMutableStateImpl is Parcelable, but we do extra checks
if (value is SnapshotMutableState<*>) {
if (value.policy === neverEqualPolicy<Any?>() ||
value.policy === structuralEqualityPolicy<Any?>() ||
value.policy === referentialEqualityPolicy<Any?>()
) {
val stateValue = value.value
return if (stateValue == null) true else canBeSavedToBundle(stateValue)
} else {
return false
}
}
for (cl in AcceptableClasses) {
if (cl.isInstance(value)) {
return true
}
}
return false
}
/**
* Contains Classes which can be stored inside [Bundle].
*
* Some of the classes are not added separately because:
*
* This classes implement Serializable:
* - Arrays (DoubleArray, BooleanArray, IntArray, LongArray, ByteArray, FloatArray, ShortArray,
* CharArray, Array<Parcelable, Array<String>)
* - ArrayList
* - Primitives (Boolean, Int, Long, Double, Float, Byte, Short, Char) will be boxed when casted
* to Any, and all the boxed classes implements Serializable.
* This class implements Parcelable:
* - Bundle
*
* Note: it is simplified copy of the array from SavedStateHandle (lifecycle-viewmodel-savedstate).
*
* Copied from DisposableSaveableStateRegistry.android.kt
*/
private val AcceptableClasses = arrayOf(
Serializable::class.java,
Parcelable::class.java,
String::class.java,
SparseArray::class.java,
Binder::class.java,
Size::class.java,
SizeF::class.java
)

@ -0,0 +1,203 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.ui.foryou
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.google.accompanist.flowlayout.FlowRow
import com.google.samples.apps.nowinandroid.R
import com.google.samples.apps.nowinandroid.data.news.NewsResource
import com.google.samples.apps.nowinandroid.data.news.Topic
@Composable
fun ForYouRoute(
modifier: Modifier = Modifier,
viewModel: ForYouViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
ForYouScreen(
modifier = modifier,
uiState = uiState,
onTopicCheckedChanged = viewModel::updateTopicSelection,
saveFollowedTopics = viewModel::saveFollowedTopics
)
}
@Composable
fun ForYouScreen(
uiState: ForYouFeedUiState,
onTopicCheckedChanged: (Int, Boolean) -> Unit,
saveFollowedTopics: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize()) {
when (uiState) {
ForYouFeedUiState.Loading -> {
val forYouLoading = stringResource(id = R.string.for_you_loading)
CircularProgressIndicator(
modifier = Modifier
.align(Alignment.Center)
.semantics {
contentDescription = forYouLoading
},
color = MaterialTheme.colorScheme.primary
)
}
is ForYouFeedUiState.PopulatedFeed -> {
LazyColumn {
when (uiState) {
is ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection -> {
TopicSelection(uiState, onTopicCheckedChanged, saveFollowedTopics)
}
is ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection -> Unit
}
items(uiState.feed) { _: NewsResource ->
// TODO: News item
}
}
}
}
}
}
/**
* The topic selection items
*/
private fun LazyListScope.TopicSelection(
uiState: ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection,
onTopicCheckedChanged: (Int, Boolean) -> Unit,
saveFollowedTopics: () -> Unit
) {
item {
FlowRow(
mainAxisSpacing = 8.dp,
crossAxisSpacing = 8.dp,
modifier = Modifier.padding(horizontal = 40.dp)
) {
uiState.selectedTopics.forEach { (topic, isSelected) ->
key(topic.id) {
// TODO: Add toggleable semantics
OutlinedButton(
onClick = {
onTopicCheckedChanged(topic.id, !isSelected)
},
shape = RoundedCornerShape(50),
colors = if (isSelected) {
ButtonDefaults.buttonColors()
} else {
ButtonDefaults.outlinedButtonColors()
}
) {
Text(
text = topic.name.uppercase(),
)
}
}
}
}
}
item {
Button(
onClick = saveFollowedTopics,
enabled = uiState.canSaveSelectedTopics,
modifier = Modifier
.padding(horizontal = 40.dp)
.fillMaxWidth()
) {
Text(text = stringResource(R.string.done))
}
}
}
@Preview
@Composable
fun ForYouScreenLoading() {
ForYouScreen(
uiState = ForYouFeedUiState.Loading,
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}
)
}
@Preview
@Composable
fun ForYouScreenTopicSelection() {
ForYouScreen(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
selectedTopics = listOf(
Topic(
id = 0,
name = "Headlines",
description = ""
) to false,
Topic(
id = 1,
name = "UI",
description = ""
) to true,
Topic(
id = 2,
name = "Tools",
description = ""
) to false
),
feed = emptyList()
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}
)
}
@Preview
@Composable
fun PopulatedFeed() {
ForYouScreen(
uiState = ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection(
feed = emptyList()
),
onTopicCheckedChanged = { _, _ -> },
saveFollowedTopics = {}
)
}

@ -0,0 +1,199 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.ui.foryou
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.snapshots.Snapshot.Companion.withMutableSnapshot
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.samples.apps.nowinandroid.data.news.NewsRepository
import com.google.samples.apps.nowinandroid.data.news.NewsResource
import com.google.samples.apps.nowinandroid.data.news.Topic
import com.google.samples.apps.nowinandroid.data.news.TopicsRepository
import com.google.samples.apps.nowinandroid.ui.saveable
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.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@HiltViewModel
class ForYouViewModel @Inject constructor(
private val topicsRepository: TopicsRepository,
private val newsRepository: NewsRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val followedTopicsStateFlow = topicsRepository.getFollowedTopicIdsStream()
.map { followedTopics ->
if (followedTopics.isEmpty()) {
FollowedTopicsState.None
} else {
FollowedTopicsState.FollowedTopics(
topics = followedTopics
)
}
}
.stateIn(
viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = FollowedTopicsState.Unknown
)
/**
* The in-progress set of topics to be selected, persisted through process death with a
* [SavedStateHandle].
*/
private var inProgressTopicSelection by savedStateHandle.saveable {
mutableStateOf<Set<Int>>(emptySet())
}
val uiState: StateFlow<ForYouFeedUiState> = combine(
followedTopicsStateFlow,
topicsRepository.getTopicsStream(),
snapshotFlow { inProgressTopicSelection },
) { followedTopicsUserState, availableTopics, inProgressTopicSelection ->
when (followedTopicsUserState) {
// If we don't know the current selection state, just emit loading.
FollowedTopicsState.Unknown -> flowOf<ForYouFeedUiState>(ForYouFeedUiState.Loading)
// If the user has followed topics, use those followed topics to populate the feed
is FollowedTopicsState.FollowedTopics -> {
newsRepository.getNewsResourcesStream(
filterTopicIds = followedTopicsUserState.topics
)
.map { feed ->
ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection(
feed = feed
)
}
}
// If the user hasn't followed topics yet, show the topic selection, as well as a
// realtime populated feed based on those in-progress topic selections.
FollowedTopicsState.None -> {
newsRepository.getNewsResourcesStream(
filterTopicIds = inProgressTopicSelection
)
.map { feed ->
ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
selectedTopics = availableTopics.map { topic ->
topic to (topic.id in inProgressTopicSelection)
},
feed = feed
)
}
}
}
}
// Flatten the feed flows.
// As the selected topics and topic state changes, this will cancel the old feed monitoring
// and start the new one.
.flatMapLatest { it }
.stateIn(
scope = viewModelScope,
started = SharingStarted.Eagerly,
initialValue = ForYouFeedUiState.Loading
)
fun updateTopicSelection(topicId: Int, isChecked: Boolean) {
withMutableSnapshot {
inProgressTopicSelection =
// Update the in-progress selection based on whether the topic id was checked
if (isChecked) {
inProgressTopicSelection + topicId
} else {
inProgressTopicSelection - topicId
}
}
}
fun saveFollowedTopics() {
if (inProgressTopicSelection.isEmpty()) return
viewModelScope.launch {
topicsRepository.setFollowedTopicIds(inProgressTopicSelection)
}
}
}
/**
* A sealed hierarchy for the user's current followed topics state.
*/
private sealed interface FollowedTopicsState {
/**
* The current state is unknown (hasn't loaded yet)
*/
object Unknown : FollowedTopicsState
/**
* The user hasn't followed any topics yet.
*/
object None : FollowedTopicsState
/**
* The user has followed the given (non-empty) set of [topics].
*/
data class FollowedTopics(
val topics: Set<Int>,
) : FollowedTopicsState
}
/**
* A sealed hierarchy describing the for you screen state.
*/
sealed interface ForYouFeedUiState {
/**
* The screen is still loading.
*/
object Loading : ForYouFeedUiState
/**
* Loaded with a populated [feed] of [NewsResource]s.
*/
sealed interface PopulatedFeed : ForYouFeedUiState {
/**
* The list of news resources contained in this [PopulatedFeed].
*/
val feed: List<NewsResource>
/**
* The feed, along with a list of topics that can be selected.
*/
data class FeedWithTopicSelection(
val selectedTopics: List<Pair<Topic, Boolean>>,
override val feed: List<NewsResource>
) : PopulatedFeed {
val canSaveSelectedTopics: Boolean = selectedTopics.any { it.second }
}
/**
* Just the feed.
*/
data class FeedWithoutTopicSelection(
override val feed: List<NewsResource>
) : PopulatedFeed
}
}

@ -0,0 +1,24 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
syntax = "proto3";
option java_package = "com.google.samples.apps.nowinandroid.data";
option java_multiple_files = true;
message UserPreferences {
repeated int32 followed_topic_ids = 1;
}

@ -19,4 +19,6 @@
<string name="episodes">Episodes</string>
<string name="saved">Saved</string>
<string name="topics">Topics</string>
<string name="done">Done</string>
<string name="for_you_loading">Loading for you…</string>
</resources>

@ -0,0 +1,64 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.data
import androidx.datastore.core.CorruptionException
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class UserPreferencesSerializerTest {
private val userPreferencesSerializer = UserPreferencesSerializer()
@Test
fun defaultUserPreferences_isEmpty() {
assertEquals(
userPreferences {
// Default value
},
userPreferencesSerializer.defaultValue
)
}
@Test
fun writingAndReadingUserPreferences_outputsCorrectValue() = runTest {
val expectedUserPreferences = userPreferences {
followedTopicIds.add(0)
followedTopicIds.add(1)
}
val outputStream = ByteArrayOutputStream()
expectedUserPreferences.writeTo(outputStream)
val inputStream = ByteArrayInputStream(outputStream.toByteArray())
val actualUserPreferences = userPreferencesSerializer.readFrom(inputStream)
assertEquals(
expectedUserPreferences,
actualUserPreferences
)
}
@Test(expected = CorruptionException::class)
fun readingInvalidUserPreferences_throwsCorruptionException() = runTest {
userPreferencesSerializer.readFrom(ByteArrayInputStream(byteArrayOf(0)))
}
}

@ -16,28 +16,32 @@
package com.google.samples.apps.nowinandroid.data.news.fake
import com.google.samples.apps.nowinandroid.di.DefaultNiaDispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestCoroutineDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
class FakeNewsResourceRepositoryTest {
class FakeNewsRepositoryTest {
private lateinit var subject: FakeNewsResourceRepository
private lateinit var subject: FakeNewsRepository
@Before
fun setup() {
subject = FakeNewsResourceRepository(
ioDispatcher = TestCoroutineDispatcher()
subject = FakeNewsRepository(
// TODO: Create test-specific NiaDispatchers
dispatchers = DefaultNiaDispatchers(),
networkJson = Json { ignoreUnknownKeys = true }
)
}
@org.junit.Test
fun testDeserializationOfNewsResources() = runBlocking {
@Test
fun testDeserializationOfNewsResources() = runTest {
assertEquals(
FakeDataSource.sampleResource,
subject.monitor().first().first()
subject.getNewsResourcesStream().first().first()
)
}
}

@ -0,0 +1,46 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.testutil
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
/**
* A [TestRule] that initializes the main dispatcher to [dispatcher], which defaults to a
* [StandardTestDispatcher].
*/
class TestDispatcherRule(
private val dispatcher: CoroutineDispatcher = StandardTestDispatcher()
) : TestRule {
override fun apply(base: Statement, description: Description): Statement =
object : Statement() {
override fun evaluate() {
Dispatchers.setMain(dispatcher)
try {
base.evaluate()
} finally {
Dispatchers.resetMain()
}
}
}
}

@ -0,0 +1,49 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.testutil
import com.google.samples.apps.nowinandroid.data.news.NewsRepository
import com.google.samples.apps.nowinandroid.data.news.NewsResource
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.map
class TestNewsRepository : NewsRepository {
/**
* The backing hot flow for the list of topics ids for testing.
*/
private val newsResourcesFlow: MutableSharedFlow<List<NewsResource>> =
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override fun getNewsResourcesStream(): Flow<List<NewsResource>> = newsResourcesFlow
override fun getNewsResourcesStream(
filterTopicIds: Set<Int>
): Flow<List<NewsResource>> =
getNewsResourcesStream().map { newsResources ->
newsResources.filter { it.topics.intersect(filterTopicIds).isNotEmpty() }
}
/**
* A test-only API to allow controlling the list of news resources from tests.
*/
fun sendNewsResources(newsResources: List<NewsResource>) {
newsResourcesFlow.tryEmit(newsResources)
}
}

@ -0,0 +1,57 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.testutil
import com.google.samples.apps.nowinandroid.data.news.Topic
import com.google.samples.apps.nowinandroid.data.news.TopicsRepository
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
class TestTopicsRepository : TopicsRepository {
/**
* The backing hot flow for the list of followed topic ids for testing.
*/
private val _followedTopicIds: MutableSharedFlow<Set<Int>> =
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
/**
* The backing hot flow for the list of topics ids for testing.
*/
private val topicsFlow: MutableSharedFlow<List<Topic>> =
MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override fun getTopicsStream(): Flow<List<Topic>> = topicsFlow
override suspend fun setFollowedTopicIds(followedTopicIds: Set<Int>) {
_followedTopicIds.tryEmit(followedTopicIds)
}
override fun getFollowedTopicIdsStream(): Flow<Set<Int>> = _followedTopicIds
/**
* A test-only API to allow controlling the list of topics from tests.
*/
fun sendTopics(topics: List<Topic>) {
topicsFlow.tryEmit(topics)
}
/**
* A test-only API to allow querying the current followed topics.
*/
fun getCurrentFollowedTopics(): Set<Int>? = _followedTopicIds.replayCache.firstOrNull()
}

@ -0,0 +1,266 @@
/*
* Copyright 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.ui.foryou
import androidx.lifecycle.SavedStateHandle
import app.cash.turbine.test
import com.google.samples.apps.nowinandroid.data.news.Topic
import com.google.samples.apps.nowinandroid.testutil.TestDispatcherRule
import com.google.samples.apps.nowinandroid.testutil.TestNewsRepository
import com.google.samples.apps.nowinandroid.testutil.TestTopicsRepository
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class ForYouViewModelTest {
@get:Rule
val dispatcherRule = TestDispatcherRule()
private val topicsRepository = TestTopicsRepository()
private val newsRepository = TestNewsRepository()
private lateinit var viewModel: ForYouViewModel
@Before
fun setup() {
viewModel = ForYouViewModel(
topicsRepository = topicsRepository,
newsRepository = newsRepository,
savedStateHandle = SavedStateHandle()
)
}
@Test
fun stateIsInitiallyLoading() = runTest {
viewModel.uiState.test {
assertEquals(ForYouFeedUiState.Loading, awaitItem())
cancel()
}
}
@Test
fun stateIsLoadingWhenFollowedTopicsAreLoading() = runTest {
viewModel.uiState.test {
assertEquals(ForYouFeedUiState.Loading, awaitItem())
topicsRepository.sendTopics(sampleTopics)
cancel()
}
}
@Test
fun stateIsLoadingWhenTopicsAreLoading() = runTest {
viewModel.uiState.test {
assertEquals(ForYouFeedUiState.Loading, awaitItem())
topicsRepository.setFollowedTopicIds(emptySet())
cancel()
}
}
@Test
fun stateIsLoadingWhenNewsResourcesAreLoading() = runTest {
viewModel.uiState.test {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(emptySet())
cancel()
}
}
@Test
fun stateIsTopicSelectionAfterLoadingEmptyFollowedTopics() = runTest {
viewModel.uiState
.test {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(emptySet())
newsRepository.sendNewsResources(emptyList())
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
selectedTopics = listOf(
Topic(
id = 0,
name = "Headlines",
description = ""
) to false,
Topic(
id = 1,
name = "UI",
description = ""
) to false,
Topic(
id = 2,
name = "Tools",
description = ""
) to false
),
feed = emptyList()
),
awaitItem()
)
cancel()
}
}
@Test
fun stateIsWithoutTopicSelectionAfterLoadingFollowedTopics() = runTest {
viewModel.uiState
.test {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(setOf(0, 1))
newsRepository.sendNewsResources(emptyList())
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection(
feed = emptyList()
),
awaitItem()
)
cancel()
}
}
@Test
fun topicSelectionUpdatesAfterSelectingTopic() = runTest {
viewModel.uiState
.test {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(emptySet())
newsRepository.sendNewsResources(emptyList())
awaitItem()
viewModel.updateTopicSelection(1, isChecked = true)
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
selectedTopics = listOf(
Topic(
id = 0,
name = "Headlines",
description = ""
) to false,
Topic(
id = 1,
name = "UI",
description = ""
) to true,
Topic(
id = 2,
name = "Tools",
description = ""
) to false
),
feed = emptyList()
),
awaitItem()
)
cancel()
}
}
@Test
fun topicSelectionUpdatesAfterUnselectingTopic() = runTest {
viewModel.uiState
.test {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(emptySet())
newsRepository.sendNewsResources(emptyList())
awaitItem()
viewModel.updateTopicSelection(1, isChecked = true)
awaitItem()
viewModel.updateTopicSelection(1, isChecked = false)
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithTopicSelection(
selectedTopics = listOf(
Topic(
id = 0,
name = "Headlines",
description = ""
) to false,
Topic(
id = 1,
name = "UI",
description = ""
) to false,
Topic(
id = 2,
name = "Tools",
description = ""
) to false
),
feed = emptyList()
),
awaitItem()
)
cancel()
}
}
@Test
fun topicSelectionUpdatesAfterSavingTopics() = runTest {
viewModel.uiState
.test {
awaitItem()
topicsRepository.sendTopics(sampleTopics)
topicsRepository.setFollowedTopicIds(emptySet())
newsRepository.sendNewsResources(emptyList())
awaitItem()
viewModel.updateTopicSelection(1, isChecked = true)
awaitItem()
viewModel.saveFollowedTopics()
assertEquals(
ForYouFeedUiState.PopulatedFeed.FeedWithoutTopicSelection(
feed = emptyList()
),
awaitItem()
)
assertEquals(setOf(1), topicsRepository.getCurrentFollowedTopics())
cancel()
}
}
}
private val sampleTopics = listOf(
Topic(
id = 0,
name = "Headlines",
description = ""
),
Topic(
id = 1,
name = "UI",
description = ""
),
Topic(
id = 2,
name = "Tools",
description = ""
)
)

@ -5,9 +5,11 @@ androidGradlePlugin = "7.0.3"
androidxActivity = "1.4.0"
androidxAppCompat = "1.3.0"
androidxCompose = "1.1.0-beta04"
androidxComposeMaterial3 = "1.0.0-alpha01"
androidxComposeMaterial3 = "1.0.0-alpha03"
androidxCore = "1.7.0"
androidxDataStore = "1.0.0"
androidxEspresso = "3.3.0"
androidxHiltNavigationCompose = "1.0.0-rc01"
androidxLifecycle = "2.4.0"
androidxNavigation = "2.4.0-rc01"
androidxTest = "1.4.0"
@ -16,16 +18,20 @@ hilt = "2.40.5"
jacoco = "0.8.7"
junit4 = "4.13"
kotlin = "1.6.0"
kotlinxCoroutines = "1.5.2"
kotlinxCoroutinesTest = "1.5.2"
kotlinxCoroutines = "1.6.0"
kotlinxCoroutinesTest = "1.6.0"
kotlinxDatetime = "0.3.1"
kotlinxSerializationJson = "1.3.1"
ktlint = "0.43.0"
material3 = "1.5.0-alpha05"
mockk = "1.12.1"
protobuf = "3.19.1"
protobufPlugin = "0.8.18"
spotless = "6.0.0"
turbine = "0.7.0"
[libraries]
accompanist-flowlayout = { group = "com.google.accompanist", name = "accompanist-flowlayout", version.ref = "accompanist" }
accompanist-insets = { group = "com.google.accompanist", name = "accompanist-insets", version.ref = "accompanist" }
android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" }
android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" }
@ -42,6 +48,8 @@ androidx-compose-ui-testManifest = { group = "androidx.compose.ui", name = "ui-t
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "androidxCompose" }
androidx-compose-ui-util = { group = "androidx.compose.ui", name = "ui-util", version.ref = "androidxCompose" }
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidxCore" }
androidx-dataStore = { group = "androidx.datastore", name = "datastore", version.ref = "androidxDataStore" }
androidx-hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "androidxHiltNavigationCompose" }
androidx-lifecycle-viewModelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }
androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "androidxNavigation" }
androidx-test-core = { group = "androidx.test", name = "core", version.ref = "androidxTest" }
@ -60,6 +68,10 @@ kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime",
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
material3 = { group = "com.google.android.material", name = "material", version.ref = "material3" }
mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" }
protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" }
protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" }
turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" }
[plugins]
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }

Loading…
Cancel
Save