Implement multiplatform settings

pull/1323/head
lihenggui 2 years ago
parent 015cf5ad29
commit 4f0c2e7b92

@ -53,7 +53,9 @@ internal fun Project.configureKotlinMultiplatform() {
}
// tier 1
linuxX64()
// :core:datastore:linuxMain: Could not resolve com.russhwolf:multiplatform-settings-no-arg:1.1.1.
// https://github.com/russhwolf/multiplatform-settings/issues/113
// linuxX64()
macosX64()
macosArm64()
iosSimulatorArm64()
@ -82,8 +84,8 @@ internal fun Project.configureKotlinMultiplatform() {
// linking fails for the linux test build if not built on a linux host
// ensure the tests and linking for them is only done on linux hosts
project.tasks.named("linuxX64Test") { enabled = HostManager.hostIsLinux }
project.tasks.named("linkDebugTestLinuxX64") { enabled = HostManager.hostIsLinux }
// project.tasks.named("linuxX64Test") { enabled = HostManager.hostIsLinux }
// project.tasks.named("linkDebugTestLinuxX64") { enabled = HostManager.hostIsLinux }
// Suppress 'expect'/'actual' classes are in Beta.
targets.configureEach {

@ -24,7 +24,6 @@ import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.db.SqlSchema
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Inject
import me.tatarka.inject.annotations.Provides
@Component

@ -23,7 +23,6 @@ import com.google.samples.apps.nowinandroid.core.database.dao.RecentSearchQueryD
import com.google.samples.apps.nowinandroid.core.database.dao.TopicDao
import com.google.samples.apps.nowinandroid.core.database.dao.TopicFtsDao
import com.google.samples.apps.nowinandroid.core.di.IODispatcher
import kotlinx.coroutines.Dispatchers
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides

@ -17,6 +17,7 @@
plugins {
alias(libs.plugins.nowinandroid.kmp.library)
alias(libs.plugins.protobuf)
id("kotlinx-serialization")
}
android {
@ -31,9 +32,6 @@ protobuf {
generateProtoTasks {
all().forEach { task ->
task.builtins {
register("java") {
option("lite")
}
register("kotlin") {
option("lite")
}
@ -42,18 +40,11 @@ protobuf {
}
}
androidComponents.beforeVariants {
android.sourceSets.register(it.name) {
val buildDir = layout.buildDirectory.get().asFile
java.srcDir(buildDir.resolve("generated/source/proto/${it.name}/java"))
kotlin.srcDir(buildDir.resolve("generated/source/proto/${it.name}/kotlin"))
}
}
kotlin {
sourceSets {
commonMain.dependencies {
api(libs.protobuf.kotlin.lite)
implementation(libs.kotlinx.serialization.core)
}
}
}

@ -0,0 +1,24 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.datastore
enum class DarkThemeConfigProto {
DARK_THEME_CONFIG_UNSPECIFIED,
DARK_THEME_CONFIG_FOLLOW_SYSTEM,
DARK_THEME_CONFIG_LIGHT,
DARK_THEME_CONFIG_DARK,
}

@ -0,0 +1,23 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.datastore
enum class ThemeBrandProto {
THEME_BRAND_UNSPECIFIED,
THEME_BRAND_DEFAULT,
THEME_BRAND_ANDROID,
}

@ -0,0 +1,53 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.datastore
import com.google.samples.apps.nowinandroid.core.datastore.DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM
import com.google.samples.apps.nowinandroid.core.datastore.ThemeBrandProto.THEME_BRAND_UNSPECIFIED
import kotlinx.serialization.Serializable
// A lot of workaround brought by Proto
@Serializable
data class UserPreferences(
val topicChangeListVersion: Int,
val authorChangeListVersion: Int,
val newsResourceChangeListVersion: Int,
val hasDoneIntToStringIdMigration: Boolean,
val hasDoneListToMapMigration: Boolean,
val followedTopicIds: Set<String> = emptySet(),
val followedAuthorIds: Set<String> = emptySet(),
val bookmarkedNewsResourceIds: Set<String> = emptySet(),
val viewedNewsResourceIds: Set<String> = emptySet(),
val themeBrand: ThemeBrandProto,
val darkThemeConfig: DarkThemeConfigProto,
val shouldHideOnboarding: Boolean,
val useDynamicColor: Boolean,
) {
companion object {
val DEFAULT = UserPreferences(
topicChangeListVersion = 0,
authorChangeListVersion = 0,
newsResourceChangeListVersion = 0,
hasDoneIntToStringIdMigration = false,
hasDoneListToMapMigration = false,
themeBrand = THEME_BRAND_UNSPECIFIED,
darkThemeConfig = DARK_THEME_CONFIG_FOLLOW_SYSTEM,
shouldHideOnboarding = false,
useDynamicColor = false,
)
}
}

@ -39,17 +39,15 @@ kotlin {
implementation(libs.multiplatform.settings.serialization)
implementation(libs.multiplatform.settings.coroutines)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.core)
implementation(projects.core.model)
implementation(projects.core.common)
implementation(projects.core.datastoreProto)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotlinx.coroutines.test)
}
androidMain.dependencies {
implementation(libs.androidx.datastore.core)
implementation(libs.androidx.datastore.preferences)
implementation(libs.multiplatform.settings.datastore)
implementation(libs.multiplatform.settings.test)
}
}
}

@ -1,37 +0,0 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.datastore.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import com.google.samples.apps.nowinandroid.core.datastore.UserPreferencesSerializer
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides
@Component
abstract class DataStoreComponent {
private val Context.dataStore by preferencesDataStore("user_preferences")
@Provides
fun providesDataStore(context: Context): DataStore<Preferences> {
return context.dataStore
}
}

@ -1,36 +0,0 @@
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.samples.apps.nowinandroid.core.datastore.di
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import com.russhwolf.settings.ExperimentalSettingsApi
import com.russhwolf.settings.coroutines.FlowSettings
import com.russhwolf.settings.datastore.DataStoreSettings
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides
@Component
actual abstract class SettingsComponent {
@OptIn(ExperimentalSettingsApi::class)
@Provides
actual fun providesFlowSettings(
dataStore: DataStore<Preferences>
): FlowSettings {
return DataStoreSettings()
}
}

@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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
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.
-->
<manifest />

@ -1,50 +0,0 @@
/*
* 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.core.datastore
import androidx.datastore.core.DataMigration
/**
* Migrates saved ids from [Int] to [String] types
*/
internal object IntToStringIdsMigration : DataMigration<UserPreferences> {
override suspend fun cleanUp() = Unit
override suspend fun migrate(currentData: UserPreferences): UserPreferences =
currentData.copy {
// Migrate topic ids
deprecatedFollowedTopicIds.clear()
deprecatedFollowedTopicIds.addAll(
currentData.deprecatedIntFollowedTopicIdsList.map(Int::toString),
)
deprecatedIntFollowedTopicIds.clear()
// Migrate author ids
deprecatedFollowedAuthorIds.clear()
deprecatedFollowedAuthorIds.addAll(
currentData.deprecatedIntFollowedAuthorIdsList.map(Int::toString),
)
deprecatedIntFollowedAuthorIds.clear()
// Mark migration as complete
hasDoneIntToStringIdMigration = true
}
override suspend fun shouldMigrate(currentData: UserPreferences): Boolean =
!currentData.hasDoneIntToStringIdMigration
}

@ -1,57 +0,0 @@
/*
* 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.core.datastore
import androidx.datastore.core.DataMigration
/**
* Migrates from using lists to maps for user data.
*/
internal object ListToMapMigration : DataMigration<UserPreferences> {
override suspend fun cleanUp() = Unit
override suspend fun migrate(currentData: UserPreferences): UserPreferences =
currentData.copy {
// Migrate topic id lists
followedTopicIds.clear()
followedTopicIds.putAll(
currentData.deprecatedFollowedTopicIdsList.associateWith { true },
)
deprecatedFollowedTopicIds.clear()
// Migrate author ids
followedAuthorIds.clear()
followedAuthorIds.putAll(
currentData.deprecatedFollowedAuthorIdsList.associateWith { true },
)
deprecatedFollowedAuthorIds.clear()
// Migrate bookmarks
bookmarkedNewsResourceIds.clear()
bookmarkedNewsResourceIds.putAll(
currentData.deprecatedBookmarkedNewsResourceIdsList.associateWith { true },
)
deprecatedBookmarkedNewsResourceIds.clear()
// Mark migration as complete
hasDoneListToMapMigration = true
}
override suspend fun shouldMigrate(currentData: UserPreferences): Boolean =
!currentData.hasDoneListToMapMigration
}

@ -16,193 +16,199 @@
package com.google.samples.apps.nowinandroid.core.datastore
import android.util.Log
import androidx.datastore.core.DataStore
import com.google.samples.apps.nowinandroid.core.di.IODispatcher
import com.google.samples.apps.nowinandroid.core.model.data.DarkThemeConfig
import com.google.samples.apps.nowinandroid.core.model.data.ThemeBrand
import com.google.samples.apps.nowinandroid.core.model.data.UserData
import com.russhwolf.settings.ExperimentalSettingsApi
import com.russhwolf.settings.ObservableSettings
import com.russhwolf.settings.Settings
import com.russhwolf.settings.coroutines.FlowSettings
import com.russhwolf.settings.coroutines.toFlowSettings
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map
import java.io.IOException
import javax.inject.Inject
@OptIn(ExperimentalSettingsApi::class)
class NiaPreferencesDataSource constructor(
private val settings: FlowSettings,
import com.russhwolf.settings.serialization.decodeValue
import com.russhwolf.settings.serialization.decodeValueOrNull
import com.russhwolf.settings.serialization.encodeValue
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
private const val USER_DATA_KEY = "userData"
@OptIn(ExperimentalSerializationApi::class, ExperimentalSettingsApi::class)
class NiaPreferencesDataSource(
private val settings: Settings,
private val dispatcher: IODispatcher,
) {
// FlowSettings did not support JS, use a workaround instead
// https://github.com/russhwolf/multiplatform-settings/issues/139
val userData = MutableStateFlow(
settings.decodeValue(
key = USER_DATA_KEY,
serializer = UserPreferences.serializer(),
defaultValue = settings.decodeValueOrNull(
key = USER_DATA_KEY,
serializer = UserPreferences.serializer(),
) ?: UserPreferences.DEFAULT,
),
)
suspend fun setFollowedTopicIds(topicIds: Set<String>) = withContext(dispatcher) {
val preference = settings.getUserPreference()
.copy(followedTopicIds = topicIds)
.updateShouldHideOnboardingIfNecessary()
settings.putUserPreference(preference)
userData.value = preference
}
val userData = userPreferences.data
.map {
UserData(
bookmarkedNewsResources = it.bookmarkedNewsResourceIdsMap.keys,
viewedNewsResources = it.viewedNewsResourceIdsMap.keys,
followedTopics = it.followedTopicIdsMap.keys,
themeBrand = when (it.themeBrand) {
null,
ThemeBrandProto.THEME_BRAND_UNSPECIFIED,
ThemeBrandProto.UNRECOGNIZED,
ThemeBrandProto.THEME_BRAND_DEFAULT,
-> ThemeBrand.DEFAULT
ThemeBrandProto.THEME_BRAND_ANDROID -> ThemeBrand.ANDROID
},
darkThemeConfig = when (it.darkThemeConfig) {
null,
DarkThemeConfigProto.DARK_THEME_CONFIG_UNSPECIFIED,
DarkThemeConfigProto.UNRECOGNIZED,
DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM,
->
DarkThemeConfig.FOLLOW_SYSTEM
DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT ->
DarkThemeConfig.LIGHT
DarkThemeConfigProto.DARK_THEME_CONFIG_DARK -> DarkThemeConfig.DARK
suspend fun setTopicIdFollowed(topicId: String, followed: Boolean) = withContext(dispatcher) {
val preference = settings.getUserPreference()
val newPreference = preference
.copy(
followedTopicIds = if (followed) {
preference.followedTopicIds + topicId
} else {
preference.followedTopicIds - topicId
},
useDynamicColor = it.useDynamicColor,
shouldHideOnboarding = it.shouldHideOnboarding,
)
}
suspend fun setFollowedTopicIds(topicIds: Set<String>) {
try {
userPreferences.updateData {
it.copy {
followedTopicIds.clear()
followedTopicIds.putAll(topicIds.associateWith { true })
updateShouldHideOnboardingIfNecessary()
}
}
} catch (ioException: IOException) {
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
}
.updateShouldHideOnboardingIfNecessary()
settings.putUserPreference(newPreference)
userData.value = newPreference
}
suspend fun setTopicIdFollowed(topicId: String, followed: Boolean) {
try {
userPreferences.updateData {
it.copy {
if (followed) {
followedTopicIds.put(topicId, true)
} else {
followedTopicIds.remove(topicId)
}
updateShouldHideOnboardingIfNecessary()
}
}
} catch (ioException: IOException) {
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
}
suspend fun setThemeBrand(themeBrand: ThemeBrand) = withContext(dispatcher) {
val newPreference = settings.getUserPreference()
.copy(themeBrand = themeBrand.toThemeBrandProto())
settings.putUserPreference(newPreference)
userData.value = newPreference
}
suspend fun setThemeBrand(themeBrand: ThemeBrand) {
userPreferences.updateData {
it.copy {
this.themeBrand = when (themeBrand) {
ThemeBrand.DEFAULT -> ThemeBrandProto.THEME_BRAND_DEFAULT
ThemeBrand.ANDROID -> ThemeBrandProto.THEME_BRAND_ANDROID
}
}
}
suspend fun setDynamicColorPreference(useDynamicColor: Boolean) = withContext(dispatcher) {
val newPreference = settings.getUserPreference()
.copy(useDynamicColor = useDynamicColor)
settings.putUserPreference(newPreference)
userData.value = newPreference
}
suspend fun setDynamicColorPreference(useDynamicColor: Boolean) {
userPreferences.updateData {
it.copy { this.useDynamicColor = useDynamicColor }
}
suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) = withContext(dispatcher) {
val newPreference = settings.getUserPreference()
.copy(darkThemeConfig = darkThemeConfig.toDarkThemeConfigProto())
settings.putUserPreference(newPreference)
userData.value = newPreference
}
suspend fun setDarkThemeConfig(darkThemeConfig: DarkThemeConfig) {
userPreferences.updateData {
it.copy {
this.darkThemeConfig = when (darkThemeConfig) {
DarkThemeConfig.FOLLOW_SYSTEM ->
DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM
DarkThemeConfig.LIGHT -> DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT
DarkThemeConfig.DARK -> DarkThemeConfigProto.DARK_THEME_CONFIG_DARK
}
}
}
}
suspend fun setNewsResourceBookmarked(newsResourceId: String, bookmarked: Boolean) {
try {
userPreferences.updateData {
it.copy {
if (bookmarked) {
bookmarkedNewsResourceIds.put(newsResourceId, true)
suspend fun setNewsResourceBookmarked(newsResourceId: String, bookmarked: Boolean) =
withContext(dispatcher) {
val preference = settings.getUserPreference()
val newPreferences = preference
.copy(
bookmarkedNewsResourceIds = if (bookmarked) {
preference.bookmarkedNewsResourceIds + newsResourceId
} else {
bookmarkedNewsResourceIds.remove(newsResourceId)
}
}
}
} catch (ioException: IOException) {
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
preference.bookmarkedNewsResourceIds - newsResourceId
},
)
settings.putUserPreference(newPreferences)
userData.value = newPreferences
}
}
suspend fun setNewsResourceViewed(newsResourceId: String, viewed: Boolean) {
setNewsResourcesViewed(listOf(newsResourceId), viewed)
}
suspend fun setNewsResourcesViewed(newsResourceIds: List<String>, viewed: Boolean) {
userPreferences.updateData { prefs ->
prefs.copy {
newsResourceIds.forEach { id ->
if (viewed) {
viewedNewsResourceIds.put(id, true)
suspend fun setNewsResourcesViewed(newsResourceIds: List<String>, viewed: Boolean) =
withContext(dispatcher) {
val preference = settings.getUserPreference()
val newPreferences = preference
.copy(
viewedNewsResourceIds = if (viewed) {
preference.viewedNewsResourceIds + newsResourceIds
} else {
viewedNewsResourceIds.remove(id)
}
}
}
preference.viewedNewsResourceIds - newsResourceIds.toSet()
},
)
settings.putUserPreference(newPreferences)
userData.value = newPreferences
}
}
suspend fun getChangeListVersions() = userPreferences.data
.map {
ChangeListVersions(
topicVersion = it.topicChangeListVersion,
newsResourceVersion = it.newsResourceChangeListVersion,
)
}
.firstOrNull() ?: ChangeListVersions()
suspend fun getChangeListVersions(): ChangeListVersions = withContext(dispatcher) {
val preferences = settings.getUserPreference()
return@withContext ChangeListVersions(
topicVersion = preferences.topicChangeListVersion,
newsResourceVersion = preferences.newsResourceChangeListVersion,
)
}
/**
* Update the [ChangeListVersions] using [update].
*/
suspend fun updateChangeListVersion(update: ChangeListVersions.() -> ChangeListVersions) {
try {
userPreferences.updateData { currentPreferences ->
val updatedChangeListVersions = update(
ChangeListVersions(
topicVersion = currentPreferences.topicChangeListVersion,
newsResourceVersion = currentPreferences.newsResourceChangeListVersion,
),
)
currentPreferences.copy {
topicChangeListVersion = updatedChangeListVersions.topicVersion
newsResourceChangeListVersion = updatedChangeListVersions.newsResourceVersion
}
}
} catch (ioException: IOException) {
Log.e("NiaPreferences", "Failed to update user preferences", ioException)
suspend fun updateChangeListVersion(update: ChangeListVersions.() -> ChangeListVersions) =
withContext(dispatcher) {
val currentPreferences = settings.getUserPreference()
val updatedChangeListVersions = update(
ChangeListVersions(
topicVersion = currentPreferences.topicChangeListVersion,
newsResourceVersion = currentPreferences.newsResourceChangeListVersion,
),
)
val updatedPreference = currentPreferences.copy(
topicChangeListVersion = updatedChangeListVersions.topicVersion,
newsResourceChangeListVersion = updatedChangeListVersions.newsResourceVersion,
)
settings.putUserPreference(updatedPreference)
userData.value = updatedPreference
}
suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) = withContext(dispatcher) {
val newPreference = settings.getUserPreference()
.copy(shouldHideOnboarding = shouldHideOnboarding)
settings.putUserPreference(newPreference)
userData.value = newPreference
}
}
suspend fun setShouldHideOnboarding(shouldHideOnboarding: Boolean) {
userPreferences.updateData {
it.copy { this.shouldHideOnboarding = shouldHideOnboarding }
}
private fun UserPreferences.updateShouldHideOnboardingIfNecessary(): UserPreferences {
return if (followedTopicIds.isEmpty() && followedAuthorIds.isEmpty()) {
this.copy(shouldHideOnboarding = false)
} else {
this
}
}
@OptIn(ExperimentalSerializationApi::class, ExperimentalSettingsApi::class)
private fun Settings.putUserPreference(preference: UserPreferences) {
encodeValue(
key = USER_DATA_KEY,
serializer = UserPreferences.serializer(),
value = preference,
)
}
@OptIn(ExperimentalSerializationApi::class, ExperimentalSettingsApi::class)
private fun Settings.getUserPreference(): UserPreferences {
return decodeValue(
key = USER_DATA_KEY,
serializer = UserPreferences.serializer(),
defaultValue = UserPreferences.DEFAULT,
)
}
fun ThemeBrandProto.toThemeBrand(): ThemeBrand {
return when (this) {
ThemeBrandProto.THEME_BRAND_UNSPECIFIED,
ThemeBrandProto.THEME_BRAND_DEFAULT,
-> ThemeBrand.DEFAULT
ThemeBrandProto.THEME_BRAND_ANDROID -> ThemeBrand.ANDROID
}
}
private fun ThemeBrand.toThemeBrandProto(): ThemeBrandProto {
return when (this) {
ThemeBrand.DEFAULT -> ThemeBrandProto.THEME_BRAND_DEFAULT
ThemeBrand.ANDROID -> ThemeBrandProto.THEME_BRAND_ANDROID
}
}
private fun UserPreferencesKt.Dsl.updateShouldHideOnboardingIfNecessary() {
if (followedTopicIds.isEmpty() && followedAuthorIds.isEmpty()) {
shouldHideOnboarding = false
private fun DarkThemeConfig.toDarkThemeConfigProto(): DarkThemeConfigProto {
return when (this) {
DarkThemeConfig.FOLLOW_SYSTEM -> DarkThemeConfigProto.DARK_THEME_CONFIG_FOLLOW_SYSTEM
DarkThemeConfig.DARK -> DarkThemeConfigProto.DARK_THEME_CONFIG_DARK
DarkThemeConfig.LIGHT -> DarkThemeConfigProto.DARK_THEME_CONFIG_LIGHT
}
}

@ -1,44 +0,0 @@
/*
* 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.core.datastore
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
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
t.writeTo(output)
}
}

@ -16,12 +16,12 @@
package com.google.samples.apps.nowinandroid.core.datastore.di
import com.russhwolf.settings.ExperimentalSettingsApi
import com.russhwolf.settings.coroutines.FlowSettings
import com.russhwolf.settings.Settings
import me.tatarka.inject.annotations.Component
import me.tatarka.inject.annotations.Provides
expect abstract class SettingsComponent {
@OptIn(ExperimentalSettingsApi::class)
@Component
abstract class SettingsComponent {
@Provides
fun providesFlowSettings(): FlowSettings
}
fun providesSettings(): Settings = Settings()
}

@ -1,86 +0,0 @@
/*
* 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.core.datastore
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Unit test for [IntToStringIdsMigration]
*/
class IntToStringIdsMigrationTest {
@Test
fun IntToStringIdsMigration_should_migrate_topic_ids() = runTest {
// Set up existing preferences with topic int ids
val preMigrationUserPreferences = userPreferences {
deprecatedIntFollowedTopicIds.addAll(listOf(1, 2, 3))
}
// Assert that there are no string topic ids yet
assertEquals(
emptyList<String>(),
preMigrationUserPreferences.deprecatedFollowedTopicIdsList,
)
// Run the migration
val postMigrationUserPreferences =
IntToStringIdsMigration.migrate(preMigrationUserPreferences)
// Assert the deprecated int topic ids have been migrated to the string topic ids
assertEquals(
userPreferences {
deprecatedFollowedTopicIds.addAll(listOf("1", "2", "3"))
hasDoneIntToStringIdMigration = true
},
postMigrationUserPreferences,
)
// Assert that the migration has been marked complete
assertTrue(postMigrationUserPreferences.hasDoneIntToStringIdMigration)
}
@Test
fun IntToStringIdsMigration_should_migrate_author_ids() = runTest {
// Set up existing preferences with author int ids
val preMigrationUserPreferences = userPreferences {
deprecatedIntFollowedAuthorIds.addAll(listOf(4, 5, 6))
}
// Assert that there are no string author ids yet
assertEquals(
emptyList<String>(),
preMigrationUserPreferences.deprecatedFollowedAuthorIdsList,
)
// Run the migration
val postMigrationUserPreferences =
IntToStringIdsMigration.migrate(preMigrationUserPreferences)
// Assert the deprecated int author ids have been migrated to the string author ids
assertEquals(
userPreferences {
deprecatedFollowedAuthorIds.addAll(listOf("4", "5", "6"))
hasDoneIntToStringIdMigration = true
},
postMigrationUserPreferences,
)
// Assert that the migration has been marked complete
assertTrue(postMigrationUserPreferences.hasDoneIntToStringIdMigration)
}
}

@ -1,103 +0,0 @@
/*
* 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.core.datastore
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ListToMapMigrationTest {
@Test
fun ListToMapMigration_should_migrate_topic_ids() = runTest {
// Set up existing preferences with topic ids
val preMigrationUserPreferences = userPreferences {
deprecatedFollowedTopicIds.addAll(listOf("1", "2", "3"))
}
// Assert that there are no topic ids in the map yet
assertEquals(
emptyMap<String, Boolean>(),
preMigrationUserPreferences.followedTopicIdsMap,
)
// Run the migration
val postMigrationUserPreferences =
ListToMapMigration.migrate(preMigrationUserPreferences)
// Assert the deprecated topic ids have been migrated to the topic ids map
assertEquals(
mapOf("1" to true, "2" to true, "3" to true),
postMigrationUserPreferences.followedTopicIdsMap,
)
// Assert that the migration has been marked complete
assertTrue(postMigrationUserPreferences.hasDoneListToMapMigration)
}
@Test
fun ListToMapMigration_should_migrate_author_ids() = runTest {
// Set up existing preferences with author ids
val preMigrationUserPreferences = userPreferences {
deprecatedFollowedAuthorIds.addAll(listOf("4", "5", "6"))
}
// Assert that there are no author ids in the map yet
assertEquals(
emptyMap<String, Boolean>(),
preMigrationUserPreferences.followedAuthorIdsMap,
)
// Run the migration
val postMigrationUserPreferences =
ListToMapMigration.migrate(preMigrationUserPreferences)
// Assert the deprecated author ids have been migrated to the author ids map
assertEquals(
mapOf("4" to true, "5" to true, "6" to true),
postMigrationUserPreferences.followedAuthorIdsMap,
)
// Assert that the migration has been marked complete
assertTrue(postMigrationUserPreferences.hasDoneListToMapMigration)
}
@Test
fun ListToMapMigration_should_migrate_bookmarks() = runTest {
// Set up existing preferences with bookmarks
val preMigrationUserPreferences = userPreferences {
deprecatedBookmarkedNewsResourceIds.addAll(listOf("7", "8", "9"))
}
// Assert that there are no bookmarks in the map yet
assertEquals(
emptyMap<String, Boolean>(),
preMigrationUserPreferences.bookmarkedNewsResourceIdsMap,
)
// Run the migration
val postMigrationUserPreferences =
ListToMapMigration.migrate(preMigrationUserPreferences)
// Assert the deprecated bookmarks have been migrated to the bookmarks map
assertEquals(
mapOf("7" to true, "8" to true, "9" to true),
postMigrationUserPreferences.bookmarkedNewsResourceIdsMap,
)
// Assert that the migration has been marked complete
assertTrue(postMigrationUserPreferences.hasDoneListToMapMigration)
}
}

@ -16,31 +16,30 @@
package com.google.samples.apps.nowinandroid.core.datastore
import com.google.samples.apps.nowinandroid.core.datastore.test.testUserPreferencesDataStore
import com.russhwolf.settings.MapSettings
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class NiaPreferencesDataSourceTest {
@OptIn(ExperimentalCoroutinesApi::class)
private val testScope = TestScope(UnconfinedTestDispatcher())
private lateinit var subject: NiaPreferencesDataSource
@get:Rule
val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
@Before
@BeforeTest
fun setup() {
subject = NiaPreferencesDataSource(
tmpFolder.testUserPreferencesDataStore(testScope),
settings = MapSettings(),
dispatcher = Dispatchers.Unconfined,
)
}
@ -84,7 +83,8 @@ class NiaPreferencesDataSourceTest {
}
@Test
fun shouldUseDynamicColorFalseByDefault() = testScope.runTest {
fun shouldUseDynamicColorFalseWhenSet() = testScope.runTest {
subject.setDynamicColorPreference(false)
assertFalse(subject.userData.first().useDynamicColor)
}

@ -1,64 +0,0 @@
/*
* 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.core.datastore
import androidx.datastore.core.CorruptionException
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import kotlin.test.assertEquals
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.put("0", true)
followedTopicIds.put("1", true)
}
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)))
}
}

@ -136,6 +136,7 @@ kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor
kotlinx-coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version.ref = "kotlinxCoroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" }
kotlinx-datetime = { group = "org.jetbrains.kotlinx", name = "kotlinx-datetime", version.ref = "kotlinxDatetime" }
kotlinx-serialization-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-core", version.ref = "kotlinxSerializationJson" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
lint-api = { group = "com.android.tools.lint", name = "lint-api", version.ref = "androidTools" }
lint-checks = { group = "com.android.tools.lint", name = "lint-checks", version.ref = "androidTools" }
@ -170,8 +171,8 @@ sqldelight-coroutines-extensions = { group = "app.cash.sqldelight", name = "coro
sqldelight-primitive-adapters = { group = "app.cash.sqldelight", name = "primitive-adapters", version.ref = "sqldelight" }
kotlin-inject-compiler-ksp = { group = "me.tatarka.inject", name = "kotlin-inject-compiler-ksp", version.ref = "kotlinInject" }
kotlin-inject-runtime = { group = "me.tatarka.inject", name = "kotlin-inject-runtime", version.ref = "kotlinInject" }
multiplatform-settings = { group = "com.russhwolf", name = "multiplatform-settings", version.ref = "multiplatform-settings" }
multiplatform-settings-datastore = { group = "com.russhwolf", name = "multiplatform-settings-datastore", version.ref = "multiplatform-settings" }
multiplatform-settings = { group = "com.russhwolf", name = "multiplatform-settings-no-arg", version.ref = "multiplatform-settings" }
multiplatform-settings-test = { group = "com.russhwolf", name = "multiplatform-settings-test", version.ref = "multiplatform-settings" }
multiplatform-settings-serialization = { group = "com.russhwolf", name = "multiplatform-settings-serialization", version.ref = "multiplatform-settings" }
multiplatform-settings-coroutines = { group = "com.russhwolf", name = "multiplatform-settings-coroutines", version.ref = "multiplatform-settings" }

Loading…
Cancel
Save