From 54756a3265ff06e196e8558828067c99bfd761ea Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 30 Aug 2026 22:43:26 -0500 Subject: [PATCH] feat(datastore): enable wasmJs via a platform-neutral Store abstraction core:datastore wraps androidx.datastore.core.DataStore for five payload types (four proto messages plus a Preferences-backed store). androidx.datastore:datastore -- the core artifact providing DataStore/OkioSerializer/CorruptionException, not just the -preferences extension core:prefs already hit -- publishes no wasmJs variant at any version, confirmed against its Gradle Module Metadata. Introduces Store (core/datastore/store/Store.kt), a direct analog to core:prefs's PrefsStore but simpler: each store here already holds one whole serializable value, so no key/snapshot abstraction is needed, just data/updateData mirroring DataStore's own two members. nonWebMain's DataStoreAdapter wraps a real DataStore unchanged; wasmJsMain's LocalStorageStore is backed by localStorage, running each payload's Wire ADAPTER.decode/.encode against an in-memory okio.Buffer and base64-encoding the resulting bytes (localStorage is string-only). Corruption policy mirrors the existing Android/JVM ReplaceFileCorruptionHandler: no value yet or a value that fails to decode both fall back to the default, with a decode failure also logged and the recovered default written back so it isn't re-hit on every access. The four proto-payload DataSources (ChannelSetDataSource and friends) needed zero logic changes -- they already depended only on data/updateData, not on any other DataStore member. CorePreferencesDataStore (DataStore) has no wasmJs counterpart: androidx.datastore.preferences's Preferences type itself has no wasmJs variant, so there's no way to construct a Store there at all -- this is the payload type being unavailable, not a missing adapter. It and its three consumers (RecentAddressesDataSource/BootloaderWarningDataSource/ FirmwareRecoveryDataSource) move to nonWebMain unchanged, deferred rather than dropped -- a future pass could rewrite them against core:prefs's own PrefsStore instead. Caught before shipping: hoisting androidx.datastore/ androidx.datastore.preferences to nonWebMain-only initially demoted them from api to implementation, mirroring core:prefs's precedent -- but androidApp's google flavor (GoogleMapsDataStore.kt/ GoogleMapsPrefs.kt) imports those packages directly with no dependency of its own, relying entirely on this module's transitive api exposure. Kept as api at the nonWebMain/commonMain level instead; verified via a real compileGoogleDebugKotlin + testGoogleDebugUnitTest run, not just the fdroid flavor the first pass checked. CoreDatastoreWasmJsModule is not registered anywhere yet -- no webApp module exists in this repo pass, same state as core:database's SingleDatabaseProvider and core:prefs's CorePrefsWasmJsModule. Co-Authored-By: Claude Sonnet 5 --- core/datastore/build.gradle.kts | 54 +++++++++++- .../di/CoreDatastoreAndroidModule.kt | 5 ++ .../core/datastore/di/CoreDataStores.kt | 42 +++++----- .../meshtastic/core/datastore/store/Store.kt | 44 ++++++++++ .../datastore/BootloaderWarningDataSource.kt | 0 .../datastore/FirmwareRecoveryDataSource.kt | 0 .../datastore/RecentAddressesDataSource.kt | 0 .../datastore/di/CorePreferencesDataStore.kt | 38 +++++++++ .../serializer/ChannelSetSerializer.kt | 0 .../serializer/LocalConfigSerializer.kt | 0 .../serializer/LocalStatsSerializer.kt | 0 .../serializer/ModuleConfigSerializer.kt | 0 .../core/datastore/store/DataStoreAdapter.kt | 32 +++++++ .../RecentAddressesDataSourceTest.kt | 0 .../datastore/di/CoreDatastoreWasmJsModule.kt | 77 +++++++++++++++++ .../core/datastore/store/LocalStorageStore.kt | 83 +++++++++++++++++++ .../desktop/di/DesktopPlatformModule.kt | 5 ++ 17 files changed, 354 insertions(+), 26 deletions(-) create mode 100644 core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/store/Store.kt rename core/datastore/src/{commonMain => nonWebMain}/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt (100%) rename core/datastore/src/{commonMain => nonWebMain}/kotlin/org/meshtastic/core/datastore/FirmwareRecoveryDataSource.kt (100%) rename core/datastore/src/{commonMain => nonWebMain}/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSource.kt (100%) create mode 100644 core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/di/CorePreferencesDataStore.kt rename core/datastore/src/{commonMain => nonWebMain}/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt (100%) rename core/datastore/src/{commonMain => nonWebMain}/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt (100%) rename core/datastore/src/{commonMain => nonWebMain}/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt (100%) rename core/datastore/src/{commonMain => nonWebMain}/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt (100%) create mode 100644 core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/store/DataStoreAdapter.kt rename core/datastore/src/{commonTest => nonWebTest}/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt (100%) create mode 100644 core/datastore/src/wasmJsMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreWasmJsModule.kt create mode 100644 core/datastore/src/wasmJsMain/kotlin/org/meshtastic/core/datastore/store/LocalStorageStore.kt diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index afa071a5de..419f653507 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -14,6 +14,11 @@ * You should have received a copy of the GNU General Public License * 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.kotlinx.serialization) @@ -23,17 +28,60 @@ plugins { kotlin { android { withHostTest {} } + // Library module: bare wasmJs(), no browser() (that's for the eventual webApp executable). + @OptIn(ExperimentalWasmDsl::class) + wasmJs() + + // nonWebMain: androidx.datastore:datastore (DataStore/OkioSerializer/CorruptionException) and + // androidx.datastore.preferences (the Preferences type) both publish no wasmJs variant at all — confirmed + // against their real Gradle Module Metadata, the same absence core:prefs hit for datastore-preferences alone + // (see core/prefs/build.gradle.kts). So the four proto serializers, CorePreferencesDataStore, and its three + // Preferences-backed consumers (RecentAddressesDataSource/BootloaderWarningDataSource/ + // FirmwareRecoveryDataSource) all live here instead of commonMain. The four proto DataSources themselves + // (ChannelSetDataSource etc.) stay in commonMain unchanged — they depend only on the platform-neutral Store + // abstraction (see core/datastore/store/Store.kt), never on DataStore directly. 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.common) implementation(projects.core.model) implementation(libs.meshtastic.protobufs) - api(libs.androidx.datastore) - api(libs.androidx.datastore.preferences) implementation(libs.kotlinx.serialization.json) implementation(libs.kermit) + // api, not implementation: androidApp (e.g. the google flavor's GoogleMapsDataStore/GoogleMapsPrefs) and + // other consumers import okio.* directly today with no dependency of their own, relying entirely on this + // module exposing it transitively (previously via androidx.datastore's own api-exposed okio dependency, + // now directly since androidx.datastore itself moved to nonWebMain — see below). + api(libs.okio) } - commonTest.dependencies { implementation(libs.okio) } + // android/jvm/ios only (see hierarchy template above) — androidx.datastore has no wasmJs variant, and the + // serializers/CorePreferencesDataStore (the sole consumers) live here. api, not implementation: androidApp's + // google flavor (GoogleMapsDataStore.kt/GoogleMapsPrefs.kt) imports androidx.datastore.* directly with no + // dependency of its own, relying entirely on this module's transitive exposure — confirmed by grepping + // androidApp/build.gradle.kts for a direct dependency (none exists). An intermediate source set's `api` + // dependency still propagates to every consumer of the targets under it (android/jvm/iOS here), while + // correctly staying invisible to wasmJs consumers, exactly preserving the original commonMain-level `api`'s + // effective reach. + getByName("nonWebMain").dependencies { + api(libs.androidx.datastore) + api(libs.androidx.datastore.preferences) + } + + wasmJsMain.dependencies { implementation(libs.kotlinx.browser) } + + // The only commonTest file (RecentAddressesDataSourceTest) constructs a real DataStore via + // PreferenceDataStoreFactory, so it moved to nonWebTest wholesale (same split core:prefs/core:database's + // DataStore-backed tests got) — commonTest is empty now. } } diff --git a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt index d915c5e206..f8b08302aa 100644 --- a/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt +++ b/core/datastore/src/androidMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreAndroidModule.kt @@ -36,6 +36,7 @@ import org.meshtastic.core.datastore.serializer.ChannelSetSerializer import org.meshtastic.core.datastore.serializer.LocalConfigSerializer import org.meshtastic.core.datastore.serializer.LocalStatsSerializer import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer +import org.meshtastic.core.datastore.store.asStore import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.LocalConfig import org.meshtastic.proto.LocalModuleConfig @@ -68,6 +69,7 @@ class LocalConfigDataStoreModule { produceNewData = { LocalConfig() }, scope = scope, ) + .asStore() .asCoreLocalConfigDataStore() } @@ -80,6 +82,7 @@ class ModuleConfigDataStoreModule { produceNewData = { LocalModuleConfig() }, scope = scope, ) + .asStore() .asCoreModuleConfigDataStore() } @@ -92,6 +95,7 @@ class ChannelSetDataStoreModule { produceNewData = { ChannelSet() }, scope = scope, ) + .asStore() .asCoreChannelSetDataStore() } @@ -104,6 +108,7 @@ class LocalStatsDataStoreModule { produceNewData = { LocalStats() }, scope = scope, ) + .asStore() .asCoreLocalStatsDataStore() } diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDataStores.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDataStores.kt index 86af5e698e..6d174a78b2 100644 --- a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDataStores.kt +++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/di/CoreDataStores.kt @@ -16,52 +16,48 @@ */ package org.meshtastic.core.datastore.di -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences import kotlinx.coroutines.CoroutineScope +import org.meshtastic.core.datastore.store.Store import org.meshtastic.proto.ChannelSet import org.meshtastic.proto.LocalConfig import org.meshtastic.proto.LocalModuleConfig import org.meshtastic.proto.LocalStats // One type per store, so the compiler distinguishes them instead of a Koin string qualifier. Each is a transparent -// `DataStore` of its payload — inject and use it exactly like one. +// `Store` of its payload — inject and use it exactly like one. `CorePreferencesDataStore` (DataStore) +// is not here: `androidx.datastore.preferences`'s `Preferences` type has no wasmJs variant, so it can't be ported +// behind `Store` at all — see nonWebMain's `CorePreferencesDataStore.kt`. /** - * Application-lifetime scope shared by every [DataStore]. Per the DataStore docs this must not be cancelled by UI - * lifecycle events: `DataStore` has no `close()`, so its in-memory cache is released only when this job ends. + * Application-lifetime scope shared by every real `DataStore` (android/jvm/iOS only). Per the DataStore docs this must + * not be cancelled by UI lifecycle events: `DataStore` has no `close()`, so its in-memory cache is released only when + * this job ends. wasmJs's `LocalStorageStore` needs no scope — `localStorage` access is synchronous. */ interface DataStoreScope : CoroutineScope /** Presents an existing scope as [DataStoreScope]; the wrapper adds nothing but identity. */ fun CoroutineScope.asDataStoreScope(): DataStoreScope = object : DataStoreScope, CoroutineScope by this {} -interface CorePreferencesDataStore : DataStore - -/** Presents an existing store as [CorePreferencesDataStore]; the wrapper adds nothing but identity. */ -fun DataStore.asCorePreferencesDataStore(): CorePreferencesDataStore = - object : CorePreferencesDataStore, DataStore by this {} - -interface CoreChannelSetDataStore : DataStore +interface CoreChannelSetDataStore : Store /** Presents an existing store as [CoreChannelSetDataStore]; the wrapper adds nothing but identity. */ -fun DataStore.asCoreChannelSetDataStore(): CoreChannelSetDataStore = - object : CoreChannelSetDataStore, DataStore by this {} +fun Store.asCoreChannelSetDataStore(): CoreChannelSetDataStore = + object : CoreChannelSetDataStore, Store by this {} -interface CoreLocalConfigDataStore : DataStore +interface CoreLocalConfigDataStore : Store /** Presents an existing store as [CoreLocalConfigDataStore]; the wrapper adds nothing but identity. */ -fun DataStore.asCoreLocalConfigDataStore(): CoreLocalConfigDataStore = - object : CoreLocalConfigDataStore, DataStore by this {} +fun Store.asCoreLocalConfigDataStore(): CoreLocalConfigDataStore = + object : CoreLocalConfigDataStore, Store by this {} -interface CoreLocalStatsDataStore : DataStore +interface CoreLocalStatsDataStore : Store /** Presents an existing store as [CoreLocalStatsDataStore]; the wrapper adds nothing but identity. */ -fun DataStore.asCoreLocalStatsDataStore(): CoreLocalStatsDataStore = - object : CoreLocalStatsDataStore, DataStore by this {} +fun Store.asCoreLocalStatsDataStore(): CoreLocalStatsDataStore = + object : CoreLocalStatsDataStore, Store by this {} -interface CoreModuleConfigDataStore : DataStore +interface CoreModuleConfigDataStore : Store /** Presents an existing store as [CoreModuleConfigDataStore]; the wrapper adds nothing but identity. */ -fun DataStore.asCoreModuleConfigDataStore(): CoreModuleConfigDataStore = - object : CoreModuleConfigDataStore, DataStore by this {} +fun Store.asCoreModuleConfigDataStore(): CoreModuleConfigDataStore = + object : CoreModuleConfigDataStore, Store by this {} diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/store/Store.kt b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/store/Store.kt new file mode 100644 index 0000000000..604bcd35fb --- /dev/null +++ b/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/store/Store.kt @@ -0,0 +1,44 @@ +/* + * 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.datastore.store + +import kotlinx.coroutines.flow.Flow + +/** + * Platform-neutral replacement for `androidx.datastore.core.DataStore`, mirroring its two members exactly. + * `androidx.datastore:datastore` (the core artifact providing `DataStore`/`OkioSerializer`/`CorruptionException`) + * publishes no wasmJs variant at all — confirmed against its Gradle Module Metadata, the same absence + * `androidx.datastore.preferences` hit for core:prefs (see `core/prefs/store/PrefsStore.kt`) — so this module's + * proto-payload stores (ChannelSet/LocalConfig/LocalStats/LocalModuleConfig) can't reference the real type from + * commonMain at all. Two implementations exist: + * - nonWebMain's `asStore()` — a thin wrapper over a real `DataStore`; android/jvm/iOS keep using the real DataStore + * machinery underneath, unchanged. + * - wasmJsMain's `LocalStorageStore` — backed by the browser's `localStorage`, encoding each value's proto bytes as + * base64 (localStorage is string-only). + * + * Unlike core:prefs's `PrefsStore` (many keys under one store), each `Store` here already holds one whole + * serializable value, so no key/snapshot abstraction is needed — this interface is a direct, simpler analog. + * + * `updateData`'s [transform] is deliberately non-suspend (androidx's is `suspend`) — no DataSource in this module needs + * suspension inside a transform, and a non-suspend lambda already satisfies a `suspend` function type, so nonWebMain's + * real-DataStore adapter passes it straight through with no wrapping needed. + */ +interface Store { + val data: Flow + + suspend fun updateData(transform: (T) -> T): T +} diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt similarity index 100% rename from core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt rename to core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/BootloaderWarningDataSource.kt diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/FirmwareRecoveryDataSource.kt b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/FirmwareRecoveryDataSource.kt similarity index 100% rename from core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/FirmwareRecoveryDataSource.kt rename to core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/FirmwareRecoveryDataSource.kt diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSource.kt b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSource.kt similarity index 100% rename from core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSource.kt rename to core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSource.kt diff --git a/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/di/CorePreferencesDataStore.kt b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/di/CorePreferencesDataStore.kt new file mode 100644 index 0000000000..09c00d117f --- /dev/null +++ b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/di/CorePreferencesDataStore.kt @@ -0,0 +1,38 @@ +/* + * 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.datastore.di + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences + +/** + * `androidx.datastore.preferences`'s `Preferences` type has no wasmJs variant at all (the same absence `core:prefs` hit + * — see `core/prefs/store/PrefsStore.kt`), so unlike the four proto-payload stores in `CoreDataStores.kt`, this one + * cannot be ported behind the generic `Store` abstraction: there is no way to construct a `Store` on + * wasmJs, because the `Preferences` type itself doesn't resolve there — this is not a missing-adapter gap, it's the + * payload type being unavailable. Stays directly typed against the real `DataStore`, android/jvm/iOS only. + * Its three consumers — `RecentAddressesDataSource`, `BootloaderWarningDataSource`, `FirmwareRecoveryDataSource` — move + * here with it, since they use `androidx.datastore.preferences`'s typed-key API directly. [DEFERRED]: a future pass + * could give these three web support by rewriting them against core:prefs's own `PrefsStore`/`PrefsKey` abstraction + * instead (which already solves "key-value settings on wasmJs" for the rest of the app) — not attempted here, out of + * this module's scope. + */ +interface CorePreferencesDataStore : DataStore + +/** Presents an existing store as [CorePreferencesDataStore]; the wrapper adds nothing but identity. */ +fun DataStore.asCorePreferencesDataStore(): CorePreferencesDataStore = + object : CorePreferencesDataStore, DataStore by this {} diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt similarity index 100% rename from core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt rename to core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/serializer/ChannelSetSerializer.kt diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt similarity index 100% rename from core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt rename to core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/serializer/LocalConfigSerializer.kt diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt similarity index 100% rename from core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt rename to core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/serializer/LocalStatsSerializer.kt diff --git a/core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt similarity index 100% rename from core/datastore/src/commonMain/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt rename to core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/serializer/ModuleConfigSerializer.kt diff --git a/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/store/DataStoreAdapter.kt b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/store/DataStoreAdapter.kt new file mode 100644 index 0000000000..40bcbb068e --- /dev/null +++ b/core/datastore/src/nonWebMain/kotlin/org/meshtastic/core/datastore/store/DataStoreAdapter.kt @@ -0,0 +1,32 @@ +/* + * 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.datastore.store + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.flow.Flow + +/** + * Adapts a real `DataStore` (android/jvm/iOS only — `androidx.datastore:datastore` has no wasmJs variant) to the + * platform-neutral [Store]. Every `CoreDatastoreAndroidModule`/`DesktopPlatformModule` call site that used to hand a + * `DataStore` straight to an `asCoreXDataStore()` wrapper now inserts this adapter first: + * `protoStore(...).asStore().asCoreXDataStore()`. + */ +fun DataStore.asStore(): Store = object : Store { + override val data: Flow = this@asStore.data + + override suspend fun updateData(transform: (T) -> T): T = this@asStore.updateData(transform) +} diff --git a/core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt b/core/datastore/src/nonWebTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt similarity index 100% rename from core/datastore/src/commonTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt rename to core/datastore/src/nonWebTest/kotlin/org/meshtastic/core/datastore/RecentAddressesDataSourceTest.kt diff --git a/core/datastore/src/wasmJsMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreWasmJsModule.kt b/core/datastore/src/wasmJsMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreWasmJsModule.kt new file mode 100644 index 0000000000..502f9c32db --- /dev/null +++ b/core/datastore/src/wasmJsMain/kotlin/org/meshtastic/core/datastore/di/CoreDatastoreWasmJsModule.kt @@ -0,0 +1,77 @@ +/* + * 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.datastore.di + +import org.koin.core.annotation.Module +import org.koin.core.annotation.Single +import org.meshtastic.core.datastore.store.LocalStorageStore +import org.meshtastic.proto.ChannelSet +import org.meshtastic.proto.LocalConfig +import org.meshtastic.proto.LocalModuleConfig +import org.meshtastic.proto.LocalStats + +/** + * Koin module providing wasmJs [org.meshtastic.core.datastore.store.Store]-backed proto stores, one per payload type, + * backed by the browser's `localStorage`. Mirrors `CoreDatastoreAndroidModule`/desktopApp's + * `desktopProtoDataStoreModule` — one singleton store per proto payload, minus the `DataStoreScope`/on-disk-path + * plumbing (`localStorage` access is synchronous, no scope or file path needed). + * + * `CorePreferencesDataStore` has no counterpart here: see its own KDoc in nonWebMain for why (the `Preferences` type + * itself has no wasmJs variant), which is also why `RecentAddressesDataSource`/`BootloaderWarningDataSource`/ + * `FirmwareRecoveryDataSource` have no web binding this pass. + * + * Not yet registered anywhere: like `CorePrefsWasmJsModule`/core:database's `SingleDatabaseProvider`, nothing on wasmJs + * composes a Koin graph yet (no `webApp` module exists in this repo pass). + */ +@Module +class CoreDatastoreWasmJsModule { + @Single + fun provideChannelSetDataStore(): CoreChannelSetDataStore = LocalStorageStore( + storageKey = "channel_set_ds", + defaultValue = ChannelSet(), + decode = ChannelSet.ADAPTER::decode, + encode = { value, sink -> ChannelSet.ADAPTER.encode(sink, value) }, + ) + .asCoreChannelSetDataStore() + + @Single + fun provideLocalConfigDataStore(): CoreLocalConfigDataStore = LocalStorageStore( + storageKey = "local_config_ds", + defaultValue = LocalConfig(), + decode = LocalConfig.ADAPTER::decode, + encode = { value, sink -> LocalConfig.ADAPTER.encode(sink, value) }, + ) + .asCoreLocalConfigDataStore() + + @Single + fun provideLocalStatsDataStore(): CoreLocalStatsDataStore = LocalStorageStore( + storageKey = "local_stats_ds", + defaultValue = LocalStats(), + decode = LocalStats.ADAPTER::decode, + encode = { value, sink -> LocalStats.ADAPTER.encode(sink, value) }, + ) + .asCoreLocalStatsDataStore() + + @Single + fun provideModuleConfigDataStore(): CoreModuleConfigDataStore = LocalStorageStore( + storageKey = "module_config_ds", + defaultValue = LocalModuleConfig(), + decode = LocalModuleConfig.ADAPTER::decode, + encode = { value, sink -> LocalModuleConfig.ADAPTER.encode(sink, value) }, + ) + .asCoreModuleConfigDataStore() +} diff --git a/core/datastore/src/wasmJsMain/kotlin/org/meshtastic/core/datastore/store/LocalStorageStore.kt b/core/datastore/src/wasmJsMain/kotlin/org/meshtastic/core/datastore/store/LocalStorageStore.kt new file mode 100644 index 0000000000..529bac3cc1 --- /dev/null +++ b/core/datastore/src/wasmJsMain/kotlin/org/meshtastic/core/datastore/store/LocalStorageStore.kt @@ -0,0 +1,83 @@ +/* + * 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.datastore.store + +import co.touchlab.kermit.Logger +import kotlinx.browser.localStorage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import okio.Buffer +import okio.BufferedSink +import okio.BufferedSource +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Backed by the browser's `localStorage`. [decode]/[encode] run against an in-memory [Buffer] — it implements both + * `BufferedSource` and `BufferedSink` purely in memory (no file, works on every target) — to get/put the value's raw + * proto bytes, which are then [Base64]-encoded since `localStorage` only stores strings. + * + * Reads are live (no caching), matching `LocalStoragePrefsStore`: [revision] exists purely to give [data] a fresh + * emission after every [updateData] — [Revision] has no `equals` override, so each freshly-constructed instance is + * unequal by reference to the last, exactly what [MutableStateFlow] needs to avoid conflating the update away. + * + * Corruption policy mirrors this module's Android/JVM `ReplaceFileCorruptionHandler` (see + * `CoreDatastoreAndroidModule`'s `protoStore`): no value yet (first launch) or a value that fails to decode both fall + * back to [defaultValue]. A decode failure is also logged and the recovered default written back, so a corrupted read + * is not repeated on every subsequent access — "first launch" is not logged, since it isn't corruption. + */ +@OptIn(ExperimentalEncodingApi::class) +internal class LocalStorageStore( + private val storageKey: String, + private val defaultValue: T, + private val decode: (BufferedSource) -> T, + private val encode: (T, BufferedSink) -> Unit, +) : Store { + private class Revision + + private val revision = MutableStateFlow(Revision()) + + override val data: Flow = revision.map { readCurrent() } + + override suspend fun updateData(transform: (T) -> T): T { + val updated = transform(readCurrent()) + writeCurrent(updated) + revision.value = Revision() + return updated + } + + // Heterogeneous failure modes (Base64's IllegalArgumentException, Wire/okio's various decode-time exceptions) are + // deliberately all treated the same way: recover to defaultValue. + @Suppress("TooGenericExceptionCaught") + private fun readCurrent(): T { + val raw = localStorage.getItem(storageKey) ?: return defaultValue + return try { + decode(Buffer().apply { write(Base64.Default.decode(raw)) }) + } catch (e: Exception) { + Logger.w(e) { "Corrupt localStorage value for '$storageKey', resetting to default" } + writeCurrent(defaultValue) + defaultValue + } + } + + private fun writeCurrent(value: T) { + val buffer = Buffer() + encode(value, buffer) + localStorage.setItem(storageKey, Base64.Default.encode(buffer.readByteArray())) + } +} 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 5ff8befa38..c66cbacda1 100644 --- a/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt +++ b/desktopApp/src/main/kotlin/org/meshtastic/desktop/di/DesktopPlatformModule.kt @@ -52,6 +52,7 @@ import org.meshtastic.core.datastore.serializer.ChannelSetSerializer import org.meshtastic.core.datastore.serializer.LocalConfigSerializer import org.meshtastic.core.datastore.serializer.LocalStatsSerializer import org.meshtastic.core.datastore.serializer.ModuleConfigSerializer +import org.meshtastic.core.datastore.store.asStore import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.prefs.di.AnalyticsDataStore import org.meshtastic.core.prefs.di.AppDataStore @@ -192,21 +193,25 @@ private fun desktopProtoDataStoreModule() = module { single { protoStore(LocalConfigSerializer, "$protoDir/local_config.pb", { LocalConfig() }, get()) + .asStore() .asCoreLocalConfigDataStore() } single { protoStore(ModuleConfigSerializer, "$protoDir/module_config.pb", { LocalModuleConfig() }, get()) + .asStore() .asCoreModuleConfigDataStore() } single { protoStore(ChannelSetSerializer, "$protoDir/channel_set.pb", { ChannelSet() }, get()) + .asStore() .asCoreChannelSetDataStore() } single { protoStore(LocalStatsSerializer, "$protoDir/local_stats.pb", { LocalStats() }, get()) + .asStore() .asCoreLocalStatsDataStore() } }