KMP DataStorePreferences

This commit is contained in:
FooIbar committed 2025-08-12 10:40:45 +08:00
1 parent e017efe35f
commit 9a43c36758
19 files changed
+251 -84

No files matched your search

+1 -2
View File
@@ -166,6 +166,7 @@ baselineProfile {
}
dependencies {
implementation(projects.core.data)
implementation(projects.core.i18n)
// https://developer.android.com/jetpack/androidx/releases/activity
@@ -254,8 +255,6 @@ kotlin {
"coil3.annotation.ExperimentalCoilApi",
"androidx.paging.ExperimentalPagingApi",
"kotlinx.serialization.ExperimentalSerializationApi",
"splitties.experimental.ExperimentalSplittiesApi",
"splitties.preferences.DataStorePreferencesPreview",
)
}
}
@@ -130,7 +130,7 @@ class EhApplication :
launchIO { dataStateFlow.value }
launchIO { OSUtils.totalMemory }
launch {
if (DownloadManager.labelList.isNotEmpty() && Settings.downloadFilterMode.key !in Settings.prefs) {
if (DownloadManager.labelList.isNotEmpty() && Settings.downloadFilterMode !in Settings.snapshot()) {
Settings.downloadFilterMode.value = DownloadsFilterMode.CUSTOM.flag
}
DownloadManager.readMetadataFromLocal()
@@ -9,6 +9,9 @@ import androidx.compose.runtime.Stable
import androidx.compose.runtime.State
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import com.ehviewer.core.preferences.DataStorePreferences
import com.ehviewer.core.preferences.PrefDelegate
import com.ehviewer.core.preferences.edit
import com.hippo.ehviewer.client.EhCookieStore
import com.hippo.ehviewer.client.data.FavListUrlBuilder
import com.hippo.ehviewer.download.DownloadsFilterMode
@@ -25,47 +28,12 @@ import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.map
import splitties.preferences.BoolPref
import splitties.preferences.DataStorePreferences
import splitties.preferences.FloatPref
import splitties.preferences.IntPref
import splitties.preferences.LongPref
import splitties.preferences.PrefDelegate
import splitties.preferences.StringOrNullPref
import splitties.preferences.StringPref
import splitties.preferences.StringSetOrNullPref
import splitties.preferences.StringSetPref
import splitties.preferences.edit
@Suppress("IMPLICIT_CAST_TO_ANY", "UNCHECKED_CAST")
fun <T> PrefDelegate<T>.getValue(): T = when (this) {
is BoolPref -> value
is IntPref -> value
is FloatPref -> value
is LongPref -> value
is StringPref -> value
is StringOrNullPref -> value
is StringSetPref -> value
is StringSetOrNullPref -> value
} as T
@Suppress("UNCHECKED_CAST")
fun <T> PrefDelegate<T>.setValue(newValue: T) = when (this) {
is BoolPref -> value = newValue as Boolean
is IntPref -> value = newValue as Int
is FloatPref -> value = newValue as Float
is LongPref -> value = newValue as Long
is StringPref -> value = newValue as String
is StringOrNullPref -> value = newValue as? String
is StringSetPref -> value = newValue as Set<String?>
is StringSetOrNullPref -> value = newValue as? Set<String?>
}
@Stable
@Composable
inline fun <R, T> PrefDelegate<T>.collectAsState(crossinline transform: @DisallowComposableCalls (T) -> R): State<R> {
val flow = remember { valueFlow().map { transform(it) } }
val init = getValue()
val init = value
return flow.collectAsState(transform(init))
}
@@ -73,32 +41,28 @@ inline fun <R, T> PrefDelegate<T>.collectAsState(crossinline transform: @Disallo
@Composable
fun <T> PrefDelegate<T>.collectAsState(): State<T> {
val flow = remember { valueFlow() }
val init = getValue()
val init = value
return flow.collectAsState(init)
}
@Stable
@Composable
fun <T> PrefDelegate<T>.asMutableState(): MutableState<T> {
val flow = remember { valueFlow() }
val init = getValue()
val readOnly = flow.collectAsState(init)
val readOnly = collectAsState()
return remember {
object : MutableState<T> {
override var value: T
get() = readOnly.value
set(value) {
setValue(value)
this@asMutableState.value = value
}
override fun component1() = readOnly.value
override fun component2() = ::setValue
override fun component2() = this@asMutableState::value::set
}
}
}
object Settings : DataStorePreferences(null) {
private const val KEY_SHOW_TAG_TRANSLATIONS = "show_tag_translations"
@Suppress("ktlint:standard:backing-property-naming")
private val _favFlow = MutableSharedFlow<Unit>()
val favChangesFlow = _favFlow.debounce(1000)
@@ -121,7 +85,7 @@ object Settings : DataStorePreferences(null) {
val showVoteStatus = boolPref("show_vote_status", false)
val showComments = boolPref("show_gallery_comments", true)
val commentThreshold = intPref("comment_threshold", -100)
val showTagTranslations = boolPref(KEY_SHOW_TAG_TRANSLATIONS, false).observed { updateWhenTagTranslationChanges() }
val showTagTranslations = boolPref("show_tag_translations", false).observed { updateWhenTagTranslationChanges() }
val meteredNetworkWarning = boolPref("cellular_network_warning", false)
val showJpnTitle = boolPref("show_jpn_title", false)
val requestNews = boolPref("request_news", false).observed { updateWhenRequestNewsChanges() }
@@ -167,15 +131,15 @@ object Settings : DataStorePreferences(null) {
val needSignIn = boolPref("need_sign_in", true)
val gridView = boolPref("grid_view", false)
val qSSaveProgress = boolPref("qs_save_progress", true)
val displayName = stringOrNullPref("display_name", null)
val avatar = stringOrNullPref("avatar", null)
val recentDownloadLabel = stringOrNullPref("recent_download_label", null)
val displayName = stringOrNullPref("display_name")
val avatar = stringOrNullPref("avatar")
val recentDownloadLabel = stringOrNullPref("recent_download_label")
var downloadScheme by stringOrNullPref("image_scheme", null)
var downloadAuthority by stringOrNullPref("image_authority", null)
var downloadPath by stringOrNullPref("image_path", null)
var downloadQuery by stringOrNullPref("image_query", null)
var downloadFragment by stringOrNullPref("image_fragment", null)
var downloadScheme by stringOrNullPref("image_scheme")
var downloadAuthority by stringOrNullPref("image_authority")
var downloadPath by stringOrNullPref("image_path")
var downloadQuery by stringOrNullPref("image_query")
var downloadFragment by stringOrNullPref("image_fragment")
var archivePasswds by stringSetPref("archive_passwds")
var appLinkVerifyTip by boolPref("app_link_verify_tip", false)
var hasDefaultDownloadLabel by boolPref("has_default_download_label", false)
@@ -185,7 +149,7 @@ object Settings : DataStorePreferences(null) {
var requestNewsTime by intPref("request_news_time", 0).observed { updateWhenRequestNewsChanges() }
var lastDawnDays by intPref("last_dawn_days", 0)
var recentToplist by stringPref("recent_toplist", "11")
var defaultDownloadLabel by stringOrNullPref("default_download_label", null)
var defaultDownloadLabel by stringOrNullPref("default_download_label")
var lastUpdateTime by longPref("last_update_time", BuildConfig.COMMIT_TIME)
// Reader
@@ -223,13 +187,13 @@ object Settings : DataStorePreferences(null) {
val stripExtraneousAds = boolPref("strip_extraneous_ads", false)
init {
if ("CN" == Locale.getDefault().country) {
edit {
if (KEY_SHOW_TAG_TRANSLATIONS !in prefs) showTagTranslations.value = true
edit { pref ->
if ("CN" == Locale.getDefault().country) {
if (showTagTranslations !in pref) pref[showTagTranslations] = true
}
if (pref[downloadFilterMode] == DownloadsFilterMode.ARTIST.flag && pref[recentDownloadLabel] == null) {
pref[recentDownloadLabel] = ""
}
}
if (downloadFilterMode.value == DownloadsFilterMode.ARTIST.flag && recentDownloadLabel.value == null) {
recentDownloadLabel.value = ""
}
}
@@ -247,7 +211,7 @@ object Settings : DataStorePreferences(null) {
override fun getValue(thisRef: Any?, prop: KProperty<*>?): IntArray = _value.map { it.value }.toIntArray()
override fun setValue(thisRef: Any?, prop: KProperty<*>?, value: IntArray) {
check(value.size == count)
edit { value.zip(_value) { v, d -> d.value = v } }
edit { pref -> value.zip(_value) { v, d -> pref[d] = v } }
}
}
@@ -259,7 +223,7 @@ object Settings : DataStorePreferences(null) {
override fun getValue(thisRef: Any?, prop: KProperty<*>?): Array<String> = _value.map { it.value }.toTypedArray()
override fun setValue(thisRef: Any?, prop: KProperty<*>?, value: Array<String>) {
check(value.size == count)
edit { value.zip(_value) { v, d -> d.value = v } }
edit { pref -> value.zip(_value) { v, d -> pref[d] = v } }
}
}
}
@@ -2,6 +2,7 @@ package com.hippo.ehviewer
import android.app.UiModeManager
import androidx.appcompat.app.AppCompatDelegate
import com.ehviewer.core.preferences.PrefDelegate
import com.hippo.ehviewer.client.EhEngine
import com.hippo.ehviewer.client.EhTagDatabase
import com.hippo.ehviewer.dailycheck.updateDailyCheckWork
@@ -16,7 +17,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import splitties.init.appCtx
import splitties.preferences.PrefDelegate
import splitties.systemservices.uiModeManager
private val collectScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
@@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.core.util.size
import arrow.fx.coroutines.parMapNotNull
import com.ehviewer.core.preferences.edit
import com.hippo.ehviewer.EhDB
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.client.data.BaseGalleryInfo
@@ -64,7 +65,6 @@ import logcat.LogPriority
import okio.Path
import okio.Path.Companion.toOkioPath
import okio.Path.Companion.toPath
import splitties.preferences.edit
object DownloadManager : OnSpiderListener, CoroutineScope {
override val coroutineContext = Dispatchers.IO + Job()
@@ -53,7 +53,7 @@ suspend inline fun <T> useArchivePageLoader(
{ _, _ -> closeArchive() },
)
check(size > 0) { "Archive have no content!" }
if (needPassword() && archivePasswds.filterNotNull().none(::providePassword)) {
if (needPassword() && archivePasswds.none(::providePassword)) {
archivePasswds += passwdProvider(::providePassword)
}
val loader = install(
@@ -10,6 +10,7 @@ import android.net.http.UrlRequest
import android.net.http.UrlResponseInfo
import android.os.Build
import androidx.annotation.RequiresExtension
import com.ehviewer.core.preferences.edit
import com.hippo.ehviewer.Settings
import com.hippo.ehviewer.util.restartApplication
import io.ktor.client.engine.HttpClientEngineBase
@@ -40,7 +41,6 @@ import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.job
import kotlinx.coroutines.suspendCancellableCoroutine
import splitties.preferences.edit
class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cronet") {
// Limit thread to 1 since we are async & non-blocking
@@ -108,7 +108,7 @@ class CronetEngine(override val config: CronetConfig) : HttpClientEngineBase("Cr
val message = error.message.orEmpty()
if ("ERR_FILE_NOT_FOUND" in message || "ERR_PROXY_CONNECTION_FAILED" in message) {
Settings.edit(true) {
enableCronet.value = false
it[enableCronet] = false
}
with(config.context) { restartApplication() }
}
@@ -4,11 +4,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisallowComposableCalls
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.datastore.dataStoreFile
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.byteArrayPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.lifecycle.compose.LifecycleResumeEffect
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -22,7 +22,7 @@ import kotlinx.serialization.serializer
import splitties.init.appCtx
val dataStoreScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val dataStore = PreferenceDataStoreFactory.create { appCtx.dataStoreFile("Remembered.preferences_pb") }
val dataStore = PreferenceDataStoreFactory.create { appCtx.preferencesDataStoreFile("Remembered") }
val dataStateFlow = dataStore.data.stateIn(dataStoreScope, SharingStarted.Eagerly, emptyPreferences())
// Find out how make this work with any generic `Serializable` type
@@ -15,7 +15,7 @@
*/
package com.hippo.ehviewer.util
import android.os.Looper
import com.ehviewer.core.mainthread.checkNotMainThread
import eu.kanade.tachiyomi.util.system.logcat
import java.io.File
import kotlinx.coroutines.CoroutineScope
@@ -51,11 +51,4 @@ object OSUtils {
}
}
val isMainThread: Boolean
get() = Looper.getMainLooper().thread === Thread.currentThread()
fun assertNotMainThread() {
check(!isMainThread) { "Cannot access database on the main thread since" + " it may potentially lock the UI for a long period of time." }
}
fun <T> runAssertingNotMainThread(block: suspend CoroutineScope.() -> T) = assertNotMainThread().run { runBlocking(block = block) }
fun <T> runAssertingNotMainThread(block: suspend CoroutineScope.() -> T) = checkNotMainThread().run { runBlocking(block = block) }
+21
View File
@@ -0,0 +1,21 @@
plugins {
alias(libs.plugins.ehviewer.multiplatform.library)
}
kotlin {
sourceSets {
commonMain {
dependencies {
api(project.dependencies.platform(libs.okio.bom))
api(libs.androidx.datastore)
api(libs.kotlinx.coroutines.core)
}
}
androidMain {
dependencies {
api(libs.kotlinx.coroutines.android)
api(libs.splitties.appctx)
}
}
}
}
@@ -0,0 +1,6 @@
package com.ehviewer.core.mainthread
import android.os.Looper
internal actual val isMainThread: Boolean
get() = Looper.getMainLooper().isCurrentThread
@@ -0,0 +1,20 @@
package com.ehviewer.core.preferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import com.ehviewer.core.mainthread.checkNotMainThread
import splitties.init.appCtx
internal actual fun getDateStore(name: String?): DataStore<Preferences> {
checkNotMainThread()
val context = appCtx
val actualName = name ?: "${context.packageName}_preferences"
return PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context = context, sharedPreferencesName = actualName)),
) {
context.preferencesDataStoreFile(actualName)
}
}
@@ -0,0 +1,11 @@
package com.ehviewer.core.mainthread
internal expect val isMainThread: Boolean
fun checkMainThread() {
check(isMainThread) { "Must be called from the main thread" }
}
fun checkNotMainThread() {
check(!isMainThread) { "Must not be called from the main thread" }
}
@@ -0,0 +1,81 @@
package com.ehviewer.core.preferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.byteArrayPreferencesKey
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
internal expect fun getDateStore(name: String?): DataStore<Preferences>
abstract class DataStorePreferences(private val dataStore: DataStore<Preferences>) {
constructor(name: String?) : this(getDateStore(name))
val data: Flow<Snapshot>
get() = dataStore.data.map { Snapshot(it) }
suspend fun snapshot(): Snapshot = data.first()
suspend fun withMutableSnapshot(block: (MutableSnapshot) -> Unit) {
dataStore.edit {
block(MutableSnapshot(it))
}
}
protected fun boolPref(
key: String,
defaultValue: Boolean,
) = PrefDelegate(this, booleanPreferencesKey(name = key), defaultValue = defaultValue)
protected fun intPref(
key: String,
defaultValue: Int,
) = PrefDelegate(this, intPreferencesKey(name = key), defaultValue = defaultValue)
protected fun floatPref(
key: String,
defaultValue: Float,
) = PrefDelegate(this, floatPreferencesKey(name = key), defaultValue = defaultValue)
protected fun doublePref(
key: String,
defaultValue: Double,
) = PrefDelegate(this, doublePreferencesKey(name = key), defaultValue = defaultValue)
protected fun longPref(
key: String,
defaultValue: Long,
) = PrefDelegate(this, longPreferencesKey(name = key), defaultValue = defaultValue)
protected fun stringPref(
key: String,
defaultValue: String,
) = PrefDelegate(this, stringPreferencesKey(name = key), defaultValue = defaultValue)
protected fun stringOrNullPref(
key: String,
) = PrefDelegate(this, stringPreferencesKey(name = key), null)
protected fun stringSetPref(
key: String,
defaultValue: Set<String> = emptySet(),
) = PrefDelegate(this, stringSetPreferencesKey(name = key), defaultValue = defaultValue)
protected fun stringSetOrNullPref(
key: String,
) = PrefDelegate(this, stringSetPreferencesKey(name = key), null)
protected fun byteArrayPref(
key: String,
defaultValue: ByteArray,
) = PrefDelegate(this, byteArrayPreferencesKey(name = key), defaultValue = defaultValue)
}
@@ -0,0 +1,23 @@
package com.ehviewer.core.preferences
import com.ehviewer.core.mainthread.checkNotMainThread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
fun <P : DataStorePreferences> P.edit(blocking: Boolean = false, block: P.(MutableSnapshot) -> Unit) {
if (blocking) {
checkNotMainThread()
runBlocking {
withMutableSnapshot { block(it) }
}
} else {
scope.launch {
withMutableSnapshot { block(it) }
}
}
}
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
@@ -0,0 +1,29 @@
package com.ehviewer.core.preferences
import androidx.datastore.preferences.core.Preferences
import kotlin.reflect.KProperty
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
@Suppress("NOTHING_TO_INLINE")
class PrefDelegate<T> internal constructor(
private val preferences: DataStorePreferences,
val key: Preferences.Key<T & Any>,
val defaultValue: T,
) {
fun changesFlow(): Flow<Unit> = valueFlow().drop(1).map {}
fun valueFlow(): Flow<T> = preferences.data.map { it[this] }.distinctUntilChanged()
var value: T
get() = runBlocking { preferences.snapshot() }[this]
set(value) = preferences.edit { it[this@PrefDelegate] = value }
inline operator fun getValue(thisRef: Any?, prop: KProperty<*>?): T = value
inline operator fun setValue(thisRef: Any?, prop: KProperty<*>?, value: T) {
this.value = value
}
}
@@ -0,0 +1,19 @@
package com.ehviewer.core.preferences
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
open class Snapshot internal constructor(private val delegate: Preferences) {
operator fun <T> contains(pref: PrefDelegate<T>): Boolean = pref.key in delegate
operator fun <T> get(pref: PrefDelegate<T>): T = delegate[pref.key] ?: pref.defaultValue
}
class MutableSnapshot internal constructor(private val delegate: MutablePreferences) : Snapshot(delegate) {
operator fun <T> set(pref: PrefDelegate<T>, value: T) {
if (value == null) {
delegate.remove(pref.key)
} else {
delegate[pref.key] = value
}
}
}
+3 -3
View File
@@ -48,7 +48,7 @@ androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "a
androidx-browser = { module = "androidx.browser:browser", version.ref = "androidx-browser" }
androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "androidx-core-splashscreen" }
androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "androidx-datastore" }
androidx-datastore = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" }
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "androidx-lifecycle" }
androidx-lifecycle-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
@@ -91,6 +91,7 @@ desugar = "com.android.tools:desugar_jdk_libs:2.1.5"
diff = "io.github.petertrr:kotlin-multiplatform-diff:1.1.0"
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
@@ -116,7 +117,6 @@ serialization-xml-io = { module = "io.github.pdvrieze.xmlutil:serialization-io",
splitties-appctx = { module = "com.louiscad.splitties:splitties-appctx", version.ref = "splitties" }
splitties-arch-room = { module = "com.louiscad.splitties:splitties-arch-room", version.ref = "splitties" }
splitties-preferences = { module = "com.louiscad.splitties:splitties-preferences", version.ref = "splitties" }
splitties-systemservices = { module = "com.louiscad.splitties:splitties-systemservices", version.ref = "splitties" }
telephoto-zoomable = { module = "me.saket.telephoto:zoomable", version.ref = "telephoto" }
@@ -135,7 +135,7 @@ arrow = ["arrow-fx-coroutines", "arrow-functions", "arrow-resilience"]
coil = ["coil-compose", "coil-gif", "coil-network"]
compose = ["compose-foundation", "compose-material-icons-extended", "compose-material3", "compose-material3-adaptive", "compose-ui-util"]
kotlinx-serialization = ["serialization-cbor", "serialization-json", "serialization-json-io", "serialization-xml", "serialization-xml-io"]
splitties = ["splitties-appctx", "splitties-arch-room", "splitties-preferences", "splitties-systemservices"]
splitties = ["splitties-appctx", "splitties-arch-room", "splitties-systemservices"]
[plugins]
aboutlibrariesPlugin = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlibraries" }
+1
View File
@@ -44,4 +44,5 @@ enableFeaturePreview("STABLE_CONFIGURATION_CACHE")
rootProject.name = "EhViewer"
include(":app")
include(":benchmark")
include(":core:data")
include(":core:i18n")