From 9a43c36758a8781a754a697a7333224ec3528920 Mon Sep 17 00:00:00 2001 From: FooIbar <118464521+FooIbar@users.noreply.github.com> Date: Mon, 11 Aug 2025 23:18:26 +0800 Subject: [PATCH] KMP DataStorePreferences --- app/build.gradle.kts | 3 +- .../com/hippo/ehviewer/EhApplication.kt | 2 +- .../kotlin/com/hippo/ehviewer/Settings.kt | 88 ++++++------------- .../com/hippo/ehviewer/SettingsCollector.kt | 2 +- .../ehviewer/download/DownloadManager.kt | 2 +- .../ehviewer/gallery/ArchivePageLoader.kt | 2 +- .../com/hippo/ehviewer/ktor/CronetEngine.kt | 4 +- .../hippo/ehviewer/ui/tools/DateStoreState.kt | 4 +- .../kotlin/com/hippo/ehviewer/util/OSUtils.kt | 11 +-- core/data/build.gradle.kts | 21 +++++ .../core/mainthread/MainThread.android.kt | 6 ++ .../DataStorePreferences.android.kt | 20 +++++ .../ehviewer/core/mainthread/MainThread.kt | 11 +++ .../core/preferences/DataStorePreferences.kt | 81 +++++++++++++++++ .../com/ehviewer/core/preferences/Edit.kt | 23 +++++ .../ehviewer/core/preferences/PrefDelegate.kt | 29 ++++++ .../com/ehviewer/core/preferences/Snapshot.kt | 19 ++++ gradle/libs.versions.toml | 6 +- settings.gradle.kts | 1 + 19 files changed, 251 insertions(+), 84 deletions(-) create mode 100644 core/data/build.gradle.kts create mode 100644 core/data/src/androidMain/kotlin/com/ehviewer/core/mainthread/MainThread.android.kt create mode 100644 core/data/src/androidMain/kotlin/com/ehviewer/core/preferences/DataStorePreferences.android.kt create mode 100644 core/data/src/commonMain/kotlin/com/ehviewer/core/mainthread/MainThread.kt create mode 100644 core/data/src/commonMain/kotlin/com/ehviewer/core/preferences/DataStorePreferences.kt create mode 100644 core/data/src/commonMain/kotlin/com/ehviewer/core/preferences/Edit.kt create mode 100644 core/data/src/commonMain/kotlin/com/ehviewer/core/preferences/PrefDelegate.kt create mode 100644 core/data/src/commonMain/kotlin/com/ehviewer/core/preferences/Snapshot.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4cbff5e80..e3b5043b9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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", ) } } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/EhApplication.kt b/app/src/main/kotlin/com/hippo/ehviewer/EhApplication.kt index d9ec79109..7f72b7ae4 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/EhApplication.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/EhApplication.kt @@ -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() diff --git a/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt b/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt index 88e717445..51ef5372a 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/Settings.kt @@ -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 PrefDelegate.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 PrefDelegate.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 - is StringSetOrNullPref -> value = newValue as? Set -} @Stable @Composable inline fun PrefDelegate.collectAsState(crossinline transform: @DisallowComposableCalls (T) -> R): State { val flow = remember { valueFlow().map { transform(it) } } - val init = getValue() + val init = value return flow.collectAsState(transform(init)) } @@ -73,32 +41,28 @@ inline fun PrefDelegate.collectAsState(crossinline transform: @Disallo @Composable fun PrefDelegate.collectAsState(): State { val flow = remember { valueFlow() } - val init = getValue() + val init = value return flow.collectAsState(init) } @Stable @Composable fun PrefDelegate.asMutableState(): MutableState { - val flow = remember { valueFlow() } - val init = getValue() - val readOnly = flow.collectAsState(init) + val readOnly = collectAsState() return remember { object : MutableState { 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() 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 = _value.map { it.value }.toTypedArray() override fun setValue(thisRef: Any?, prop: KProperty<*>?, value: Array) { check(value.size == count) - edit { value.zip(_value) { v, d -> d.value = v } } + edit { pref -> value.zip(_value) { v, d -> pref[d] = v } } } } } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/SettingsCollector.kt b/app/src/main/kotlin/com/hippo/ehviewer/SettingsCollector.kt index 14cd41ae2..4a8b1e750 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/SettingsCollector.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/SettingsCollector.kt @@ -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()) diff --git a/app/src/main/kotlin/com/hippo/ehviewer/download/DownloadManager.kt b/app/src/main/kotlin/com/hippo/ehviewer/download/DownloadManager.kt index 70f78cb81..4c929c91a 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/download/DownloadManager.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/download/DownloadManager.kt @@ -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() diff --git a/app/src/main/kotlin/com/hippo/ehviewer/gallery/ArchivePageLoader.kt b/app/src/main/kotlin/com/hippo/ehviewer/gallery/ArchivePageLoader.kt index 2a666b4cc..aec122c9f 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/gallery/ArchivePageLoader.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/gallery/ArchivePageLoader.kt @@ -53,7 +53,7 @@ suspend inline fun 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( diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ktor/CronetEngine.kt b/app/src/main/kotlin/com/hippo/ehviewer/ktor/CronetEngine.kt index d8fe9f83a..3f925d8e0 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ktor/CronetEngine.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ktor/CronetEngine.kt @@ -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() } } diff --git a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/DateStoreState.kt b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/DateStoreState.kt index eb9beedfd..b1e46d836 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/DateStoreState.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/ui/tools/DateStoreState.kt @@ -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 diff --git a/app/src/main/kotlin/com/hippo/ehviewer/util/OSUtils.kt b/app/src/main/kotlin/com/hippo/ehviewer/util/OSUtils.kt index 0b324ac0a..a7382191f 100644 --- a/app/src/main/kotlin/com/hippo/ehviewer/util/OSUtils.kt +++ b/app/src/main/kotlin/com/hippo/ehviewer/util/OSUtils.kt @@ -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 runAssertingNotMainThread(block: suspend CoroutineScope.() -> T) = assertNotMainThread().run { runBlocking(block = block) } +fun runAssertingNotMainThread(block: suspend CoroutineScope.() -> T) = checkNotMainThread().run { runBlocking(block = block) } diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts new file mode 100644 index 000000000..b7c333a98 --- /dev/null +++ b/core/data/build.gradle.kts @@ -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) + } + } + } +} diff --git a/core/data/src/androidMain/kotlin/com/ehviewer/core/mainthread/MainThread.android.kt b/core/data/src/androidMain/kotlin/com/ehviewer/core/mainthread/MainThread.android.kt new file mode 100644 index 000000000..d63d04a37 --- /dev/null +++ b/core/data/src/androidMain/kotlin/com/ehviewer/core/mainthread/MainThread.android.kt @@ -0,0 +1,6 @@ +package com.ehviewer.core.mainthread + +import android.os.Looper + +internal actual val isMainThread: Boolean + get() = Looper.getMainLooper().isCurrentThread diff --git a/core/data/src/androidMain/kotlin/com/ehviewer/core/preferences/DataStorePreferences.android.kt b/core/data/src/androidMain/kotlin/com/ehviewer/core/preferences/DataStorePreferences.android.kt new file mode 100644 index 000000000..12e3ee501 --- /dev/null +++ b/core/data/src/androidMain/kotlin/com/ehviewer/core/preferences/DataStorePreferences.android.kt @@ -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 { + checkNotMainThread() + val context = appCtx + val actualName = name ?: "${context.packageName}_preferences" + return PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context = context, sharedPreferencesName = actualName)), + ) { + context.preferencesDataStoreFile(actualName) + } +} diff --git a/core/data/src/commonMain/kotlin/com/ehviewer/core/mainthread/MainThread.kt b/core/data/src/commonMain/kotlin/com/ehviewer/core/mainthread/MainThread.kt new file mode 100644 index 000000000..ccb7d94a9 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/ehviewer/core/mainthread/MainThread.kt @@ -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" } +} diff --git a/core/data/src/commonMain/kotlin/com/ehviewer/core/preferences/DataStorePreferences.kt b/core/data/src/commonMain/kotlin/com/ehviewer/core/preferences/DataStorePreferences.kt new file mode 100644 index 000000000..ed3182813 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/ehviewer/core/preferences/DataStorePreferences.kt @@ -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 + +abstract class DataStorePreferences(private val dataStore: DataStore) { + constructor(name: String?) : this(getDateStore(name)) + + val data: Flow + 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 = 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) +} diff --git a/core/data/src/commonMain/kotlin/com/ehviewer/core/preferences/Edit.kt b/core/data/src/commonMain/kotlin/com/ehviewer/core/preferences/Edit.kt new file mode 100644 index 000000000..8b67d4684 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/ehviewer/core/preferences/Edit.kt @@ -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.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()) diff --git a/core/data/src/commonMain/kotlin/com/ehviewer/core/preferences/PrefDelegate.kt b/core/data/src/commonMain/kotlin/com/ehviewer/core/preferences/PrefDelegate.kt new file mode 100644 index 000000000..8a8a980e9 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/ehviewer/core/preferences/PrefDelegate.kt @@ -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 internal constructor( + private val preferences: DataStorePreferences, + val key: Preferences.Key, + val defaultValue: T, +) { + fun changesFlow(): Flow = valueFlow().drop(1).map {} + + fun valueFlow(): Flow = 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 + } +} diff --git a/core/data/src/commonMain/kotlin/com/ehviewer/core/preferences/Snapshot.kt b/core/data/src/commonMain/kotlin/com/ehviewer/core/preferences/Snapshot.kt new file mode 100644 index 000000000..3093e4c12 --- /dev/null +++ b/core/data/src/commonMain/kotlin/com/ehviewer/core/preferences/Snapshot.kt @@ -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 contains(pref: PrefDelegate): Boolean = pref.key in delegate + operator fun get(pref: PrefDelegate): T = delegate[pref.key] ?: pref.defaultValue +} + +class MutableSnapshot internal constructor(private val delegate: MutablePreferences) : Snapshot(delegate) { + operator fun set(pref: PrefDelegate, value: T) { + if (value == null) { + delegate.remove(pref.key) + } else { + delegate[pref.key] = value + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5f3a0649a..b0fe7d982 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 4e2527837..bcfcd2c6b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -44,4 +44,5 @@ enableFeaturePreview("STABLE_CONFIGURATION_CACHE") rootProject.name = "EhViewer" include(":app") include(":benchmark") +include(":core:data") include(":core:i18n")