From e3dc4b5ab0ba009a4a2f75cd4abce4498b4e093f Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 30 Aug 2026 21:44:05 -0500 Subject: [PATCH] feat(prefs): enable wasmJs via a platform-neutral PrefsStore abstraction androidx.datastore.preferences publishes no wasmJs variant at any version, so core:prefs can no longer depend on DataStore from commonMain. Introduces PrefsStore/PrefsSnapshot/PrefsKey (core/prefs/store/PrefsStore.kt), a minimal interface mirroring DataStore's own shape closely enough that porting each *PrefsImpl off DataStore is a mechanical key-factory rename, not a rewrite. Two implementations: nonWebMain's DataStorePrefsStore adapts a real DataStore (android/jvm/iOS unchanged underneath), and wasmJsMain's LocalStoragePrefsStore is backed by the browser's localStorage directly -- synchronous and built-in, so unlike core:database's OPFS story this needs no Worker or npm dependency. Unlike core:database's DatabaseManager split, none of the 17 *PrefsImpl classes needed to move out of commonMain: the platform difference is fully absorbed inside PrefsStore's two implementations, so every impl's diff is exactly the mechanical shape promised (defaults, migration logic, dynamic per-node keys all unchanged). PrefsDataStores.kt's 12 marker interfaces/wrappers get the same mechanical swap. Enabling core:prefs's wasmJs target exposed a second, one-module-upstream gap: core:repository had no wasmJs target at all (its `expect class Location` had android/jvm/iOS actuals but no wasmJs one). Fixed with a wasmJsMain placeholder mirroring the existing jvmMain placeholder exactly -- desktop has no real location hardware either, so this introduces no new product decision, just parity with the platform that already has none. core:service's MeshLogCleanupWorkerTest fake DataStore is updated to fake PrefsStore/PrefsSnapshot instead, following the interface change through its one external consumer. CorePrefsWasmJsModule (new Koin module, localStorage-backed) is not yet registered anywhere -- no webApp module exists yet to wire it into, same as core:database's SingleDatabaseProvider precedent. Co-Authored-By: Claude Sonnet 5 --- core/prefs/build.gradle.kts | 38 ++++++- .../core/prefs/di/CorePrefsAndroidModule.kt | 21 ++-- .../prefs/analytics/AnalyticsPrefsImpl.kt | 9 +- .../appfunctions/AppFunctionsPrefsImpl.kt | 29 +++-- .../core/prefs/di/PrefsDataStores.kt | 66 ++++++------ .../prefs/discovery/DiscoveryPrefsImpl.kt | 15 ++- .../prefs/discovery/MeshBeaconPrefsImpl.kt | 5 +- .../core/prefs/emoji/CustomEmojiPrefsImpl.kt | 9 +- .../core/prefs/filter/FilterPrefsImpl.kt | 9 +- .../prefs/homoglyph/HomoglyphPrefsImpl.kt | 5 +- .../core/prefs/map/MapConsentPrefsImpl.kt | 7 +- .../meshtastic/core/prefs/map/MapPrefsImpl.kt | 47 ++++---- .../prefs/map/MapTileProviderPrefsImpl.kt | 7 +- .../core/prefs/mesh/MeshPrefsImpl.kt | 11 +- .../core/prefs/meshlog/MeshLogPrefsImpl.kt | 9 +- .../notification/NotificationPrefsImpl.kt | 15 ++- .../core/prefs/radio/RadioPrefsImpl.kt | 7 +- .../meshtastic/core/prefs/store/PrefsStore.kt | 101 ++++++++++++++++++ .../meshtastic/core/prefs/tak/TakPrefsImpl.kt | 11 +- .../prefs/ui/NodeListLayoutPreferences.kt | 24 ++--- .../meshtastic/core/prefs/ui/UiPrefsImpl.kt | 59 +++++----- .../core/prefs/store/DataStorePrefsStore.kt | 78 ++++++++++++++ .../core/prefs/filter/FilterPrefsTest.kt | 3 +- .../core/prefs/map/MapPrefsImplTest.kt | 3 +- .../core/prefs/mesh/MeshPrefsImplTest.kt | 6 +- .../notification/NotificationPrefsTest.kt | 3 +- .../meshtastic/core/prefs/tak/TakPrefsTest.kt | 3 +- .../core/prefs/ui/NodeListLayoutPrefsTest.kt | 3 +- .../core/prefs/ui/UiPrefsImplTest.kt | 17 +-- .../core/prefs/di/CorePrefsWasmJsModule.kt | 69 ++++++++++++ .../prefs/store/LocalStoragePrefsStore.kt | 97 +++++++++++++++++ core/repository/build.gradle.kts | 7 ++ .../meshtastic/core/repository/Location.kt | 24 +++++ .../core/service/MeshLogCleanupWorkerTest.kt | 46 ++++++-- .../desktop/di/DesktopPlatformModule.kt | 38 ++++--- 35 files changed, 675 insertions(+), 226 deletions(-) create mode 100644 core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/store/PrefsStore.kt create mode 100644 core/prefs/src/nonWebMain/kotlin/org/meshtastic/core/prefs/store/DataStorePrefsStore.kt rename core/prefs/src/{commonTest => nonWebTest}/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt (95%) rename core/prefs/src/{commonTest => nonWebTest}/kotlin/org/meshtastic/core/prefs/map/MapPrefsImplTest.kt (96%) rename core/prefs/src/{commonTest => nonWebTest}/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImplTest.kt (93%) rename core/prefs/src/{commonTest => nonWebTest}/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt (98%) rename core/prefs/src/{commonTest => nonWebTest}/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt (96%) rename core/prefs/src/{commonTest => nonWebTest}/kotlin/org/meshtastic/core/prefs/ui/NodeListLayoutPrefsTest.kt (97%) rename core/prefs/src/{commonTest => nonWebTest}/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt (92%) create mode 100644 core/prefs/src/wasmJsMain/kotlin/org/meshtastic/core/prefs/di/CorePrefsWasmJsModule.kt create mode 100644 core/prefs/src/wasmJsMain/kotlin/org/meshtastic/core/prefs/store/LocalStoragePrefsStore.kt create mode 100644 core/repository/src/wasmJsMain/kotlin/org/meshtastic/core/repository/Location.kt diff --git a/core/prefs/build.gradle.kts b/core/prefs/build.gradle.kts index a93b99a725..6491511d5d 100644 --- a/core/prefs/build.gradle.kts +++ b/core/prefs/build.gradle.kts @@ -15,6 +15,10 @@ * along with this program. If not, see . */ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl +import org.jetbrains.kotlin.gradle.plugin.KotlinHierarchyTemplate + plugins { alias(libs.plugins.meshtastic.kmp.library) alias(libs.plugins.meshtastic.koin) @@ -23,16 +27,48 @@ plugins { kotlin { android { withHostTest {} } + // Library module: bare wasmJs(), no browser() (that's for the eventual webApp executable). + @OptIn(ExperimentalWasmDsl::class) + wasmJs() + + // nonWebMain: androidx.datastore.preferences has no wasmJs variant (the `Preferences` type itself doesn't + // resolve there), so DataStorePrefsStore — the adapter wrapping a real DataStore — and the + // Android-only CorePrefsAndroidModule live here/in androidMain instead of commonMain. All 17 `*PrefsImpl` + // classes themselves stay in commonMain: they depend only on the platform-neutral PrefsStore/PrefsSnapshot/ + // PrefsKey abstraction (see core/prefs/store/PrefsStore.kt), not on Preferences directly, so unlike + // core:database's DatabaseManager split, nothing about their own code needs to move. Predicate, not + // withAndroidTarget()/withApple() — those silently drop androidMain under + // com.android.kotlin.multiplatform.library (KT-80409). See core/ble/build.gradle.kts for the same pattern. + @OptIn(ExperimentalKotlinGradlePluginApi::class) + applyHierarchyTemplate(KotlinHierarchyTemplate.default) { + common { group("nonWeb") { withCompilations { it.target.targetName != "wasmJs" } } } + } + + // The predicate above misses iosMain itself (only reaches the two leaf iOS compilations), so any + // nonWebMain-only actual/declaration can't see nonWebMain's members without this explicit edge. + sourceSets.getByName("iosMain") { dependsOn(sourceSets.getByName("nonWebMain")) } + sourceSets { commonMain.dependencies { implementation(projects.core.repository) implementation(projects.core.common) implementation(projects.core.di) - implementation(libs.androidx.datastore.preferences) implementation(libs.kotlinx.atomicfu) implementation(libs.kotlinx.collections.immutable) implementation(libs.kotlinx.coroutines.core) } + + // android/jvm/ios only (see hierarchy template above) — androidx.datastore.preferences has no wasmJs + // variant, and DataStorePrefsStore (the sole consumer) lives here. + getByName("nonWebMain").dependencies { implementation(libs.androidx.datastore.preferences) } + + wasmJsMain.dependencies { implementation(libs.kotlinx.browser) } + + // All 7 existing commonTest files construct a real DataStore via PreferenceDataStoreFactory, + // so they move to nonWebTest wholesale (same nonWebTest split core:database's DataStore/DAO tests got) — + // nothing wasmJs-reachable in this module's tests needs a real DataStore today. No extra dependency + // needed here: kotlin("test")/kotest/turbine/coroutines-test come from configureKmpTestDependencies()'s + // commonTest additions, and nonWebTest inherits them via the hierarchy template's dependsOn edge. } } diff --git a/core/prefs/src/androidMain/kotlin/org/meshtastic/core/prefs/di/CorePrefsAndroidModule.kt b/core/prefs/src/androidMain/kotlin/org/meshtastic/core/prefs/di/CorePrefsAndroidModule.kt index 5ff3af23cb..f49726c2bc 100644 --- a/core/prefs/src/androidMain/kotlin/org/meshtastic/core/prefs/di/CorePrefsAndroidModule.kt +++ b/core/prefs/src/androidMain/kotlin/org/meshtastic/core/prefs/di/CorePrefsAndroidModule.kt @@ -27,9 +27,11 @@ import kotlinx.coroutines.SupervisorJob import org.koin.core.annotation.Module import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers +import org.meshtastic.core.prefs.store.PrefsStore +import org.meshtastic.core.prefs.store.asPrefsStore /** - * Koin module providing Android [DataStore] instances for each preference domain. + * Koin module providing Android [PrefsStore] instances (each backed by a real [DataStore]) for each preference domain. * * Each DataStore is a singleton backed by its own [CoroutineScope] using the injected [CoroutineDispatchers.io] * dispatcher, and includes a [SharedPreferencesMigration] to migrate legacy SharedPreferences data on first access. @@ -97,14 +99,21 @@ class CorePrefsAndroidModule { /** * [legacyName] is the SharedPreferences file this domain migrates from, [fileName] the DataStore file it lives in now. * Both are on-disk identities — changing either orphans existing user data. + * + * Returns [PrefsStore] (not the underlying `DataStore` directly) so every `provideXDataStore()` call site + * above can keep calling `.asXDataStore()` unchanged — `asPrefsStore()` is the one line doing the adaptation. */ private fun store( context: Context, dispatchers: CoroutineDispatchers, legacyName: String, fileName: String, -): DataStore = PreferenceDataStoreFactory.create( - migrations = listOf(SharedPreferencesMigration(context, legacyName)), - scope = CoroutineScope(dispatchers.io + SupervisorJob()), - produceFile = { context.preferencesDataStoreFile(fileName) }, -) +): PrefsStore { + val dataStore: DataStore = + PreferenceDataStoreFactory.create( + migrations = listOf(SharedPreferencesMigration(context, legacyName)), + scope = CoroutineScope(dispatchers.io + SupervisorJob()), + produceFile = { context.preferencesDataStoreFile(fileName) }, + ) + return dataStore.asPrefsStore() +} diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefsImpl.kt index a55e3d46c6..2e1ce4344e 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/analytics/AnalyticsPrefsImpl.kt @@ -16,9 +16,6 @@ */ package org.meshtastic.core.prefs.analytics -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted @@ -30,6 +27,8 @@ import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.AnalyticsDataStore import org.meshtastic.core.prefs.di.AppDataStore +import org.meshtastic.core.prefs.store.booleanPrefsKey +import org.meshtastic.core.prefs.store.stringPrefsKey import org.meshtastic.core.repository.AnalyticsPrefs import kotlin.uuid.Uuid @@ -67,7 +66,7 @@ class AnalyticsPrefsImpl( const val KEY_ANALYTICS_ALLOWED = "allowed" const val KEY_INSTALL_ID = "appPrefs_install_id" - val KEY_ANALYTICS_ALLOWED_PREF = booleanPreferencesKey(KEY_ANALYTICS_ALLOWED) - val KEY_INSTALL_ID_PREF = stringPreferencesKey(KEY_INSTALL_ID) + val KEY_ANALYTICS_ALLOWED_PREF = booleanPrefsKey(KEY_ANALYTICS_ALLOWED) + val KEY_INSTALL_ID_PREF = stringPrefsKey(KEY_INSTALL_ID) } } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/appfunctions/AppFunctionsPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/appfunctions/AppFunctionsPrefsImpl.kt index bf2e6be0a1..1e0e22cfdf 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/appfunctions/AppFunctionsPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/appfunctions/AppFunctionsPrefsImpl.kt @@ -16,9 +16,6 @@ */ package org.meshtastic.core.prefs.appfunctions -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted @@ -29,6 +26,8 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.AppDataStore +import org.meshtastic.core.prefs.store.PrefsKey +import org.meshtastic.core.prefs.store.booleanPrefsKey import org.meshtastic.core.repository.AppFunctionsPrefs @Single @@ -68,23 +67,23 @@ class AppFunctionsPrefsImpl(private val dataStore: AppDataStore, dispatchers: Co override fun setGetUnreadSummaryEnabled(enabled: Boolean) = set(KEY_GET_UNREAD_SUMMARY, enabled) - private fun booleanPref(key: Preferences.Key, default: Boolean): StateFlow = + private fun booleanPref(key: PrefsKey, default: Boolean): StateFlow = dataStore.data.map { it[key] ?: default }.stateIn(scope, SharingStarted.Eagerly, default) - private fun set(key: Preferences.Key, value: Boolean) { + private fun set(key: PrefsKey, value: Boolean) { scope.launch { dataStore.edit { prefs -> prefs[key] = value } } } companion object { - private val KEY_MASTER = booleanPreferencesKey("appfn_master_enabled") - private val KEY_SEND_MESSAGE = booleanPreferencesKey("appfn_send_message") - private val KEY_GET_MESH_STATUS = booleanPreferencesKey("appfn_get_mesh_status") - private val KEY_GET_NODE_LIST = booleanPreferencesKey("appfn_get_node_list") - private val KEY_GET_CHANNEL_INFO = booleanPreferencesKey("appfn_get_channel_info") - private val KEY_GET_DEVICE_STATUS = booleanPreferencesKey("appfn_get_device_status") - private val KEY_GET_NODE_DETAILS = booleanPreferencesKey("appfn_get_node_details") - private val KEY_GET_MESH_METRICS = booleanPreferencesKey("appfn_get_mesh_metrics") - private val KEY_GET_RECENT_MESSAGES = booleanPreferencesKey("appfn_get_recent_messages") - private val KEY_GET_UNREAD_SUMMARY = booleanPreferencesKey("appfn_get_unread_summary") + private val KEY_MASTER = booleanPrefsKey("appfn_master_enabled") + private val KEY_SEND_MESSAGE = booleanPrefsKey("appfn_send_message") + private val KEY_GET_MESH_STATUS = booleanPrefsKey("appfn_get_mesh_status") + private val KEY_GET_NODE_LIST = booleanPrefsKey("appfn_get_node_list") + private val KEY_GET_CHANNEL_INFO = booleanPrefsKey("appfn_get_channel_info") + private val KEY_GET_DEVICE_STATUS = booleanPrefsKey("appfn_get_device_status") + private val KEY_GET_NODE_DETAILS = booleanPrefsKey("appfn_get_node_details") + private val KEY_GET_MESH_METRICS = booleanPrefsKey("appfn_get_mesh_metrics") + private val KEY_GET_RECENT_MESSAGES = booleanPrefsKey("appfn_get_recent_messages") + private val KEY_GET_UNREAD_SUMMARY = booleanPrefsKey("appfn_get_unread_summary") } } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/di/PrefsDataStores.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/di/PrefsDataStores.kt index 97c66164c7..8c5f43b0df 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/di/PrefsDataStores.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/di/PrefsDataStores.kt @@ -18,76 +18,72 @@ package org.meshtastic.core.prefs.di -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences +import org.meshtastic.core.prefs.store.PrefsStore // One type per preference domain, so the compiler distinguishes them instead of a Koin string qualifier. Each is a -// transparent `DataStore` — inject and use it exactly like one. +// transparent PrefsStore — inject and use it exactly like one. PrefsStore itself (not `DataStore` +// directly — androidx.datastore.preferences has no wasmJs variant) is what makes these usable from every target: +// nonWebMain's DataStorePrefsStore adapts a real DataStore, wasmJsMain's LocalStoragePrefsStore is +// backed by localStorage. See core/prefs/store/PrefsStore.kt. -interface AnalyticsDataStore : DataStore +interface AnalyticsDataStore : PrefsStore /** Presents an existing store as [AnalyticsDataStore]; the wrapper adds nothing but identity. */ -fun DataStore.asAnalyticsDataStore(): AnalyticsDataStore = - object : AnalyticsDataStore, DataStore by this {} +fun PrefsStore.asAnalyticsDataStore(): AnalyticsDataStore = object : AnalyticsDataStore, PrefsStore by this {} -interface AppDataStore : DataStore +interface AppDataStore : PrefsStore /** Presents an existing store as [AppDataStore]; the wrapper adds nothing but identity. */ -fun DataStore.asAppDataStore(): AppDataStore = object : AppDataStore, DataStore by this {} +fun PrefsStore.asAppDataStore(): AppDataStore = object : AppDataStore, PrefsStore by this {} -interface CustomEmojiDataStore : DataStore +interface CustomEmojiDataStore : PrefsStore /** Presents an existing store as [CustomEmojiDataStore]; the wrapper adds nothing but identity. */ -fun DataStore.asCustomEmojiDataStore(): CustomEmojiDataStore = - object : CustomEmojiDataStore, DataStore by this {} +fun PrefsStore.asCustomEmojiDataStore(): CustomEmojiDataStore = object : CustomEmojiDataStore, PrefsStore by this {} -interface FilterDataStore : DataStore +interface FilterDataStore : PrefsStore /** Presents an existing store as [FilterDataStore]; the wrapper adds nothing but identity. */ -fun DataStore.asFilterDataStore(): FilterDataStore = - object : FilterDataStore, DataStore by this {} +fun PrefsStore.asFilterDataStore(): FilterDataStore = object : FilterDataStore, PrefsStore by this {} -interface HomoglyphEncodingDataStore : DataStore +interface HomoglyphEncodingDataStore : PrefsStore /** Presents an existing store as [HomoglyphEncodingDataStore]; the wrapper adds nothing but identity. */ -fun DataStore.asHomoglyphEncodingDataStore(): HomoglyphEncodingDataStore = - object : HomoglyphEncodingDataStore, DataStore by this {} +fun PrefsStore.asHomoglyphEncodingDataStore(): HomoglyphEncodingDataStore = + object : HomoglyphEncodingDataStore, PrefsStore by this {} -interface MapConsentDataStore : DataStore +interface MapConsentDataStore : PrefsStore /** Presents an existing store as [MapConsentDataStore]; the wrapper adds nothing but identity. */ -fun DataStore.asMapConsentDataStore(): MapConsentDataStore = - object : MapConsentDataStore, DataStore by this {} +fun PrefsStore.asMapConsentDataStore(): MapConsentDataStore = object : MapConsentDataStore, PrefsStore by this {} -interface MapDataStore : DataStore +interface MapDataStore : PrefsStore /** Presents an existing store as [MapDataStore]; the wrapper adds nothing but identity. */ -fun DataStore.asMapDataStore(): MapDataStore = object : MapDataStore, DataStore by this {} +fun PrefsStore.asMapDataStore(): MapDataStore = object : MapDataStore, PrefsStore by this {} -interface MapTileProviderDataStore : DataStore +interface MapTileProviderDataStore : PrefsStore /** Presents an existing store as [MapTileProviderDataStore]; the wrapper adds nothing but identity. */ -fun DataStore.asMapTileProviderDataStore(): MapTileProviderDataStore = - object : MapTileProviderDataStore, DataStore by this {} +fun PrefsStore.asMapTileProviderDataStore(): MapTileProviderDataStore = + object : MapTileProviderDataStore, PrefsStore by this {} -interface MeshDataStore : DataStore +interface MeshDataStore : PrefsStore /** Presents an existing store as [MeshDataStore]; the wrapper adds nothing but identity. */ -fun DataStore.asMeshDataStore(): MeshDataStore = object : MeshDataStore, DataStore by this {} +fun PrefsStore.asMeshDataStore(): MeshDataStore = object : MeshDataStore, PrefsStore by this {} -interface MeshLogDataStore : DataStore +interface MeshLogDataStore : PrefsStore /** Presents an existing store as [MeshLogDataStore]; the wrapper adds nothing but identity. */ -fun DataStore.asMeshLogDataStore(): MeshLogDataStore = - object : MeshLogDataStore, DataStore by this {} +fun PrefsStore.asMeshLogDataStore(): MeshLogDataStore = object : MeshLogDataStore, PrefsStore by this {} -interface RadioDataStore : DataStore +interface RadioDataStore : PrefsStore /** Presents an existing store as [RadioDataStore]; the wrapper adds nothing but identity. */ -fun DataStore.asRadioDataStore(): RadioDataStore = - object : RadioDataStore, DataStore by this {} +fun PrefsStore.asRadioDataStore(): RadioDataStore = object : RadioDataStore, PrefsStore by this {} -interface UiDataStore : DataStore +interface UiDataStore : PrefsStore /** Presents an existing store as [UiDataStore]; the wrapper adds nothing but identity. */ -fun DataStore.asUiDataStore(): UiDataStore = object : UiDataStore, DataStore by this {} +fun PrefsStore.asUiDataStore(): UiDataStore = object : UiDataStore, PrefsStore by this {} diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/discovery/DiscoveryPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/discovery/DiscoveryPrefsImpl.kt index e93bc20b2d..b8ccd37a7a 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/discovery/DiscoveryPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/discovery/DiscoveryPrefsImpl.kt @@ -16,10 +16,6 @@ */ package org.meshtastic.core.prefs.discovery -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.intPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted @@ -30,6 +26,9 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.UiDataStore +import org.meshtastic.core.prefs.store.booleanPrefsKey +import org.meshtastic.core.prefs.store.intPrefsKey +import org.meshtastic.core.prefs.store.stringPrefsKey import org.meshtastic.core.repository.DiscoveryPrefs @Single @@ -72,10 +71,10 @@ class DiscoveryPrefsImpl(private val dataStore: UiDataStore, dispatchers: Corout } companion object { - private val KEY_DWELL_MINUTES = intPreferencesKey("discovery_dwell_minutes") - private val KEY_SELECTED_PRESETS = stringPreferencesKey("discovery_selected_presets") - private val KEY_AI_ENABLED = booleanPreferencesKey("discovery_ai_enabled") - private val KEY_TOPOLOGY_OVERLAY = booleanPreferencesKey("discovery_topology_overlay") + private val KEY_DWELL_MINUTES = intPrefsKey("discovery_dwell_minutes") + private val KEY_SELECTED_PRESETS = stringPrefsKey("discovery_selected_presets") + private val KEY_AI_ENABLED = booleanPrefsKey("discovery_ai_enabled") + private val KEY_TOPOLOGY_OVERLAY = booleanPrefsKey("discovery_topology_overlay") private const val PRESET_DELIMITER = "," } } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/discovery/MeshBeaconPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/discovery/MeshBeaconPrefsImpl.kt index 7322dd9b54..c24236188c 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/discovery/MeshBeaconPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/discovery/MeshBeaconPrefsImpl.kt @@ -16,8 +16,6 @@ */ package org.meshtastic.core.prefs.discovery -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow @@ -31,6 +29,7 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.UiDataStore +import org.meshtastic.core.prefs.store.stringPrefsKey import org.meshtastic.core.repository.MeshBeaconPrefs @Single @@ -64,7 +63,7 @@ class MeshBeaconPrefsImpl(private val dataStore: UiDataStore, dispatchers: Corou } private companion object { - val KEY_STORED_BEACONS = stringPreferencesKey("mesh_beacon_stored_offers") + val KEY_STORED_BEACONS = stringPrefsKey("mesh_beacon_stored_offers") // Newline can never appear in a beacon record (fields are numeric + base64), so it is a safe row separator. const val RECORD_DELIMITER = "\n" diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/emoji/CustomEmojiPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/emoji/CustomEmojiPrefsImpl.kt index ae9bb8faf9..1b34e219b4 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/emoji/CustomEmojiPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/emoji/CustomEmojiPrefsImpl.kt @@ -16,9 +16,6 @@ */ package org.meshtastic.core.prefs.emoji -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.intPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted @@ -29,6 +26,8 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.CustomEmojiDataStore +import org.meshtastic.core.prefs.store.intPrefsKey +import org.meshtastic.core.prefs.store.stringPrefsKey import org.meshtastic.core.repository.CustomEmojiPrefs @Single @@ -60,7 +59,7 @@ class CustomEmojiPrefsImpl(private val dataStore: CustomEmojiDataStore, dispatch companion object { const val KEY_EMOJI_FREQ = "pref_key_custom_emoji_freq" - val KEY_EMOJI_FREQ_PREF = stringPreferencesKey(KEY_EMOJI_FREQ) - val KEY_SKIN_TONE_PREF = intPreferencesKey("pref_key_skin_tone") + val KEY_EMOJI_FREQ_PREF = stringPrefsKey(KEY_EMOJI_FREQ) + val KEY_SKIN_TONE_PREF = intPrefsKey("pref_key_skin_tone") } } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsImpl.kt index be5dd819d1..cc64a9ccbf 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsImpl.kt @@ -16,9 +16,6 @@ */ package org.meshtastic.core.prefs.filter -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringSetPreferencesKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted @@ -29,6 +26,8 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.FilterDataStore +import org.meshtastic.core.prefs.store.booleanPrefsKey +import org.meshtastic.core.prefs.store.stringSetPrefsKey import org.meshtastic.core.repository.FilterPrefs @Single @@ -56,7 +55,7 @@ class FilterPrefsImpl(private val dataStore: FilterDataStore, dispatchers: Corou const val KEY_FILTER_WORDS = "filter_words" const val FILTER_PREFS_NAME = "filter-prefs" - val KEY_FILTER_ENABLED_PREF = booleanPreferencesKey(KEY_FILTER_ENABLED) - val KEY_FILTER_WORDS_PREF = stringSetPreferencesKey(KEY_FILTER_WORDS) + val KEY_FILTER_ENABLED_PREF = booleanPrefsKey(KEY_FILTER_ENABLED) + val KEY_FILTER_WORDS_PREF = stringSetPrefsKey(KEY_FILTER_WORDS) } } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/homoglyph/HomoglyphPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/homoglyph/HomoglyphPrefsImpl.kt index 7e5693ff9e..7ce0638200 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/homoglyph/HomoglyphPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/homoglyph/HomoglyphPrefsImpl.kt @@ -16,8 +16,6 @@ */ package org.meshtastic.core.prefs.homoglyph -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted @@ -28,6 +26,7 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.HomoglyphEncodingDataStore +import org.meshtastic.core.prefs.store.booleanPrefsKey import org.meshtastic.core.repository.HomoglyphPrefs @Single @@ -44,6 +43,6 @@ class HomoglyphPrefsImpl(private val dataStore: HomoglyphEncodingDataStore, disp companion object { const val KEY_ENABLED = "enabled" - val KEY_ENABLED_PREF = booleanPreferencesKey(KEY_ENABLED) + val KEY_ENABLED_PREF = booleanPrefsKey(KEY_ENABLED) } } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt index cf6e630014..bcf87e0329 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapConsentPrefsImpl.kt @@ -16,8 +16,6 @@ */ package org.meshtastic.core.prefs.map -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit import kotlinx.atomicfu.atomic import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.CoroutineScope @@ -31,6 +29,7 @@ import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.cachedFlow import org.meshtastic.core.prefs.di.MapConsentDataStore +import org.meshtastic.core.prefs.store.booleanPrefsKey import org.meshtastic.core.repository.MapConsentPrefs @Single @@ -41,11 +40,11 @@ class MapConsentPrefsImpl(private val dataStore: MapConsentDataStore, dispatcher private val consentFlows = atomic(persistentMapOf>>()) override fun shouldReportLocation(nodeNum: Int?): StateFlow = cachedFlow(consentFlows, nodeNum) { - val key = booleanPreferencesKey(nodeNum.toString()) + val key = booleanPrefsKey(nodeNum.toString()) dataStore.data.map { it[key] ?: false }.stateIn(scope, SharingStarted.Eagerly, false) } override fun setShouldReportLocation(nodeNum: Int?, report: Boolean) { - scope.launch { dataStore.edit { prefs -> prefs[booleanPreferencesKey(nodeNum.toString())] = report } } + scope.launch { dataStore.edit { prefs -> prefs[booleanPrefsKey(nodeNum.toString())] = report } } } } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt index a500b6f70c..b40d08001a 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapPrefsImpl.kt @@ -16,12 +16,6 @@ */ package org.meshtastic.core.prefs.map -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.doublePreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.intPreferencesKey -import androidx.datastore.preferences.core.longPreferencesKey -import androidx.datastore.preferences.core.stringSetPreferencesKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted @@ -33,6 +27,11 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.MapDataStore +import org.meshtastic.core.prefs.store.booleanPrefsKey +import org.meshtastic.core.prefs.store.doublePrefsKey +import org.meshtastic.core.prefs.store.intPrefsKey +import org.meshtastic.core.prefs.store.longPrefsKey +import org.meshtastic.core.prefs.store.stringSetPrefsKey import org.meshtastic.core.repository.MapCameraPosition import org.meshtastic.core.repository.MapPrefs @@ -190,23 +189,23 @@ class MapPrefsImpl(private val dataStore: MapDataStore, dispatchers: CoroutineDi .first() companion object { - val KEY_MAP_STYLE_PREF = intPreferencesKey("map_style_id") - val KEY_SHOW_ONLY_FAVORITES_PREF = booleanPreferencesKey("show_only_favorites") - val KEY_SHOW_WAYPOINTS_PREF = booleanPreferencesKey("show_waypoints") - val KEY_SHOW_PRECISION_CIRCLE_PREF = booleanPreferencesKey("show_precision_circle") - val KEY_LAST_HEARD_FILTER_PREF = longPreferencesKey("last_heard_filter") - val KEY_LAST_HEARD_TRACK_FILTER_PREF = longPreferencesKey("last_heard_track_filter") - val KEY_HIDDEN_LAYER_URLS_PREF = stringSetPreferencesKey("hidden_layer_urls") - val KEY_NETWORK_MAP_LAYERS_PREF = stringSetPreferencesKey("network_map_layers") - val KEY_LAYER_OPACITY_PREF = stringSetPreferencesKey("layer_opacity") - val KEY_ONLY_ONLINE_PREF = booleanPreferencesKey("map_only_online") - val KEY_ONLY_DIRECT_PREF = booleanPreferencesKey("map_only_direct") - val KEY_EXCLUDE_MQTT_PREF = booleanPreferencesKey("map_exclude_mqtt") - val KEY_SHOW_IGNORED_PREF = booleanPreferencesKey("map_show_ignored") - val KEY_INCLUDE_UNKNOWN_PREF = booleanPreferencesKey("map_include_unknown") - val KEY_EXCLUDED_ROLES_PREF = stringSetPreferencesKey("map_excluded_roles") - val KEY_CAMERA_LATITUDE = doublePreferencesKey("camera_latitude") - val KEY_CAMERA_LONGITUDE = doublePreferencesKey("camera_longitude") - val KEY_CAMERA_ZOOM = doublePreferencesKey("camera_zoom") + val KEY_MAP_STYLE_PREF = intPrefsKey("map_style_id") + val KEY_SHOW_ONLY_FAVORITES_PREF = booleanPrefsKey("show_only_favorites") + val KEY_SHOW_WAYPOINTS_PREF = booleanPrefsKey("show_waypoints") + val KEY_SHOW_PRECISION_CIRCLE_PREF = booleanPrefsKey("show_precision_circle") + val KEY_LAST_HEARD_FILTER_PREF = longPrefsKey("last_heard_filter") + val KEY_LAST_HEARD_TRACK_FILTER_PREF = longPrefsKey("last_heard_track_filter") + val KEY_HIDDEN_LAYER_URLS_PREF = stringSetPrefsKey("hidden_layer_urls") + val KEY_NETWORK_MAP_LAYERS_PREF = stringSetPrefsKey("network_map_layers") + val KEY_LAYER_OPACITY_PREF = stringSetPrefsKey("layer_opacity") + val KEY_ONLY_ONLINE_PREF = booleanPrefsKey("map_only_online") + val KEY_ONLY_DIRECT_PREF = booleanPrefsKey("map_only_direct") + val KEY_EXCLUDE_MQTT_PREF = booleanPrefsKey("map_exclude_mqtt") + val KEY_SHOW_IGNORED_PREF = booleanPrefsKey("map_show_ignored") + val KEY_INCLUDE_UNKNOWN_PREF = booleanPrefsKey("map_include_unknown") + val KEY_EXCLUDED_ROLES_PREF = stringSetPrefsKey("map_excluded_roles") + val KEY_CAMERA_LATITUDE = doublePrefsKey("camera_latitude") + val KEY_CAMERA_LONGITUDE = doublePrefsKey("camera_longitude") + val KEY_CAMERA_ZOOM = doublePrefsKey("camera_zoom") } } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefsImpl.kt index 1787ffc618..bed29bac3d 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/map/MapTileProviderPrefsImpl.kt @@ -16,8 +16,6 @@ */ package org.meshtastic.core.prefs.map -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted @@ -28,6 +26,7 @@ import kotlinx.coroutines.flow.stateIn import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.MapTileProviderDataStore +import org.meshtastic.core.prefs.store.stringPrefsKey import org.meshtastic.core.repository.MapTileProviderPrefs @Single @@ -69,7 +68,7 @@ class MapTileProviderPrefsImpl(private val dataStore: MapTileProviderDataStore, companion object { const val KEY_CUSTOM_PROVIDERS = "custom_tile_providers" const val KEY_SELECTED_CUSTOM_PROVIDER_ID = "selected_custom_tile_provider_id" - val KEY_CUSTOM_PROVIDERS_PREF = stringPreferencesKey(KEY_CUSTOM_PROVIDERS) - val KEY_SELECTED_CUSTOM_PROVIDER_ID_PREF = stringPreferencesKey(KEY_SELECTED_CUSTOM_PROVIDER_ID) + val KEY_CUSTOM_PROVIDERS_PREF = stringPrefsKey(KEY_CUSTOM_PROVIDERS) + val KEY_SELECTED_CUSTOM_PROVIDER_ID_PREF = stringPrefsKey(KEY_SELECTED_CUSTOM_PROVIDER_ID) } } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt index 61a15b0e8b..a8c8a51541 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImpl.kt @@ -16,9 +16,6 @@ */ package org.meshtastic.core.prefs.mesh -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.intPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.atomicfu.atomic import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.CoroutineScope @@ -34,6 +31,8 @@ import org.meshtastic.core.common.util.normalizeAddress import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.cachedFlow import org.meshtastic.core.prefs.di.MeshDataStore +import org.meshtastic.core.prefs.store.intPrefsKey +import org.meshtastic.core.prefs.store.stringPrefsKey import org.meshtastic.core.repository.MeshPrefs @Single @@ -63,14 +62,14 @@ class MeshPrefsImpl(private val dataStore: MeshDataStore, dispatchers: Coroutine dataStore.data.first()[KEY_DEVICE_ADDRESS_PREF] ?: NO_DEVICE_SELECTED override fun getStoreForwardLastRequest(address: String?): StateFlow = cachedFlow(storeForwardFlows, address) { - val key = intPreferencesKey(storeForwardKey(address)) + val key = intPrefsKey(storeForwardKey(address)) dataStore.data.map { it[key] ?: 0 }.stateIn(scope, SharingStarted.Eagerly, 0) } override fun setStoreForwardLastRequest(address: String?, timestamp: Int) { scope.launch { dataStore.edit { prefs -> - val key = intPreferencesKey(storeForwardKey(address)) + val key = intPrefsKey(storeForwardKey(address)) if (timestamp <= 0) { prefs.remove(key) } else { @@ -83,7 +82,7 @@ class MeshPrefsImpl(private val dataStore: MeshDataStore, dispatchers: Coroutine private fun storeForwardKey(address: String?): String = "store-forward-last-request-${normalizeAddress(address)}" companion object { - val KEY_DEVICE_ADDRESS_PREF = stringPreferencesKey("device_address") + val KEY_DEVICE_ADDRESS_PREF = stringPrefsKey("device_address") } } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefsImpl.kt index f2d8868410..74eee1238f 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/meshlog/MeshLogPrefsImpl.kt @@ -16,9 +16,6 @@ */ package org.meshtastic.core.prefs.meshlog -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.intPreferencesKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted @@ -30,6 +27,8 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.MeshLogDataStore +import org.meshtastic.core.prefs.store.booleanPrefsKey +import org.meshtastic.core.prefs.store.intPrefsKey import org.meshtastic.core.repository.MeshLogCleanupPolicy import org.meshtastic.core.repository.MeshLogPrefs @@ -68,7 +67,7 @@ class MeshLogPrefsImpl(private val dataStore: MeshLogDataStore, dispatchers: Cor const val DEFAULT_RETENTION_DAYS = 30 const val DEFAULT_LOGGING_ENABLED = true - val KEY_RETENTION_DAYS_PREF = intPreferencesKey(RETENTION_DAYS_KEY) - val KEY_LOGGING_ENABLED_PREF = booleanPreferencesKey(LOGGING_ENABLED_KEY) + val KEY_RETENTION_DAYS_PREF = intPrefsKey(RETENTION_DAYS_KEY) + val KEY_LOGGING_ENABLED_PREF = booleanPrefsKey(LOGGING_ENABLED_KEY) } } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsImpl.kt index cbf73b407d..df44ee4b2d 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsImpl.kt @@ -16,9 +16,6 @@ */ package org.meshtastic.core.prefs.notification -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted @@ -29,6 +26,8 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.UiDataStore +import org.meshtastic.core.prefs.store.booleanPrefsKey +import org.meshtastic.core.prefs.store.stringPrefsKey import org.meshtastic.core.repository.NotificationPrefs @Single @@ -116,10 +115,10 @@ class NotificationPrefsImpl(private val dataStore: UiDataStore, dispatchers: Cor private fun parseOptInIds(csv: String?): List = csv?.split(',')?.mapNotNull { it.toIntOrNull() }?.distinct() ?: emptyList() - private val KEY_MESSAGES_ENABLED = booleanPreferencesKey("notif_messages_enabled") - private val KEY_NODE_EVENTS_ENABLED = booleanPreferencesKey("notif_node_events_enabled") - private val KEY_NODE_EVENTS_AUTO_DISABLED = booleanPreferencesKey("notif_node_events_auto_disabled_event") - private val KEY_LOW_BATTERY_ENABLED = booleanPreferencesKey("notif_low_battery_enabled") - private val KEY_GEOFENCE_ALERT_OPT_INS = stringPreferencesKey("notif_geofence_alert_opt_ins") + private val KEY_MESSAGES_ENABLED = booleanPrefsKey("notif_messages_enabled") + private val KEY_NODE_EVENTS_ENABLED = booleanPrefsKey("notif_node_events_enabled") + private val KEY_NODE_EVENTS_AUTO_DISABLED = booleanPrefsKey("notif_node_events_auto_disabled_event") + private val KEY_LOW_BATTERY_ENABLED = booleanPrefsKey("notif_low_battery_enabled") + private val KEY_GEOFENCE_ALERT_OPT_INS = stringPrefsKey("notif_geofence_alert_opt_ins") } } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImpl.kt index 6f4cf80ebc..2a7e0f506e 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/radio/RadioPrefsImpl.kt @@ -16,8 +16,6 @@ */ package org.meshtastic.core.prefs.radio -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted @@ -28,6 +26,7 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.RadioDataStore +import org.meshtastic.core.prefs.store.stringPrefsKey import org.meshtastic.core.repository.RadioPrefs @Single @@ -65,7 +64,7 @@ class RadioPrefsImpl(private val dataStore: RadioDataStore, dispatchers: Corouti } companion object { - val KEY_DEV_ADDR_PREF = stringPreferencesKey("devAddr2") - val KEY_DEV_NAME_PREF = stringPreferencesKey("devName") + val KEY_DEV_ADDR_PREF = stringPrefsKey("devAddr2") + val KEY_DEV_NAME_PREF = stringPrefsKey("devName") } } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/store/PrefsStore.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/store/PrefsStore.kt new file mode 100644 index 0000000000..304d68d0a9 --- /dev/null +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/store/PrefsStore.kt @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.prefs.store + +import kotlinx.coroutines.flow.Flow + +/** + * The value shapes a [PrefsKey] can address — every case a real preference in this module actually uses. `internal`: + * only the two platform-specific [PrefsStore] implementations (nonWebMain's `DataStorePrefsStore`, wasmJsMain's + * `LocalStoragePrefsStore`) ever need to switch on it; every `*PrefsImpl` consumer only ever calls + * [PrefsSnapshot.get]/[PrefsSnapshot.Editor.set] generically and never inspects a key's shape. + */ +internal enum class PrefsKeyType { + BOOLEAN, + INT, + LONG, + DOUBLE, + STRING, + STRING_SET, +} + +/** + * A single, platform-neutral key into a [PrefsStore]. Mirrors `androidx.datastore.preferences.core.Preferences.Key` + * closely enough that porting a `*PrefsImpl` off DataStore is a mechanical rename, not a rewrite: swap the + * `androidx.datastore.preferences.core.*PreferencesKey` import/call for the matching `*PrefsKey` factory below, and + * `Preferences`/`MutablePreferences` for [PrefsSnapshot]/[PrefsSnapshot.Editor]. + * + * Equality/hashing is by [name] alone (matching `Preferences.Key`), so two keys constructed for the same name — even + * from different call sites, e.g. a dynamic per-node key built fresh on every read and write — address the same + * underlying value. + */ +class PrefsKey internal constructor(internal val name: String, internal val type: PrefsKeyType) { + override fun equals(other: Any?): Boolean = other is PrefsKey<*> && name == other.name + + override fun hashCode(): Int = name.hashCode() + + override fun toString(): String = name +} + +fun booleanPrefsKey(name: String): PrefsKey = PrefsKey(name, PrefsKeyType.BOOLEAN) + +fun intPrefsKey(name: String): PrefsKey = PrefsKey(name, PrefsKeyType.INT) + +fun longPrefsKey(name: String): PrefsKey = PrefsKey(name, PrefsKeyType.LONG) + +fun doublePrefsKey(name: String): PrefsKey = PrefsKey(name, PrefsKeyType.DOUBLE) + +fun stringPrefsKey(name: String): PrefsKey = PrefsKey(name, PrefsKeyType.STRING) + +fun stringSetPrefsKey(name: String): PrefsKey> = PrefsKey(name, PrefsKeyType.STRING_SET) + +/** Read-only view of a [PrefsStore]'s current values. Mirrors `androidx.datastore.preferences.core.Preferences`. */ +interface PrefsSnapshot { + operator fun get(key: PrefsKey): T? + + operator fun contains(key: PrefsKey<*>): Boolean + + /** + * Mutable view passed to [PrefsStore.edit]'s transform block. Mirrors `MutablePreferences` — including reading back + * values already written earlier in the same transform, which several `*PrefsImpl` read-modify-write call sites + * rely on (e.g. bumping an insertion-ordered CSV, or defaulting a value only if absent). + */ + interface Editor : PrefsSnapshot { + operator fun set(key: PrefsKey, value: T) + + fun remove(key: PrefsKey) + } +} + +/** + * Platform-neutral replacement for `DataStore`. `androidx.datastore.preferences` itself publishes no + * wasmJs (or even plain JS) variant at any version — the `Preferences` type doesn't exist for that target — so this + * module can't reference it from commonMain at all. Two implementations exist: + * - nonWebMain's `DataStorePrefsStore` — a thin wrapper over a real `DataStore`; android/jvm/iOS keep + * using the real DataStore machinery underneath, unchanged. + * - wasmJsMain's `LocalStoragePrefsStore` — backed by the browser's `localStorage`, a synchronous, built-in key-value + * string store. Unlike core:database's OPFS story, no Worker or npm dependency is needed here at all. + * + * `edit`'s [transform] is deliberately non-suspend (androidx's `DataStore.edit`'s is `suspend`) and returns `Unit` + * (androidx's returns the resulting `Preferences`) — no call site in this module uses either capability, and keeping it + * synchronous means no suspension point can interleave between a transform's reads and its writes on any platform. + */ +interface PrefsStore { + val data: Flow + + suspend fun edit(transform: (PrefsSnapshot.Editor) -> Unit) +} diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/tak/TakPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/tak/TakPrefsImpl.kt index a80cb215e8..fa75f0f1e2 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/tak/TakPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/tak/TakPrefsImpl.kt @@ -16,9 +16,6 @@ */ package org.meshtastic.core.prefs.tak -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.intPreferencesKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.SharingStarted @@ -29,6 +26,8 @@ import kotlinx.coroutines.launch import org.koin.core.annotation.Single import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.UiDataStore +import org.meshtastic.core.prefs.store.booleanPrefsKey +import org.meshtastic.core.prefs.store.intPrefsKey import org.meshtastic.core.repository.TakPrefs @Single(binds = [TakPrefs::class]) @@ -57,8 +56,8 @@ class TakPrefsImpl(private val dataStore: UiDataStore, dispatchers: CoroutineDis } companion object { - val KEY_TAK_SERVER_ENABLED = booleanPreferencesKey("tak_server_enabled") - val KEY_TAK_MESH_TO_COT = booleanPreferencesKey("tak_mesh_to_cot") - val KEY_TAK_SERVER_CHANNEL = intPreferencesKey("tak_server_channel") + val KEY_TAK_SERVER_ENABLED = booleanPrefsKey("tak_server_enabled") + val KEY_TAK_MESH_TO_COT = booleanPrefsKey("tak_mesh_to_cot") + val KEY_TAK_SERVER_CHANNEL = intPrefsKey("tak_server_channel") } } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/NodeListLayoutPreferences.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/NodeListLayoutPreferences.kt index c4f3b19233..adf74ee6a6 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/NodeListLayoutPreferences.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/NodeListLayoutPreferences.kt @@ -16,9 +16,9 @@ */ package org.meshtastic.core.prefs.ui -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey import org.meshtastic.core.model.NodeListDensity +import org.meshtastic.core.prefs.store.booleanPrefsKey +import org.meshtastic.core.prefs.store.stringPrefsKey /** * DataStore preference keys for node list layout configuration. Key strings are used directly by DataStore — do not @@ -40,15 +40,15 @@ enum class NodeListLayoutPreferences(val key: String, val defaultBoolean: Boolea private const val DENSITY_KEY = "node-list-density" val DEFAULT_DENSITY = NodeListDensity.COMPLETE.name - val KEY_DENSITY = stringPreferencesKey(DENSITY_KEY) - val KEY_SHOW_POWER = booleanPreferencesKey(SHOULD_SHOW_POWER.key) - val KEY_SHOW_LAST_HEARD = booleanPreferencesKey(SHOULD_SHOW_LAST_HEARD.key) - val KEY_LAST_HEARD_RELATIVE = booleanPreferencesKey(LAST_HEARD_IS_RELATIVE.key) - val KEY_SHOW_LOCATION = booleanPreferencesKey(SHOULD_SHOW_LOCATION.key) - val KEY_SHOW_HOPS = booleanPreferencesKey(SHOULD_SHOW_HOPS.key) - val KEY_SHOW_SIGNAL = booleanPreferencesKey(SHOULD_SHOW_SIGNAL.key) - val KEY_SHOW_CHANNEL = booleanPreferencesKey(SHOULD_SHOW_CHANNEL.key) - val KEY_SHOW_ROLE = booleanPreferencesKey(SHOULD_SHOW_ROLE.key) - val KEY_SHOW_TELEMETRY = booleanPreferencesKey(SHOULD_SHOW_TELEMETRY.key) + val KEY_DENSITY = stringPrefsKey(DENSITY_KEY) + val KEY_SHOW_POWER = booleanPrefsKey(SHOULD_SHOW_POWER.key) + val KEY_SHOW_LAST_HEARD = booleanPrefsKey(SHOULD_SHOW_LAST_HEARD.key) + val KEY_LAST_HEARD_RELATIVE = booleanPrefsKey(LAST_HEARD_IS_RELATIVE.key) + val KEY_SHOW_LOCATION = booleanPrefsKey(SHOULD_SHOW_LOCATION.key) + val KEY_SHOW_HOPS = booleanPrefsKey(SHOULD_SHOW_HOPS.key) + val KEY_SHOW_SIGNAL = booleanPrefsKey(SHOULD_SHOW_SIGNAL.key) + val KEY_SHOW_CHANNEL = booleanPrefsKey(SHOULD_SHOW_CHANNEL.key) + val KEY_SHOW_ROLE = booleanPrefsKey(SHOULD_SHOW_ROLE.key) + val KEY_SHOW_TELEMETRY = booleanPrefsKey(SHOULD_SHOW_TELEMETRY.key) } } diff --git a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt index 7e7e72a258..aee605e5d6 100644 --- a/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt +++ b/core/prefs/src/commonMain/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImpl.kt @@ -16,11 +16,6 @@ */ package org.meshtastic.core.prefs.ui -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.booleanPreferencesKey -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.intPreferencesKey -import androidx.datastore.preferences.core.stringPreferencesKey import kotlinx.atomicfu.atomic import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.CoroutineScope @@ -35,6 +30,10 @@ import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceType import org.meshtastic.core.prefs.cachedFlow import org.meshtastic.core.prefs.di.UiDataStore +import org.meshtastic.core.prefs.store.PrefsSnapshot +import org.meshtastic.core.prefs.store.booleanPrefsKey +import org.meshtastic.core.prefs.store.intPrefsKey +import org.meshtastic.core.prefs.store.stringPrefsKey import org.meshtastic.core.repository.UiPrefs @Single @@ -209,12 +208,12 @@ class UiPrefsImpl(private val dataStore: UiDataStore, dispatchers: CoroutineDisp override fun shouldProvideNodeLocation(nodeNum: Int): StateFlow = cachedFlow(provideNodeLocationFlows, nodeNum) { - val key = booleanPreferencesKey(provideLocationKey(nodeNum)) + val key = booleanPrefsKey(provideLocationKey(nodeNum)) dataStore.data.map { it[key] ?: false }.stateIn(scope, SharingStarted.Eagerly, false) } override fun setShouldProvideNodeLocation(nodeNum: Int, provide: Boolean) { - scope.launch { dataStore.edit { it[booleanPreferencesKey(provideLocationKey(nodeNum))] = provide } } + scope.launch { dataStore.edit { it[booleanPrefsKey(provideLocationKey(nodeNum))] = provide } } } private fun provideLocationKey(nodeNum: Int) = "provide-location-$nodeNum" @@ -312,34 +311,34 @@ class UiPrefsImpl(private val dataStore: UiDataStore, dispatchers: CoroutineDisp } companion object { - val KEY_HAS_SHOWN_NOT_PAIRED_WARNING_PREF = booleanPreferencesKey("has_shown_not_paired_warning") - val KEY_SHOW_QUICK_CHAT_PREF = booleanPreferencesKey("show-quick-chat") - val KEY_SHOW_FULL_MESSAGE_TIMESTAMPS = booleanPreferencesKey("show-full-message-timestamps") - val KEY_EVENT_THEME_ENABLED = booleanPreferencesKey("event-theme-enabled") + val KEY_HAS_SHOWN_NOT_PAIRED_WARNING_PREF = booleanPrefsKey("has_shown_not_paired_warning") + val KEY_SHOW_QUICK_CHAT_PREF = booleanPrefsKey("show-quick-chat") + val KEY_SHOW_FULL_MESSAGE_TIMESTAMPS = booleanPrefsKey("show-full-message-timestamps") + val KEY_EVENT_THEME_ENABLED = booleanPrefsKey("event-theme-enabled") - val KEY_APP_INTRO_COMPLETED = booleanPreferencesKey("app_intro_completed") - val KEY_THEME = intPreferencesKey("theme") - private val KEY_UNITS_OVERRIDE = intPreferencesKey("units_override") - val KEY_LOCALE = stringPreferencesKey("locale") - val KEY_NODE_SORT = intPreferencesKey("node-sort-option") - val KEY_INCLUDE_UNKNOWN = booleanPreferencesKey("include-unknown") - val KEY_EXCLUDE_INFRASTRUCTURE = booleanPreferencesKey("exclude-infrastructure") - val KEY_ONLY_ONLINE = booleanPreferencesKey("only-online") - val KEY_ONLY_DIRECT = booleanPreferencesKey("only-direct") - val KEY_SHOW_IGNORED = booleanPreferencesKey("show-ignored") - val KEY_EXCLUDE_MQTT = booleanPreferencesKey("exclude-mqtt") - val KEY_BLE_AUTO_SCAN = booleanPreferencesKey("ble-auto-scan") - val KEY_NETWORK_AUTO_SCAN = booleanPreferencesKey("network-auto-scan") - val KEY_SELECTED_CONNECTION_TRANSPORT = stringPreferencesKey("selected-connection-transport") - val KEY_FIRMWARE_UPDATE_NOTIFICATION_KEYS = stringPreferencesKey("firmware-update-notification-keys") - val KEY_SHOW_BLE_TRANSPORT = booleanPreferencesKey("show-ble-transport") - val KEY_SHOW_NETWORK_TRANSPORT = booleanPreferencesKey("show-network-transport") - val KEY_SHOW_USB_TRANSPORT = booleanPreferencesKey("show-usb-transport") + val KEY_APP_INTRO_COMPLETED = booleanPrefsKey("app_intro_completed") + val KEY_THEME = intPrefsKey("theme") + private val KEY_UNITS_OVERRIDE = intPrefsKey("units_override") + val KEY_LOCALE = stringPrefsKey("locale") + val KEY_NODE_SORT = intPrefsKey("node-sort-option") + val KEY_INCLUDE_UNKNOWN = booleanPrefsKey("include-unknown") + val KEY_EXCLUDE_INFRASTRUCTURE = booleanPrefsKey("exclude-infrastructure") + val KEY_ONLY_ONLINE = booleanPrefsKey("only-online") + val KEY_ONLY_DIRECT = booleanPrefsKey("only-direct") + val KEY_SHOW_IGNORED = booleanPrefsKey("show-ignored") + val KEY_EXCLUDE_MQTT = booleanPrefsKey("exclude-mqtt") + val KEY_BLE_AUTO_SCAN = booleanPrefsKey("ble-auto-scan") + val KEY_NETWORK_AUTO_SCAN = booleanPrefsKey("network-auto-scan") + val KEY_SELECTED_CONNECTION_TRANSPORT = stringPrefsKey("selected-connection-transport") + val KEY_FIRMWARE_UPDATE_NOTIFICATION_KEYS = stringPrefsKey("firmware-update-notification-keys") + val KEY_SHOW_BLE_TRANSPORT = booleanPrefsKey("show-ble-transport") + val KEY_SHOW_NETWORK_TRANSPORT = booleanPrefsKey("show-network-transport") + val KEY_SHOW_USB_TRANSPORT = booleanPrefsKey("show-usb-transport") private const val MAX_FIRMWARE_UPDATE_NOTIFICATION_KEYS = 100 private fun parseDeviceType(name: String): DeviceType? = DeviceType.entries.firstOrNull { it.name == name } - private fun legacySelectedConnectionTransport(preferences: Preferences): DeviceType? { + private fun legacySelectedConnectionTransport(preferences: PrefsSnapshot): DeviceType? { val hasLegacyTransportPreference = KEY_SHOW_BLE_TRANSPORT in preferences || KEY_SHOW_NETWORK_TRANSPORT in preferences || diff --git a/core/prefs/src/nonWebMain/kotlin/org/meshtastic/core/prefs/store/DataStorePrefsStore.kt b/core/prefs/src/nonWebMain/kotlin/org/meshtastic/core/prefs/store/DataStorePrefsStore.kt new file mode 100644 index 0000000000..480d901d43 --- /dev/null +++ b/core/prefs/src/nonWebMain/kotlin/org/meshtastic/core/prefs/store/DataStorePrefsStore.kt @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.prefs.store + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.doublePreferencesKey +import androidx.datastore.preferences.core.edit +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.map + +/** + * Adapts a real `DataStore` (android/jvm/iOS only — `androidx.datastore.preferences` has no wasmJs + * variant) to the platform-neutral [PrefsStore]. Every `CorePrefsAndroidModule`/`DesktopPlatformModule` call site that + * used to hand a `DataStore` straight to an `asXDataStore()` wrapper now inserts this adapter first: + * `store(...).asPrefsStore().asXDataStore()`. + */ +fun DataStore.asPrefsStore(): PrefsStore = DataStorePrefsStore(this) + +private class DataStorePrefsStore(private val delegate: DataStore) : PrefsStore { + override val data: Flow = delegate.data.map { DataStorePrefsSnapshot(it) } + + override suspend fun edit(transform: (PrefsSnapshot.Editor) -> Unit) { + delegate.edit { prefs -> transform(DataStorePrefsEditor(prefs)) } + } +} + +private class DataStorePrefsSnapshot(private val prefs: Preferences) : PrefsSnapshot { + override fun get(key: PrefsKey): T? = prefs[key.toPreferencesKey()] + + override fun contains(key: PrefsKey<*>): Boolean = key.toRawPreferencesKey() in prefs +} + +private class DataStorePrefsEditor(private val prefs: MutablePreferences) : PrefsSnapshot.Editor { + override fun get(key: PrefsKey): T? = prefs[key.toPreferencesKey()] + + override fun contains(key: PrefsKey<*>): Boolean = key.toRawPreferencesKey() in prefs + + override fun set(key: PrefsKey, value: T) { + prefs[key.toPreferencesKey()] = value + } + + override fun remove(key: PrefsKey) { + prefs.remove(key.toPreferencesKey()) + } +} + +private fun PrefsKey<*>.toRawPreferencesKey(): Preferences.Key<*> = when (type) { + PrefsKeyType.BOOLEAN -> booleanPreferencesKey(name) + PrefsKeyType.INT -> intPreferencesKey(name) + PrefsKeyType.LONG -> longPreferencesKey(name) + PrefsKeyType.DOUBLE -> doublePreferencesKey(name) + PrefsKeyType.STRING -> stringPreferencesKey(name) + PrefsKeyType.STRING_SET -> stringSetPreferencesKey(name) +} + +@Suppress("UNCHECKED_CAST") +private fun PrefsKey.toPreferencesKey(): Preferences.Key = toRawPreferencesKey() as Preferences.Key diff --git a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt b/core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt similarity index 95% rename from core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt rename to core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt index 13174ff612..a7e34176db 100644 --- a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt +++ b/core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/filter/FilterPrefsTest.kt @@ -26,6 +26,7 @@ import okio.FileSystem import okio.Path import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.asFilterDataStore +import org.meshtastic.core.prefs.store.asPrefsStore import org.meshtastic.core.repository.FilterPrefs import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -55,7 +56,7 @@ class FilterPrefsTest { produceFile = { tmpDir / "test.preferences_pb" }, ) dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) - filterPrefs = FilterPrefsImpl(dataStore.asFilterDataStore(), dispatchers) + filterPrefs = FilterPrefsImpl(dataStore.asPrefsStore().asFilterDataStore(), dispatchers) } @AfterTest diff --git a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/map/MapPrefsImplTest.kt b/core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/map/MapPrefsImplTest.kt similarity index 96% rename from core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/map/MapPrefsImplTest.kt rename to core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/map/MapPrefsImplTest.kt index c6c0f05951..5d07259e70 100644 --- a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/map/MapPrefsImplTest.kt +++ b/core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/map/MapPrefsImplTest.kt @@ -27,6 +27,7 @@ import okio.FileSystem import okio.Path import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.asMapDataStore +import org.meshtastic.core.prefs.store.asPrefsStore import org.meshtastic.core.repository.MapCameraPosition import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -54,7 +55,7 @@ class MapPrefsImplTest { ) prefs = MapPrefsImpl( - dataStore.asMapDataStore(), + dataStore.asPrefsStore().asMapDataStore(), CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher), ) } diff --git a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImplTest.kt b/core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImplTest.kt similarity index 93% rename from core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImplTest.kt rename to core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImplTest.kt index 3779e31ef0..b0aefd6b3b 100644 --- a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImplTest.kt +++ b/core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/mesh/MeshPrefsImplTest.kt @@ -19,7 +19,6 @@ package org.meshtastic.core.prefs.mesh import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.async import kotlinx.coroutines.cancel @@ -32,6 +31,7 @@ import okio.Path import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.MeshDataStore import org.meshtastic.core.prefs.di.asMeshDataStore +import org.meshtastic.core.prefs.store.asPrefsStore import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -68,9 +68,9 @@ class MeshPrefsImplTest { @Test fun `await device address waits for persisted data instead of returning the flow default`() = testScope.runTest { val persistedAddress = "xAA:BB:CC:DD:EE:FF" - dataStore.edit { preferences -> preferences[MeshPrefsImpl.KEY_DEVICE_ADDRESS_PREF] = persistedAddress } + dataStore.asPrefsStore().edit { editor -> editor[MeshPrefsImpl.KEY_DEVICE_ADDRESS_PREF] = persistedAddress } val loadGate = CompletableDeferred() - val delegate = dataStore.asMeshDataStore() + val delegate = dataStore.asPrefsStore().asMeshDataStore() val delayedDataStore = object : MeshDataStore by delegate { override val data = delegate.data.onStart { loadGate.await() } diff --git a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt b/core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt similarity index 98% rename from core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt rename to core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt index 2080402186..7a46620860 100644 --- a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt +++ b/core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/notification/NotificationPrefsTest.kt @@ -26,6 +26,7 @@ import okio.FileSystem import okio.Path import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.asUiDataStore +import org.meshtastic.core.prefs.store.asPrefsStore import org.meshtastic.core.repository.NotificationPrefs import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -55,7 +56,7 @@ class NotificationPrefsTest { produceFile = { tmpDir / "test.preferences_pb" }, ) dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) - notificationPrefs = NotificationPrefsImpl(dataStore.asUiDataStore(), dispatchers) + notificationPrefs = NotificationPrefsImpl(dataStore.asPrefsStore().asUiDataStore(), dispatchers) } @AfterTest diff --git a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt b/core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt similarity index 96% rename from core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt rename to core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt index 496e2695c3..1ebb78f2d9 100644 --- a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt +++ b/core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/tak/TakPrefsTest.kt @@ -26,6 +26,7 @@ import okio.FileSystem import okio.Path import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.asUiDataStore +import org.meshtastic.core.prefs.store.asPrefsStore import org.meshtastic.core.repository.TakPrefs import kotlin.test.AfterTest import kotlin.test.BeforeTest @@ -54,7 +55,7 @@ class TakPrefsTest { produceFile = { tmpDir / "test.preferences_pb" }, ) dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) - takPrefs = TakPrefsImpl(dataStore.asUiDataStore(), dispatchers) + takPrefs = TakPrefsImpl(dataStore.asPrefsStore().asUiDataStore(), dispatchers) } @AfterTest diff --git a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/ui/NodeListLayoutPrefsTest.kt b/core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/ui/NodeListLayoutPrefsTest.kt similarity index 97% rename from core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/ui/NodeListLayoutPrefsTest.kt rename to core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/ui/NodeListLayoutPrefsTest.kt index c75e659a78..1a0d6eede7 100644 --- a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/ui/NodeListLayoutPrefsTest.kt +++ b/core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/ui/NodeListLayoutPrefsTest.kt @@ -27,6 +27,7 @@ import okio.Path import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.NodeListDensity import org.meshtastic.core.prefs.di.asUiDataStore +import org.meshtastic.core.prefs.store.asPrefsStore import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -52,7 +53,7 @@ class NodeListLayoutPrefsTest { produceFile = { tmpDir / "test.preferences_pb" }, ) val dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) - prefs = UiPrefsImpl(dataStore.asUiDataStore(), dispatchers) + prefs = UiPrefsImpl(dataStore.asPrefsStore().asUiDataStore(), dispatchers) } @AfterTest diff --git a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt b/core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt similarity index 92% rename from core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt rename to core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt index 084cac9a40..ac9c411f2b 100644 --- a/core/prefs/src/commonTest/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt +++ b/core/prefs/src/nonWebTest/kotlin/org/meshtastic/core/prefs/ui/UiPrefsImplTest.kt @@ -19,7 +19,6 @@ package org.meshtastic.core.prefs.ui import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.PreferenceDataStoreFactory import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.TestDispatcher @@ -31,6 +30,7 @@ import okio.Path import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.DeviceType import org.meshtastic.core.prefs.di.asUiDataStore +import org.meshtastic.core.prefs.store.asPrefsStore import kotlin.test.AfterTest import kotlin.test.BeforeTest import kotlin.test.Test @@ -59,7 +59,7 @@ class UiPrefsImplTest { produceFile = { tmpDir / "test.preferences_pb" }, ) val dispatchers = CoroutineDispatchers(testDispatcher, testDispatcher, testDispatcher) - prefs = UiPrefsImpl(dataStore.asUiDataStore(), dispatchers) + prefs = UiPrefsImpl(dataStore.asPrefsStore().asUiDataStore(), dispatchers) } @AfterTest @@ -70,7 +70,7 @@ class UiPrefsImplTest { @Test fun `explicit selected connection transport wins over legacy booleans`() = testScope.runTest { - dataStore.edit { + dataStore.asPrefsStore().edit { it[UiPrefsImpl.KEY_SELECTED_CONNECTION_TRANSPORT] = DeviceType.USB.name it[UiPrefsImpl.KEY_SHOW_BLE_TRANSPORT] = true it[UiPrefsImpl.KEY_SHOW_NETWORK_TRANSPORT] = true @@ -82,7 +82,7 @@ class UiPrefsImplTest { @Test fun `invalid selected connection transport falls back to legacy booleans`() = testScope.runTest { - dataStore.edit { + dataStore.asPrefsStore().edit { it[UiPrefsImpl.KEY_SELECTED_CONNECTION_TRANSPORT] = "WIFI" it[UiPrefsImpl.KEY_SHOW_BLE_TRANSPORT] = false it[UiPrefsImpl.KEY_SHOW_NETWORK_TRANSPORT] = true @@ -98,7 +98,7 @@ class UiPrefsImplTest { @Test fun `legacy selected connection transport defaults to BLE when all transports are visible`() = testScope.runTest { - dataStore.edit { + dataStore.asPrefsStore().edit { it[UiPrefsImpl.KEY_SHOW_BLE_TRANSPORT] = true it[UiPrefsImpl.KEY_SHOW_NETWORK_TRANSPORT] = true it[UiPrefsImpl.KEY_SHOW_USB_TRANSPORT] = true @@ -109,7 +109,7 @@ class UiPrefsImplTest { @Test fun `legacy selected connection transport chooses TCP when BLE is hidden`() = testScope.runTest { - dataStore.edit { + dataStore.asPrefsStore().edit { it[UiPrefsImpl.KEY_SHOW_BLE_TRANSPORT] = false it[UiPrefsImpl.KEY_SHOW_NETWORK_TRANSPORT] = true it[UiPrefsImpl.KEY_SHOW_USB_TRANSPORT] = true @@ -120,7 +120,7 @@ class UiPrefsImplTest { @Test fun `legacy selected connection transport chooses USB when only USB is visible`() = testScope.runTest { - dataStore.edit { + dataStore.asPrefsStore().edit { it[UiPrefsImpl.KEY_SHOW_BLE_TRANSPORT] = false it[UiPrefsImpl.KEY_SHOW_NETWORK_TRANSPORT] = false it[UiPrefsImpl.KEY_SHOW_USB_TRANSPORT] = true @@ -137,7 +137,8 @@ class UiPrefsImplTest { fun `full message timestamps persist when enabled`() = testScope.runTest { prefs.setShowFullMessageTimestamps(true) - val stored = dataStore.data.first { it[UiPrefsImpl.KEY_SHOW_FULL_MESSAGE_TIMESTAMPS] == true } + val stored = + dataStore.asPrefsStore().data.first { it[UiPrefsImpl.KEY_SHOW_FULL_MESSAGE_TIMESTAMPS] == true } assertTrue(prefs.showFullMessageTimestamps.value) assertEquals(true, stored[UiPrefsImpl.KEY_SHOW_FULL_MESSAGE_TIMESTAMPS]) } diff --git a/core/prefs/src/wasmJsMain/kotlin/org/meshtastic/core/prefs/di/CorePrefsWasmJsModule.kt b/core/prefs/src/wasmJsMain/kotlin/org/meshtastic/core/prefs/di/CorePrefsWasmJsModule.kt new file mode 100644 index 0000000000..b53a01a28d --- /dev/null +++ b/core/prefs/src/wasmJsMain/kotlin/org/meshtastic/core/prefs/di/CorePrefsWasmJsModule.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.prefs.di + +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import org.meshtastic.core.prefs.store.LocalStoragePrefsStore + +/** + * Koin module providing wasmJs [org.meshtastic.core.prefs.store.PrefsStore] instances for each preference domain, + * backed by the browser's `localStorage`. Mirrors `CorePrefsAndroidModule`/desktopApp's `DesktopPlatformModule` — one + * singleton store per domain, same `_ds`-suffixed names — minus the `SharedPreferencesMigration` (nothing to migrate + * from on web) and the `CoroutineScope`/dispatcher plumbing (localStorage reads and writes are synchronous). + * + * Not yet registered anywhere: like core:database's `SingleDatabaseProvider`, nothing on wasmJs composes a Koin graph + * yet (no `webApp` module exists in this repo pass) — a future one wires this in explicitly, the same way + * `CorePrefsAndroidModule` is listed by name in androidApp's `AppKoinModule` despite already sitting inside + * `CorePrefsModule`'s `@ComponentScan("org.meshtastic.core.prefs")`. + */ +@Suppress("TooManyFunctions") +@Module +class CorePrefsWasmJsModule { + @Single + fun provideAnalyticsDataStore(): AnalyticsDataStore = LocalStoragePrefsStore("analytics_ds").asAnalyticsDataStore() + + @Single + fun provideHomoglyphEncodingDataStore(): HomoglyphEncodingDataStore = + LocalStoragePrefsStore("homoglyph_encoding_ds").asHomoglyphEncodingDataStore() + + @Single fun provideAppDataStore(): AppDataStore = LocalStoragePrefsStore("app_ds").asAppDataStore() + + @Single + fun provideCustomEmojiDataStore(): CustomEmojiDataStore = + LocalStoragePrefsStore("custom_emoji_ds").asCustomEmojiDataStore() + + @Single fun provideMapDataStore(): MapDataStore = LocalStoragePrefsStore("map_ds").asMapDataStore() + + @Single + fun provideMapConsentDataStore(): MapConsentDataStore = + LocalStoragePrefsStore("map_consent_ds").asMapConsentDataStore() + + @Single + fun provideMapTileProviderDataStore(): MapTileProviderDataStore = + LocalStoragePrefsStore("map_tile_provider_ds").asMapTileProviderDataStore() + + @Single fun provideMeshDataStore(): MeshDataStore = LocalStoragePrefsStore("mesh_ds").asMeshDataStore() + + @Single fun provideRadioDataStore(): RadioDataStore = LocalStoragePrefsStore("radio_ds").asRadioDataStore() + + @Single fun provideUiDataStore(): UiDataStore = LocalStoragePrefsStore("ui_ds").asUiDataStore() + + @Single fun provideMeshLogDataStore(): MeshLogDataStore = LocalStoragePrefsStore("meshlog_ds").asMeshLogDataStore() + + @Single fun provideFilterDataStore(): FilterDataStore = LocalStoragePrefsStore("filter_ds").asFilterDataStore() +} diff --git a/core/prefs/src/wasmJsMain/kotlin/org/meshtastic/core/prefs/store/LocalStoragePrefsStore.kt b/core/prefs/src/wasmJsMain/kotlin/org/meshtastic/core/prefs/store/LocalStoragePrefsStore.kt new file mode 100644 index 0000000000..d3785a5795 --- /dev/null +++ b/core/prefs/src/wasmJsMain/kotlin/org/meshtastic/core/prefs/store/LocalStoragePrefsStore.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.prefs.store + +import kotlinx.browser.localStorage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Backed by the browser's `localStorage` — a synchronous, built-in key-value string store, so (unlike core:database's + * OPFS story) no Worker or npm dependency is needed here at all. + * + * Every domain's store gets its own [namespace] prefix on every localStorage key, matching the file-per-domain + * isolation the real `DataStore` instances get on android/jvm/iOS (see CorePrefsAndroidModule / + * DesktopPlatformModule's per-domain `fileName`). + * + * Reads are live: [LocalStorageSnapshot] holds no cached state, it re-reads `localStorage` on every + * [PrefsSnapshot.get]/[PrefsSnapshot.contains] call. [revision] exists purely to give [data] a *new* emission after + * every [edit] — `LocalStorageSnapshot` has no `equals` override, so each freshly-constructed instance is unequal by + * reference to the last, which is exactly what [MutableStateFlow] needs to avoid conflating the update away. + * + * [DEFERRED]: same-tab writes are reflected immediately (every `edit()` publishes a new revision, and every read goes + * straight to localStorage), but a write from another browser tab is not observed — that would need a + * `window.onstorage` listener re-publishing a revision when this namespace's keys change, not attempted in this pass. + */ +internal class LocalStoragePrefsStore(private val namespace: String) : PrefsStore { + private val revision = MutableStateFlow(LocalStorageSnapshot(namespace)) + + override val data: Flow = revision + + override suspend fun edit(transform: (PrefsSnapshot.Editor) -> Unit) { + transform(LocalStorageEditor(namespace)) + revision.value = LocalStorageSnapshot(namespace) + } +} + +private class LocalStorageSnapshot(private val namespace: String) : PrefsSnapshot { + override fun get(key: PrefsKey): T? = decode(localStorage.getItem(storageKey(namespace, key)), key.type) + + override fun contains(key: PrefsKey<*>): Boolean = localStorage.getItem(storageKey(namespace, key)) != null +} + +private class LocalStorageEditor(private val namespace: String) : PrefsSnapshot.Editor { + override fun get(key: PrefsKey): T? = decode(localStorage.getItem(storageKey(namespace, key)), key.type) + + override fun contains(key: PrefsKey<*>): Boolean = localStorage.getItem(storageKey(namespace, key)) != null + + override fun set(key: PrefsKey, value: T) { + localStorage.setItem(storageKey(namespace, key), encode(value, key.type)) + } + + override fun remove(key: PrefsKey) { + localStorage.removeItem(storageKey(namespace, key)) + } +} + +private fun storageKey(namespace: String, key: PrefsKey<*>): String = "$namespace:${key.name}" + +// Matches the '|'-joined convention UiPrefsImpl's own firmwareUpdateNotificationKeys already uses for a +// Set-shaped preference — reusing it here rather than inventing a new serialization format. As there, a set +// element containing '|' itself would corrupt round-tripping; no current preference stores one. +private const val SET_DELIMITER = "|" + +@Suppress("UNCHECKED_CAST") +private fun decode(raw: String?, type: PrefsKeyType): T? { + if (raw == null) return null + val value: Any = + when (type) { + PrefsKeyType.BOOLEAN -> raw.toBooleanStrictOrNull() ?: return null + PrefsKeyType.INT -> raw.toIntOrNull() ?: return null + PrefsKeyType.LONG -> raw.toLongOrNull() ?: return null + PrefsKeyType.DOUBLE -> raw.toDoubleOrNull() ?: return null + PrefsKeyType.STRING -> raw + PrefsKeyType.STRING_SET -> raw.split(SET_DELIMITER).filter(String::isNotEmpty).toSet() + } + return value as T +} + +@Suppress("UNCHECKED_CAST") +private fun encode(value: T, type: PrefsKeyType): String = when (type) { + PrefsKeyType.STRING_SET -> (value as Set).joinToString(SET_DELIMITER) + else -> value.toString() +} diff --git a/core/repository/build.gradle.kts b/core/repository/build.gradle.kts index ad6f61a88f..c8dc4d2624 100644 --- a/core/repository/build.gradle.kts +++ b/core/repository/build.gradle.kts @@ -23,6 +23,13 @@ plugins { kotlin { android { withHostTest {} } + // Library module: bare wasmJs(), no browser() (that's for the eventual webApp executable). Unlike + // core:ble/core:database/core:prefs, no custom hierarchy group is needed: Location's android/jvm/ios + // actuals already each live in their own independent source set (no shared nonWeb code to hide from + // wasmJs), so a 4th, wasmJsMain/Location.kt, is all this module needs. + @OptIn(org.jetbrains.kotlin.gradle.ExperimentalWasmDsl::class) + wasmJs() + sourceSets { commonMain.dependencies { api(projects.core.model) diff --git a/core/repository/src/wasmJsMain/kotlin/org/meshtastic/core/repository/Location.kt b/core/repository/src/wasmJsMain/kotlin/org/meshtastic/core/repository/Location.kt new file mode 100644 index 0000000000..907529b6bb --- /dev/null +++ b/core/repository/src/wasmJsMain/kotlin/org/meshtastic/core/repository/Location.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.repository + +/** + * wasmJs placeholder location type, mirroring jvmMain's — no consumer needs real location on web yet, same as desktop. + * A real one would wrap the browser Geolocation API (`navigator.geolocation`); deferred until something actually calls + * [LocationService]/[LocationRepository] on this target. + */ +actual class Location diff --git a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshLogCleanupWorkerTest.kt b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshLogCleanupWorkerTest.kt index d21d2c1f97..044df38513 100644 --- a/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshLogCleanupWorkerTest.kt +++ b/core/service/src/androidHostTest/kotlin/org/meshtastic/core/service/MeshLogCleanupWorkerTest.kt @@ -17,8 +17,6 @@ package org.meshtastic.core.service import android.content.Context -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.preferencesOf import androidx.test.core.app.ApplicationProvider import androidx.work.ListenableWorker import androidx.work.WorkerFactory @@ -41,6 +39,8 @@ import org.junit.runner.RunWith import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.MeshLogDataStore import org.meshtastic.core.prefs.meshlog.MeshLogPrefsImpl +import org.meshtastic.core.prefs.store.PrefsKey +import org.meshtastic.core.prefs.store.PrefsSnapshot import org.meshtastic.core.service.worker.MeshLogCleanupWorker import org.meshtastic.core.testing.FakeMeshLogRepository import org.robolectric.RobolectricTestRunner @@ -169,23 +169,28 @@ class MeshLogCleanupWorkerTest { private val loadGate = CompletableDeferred() private val persisted = MutableStateFlow( - preferencesOf( - MeshLogPrefsImpl.KEY_LOGGING_ENABLED_PREF to loggingEnabled, - MeshLogPrefsImpl.KEY_RETENTION_DAYS_PREF to retentionDays, + FakePrefsSnapshot( + mapOf( + MeshLogPrefsImpl.KEY_LOGGING_ENABLED_PREF to loggingEnabled, + MeshLogPrefsImpl.KEY_RETENTION_DAYS_PREF to retentionDays, + ), ), ) var collectionCount: Int = 0 private set - override val data: Flow = flow { + override val data: Flow = flow { collectionCount += 1 loadGate.await() emitAll(persisted) } - override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences = - transform(persisted.value).also { persisted.value = it } + override suspend fun edit(transform: (PrefsSnapshot.Editor) -> Unit) { + val editor = FakePrefsEditor(persisted.value.values) + transform(editor) + persisted.value = FakePrefsSnapshot(editor.values) + } fun releaseLoad() { loadGate.complete(Unit) @@ -195,4 +200,29 @@ class MeshLogCleanupWorkerTest { loadGate.completeExceptionally(cause) } } + + /** Minimal in-memory [PrefsSnapshot]/[PrefsSnapshot.Editor] test double — no real DataStore/localStorage. */ + private class FakePrefsSnapshot(val values: Map, Any?>) : PrefsSnapshot { + @Suppress("UNCHECKED_CAST") + override fun get(key: PrefsKey): T? = values[key] as T? + + override fun contains(key: PrefsKey<*>): Boolean = values.containsKey(key) + } + + private class FakePrefsEditor(initial: Map, Any?>) : PrefsSnapshot.Editor { + val values: MutableMap, Any?> = initial.toMutableMap() + + @Suppress("UNCHECKED_CAST") + override fun get(key: PrefsKey): T? = values[key] as T? + + override fun contains(key: PrefsKey<*>): Boolean = values.containsKey(key) + + override fun set(key: PrefsKey, value: T) { + values[key] = value + } + + override fun remove(key: PrefsKey) { + values.remove(key) + } + } } diff --git a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt index ea79c13596..5ff8befa38 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt @@ -77,6 +77,7 @@ import org.meshtastic.core.prefs.di.asMeshDataStore import org.meshtastic.core.prefs.di.asMeshLogDataStore import org.meshtastic.core.prefs.di.asRadioDataStore import org.meshtastic.core.prefs.di.asUiDataStore +import org.meshtastic.core.prefs.store.asPrefsStore import org.meshtastic.desktop.DesktopBuildConfig import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.LocalConfig @@ -144,20 +145,31 @@ fun desktopPlatformModule() = module { single(named(PROCESS_LIFECYCLE)) { DesktopProcessLifecycleOwner().lifecycle } } -/** Typed preference-datastore singletons for each preference domain. */ +/** + * Typed preference-datastore singletons for each preference domain. + * + * Every core:prefs store (all but the last line) inserts [asPrefsStore] before its `asXDataStore()` call: those marker + * types are now `PrefsStore`-backed (androidx.datastore.preferences has no wasmJs variant, so core:prefs abstracts over + * it — see core/prefs/store/PrefsStore.kt), not `DataStore` directly. `CorePreferencesDataStore` belongs + * to core:datastore, a separate, unrelated module still typed directly against `DataStore` — left as-is. + */ private fun desktopPreferencesDataStoreModule() = module { - single { prefsStore("analytics", get()).asAnalyticsDataStore() } - single { prefsStore("homoglyph_encoding", get()).asHomoglyphEncodingDataStore() } - single { prefsStore("app", get()).asAppDataStore() } - single { prefsStore("custom_emoji", get()).asCustomEmojiDataStore() } - single { prefsStore("map", get()).asMapDataStore() } - single { prefsStore("map_consent", get()).asMapConsentDataStore() } - single { prefsStore("map_tile_provider", get()).asMapTileProviderDataStore() } - single { prefsStore("mesh", get()).asMeshDataStore() } - single { prefsStore("radio", get()).asRadioDataStore() } - single { prefsStore("ui", get()).asUiDataStore() } - single { prefsStore("meshlog", get()).asMeshLogDataStore() } - single { prefsStore("filter", get()).asFilterDataStore() } + single { prefsStore("analytics", get()).asPrefsStore().asAnalyticsDataStore() } + single { + prefsStore("homoglyph_encoding", get()).asPrefsStore().asHomoglyphEncodingDataStore() + } + single { prefsStore("app", get()).asPrefsStore().asAppDataStore() } + single { prefsStore("custom_emoji", get()).asPrefsStore().asCustomEmojiDataStore() } + single { prefsStore("map", get()).asPrefsStore().asMapDataStore() } + single { prefsStore("map_consent", get()).asPrefsStore().asMapConsentDataStore() } + single { + prefsStore("map_tile_provider", get()).asPrefsStore().asMapTileProviderDataStore() + } + single { prefsStore("mesh", get()).asPrefsStore().asMeshDataStore() } + single { prefsStore("radio", get()).asPrefsStore().asRadioDataStore() } + single { prefsStore("ui", get()).asPrefsStore().asUiDataStore() } + single { prefsStore("meshlog", get()).asPrefsStore().asMeshLogDataStore() } + single { prefsStore("filter", get()).asPrefsStore().asFilterDataStore() } single { prefsStore("core_preferences", get()).asCorePreferencesDataStore() } }