parent
41874d73ef
commit
5b89ea4fe5
@ -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()
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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>>
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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;
|
||||
}
|
@ -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)))
|
||||
}
|
||||
}
|
@ -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 = ""
|
||||
)
|
||||
)
|
Loading…
Reference in new issue