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<Preferences>
from commonMain. Introduces PrefsStore/PrefsSnapshot/PrefsKey
(core/prefs/store/PrefsStore.kt), a minimal interface mirroring
DataStore<Preferences>'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<Preferences> (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<Preferences> 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 <noreply@anthropic.com>
This commit is contained in:
James RichandClaude Sonnet 5 committed 2026-08-30 21:44:05 -05:00
1 parent 1f92259f6d
commit e3dc4b5ab0
35 files changed
+675 -226

No files matched your search

+37 -1
View File
@@ -15,6 +15,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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<Preferences> — 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<Preferences> 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.
}
}
@@ -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<Preferences>` 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<Preferences> = PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, legacyName)),
scope = CoroutineScope(dispatchers.io + SupervisorJob()),
produceFile = { context.preferencesDataStoreFile(fileName) },
)
): PrefsStore {
val dataStore: DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, legacyName)),
scope = CoroutineScope(dispatchers.io + SupervisorJob()),
produceFile = { context.preferencesDataStoreFile(fileName) },
)
return dataStore.asPrefsStore()
}
@@ -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)
}
}
@@ -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<Boolean>, default: Boolean): StateFlow<Boolean> =
private fun booleanPref(key: PrefsKey<Boolean>, default: Boolean): StateFlow<Boolean> =
dataStore.data.map { it[key] ?: default }.stateIn(scope, SharingStarted.Eagerly, default)
private fun set(key: Preferences.Key<Boolean>, value: Boolean) {
private fun set(key: PrefsKey<Boolean>, 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")
}
}
@@ -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<Preferences>` — inject and use it exactly like one.
// transparent PrefsStore — inject and use it exactly like one. PrefsStore itself (not `DataStore<Preferences>`
// directly — androidx.datastore.preferences has no wasmJs variant) is what makes these usable from every target:
// nonWebMain's DataStorePrefsStore adapts a real DataStore<Preferences>, wasmJsMain's LocalStoragePrefsStore is
// backed by localStorage. See core/prefs/store/PrefsStore.kt.
interface AnalyticsDataStore : DataStore<Preferences>
interface AnalyticsDataStore : PrefsStore
/** Presents an existing store as [AnalyticsDataStore]; the wrapper adds nothing but identity. */
fun DataStore<Preferences>.asAnalyticsDataStore(): AnalyticsDataStore =
object : AnalyticsDataStore, DataStore<Preferences> by this {}
fun PrefsStore.asAnalyticsDataStore(): AnalyticsDataStore = object : AnalyticsDataStore, PrefsStore by this {}
interface AppDataStore : DataStore<Preferences>
interface AppDataStore : PrefsStore
/** Presents an existing store as [AppDataStore]; the wrapper adds nothing but identity. */
fun DataStore<Preferences>.asAppDataStore(): AppDataStore = object : AppDataStore, DataStore<Preferences> by this {}
fun PrefsStore.asAppDataStore(): AppDataStore = object : AppDataStore, PrefsStore by this {}
interface CustomEmojiDataStore : DataStore<Preferences>
interface CustomEmojiDataStore : PrefsStore
/** Presents an existing store as [CustomEmojiDataStore]; the wrapper adds nothing but identity. */
fun DataStore<Preferences>.asCustomEmojiDataStore(): CustomEmojiDataStore =
object : CustomEmojiDataStore, DataStore<Preferences> by this {}
fun PrefsStore.asCustomEmojiDataStore(): CustomEmojiDataStore = object : CustomEmojiDataStore, PrefsStore by this {}
interface FilterDataStore : DataStore<Preferences>
interface FilterDataStore : PrefsStore
/** Presents an existing store as [FilterDataStore]; the wrapper adds nothing but identity. */
fun DataStore<Preferences>.asFilterDataStore(): FilterDataStore =
object : FilterDataStore, DataStore<Preferences> by this {}
fun PrefsStore.asFilterDataStore(): FilterDataStore = object : FilterDataStore, PrefsStore by this {}
interface HomoglyphEncodingDataStore : DataStore<Preferences>
interface HomoglyphEncodingDataStore : PrefsStore
/** Presents an existing store as [HomoglyphEncodingDataStore]; the wrapper adds nothing but identity. */
fun DataStore<Preferences>.asHomoglyphEncodingDataStore(): HomoglyphEncodingDataStore =
object : HomoglyphEncodingDataStore, DataStore<Preferences> by this {}
fun PrefsStore.asHomoglyphEncodingDataStore(): HomoglyphEncodingDataStore =
object : HomoglyphEncodingDataStore, PrefsStore by this {}
interface MapConsentDataStore : DataStore<Preferences>
interface MapConsentDataStore : PrefsStore
/** Presents an existing store as [MapConsentDataStore]; the wrapper adds nothing but identity. */
fun DataStore<Preferences>.asMapConsentDataStore(): MapConsentDataStore =
object : MapConsentDataStore, DataStore<Preferences> by this {}
fun PrefsStore.asMapConsentDataStore(): MapConsentDataStore = object : MapConsentDataStore, PrefsStore by this {}
interface MapDataStore : DataStore<Preferences>
interface MapDataStore : PrefsStore
/** Presents an existing store as [MapDataStore]; the wrapper adds nothing but identity. */
fun DataStore<Preferences>.asMapDataStore(): MapDataStore = object : MapDataStore, DataStore<Preferences> by this {}
fun PrefsStore.asMapDataStore(): MapDataStore = object : MapDataStore, PrefsStore by this {}
interface MapTileProviderDataStore : DataStore<Preferences>
interface MapTileProviderDataStore : PrefsStore
/** Presents an existing store as [MapTileProviderDataStore]; the wrapper adds nothing but identity. */
fun DataStore<Preferences>.asMapTileProviderDataStore(): MapTileProviderDataStore =
object : MapTileProviderDataStore, DataStore<Preferences> by this {}
fun PrefsStore.asMapTileProviderDataStore(): MapTileProviderDataStore =
object : MapTileProviderDataStore, PrefsStore by this {}
interface MeshDataStore : DataStore<Preferences>
interface MeshDataStore : PrefsStore
/** Presents an existing store as [MeshDataStore]; the wrapper adds nothing but identity. */
fun DataStore<Preferences>.asMeshDataStore(): MeshDataStore = object : MeshDataStore, DataStore<Preferences> by this {}
fun PrefsStore.asMeshDataStore(): MeshDataStore = object : MeshDataStore, PrefsStore by this {}
interface MeshLogDataStore : DataStore<Preferences>
interface MeshLogDataStore : PrefsStore
/** Presents an existing store as [MeshLogDataStore]; the wrapper adds nothing but identity. */
fun DataStore<Preferences>.asMeshLogDataStore(): MeshLogDataStore =
object : MeshLogDataStore, DataStore<Preferences> by this {}
fun PrefsStore.asMeshLogDataStore(): MeshLogDataStore = object : MeshLogDataStore, PrefsStore by this {}
interface RadioDataStore : DataStore<Preferences>
interface RadioDataStore : PrefsStore
/** Presents an existing store as [RadioDataStore]; the wrapper adds nothing but identity. */
fun DataStore<Preferences>.asRadioDataStore(): RadioDataStore =
object : RadioDataStore, DataStore<Preferences> by this {}
fun PrefsStore.asRadioDataStore(): RadioDataStore = object : RadioDataStore, PrefsStore by this {}
interface UiDataStore : DataStore<Preferences>
interface UiDataStore : PrefsStore
/** Presents an existing store as [UiDataStore]; the wrapper adds nothing but identity. */
fun DataStore<Preferences>.asUiDataStore(): UiDataStore = object : UiDataStore, DataStore<Preferences> by this {}
fun PrefsStore.asUiDataStore(): UiDataStore = object : UiDataStore, PrefsStore by this {}
@@ -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 = ","
}
}
@@ -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"
@@ -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")
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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<Int?, Lazy<StateFlow<Boolean>>>())
override fun shouldReportLocation(nodeNum: Int?): StateFlow<Boolean> = 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 } }
}
}
@@ -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")
}
}
@@ -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)
}
}
@@ -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<Int> = 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")
}
}
@@ -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)
}
}
@@ -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<Int> =
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")
}
}
@@ -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")
}
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<T> 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<Boolean> = PrefsKey(name, PrefsKeyType.BOOLEAN)
fun intPrefsKey(name: String): PrefsKey<Int> = PrefsKey(name, PrefsKeyType.INT)
fun longPrefsKey(name: String): PrefsKey<Long> = PrefsKey(name, PrefsKeyType.LONG)
fun doublePrefsKey(name: String): PrefsKey<Double> = PrefsKey(name, PrefsKeyType.DOUBLE)
fun stringPrefsKey(name: String): PrefsKey<String> = PrefsKey(name, PrefsKeyType.STRING)
fun stringSetPrefsKey(name: String): PrefsKey<Set<String>> = PrefsKey(name, PrefsKeyType.STRING_SET)
/** Read-only view of a [PrefsStore]'s current values. Mirrors `androidx.datastore.preferences.core.Preferences`. */
interface PrefsSnapshot {
operator fun <T> get(key: PrefsKey<T>): 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 <T> set(key: PrefsKey<T>, value: T)
fun <T> remove(key: PrefsKey<T>)
}
}
/**
* Platform-neutral replacement for `DataStore<Preferences>`. `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<Preferences>`; 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<PrefsSnapshot>
suspend fun edit(transform: (PrefsSnapshot.Editor) -> Unit)
}
@@ -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")
}
}
@@ -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)
}
}
@@ -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<Boolean> =
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 ||
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Preferences>` (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<Preferences>` straight to an `asXDataStore()` wrapper now inserts this adapter first:
* `store(...).asPrefsStore().asXDataStore()`.
*/
fun DataStore<Preferences>.asPrefsStore(): PrefsStore = DataStorePrefsStore(this)
private class DataStorePrefsStore(private val delegate: DataStore<Preferences>) : PrefsStore {
override val data: Flow<PrefsSnapshot> = 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 <T> get(key: PrefsKey<T>): 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 <T> get(key: PrefsKey<T>): T? = prefs[key.toPreferencesKey()]
override fun contains(key: PrefsKey<*>): Boolean = key.toRawPreferencesKey() in prefs
override fun <T> set(key: PrefsKey<T>, value: T) {
prefs[key.toPreferencesKey()] = value
}
override fun <T> remove(key: PrefsKey<T>) {
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 <T> PrefsKey<T>.toPreferencesKey(): Preferences.Key<T> = toRawPreferencesKey() as Preferences.Key<T>
@@ -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
@@ -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),
)
}
@@ -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<Unit>()
val delegate = dataStore.asMeshDataStore()
val delegate = dataStore.asPrefsStore().asMeshDataStore()
val delayedDataStore =
object : MeshDataStore by delegate {
override val data = delegate.data.onStart { loadGate.await() }
@@ -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
@@ -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
@@ -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
@@ -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])
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Preferences>` 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<PrefsSnapshot> = 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 <T> get(key: PrefsKey<T>): 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 <T> get(key: PrefsKey<T>): T? = decode(localStorage.getItem(storageKey(namespace, key)), key.type)
override fun contains(key: PrefsKey<*>): Boolean = localStorage.getItem(storageKey(namespace, key)) != null
override fun <T> set(key: PrefsKey<T>, value: T) {
localStorage.setItem(storageKey(namespace, key), encode(value, key.type))
}
override fun <T> remove(key: PrefsKey<T>) {
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<String>-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 <T> 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 <T> encode(value: T, type: PrefsKeyType): String = when (type) {
PrefsKeyType.STRING_SET -> (value as Set<String>).joinToString(SET_DELIMITER)
else -> value.toString()
}
+7
View File
@@ -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)
@@ -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 <https://www.gnu.org/licenses/>.
*/
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
@@ -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<Unit>()
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<Preferences> = flow {
override val data: Flow<PrefsSnapshot> = 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<PrefsKey<*>, Any?>) : PrefsSnapshot {
@Suppress("UNCHECKED_CAST")
override fun <T> get(key: PrefsKey<T>): T? = values[key] as T?
override fun contains(key: PrefsKey<*>): Boolean = values.containsKey(key)
}
private class FakePrefsEditor(initial: Map<PrefsKey<*>, Any?>) : PrefsSnapshot.Editor {
val values: MutableMap<PrefsKey<*>, Any?> = initial.toMutableMap()
@Suppress("UNCHECKED_CAST")
override fun <T> get(key: PrefsKey<T>): T? = values[key] as T?
override fun contains(key: PrefsKey<*>): Boolean = values.containsKey(key)
override fun <T> set(key: PrefsKey<T>, value: T) {
values[key] = value
}
override fun <T> remove(key: PrefsKey<T>) {
values.remove(key)
}
}
}
@@ -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<Preferences>` directly. `CorePreferencesDataStore` belongs
* to core:datastore, a separate, unrelated module still typed directly against `DataStore<Preferences>` — left as-is.
*/
private fun desktopPreferencesDataStoreModule() = module {
single<AnalyticsDataStore> { prefsStore("analytics", get()).asAnalyticsDataStore() }
single<HomoglyphEncodingDataStore> { prefsStore("homoglyph_encoding", get()).asHomoglyphEncodingDataStore() }
single<AppDataStore> { prefsStore("app", get()).asAppDataStore() }
single<CustomEmojiDataStore> { prefsStore("custom_emoji", get()).asCustomEmojiDataStore() }
single<MapDataStore> { prefsStore("map", get()).asMapDataStore() }
single<MapConsentDataStore> { prefsStore("map_consent", get()).asMapConsentDataStore() }
single<MapTileProviderDataStore> { prefsStore("map_tile_provider", get()).asMapTileProviderDataStore() }
single<MeshDataStore> { prefsStore("mesh", get()).asMeshDataStore() }
single<RadioDataStore> { prefsStore("radio", get()).asRadioDataStore() }
single<UiDataStore> { prefsStore("ui", get()).asUiDataStore() }
single<MeshLogDataStore> { prefsStore("meshlog", get()).asMeshLogDataStore() }
single<FilterDataStore> { prefsStore("filter", get()).asFilterDataStore() }
single<AnalyticsDataStore> { prefsStore("analytics", get()).asPrefsStore().asAnalyticsDataStore() }
single<HomoglyphEncodingDataStore> {
prefsStore("homoglyph_encoding", get()).asPrefsStore().asHomoglyphEncodingDataStore()
}
single<AppDataStore> { prefsStore("app", get()).asPrefsStore().asAppDataStore() }
single<CustomEmojiDataStore> { prefsStore("custom_emoji", get()).asPrefsStore().asCustomEmojiDataStore() }
single<MapDataStore> { prefsStore("map", get()).asPrefsStore().asMapDataStore() }
single<MapConsentDataStore> { prefsStore("map_consent", get()).asPrefsStore().asMapConsentDataStore() }
single<MapTileProviderDataStore> {
prefsStore("map_tile_provider", get()).asPrefsStore().asMapTileProviderDataStore()
}
single<MeshDataStore> { prefsStore("mesh", get()).asPrefsStore().asMeshDataStore() }
single<RadioDataStore> { prefsStore("radio", get()).asPrefsStore().asRadioDataStore() }
single<UiDataStore> { prefsStore("ui", get()).asPrefsStore().asUiDataStore() }
single<MeshLogDataStore> { prefsStore("meshlog", get()).asPrefsStore().asMeshLogDataStore() }
single<FilterDataStore> { prefsStore("filter", get()).asPrefsStore().asFilterDataStore() }
single<CorePreferencesDataStore> { prefsStore("core_preferences", get()).asCorePreferencesDataStore() }
}