refactor: migrate preferences to DataStore and decouple core:domain for KMP (#4731)

This commit is contained in:
James Rich 2026-03-05 20:37:35 -06:00 committed by GitHub
parent 87fdaa26ff
commit b9b68d2779
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
113 changed files with 1790 additions and 1320 deletions

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@ -14,28 +14,31 @@
* 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 android.content.Context
import android.content.SharedPreferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.prefs.map.GoogleMapsPrefs
import org.meshtastic.core.prefs.map.GoogleMapsPrefsImpl
import javax.inject.Qualifier
import javax.inject.Singleton
// Pref store qualifiers are internal to prevent prefs stores from being injected directly.
// Consuming code should always inject one of the prefs repositories.
@Qualifier
@Retention(AnnotationRetention.BINARY)
internal annotation class GoogleMapsSharedPreferences
internal annotation class GoogleMapsDataStore
@InstallIn(SingletonComponent::class)
@Module
@ -44,11 +47,16 @@ interface GoogleMapsModule {
@Binds fun bindGoogleMapsPrefs(googleMapsPrefsImpl: GoogleMapsPrefsImpl): GoogleMapsPrefs
companion object {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Provides
@Singleton
@GoogleMapsSharedPreferences
fun provideGoogleMapsSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
context.getSharedPreferences("google_maps_prefs", Context.MODE_PRIVATE)
@GoogleMapsDataStore
fun provideGoogleMapsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
)
}
}

View file

@ -16,39 +16,168 @@
*/
package org.meshtastic.core.prefs.map
import android.content.SharedPreferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.doublePreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.google.maps.android.compose.MapType
import org.meshtastic.core.prefs.DoublePrefDelegate
import org.meshtastic.core.prefs.FloatPrefDelegate
import org.meshtastic.core.prefs.NullableStringPrefDelegate
import org.meshtastic.core.prefs.StringSetPrefDelegate
import org.meshtastic.core.prefs.di.GoogleMapsSharedPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.di.GoogleMapsDataStore
import javax.inject.Inject
import javax.inject.Singleton
/** Interface for prefs specific to Google Maps. For general map prefs, see MapPrefs. */
interface GoogleMapsPrefs {
var selectedGoogleMapType: String?
var selectedCustomTileUrl: String?
var hiddenLayerUrls: Set<String>
var cameraTargetLat: Double
var cameraTargetLng: Double
var cameraZoom: Float
var cameraTilt: Float
var cameraBearing: Float
var networkMapLayers: Set<String>
val selectedGoogleMapType: StateFlow<String?>
fun setSelectedGoogleMapType(value: String?)
val selectedCustomTileUrl: StateFlow<String?>
fun setSelectedCustomTileUrl(value: String?)
val hiddenLayerUrls: StateFlow<Set<String>>
fun setHiddenLayerUrls(value: Set<String>)
val cameraTargetLat: StateFlow<Double>
fun setCameraTargetLat(value: Double)
val cameraTargetLng: StateFlow<Double>
fun setCameraTargetLng(value: Double)
val cameraZoom: StateFlow<Float>
fun setCameraZoom(value: Float)
val cameraTilt: StateFlow<Float>
fun setCameraTilt(value: Float)
val cameraBearing: StateFlow<Float>
fun setCameraBearing(value: Float)
val networkMapLayers: StateFlow<Set<String>>
fun setNetworkMapLayers(value: Set<String>)
}
@Singleton
class GoogleMapsPrefsImpl @Inject constructor(@GoogleMapsSharedPreferences prefs: SharedPreferences) : GoogleMapsPrefs {
override var selectedGoogleMapType: String? by
NullableStringPrefDelegate(prefs, "selected_google_map_type", MapType.NORMAL.name)
override var selectedCustomTileUrl: String? by NullableStringPrefDelegate(prefs, "selected_custom_tile_url", null)
override var hiddenLayerUrls: Set<String> by StringSetPrefDelegate(prefs, "hidden_layer_urls", emptySet())
override var cameraTargetLat: Double by DoublePrefDelegate(prefs, "camera_target_lat", 0.0)
override var cameraTargetLng: Double by DoublePrefDelegate(prefs, "camera_target_lng", 0.0)
override var cameraZoom: Float by FloatPrefDelegate(prefs, "camera_zoom", 7f)
override var cameraTilt: Float by FloatPrefDelegate(prefs, "camera_tilt", 0f)
override var cameraBearing: Float by FloatPrefDelegate(prefs, "camera_bearing", 0f)
override var networkMapLayers: Set<String> by StringSetPrefDelegate(prefs, "network_map_layers", emptySet())
class GoogleMapsPrefsImpl
@Inject
constructor(
@GoogleMapsDataStore private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : GoogleMapsPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val selectedGoogleMapType: StateFlow<String?> =
dataStore.data
.map { it[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] ?: MapType.NORMAL.name }
.stateIn(scope, SharingStarted.Eagerly, MapType.NORMAL.name)
override fun setSelectedGoogleMapType(value: String?) {
scope.launch {
dataStore.edit { prefs ->
if (value == null) {
prefs.remove(KEY_SELECTED_GOOGLE_MAP_TYPE_PREF)
} else {
prefs[KEY_SELECTED_GOOGLE_MAP_TYPE_PREF] = value
}
}
}
}
override val selectedCustomTileUrl: StateFlow<String?> =
dataStore.data.map { it[KEY_SELECTED_CUSTOM_TILE_URL_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
override fun setSelectedCustomTileUrl(value: String?) {
scope.launch {
dataStore.edit { prefs ->
if (value == null) {
prefs.remove(KEY_SELECTED_CUSTOM_TILE_URL_PREF)
} else {
prefs[KEY_SELECTED_CUSTOM_TILE_URL_PREF] = value
}
}
}
}
override val hiddenLayerUrls: StateFlow<Set<String>> =
dataStore.data
.map { it[KEY_HIDDEN_LAYER_URLS_PREF] ?: emptySet() }
.stateIn(scope, SharingStarted.Eagerly, emptySet())
override fun setHiddenLayerUrls(value: Set<String>) {
scope.launch { dataStore.edit { it[KEY_HIDDEN_LAYER_URLS_PREF] = value } }
}
override val cameraTargetLat: StateFlow<Double> =
dataStore.data.map { it[KEY_CAMERA_TARGET_LAT_PREF] ?: 0.0 }.stateIn(scope, SharingStarted.Eagerly, 0.0)
override fun setCameraTargetLat(value: Double) {
scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LAT_PREF] = value } }
}
override val cameraTargetLng: StateFlow<Double> =
dataStore.data.map { it[KEY_CAMERA_TARGET_LNG_PREF] ?: 0.0 }.stateIn(scope, SharingStarted.Eagerly, 0.0)
override fun setCameraTargetLng(value: Double) {
scope.launch { dataStore.edit { it[KEY_CAMERA_TARGET_LNG_PREF] = value } }
}
override val cameraZoom: StateFlow<Float> =
dataStore.data.map { it[KEY_CAMERA_ZOOM_PREF] ?: 7f }.stateIn(scope, SharingStarted.Eagerly, 7f)
override fun setCameraZoom(value: Float) {
scope.launch { dataStore.edit { it[KEY_CAMERA_ZOOM_PREF] = value } }
}
override val cameraTilt: StateFlow<Float> =
dataStore.data.map { it[KEY_CAMERA_TILT_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f)
override fun setCameraTilt(value: Float) {
scope.launch { dataStore.edit { it[KEY_CAMERA_TILT_PREF] = value } }
}
override val cameraBearing: StateFlow<Float> =
dataStore.data.map { it[KEY_CAMERA_BEARING_PREF] ?: 0f }.stateIn(scope, SharingStarted.Eagerly, 0f)
override fun setCameraBearing(value: Float) {
scope.launch { dataStore.edit { it[KEY_CAMERA_BEARING_PREF] = value } }
}
override val networkMapLayers: StateFlow<Set<String>> =
dataStore.data
.map { it[KEY_NETWORK_MAP_LAYERS_PREF] ?: emptySet() }
.stateIn(scope, SharingStarted.Eagerly, emptySet())
override fun setNetworkMapLayers(value: Set<String>) {
scope.launch { dataStore.edit { it[KEY_NETWORK_MAP_LAYERS_PREF] = value } }
}
companion object {
val KEY_SELECTED_GOOGLE_MAP_TYPE_PREF = stringPreferencesKey("selected_google_map_type")
val KEY_SELECTED_CUSTOM_TILE_URL_PREF = stringPreferencesKey("selected_custom_tile_url")
val KEY_HIDDEN_LAYER_URLS_PREF = stringSetPreferencesKey("hidden_layer_urls")
val KEY_CAMERA_TARGET_LAT_PREF = doublePreferencesKey("camera_target_lat")
val KEY_CAMERA_TARGET_LNG_PREF = doublePreferencesKey("camera_target_lng")
val KEY_CAMERA_ZOOM_PREF = floatPreferencesKey("camera_zoom")
val KEY_CAMERA_TILT_PREF = floatPreferencesKey("camera_tilt")
val KEY_CAMERA_BEARING_PREF = floatPreferencesKey("camera_bearing")
val KEY_NETWORK_MAP_LAYERS_PREF = stringSetPreferencesKey("network_map_layers")
}
}

View file

@ -1,39 +0,0 @@
/*
* Copyright (c) 2025 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
import android.content.SharedPreferences
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class DoublePrefDelegate(
private val preferences: SharedPreferences,
private val key: String,
private val defaultValue: Double,
) : ReadWriteProperty<Any?, Double> {
override fun getValue(thisRef: Any?, property: KProperty<*>): Double = preferences
.getFloat(key, defaultValue.toFloat())
.toDouble() // SharedPreferences doesn't have putDouble, so convert to float
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Double) {
preferences
.edit()
.putFloat(key, value.toFloat())
.apply() // SharedPreferences doesn't have putDouble, so convert to float
}
}

View file

@ -1,34 +0,0 @@
/*
* Copyright (c) 2025 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
import android.content.SharedPreferences
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
class FloatPrefDelegate(
private val preferences: SharedPreferences,
private val key: String,
private val defaultValue: Float,
) : ReadWriteProperty<Any?, Float> {
override fun getValue(thisRef: Any?, property: KProperty<*>): Float = preferences.getFloat(key, defaultValue)
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Float) {
preferences.edit().putFloat(key, value).apply()
}
}

View file

@ -1,48 +0,0 @@
/*
* Copyright (c) 2025 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
import android.content.SharedPreferences
import androidx.core.content.edit
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
/**
* A [ReadWriteProperty] delegate that provides concise, type-safe access to [SharedPreferences] for nullable strings.
*
* @param prefs The [SharedPreferences] instance to back the property.
* @param key The key used to store and retrieve the value.
* @param defaultValue The default value to return if no value is found.
*/
internal class NullableStringPrefDelegate(
private val prefs: SharedPreferences,
private val key: String,
private val defaultValue: String?,
) : ReadWriteProperty<Any?, String?> {
override fun getValue(thisRef: Any?, property: KProperty<*>): String? = prefs.getString(key, defaultValue)
override fun setValue(thisRef: Any?, property: KProperty<*>, value: String?) {
prefs.edit {
when (value) {
null -> remove(key)
else -> putString(key, value)
}
}
}
}

View file

@ -1,61 +0,0 @@
/*
* Copyright (c) 2025 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
import android.content.SharedPreferences
import androidx.core.content.edit
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
/**
* A generic [ReadWriteProperty] delegate that provides concise, type-safe access to [SharedPreferences].
*
* @param prefs The [SharedPreferences] instance to back the property.
* @param key The key used to store and retrieve the value.
* @param defaultValue The default value to return if no value is found.
* @throws IllegalArgumentException if the type is not supported.
*/
internal class PrefDelegate<T>(
private val prefs: SharedPreferences,
private val key: String,
private val defaultValue: T,
) : ReadWriteProperty<Any?, T> {
@Suppress("UNCHECKED_CAST")
override fun getValue(thisRef: Any?, property: KProperty<*>): T = when (defaultValue) {
is String -> (prefs.getString(key, defaultValue) ?: defaultValue) as T
is Int -> prefs.getInt(key, defaultValue) as T
is Boolean -> prefs.getBoolean(key, defaultValue) as T
is Float -> prefs.getFloat(key, defaultValue) as T
is Long -> prefs.getLong(key, defaultValue) as T
else -> error("Unsupported type for key '$key': $defaultValue")
}
override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
prefs.edit {
when (value) {
is String -> putString(key, value)
is Int -> putInt(key, value)
is Boolean -> putBoolean(key, value)
is Float -> putFloat(key, value)
is Long -> putLong(key, value)
else -> error("Unsupported type for key '$key': $value")
}
}
}
}

View file

@ -1,35 +0,0 @@
/*
* Copyright (c) 2025 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
import android.content.SharedPreferences
import androidx.core.content.edit
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
internal class StringSetPrefDelegate(
private val prefs: SharedPreferences,
private val key: String,
private val defaultValue: Set<String>,
) : ReadWriteProperty<Any?, Set<String>> {
override fun getValue(thisRef: Any?, property: KProperty<*>): Set<String> =
prefs.getStringSet(key, defaultValue) ?: emptySet()
override fun setValue(thisRef: Any?, property: KProperty<*>, value: Set<String>) =
prefs.edit { putStringSet(key, value) }
}

View file

@ -1,82 +0,0 @@
/*
* Copyright (c) 2025-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.analytics
import android.content.SharedPreferences
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import org.meshtastic.core.prefs.NullableStringPrefDelegate
import org.meshtastic.core.prefs.PrefDelegate
import org.meshtastic.core.prefs.di.AnalyticsSharedPreferences
import org.meshtastic.core.prefs.di.AppSharedPreferences
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.uuid.Uuid
/** Interface for managing analytics-related preferences. */
interface AnalyticsPrefs {
/** Preference for whether analytics collection is allowed by the user. */
var analyticsAllowed: Boolean
/**
* Provides a [Flow] that emits the current state of [analyticsAllowed] and subsequent changes.
*
* @return A [Flow] of [Boolean] indicating if analytics are allowed.
*/
fun getAnalyticsAllowedChangesFlow(): Flow<Boolean>
/** Unique installation ID for analytics purposes. */
val installId: String
companion object {
/** Key for the analyticsAllowed preference. */
const val KEY_ANALYTICS_ALLOWED = "allowed"
/** Name of the SharedPreferences file where analytics preferences are stored. */
const val ANALYTICS_PREFS_NAME = "analytics-prefs"
}
}
@Singleton
class AnalyticsPrefsImpl
@Inject
constructor(
@AnalyticsSharedPreferences private val analyticsSharedPreferences: SharedPreferences,
@AppSharedPreferences appPrefs: SharedPreferences,
) : AnalyticsPrefs {
override var analyticsAllowed: Boolean by
PrefDelegate(analyticsSharedPreferences, AnalyticsPrefs.KEY_ANALYTICS_ALLOWED, false)
private var _installId: String? by NullableStringPrefDelegate(appPrefs, "appPrefs_install_id", null)
override val installId: String
get() = _installId ?: Uuid.random().toString().also { _installId = it }
override fun getAnalyticsAllowedChangesFlow(): Flow<Boolean> = callbackFlow {
val listener =
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == AnalyticsPrefs.KEY_ANALYTICS_ALLOWED) {
trySend(analyticsAllowed)
}
}
// Emit the initial value
trySend(analyticsAllowed)
analyticsSharedPreferences.registerOnSharedPreferenceChangeListener(listener)
awaitClose { analyticsSharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
}
}

View file

@ -0,0 +1,78 @@
/*
* Copyright (c) 2025-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.analytics
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
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
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.di.AnalyticsDataStore
import org.meshtastic.core.prefs.di.AppDataStore
import org.meshtastic.core.repository.AnalyticsPrefs
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.uuid.Uuid
@Singleton
class AnalyticsPrefsImpl
@Inject
constructor(
@AnalyticsDataStore private val analyticsDataStore: DataStore<Preferences>,
@AppDataStore private val appDataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : AnalyticsPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val analyticsAllowed: StateFlow<Boolean> =
analyticsDataStore.data
.map { it[KEY_ANALYTICS_ALLOWED_PREF] ?: false }
.stateIn(scope, SharingStarted.Eagerly, false)
override fun setAnalyticsAllowed(allowed: Boolean) {
scope.launch { analyticsDataStore.edit { prefs -> prefs[KEY_ANALYTICS_ALLOWED_PREF] = allowed } }
}
override val installId: StateFlow<String> =
appDataStore.data.map { it[KEY_INSTALL_ID_PREF] ?: "" }.stateIn(scope, SharingStarted.Eagerly, "")
init {
scope.launch {
appDataStore.edit { prefs ->
if (prefs[KEY_INSTALL_ID_PREF] == null) {
prefs[KEY_INSTALL_ID_PREF] = Uuid.random().toString()
}
}
}
}
companion object {
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)
}
}

View file

@ -17,88 +17,92 @@
package org.meshtastic.core.prefs.di
import android.content.Context
import android.content.SharedPreferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.meshtastic.core.prefs.analytics.AnalyticsPrefsImpl
import org.meshtastic.core.prefs.emoji.CustomEmojiPrefs
import org.meshtastic.core.prefs.emoji.CustomEmojiPrefsImpl
import org.meshtastic.core.prefs.filter.FilterPrefs
import org.meshtastic.core.prefs.filter.FilterPrefsImpl
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefsImpl
import org.meshtastic.core.prefs.map.MapConsentPrefs
import org.meshtastic.core.prefs.map.MapConsentPrefsImpl
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.prefs.map.MapPrefsImpl
import org.meshtastic.core.prefs.map.MapTileProviderPrefs
import org.meshtastic.core.prefs.map.MapTileProviderPrefsImpl
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.prefs.mesh.MeshPrefsImpl
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.core.prefs.meshlog.MeshLogPrefsImpl
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.prefs.radio.RadioPrefsImpl
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.prefs.ui.UiPrefsImpl
import org.meshtastic.core.repository.AnalyticsPrefs
import org.meshtastic.core.repository.CustomEmojiPrefs
import org.meshtastic.core.repository.FilterPrefs
import org.meshtastic.core.repository.HomoglyphPrefs
import org.meshtastic.core.repository.MapConsentPrefs
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.MapTileProviderPrefs
import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.UiPrefs
import javax.inject.Qualifier
import javax.inject.Singleton
// These pref store qualifiers are internal to prevent prefs stores from being injected directly.
// Consuming code should always inject one of the prefs repositories.
@Qualifier
@Retention(AnnotationRetention.BINARY)
internal annotation class AnalyticsDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
internal annotation class AnalyticsSharedPreferences
internal annotation class HomoglyphEncodingDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
internal annotation class HomoglyphEncodingSharedPreferences
internal annotation class AppDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
internal annotation class AppSharedPreferences
internal annotation class CustomEmojiDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
internal annotation class CustomEmojiSharedPreferences
internal annotation class MapDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
internal annotation class MapSharedPreferences
internal annotation class MapConsentDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
internal annotation class MapConsentSharedPreferences
internal annotation class MapTileProviderDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
internal annotation class MapTileProviderSharedPreferences
internal annotation class MeshDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
internal annotation class MeshSharedPreferences
internal annotation class RadioDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
internal annotation class RadioSharedPreferences
internal annotation class UiDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
internal annotation class UiSharedPreferences
internal annotation class MeshLogDataStore
@Qualifier
@Retention(AnnotationRetention.BINARY)
internal annotation class MeshLogSharedPreferences
@Qualifier
@Retention(AnnotationRetention.BINARY)
internal annotation class FilterSharedPreferences
internal annotation class FilterDataStore
@Suppress("TooManyFunctions")
@InstallIn(SingletonComponent::class)
@ -109,11 +113,6 @@ interface PrefsModule {
@Binds fun bindHomoglyphEncodingPrefs(homoglyphEncodingPrefsImpl: HomoglyphPrefsImpl): HomoglyphPrefs
@Binds
fun bindSharedHomoglyphPrefs(
homoglyphEncodingPrefsImpl: HomoglyphPrefsImpl,
): org.meshtastic.core.repository.HomoglyphPrefs
@Binds fun bindCustomEmojiPrefs(customEmojiPrefsImpl: CustomEmojiPrefsImpl): CustomEmojiPrefs
@Binds fun bindMapConsentPrefs(mapConsentPrefsImpl: MapConsentPrefsImpl): MapConsentPrefs
@ -133,77 +132,126 @@ interface PrefsModule {
@Binds fun bindFilterPrefs(filterPrefsImpl: FilterPrefsImpl): FilterPrefs
companion object {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@Provides
@Singleton
@AnalyticsSharedPreferences
fun provideAnalyticsSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
context.getSharedPreferences("analytics-prefs", Context.MODE_PRIVATE)
@AnalyticsDataStore
fun provideAnalyticsDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "analytics-prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("analytics_ds") },
)
@Provides
@Singleton
@HomoglyphEncodingSharedPreferences
fun provideHomoglyphEncodingSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
context.getSharedPreferences("homoglyph-encoding-prefs", Context.MODE_PRIVATE)
@HomoglyphEncodingDataStore
fun provideHomoglyphEncodingDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "homoglyph-encoding-prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("homoglyph_encoding_ds") },
)
@Provides
@Singleton
@AppSharedPreferences
fun provideAppSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
@AppDataStore
fun provideAppDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("app_ds") },
)
@Provides
@Singleton
@CustomEmojiSharedPreferences
fun provideCustomEmojiSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
context.getSharedPreferences("org.geeksville.emoji.prefs", Context.MODE_PRIVATE)
@CustomEmojiDataStore
fun provideCustomEmojiDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "org.geeksville.emoji.prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("custom_emoji_ds") },
)
@Provides
@Singleton
@MapSharedPreferences
fun provideMapSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
context.getSharedPreferences("map_prefs", Context.MODE_PRIVATE)
@MapDataStore
fun provideMapDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "map_prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("map_ds") },
)
@Provides
@Singleton
@MapConsentSharedPreferences
fun provideMapConsentSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
context.getSharedPreferences("map_consent_preferences", Context.MODE_PRIVATE)
@MapConsentDataStore
fun provideMapConsentDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "map_consent_preferences")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("map_consent_ds") },
)
@Provides
@Singleton
@MapTileProviderSharedPreferences
fun provideMapTileProviderSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
context.getSharedPreferences("map_tile_provider_prefs", Context.MODE_PRIVATE)
@MapTileProviderDataStore
fun provideMapTileProviderDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "map_tile_provider_prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("map_tile_provider_ds") },
)
@Provides
@Singleton
@MeshSharedPreferences
fun provideMeshSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
context.getSharedPreferences("mesh-prefs", Context.MODE_PRIVATE)
@MeshDataStore
fun provideMeshDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "mesh-prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("mesh_ds") },
)
@Provides
@Singleton
@RadioSharedPreferences
fun provideRadioSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
context.getSharedPreferences("radio-prefs", Context.MODE_PRIVATE)
@RadioDataStore
fun provideRadioDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "radio-prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("radio_ds") },
)
@Provides
@Singleton
@UiSharedPreferences
fun provideUiSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
context.getSharedPreferences("ui-prefs", Context.MODE_PRIVATE)
@UiDataStore
fun provideUiDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "ui-prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("ui_ds") },
)
@Provides
@Singleton
@MeshLogSharedPreferences
fun provideMeshLogSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
context.getSharedPreferences("meshlog-prefs", Context.MODE_PRIVATE)
@MeshLogDataStore
fun provideMeshLogDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "meshlog-prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("meshlog_ds") },
)
@Provides
@Singleton
@FilterSharedPreferences
fun provideFilterSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
context.getSharedPreferences(FilterPrefs.FILTER_PREFS_NAME, Context.MODE_PRIVATE)
@FilterDataStore
fun provideFilterDataStore(@ApplicationContext context: Context): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "filter-prefs")),
scope = scope,
produceFile = { context.preferencesDataStoreFile("filter_ds") },
)
}
}

View file

@ -1,34 +0,0 @@
/*
* Copyright (c) 2025 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.emoji
import android.content.SharedPreferences
import org.meshtastic.core.prefs.NullableStringPrefDelegate
import org.meshtastic.core.prefs.di.CustomEmojiSharedPreferences
import javax.inject.Inject
import javax.inject.Singleton
interface CustomEmojiPrefs {
var customEmojiFrequency: String?
}
@Singleton
class CustomEmojiPrefsImpl @Inject constructor(@CustomEmojiSharedPreferences prefs: SharedPreferences) :
CustomEmojiPrefs {
override var customEmojiFrequency: String? by NullableStringPrefDelegate(prefs, "pref_key_custom_emoji_freq", null)
}

View file

@ -0,0 +1,64 @@
/*
* Copyright (c) 2025-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.emoji
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
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
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.di.CustomEmojiDataStore
import org.meshtastic.core.repository.CustomEmojiPrefs
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CustomEmojiPrefsImpl
@Inject
constructor(
@CustomEmojiDataStore private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : CustomEmojiPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val customEmojiFrequency: StateFlow<String?> =
dataStore.data.map { it[KEY_EMOJI_FREQ_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
override fun setCustomEmojiFrequency(frequency: String?) {
scope.launch {
dataStore.edit { prefs ->
if (frequency == null) {
prefs.remove(KEY_EMOJI_FREQ_PREF)
} else {
prefs[KEY_EMOJI_FREQ_PREF] = frequency
}
}
}
}
companion object {
const val KEY_EMOJI_FREQ = "pref_key_custom_emoji_freq"
val KEY_EMOJI_FREQ_PREF = stringPreferencesKey(KEY_EMOJI_FREQ)
}
}

View file

@ -1,50 +0,0 @@
/*
* Copyright (c) 2025-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.filter
import android.content.SharedPreferences
import org.meshtastic.core.prefs.PrefDelegate
import org.meshtastic.core.prefs.StringSetPrefDelegate
import org.meshtastic.core.prefs.di.FilterSharedPreferences
import javax.inject.Inject
import javax.inject.Singleton
/** Interface for managing message filter preferences. */
interface FilterPrefs {
/** Whether message filtering is enabled. */
var filterEnabled: Boolean
/** Set of words to filter messages on. */
var filterWords: Set<String>
companion object {
/** Key for the filterEnabled preference. */
const val KEY_FILTER_ENABLED = "filter_enabled"
/** Key for the filterWords preference. */
const val KEY_FILTER_WORDS = "filter_words"
/** Name of the SharedPreferences file where filter preferences are stored. */
const val FILTER_PREFS_NAME = "filter-prefs"
}
}
@Singleton
class FilterPrefsImpl @Inject constructor(@FilterSharedPreferences private val prefs: SharedPreferences) : FilterPrefs {
override var filterEnabled: Boolean by PrefDelegate(prefs, FilterPrefs.KEY_FILTER_ENABLED, false)
override var filterWords: Set<String> by StringSetPrefDelegate(prefs, FilterPrefs.KEY_FILTER_WORDS, emptySet())
}

View file

@ -0,0 +1,70 @@
/*
* Copyright (c) 2025-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.filter
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
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
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.di.FilterDataStore
import org.meshtastic.core.repository.FilterPrefs
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FilterPrefsImpl
@Inject
constructor(
@FilterDataStore private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : FilterPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val filterEnabled: StateFlow<Boolean> =
dataStore.data.map { it[KEY_FILTER_ENABLED_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
override fun setFilterEnabled(enabled: Boolean) {
scope.launch { dataStore.edit { prefs -> prefs[KEY_FILTER_ENABLED_PREF] = enabled } }
}
override val filterWords: StateFlow<Set<String>> =
dataStore.data
.map { it[KEY_FILTER_WORDS_PREF] ?: emptySet() }
.stateIn(scope, SharingStarted.Eagerly, emptySet())
override fun setFilterWords(words: Set<String>) {
scope.launch { dataStore.edit { prefs -> prefs[KEY_FILTER_WORDS_PREF] = words } }
}
companion object {
const val KEY_FILTER_ENABLED = "filter_enabled"
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)
}
}

View file

@ -1,68 +0,0 @@
/*
* 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.homoglyph
import android.content.SharedPreferences
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import org.meshtastic.core.prefs.PrefDelegate
import org.meshtastic.core.prefs.di.HomoglyphEncodingSharedPreferences
import javax.inject.Inject
import javax.inject.Singleton
import org.meshtastic.core.repository.HomoglyphPrefs as SharedHomoglyphPrefs
interface HomoglyphPrefs : SharedHomoglyphPrefs {
/** Preference for whether homoglyph encoding is enabled by the user. */
override var homoglyphEncodingEnabled: Boolean
/**
* Provides a [Flow] that emits the current state of [homoglyphEncodingEnabled] and subsequent changes.
*
* @return A [Flow] of [Boolean] indicating if homoglyph encoding is enabled.
*/
fun getHomoglyphEncodingEnabledChangesFlow(): Flow<Boolean>
companion object {
/** Key for the homoglyphEncodingEnabled preference. */
const val KEY_HOMOGLYPH_ENCODING_ENABLED = "enabled"
}
}
@Singleton
class HomoglyphPrefsImpl
@Inject
constructor(
@HomoglyphEncodingSharedPreferences private val homoglyphEncodingSharedPreferences: SharedPreferences,
) : HomoglyphPrefs {
override var homoglyphEncodingEnabled: Boolean by
PrefDelegate(homoglyphEncodingSharedPreferences, HomoglyphPrefs.KEY_HOMOGLYPH_ENCODING_ENABLED, false)
override fun getHomoglyphEncodingEnabledChangesFlow(): Flow<Boolean> = callbackFlow {
val listener =
SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == HomoglyphPrefs.KEY_HOMOGLYPH_ENCODING_ENABLED) {
trySend(homoglyphEncodingEnabled)
}
}
// Emit the initial value
trySend(homoglyphEncodingEnabled)
homoglyphEncodingSharedPreferences.registerOnSharedPreferenceChangeListener(listener)
awaitClose { homoglyphEncodingSharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
}
}

View file

@ -0,0 +1,56 @@
/*
* 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.homoglyph
import androidx.datastore.core.DataStore
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
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.di.HomoglyphEncodingDataStore
import org.meshtastic.core.repository.HomoglyphPrefs
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class HomoglyphPrefsImpl
@Inject
constructor(
@HomoglyphEncodingDataStore private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : HomoglyphPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val homoglyphEncodingEnabled: StateFlow<Boolean> =
dataStore.data.map { it[KEY_ENABLED_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
override fun setHomoglyphEncodingEnabled(enabled: Boolean) {
scope.launch { dataStore.edit { prefs -> prefs[KEY_ENABLED_PREF] = enabled } }
}
companion object {
const val KEY_ENABLED = "enabled"
val KEY_ENABLED_PREF = booleanPreferencesKey(KEY_ENABLED)
}
}

View file

@ -1,40 +0,0 @@
/*
* Copyright (c) 2025 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.map
import android.content.SharedPreferences
import androidx.core.content.edit
import org.meshtastic.core.prefs.di.MapConsentSharedPreferences
import javax.inject.Inject
import javax.inject.Singleton
interface MapConsentPrefs {
fun shouldReportLocation(nodeNum: Int?): Boolean
fun setShouldReportLocation(nodeNum: Int?, value: Boolean)
}
@Singleton
class MapConsentPrefsImpl @Inject constructor(@MapConsentSharedPreferences private val prefs: SharedPreferences) :
MapConsentPrefs {
override fun shouldReportLocation(nodeNum: Int?) = prefs.getBoolean(nodeNum.toString(), false)
override fun setShouldReportLocation(nodeNum: Int?, value: Boolean) {
prefs.edit { putBoolean(nodeNum.toString(), value) }
}
}

View file

@ -0,0 +1,56 @@
/*
* Copyright (c) 2025-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.map
import androidx.datastore.core.DataStore
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
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.di.MapConsentDataStore
import org.meshtastic.core.repository.MapConsentPrefs
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MapConsentPrefsImpl
@Inject
constructor(
@MapConsentDataStore private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : MapConsentPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
private val consentFlows = ConcurrentHashMap<Int?, StateFlow<Boolean>>()
override fun shouldReportLocation(nodeNum: Int?): StateFlow<Boolean> = consentFlows.getOrPut(nodeNum) {
val key = booleanPreferencesKey(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 } }
}
}

View file

@ -1,44 +0,0 @@
/*
* Copyright (c) 2025 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.map
import android.content.SharedPreferences
import org.meshtastic.core.prefs.PrefDelegate
import org.meshtastic.core.prefs.di.MapSharedPreferences
import javax.inject.Inject
import javax.inject.Singleton
/** Interface for general map prefs. For Google-specific prefs, see GoogleMapsPrefs. */
interface MapPrefs {
var mapStyle: Int
var showOnlyFavorites: Boolean
var showWaypointsOnMap: Boolean
var showPrecisionCircleOnMap: Boolean
var lastHeardFilter: Long
var lastHeardTrackFilter: Long
}
@Singleton
class MapPrefsImpl @Inject constructor(@MapSharedPreferences prefs: SharedPreferences) : MapPrefs {
override var mapStyle: Int by PrefDelegate(prefs, "map_style_id", 0)
override var showOnlyFavorites: Boolean by PrefDelegate(prefs, "show_only_favorites", false)
override var showWaypointsOnMap: Boolean by PrefDelegate(prefs, "show_waypoints", true)
override var showPrecisionCircleOnMap: Boolean by PrefDelegate(prefs, "show_precision_circle", true)
override var lastHeardFilter: Long by PrefDelegate(prefs, "last_heard_filter", 0L)
override var lastHeardTrackFilter: Long by PrefDelegate(prefs, "last_heard_track_filter", 0L)
}

View file

@ -0,0 +1,97 @@
/*
* Copyright (c) 2025-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.map
import androidx.datastore.core.DataStore
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.longPreferencesKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.di.MapDataStore
import org.meshtastic.core.repository.MapPrefs
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MapPrefsImpl
@Inject
constructor(
@MapDataStore private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : MapPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val mapStyle: StateFlow<Int> =
dataStore.data.map { it[KEY_MAP_STYLE_PREF] ?: 0 }.stateIn(scope, SharingStarted.Eagerly, 0)
override fun setMapStyle(value: Int) {
scope.launch { dataStore.edit { it[KEY_MAP_STYLE_PREF] = value } }
}
override val showOnlyFavorites: StateFlow<Boolean> =
dataStore.data.map { it[KEY_SHOW_ONLY_FAVORITES_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
override fun setShowOnlyFavorites(value: Boolean) {
scope.launch { dataStore.edit { it[KEY_SHOW_ONLY_FAVORITES_PREF] = value } }
}
override val showWaypointsOnMap: StateFlow<Boolean> =
dataStore.data.map { it[KEY_SHOW_WAYPOINTS_PREF] ?: true }.stateIn(scope, SharingStarted.Eagerly, true)
override fun setShowWaypointsOnMap(value: Boolean) {
scope.launch { dataStore.edit { it[KEY_SHOW_WAYPOINTS_PREF] = value } }
}
override val showPrecisionCircleOnMap: StateFlow<Boolean> =
dataStore.data.map { it[KEY_SHOW_PRECISION_CIRCLE_PREF] ?: true }.stateIn(scope, SharingStarted.Eagerly, true)
override fun setShowPrecisionCircleOnMap(value: Boolean) {
scope.launch { dataStore.edit { it[KEY_SHOW_PRECISION_CIRCLE_PREF] = value } }
}
override val lastHeardFilter: StateFlow<Long> =
dataStore.data.map { it[KEY_LAST_HEARD_FILTER_PREF] ?: 0L }.stateIn(scope, SharingStarted.Eagerly, 0L)
override fun setLastHeardFilter(value: Long) {
scope.launch { dataStore.edit { it[KEY_LAST_HEARD_FILTER_PREF] = value } }
}
override val lastHeardTrackFilter: StateFlow<Long> =
dataStore.data.map { it[KEY_LAST_HEARD_TRACK_FILTER_PREF] ?: 0L }.stateIn(scope, SharingStarted.Eagerly, 0L)
override fun setLastHeardTrackFilter(value: Long) {
scope.launch { dataStore.edit { it[KEY_LAST_HEARD_TRACK_FILTER_PREF] = value } }
}
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")
}
}

View file

@ -1,34 +0,0 @@
/*
* Copyright (c) 2025 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.map
import android.content.SharedPreferences
import org.meshtastic.core.prefs.NullableStringPrefDelegate
import org.meshtastic.core.prefs.di.MapTileProviderSharedPreferences
import javax.inject.Inject
import javax.inject.Singleton
interface MapTileProviderPrefs {
var customTileProviders: String?
}
@Singleton
class MapTileProviderPrefsImpl @Inject constructor(@MapTileProviderSharedPreferences prefs: SharedPreferences) :
MapTileProviderPrefs {
override var customTileProviders: String? by NullableStringPrefDelegate(prefs, "custom_tile_providers", null)
}

View file

@ -0,0 +1,64 @@
/*
* Copyright (c) 2025-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.map
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
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
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.di.MapTileProviderDataStore
import org.meshtastic.core.repository.MapTileProviderPrefs
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MapTileProviderPrefsImpl
@Inject
constructor(
@MapTileProviderDataStore private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : MapTileProviderPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val customTileProviders: StateFlow<String?> =
dataStore.data.map { it[KEY_CUSTOM_PROVIDERS_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
override fun setCustomTileProviders(providers: String?) {
scope.launch {
dataStore.edit { prefs ->
if (providers == null) {
prefs.remove(KEY_CUSTOM_PROVIDERS_PREF)
} else {
prefs[KEY_CUSTOM_PROVIDERS_PREF] = providers
}
}
}
}
companion object {
const val KEY_CUSTOM_PROVIDERS = "custom_tile_providers"
val KEY_CUSTOM_PROVIDERS_PREF = stringPreferencesKey(KEY_CUSTOM_PROVIDERS)
}
}

View file

@ -1,77 +0,0 @@
/*
* Copyright (c) 2025 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.mesh
import android.content.SharedPreferences
import androidx.core.content.edit
import org.meshtastic.core.prefs.NullableStringPrefDelegate
import org.meshtastic.core.prefs.di.MeshSharedPreferences
import java.util.Locale
import javax.inject.Inject
import javax.inject.Singleton
interface MeshPrefs {
var deviceAddress: String?
fun shouldProvideNodeLocation(nodeNum: Int?): Boolean
fun setShouldProvideNodeLocation(nodeNum: Int?, value: Boolean)
fun getStoreForwardLastRequest(address: String?): Int
fun setStoreForwardLastRequest(address: String?, value: Int)
}
@Singleton
class MeshPrefsImpl @Inject constructor(@MeshSharedPreferences private val prefs: SharedPreferences) : MeshPrefs {
override var deviceAddress: String? by NullableStringPrefDelegate(prefs, "device_address", NO_DEVICE_SELECTED)
override fun shouldProvideNodeLocation(nodeNum: Int?): Boolean =
prefs.getBoolean(provideLocationKey(nodeNum), false)
override fun setShouldProvideNodeLocation(nodeNum: Int?, value: Boolean) {
prefs.edit { putBoolean(provideLocationKey(nodeNum), value) }
}
override fun getStoreForwardLastRequest(address: String?): Int = prefs.getInt(storeForwardKey(address), 0)
override fun setStoreForwardLastRequest(address: String?, value: Int) {
prefs.edit {
if (value <= 0) {
remove(storeForwardKey(address))
} else {
putInt(storeForwardKey(address), value)
}
}
}
private fun provideLocationKey(nodeNum: Int?) = "provide-location-$nodeNum"
private fun storeForwardKey(address: String?): String = "store-forward-last-request-${normalizeAddress(address)}"
private fun normalizeAddress(address: String?): String {
val raw = address?.trim()?.takeIf { it.isNotEmpty() }
return when {
raw == null -> "DEFAULT"
raw.equals(NO_DEVICE_SELECTED, ignoreCase = true) -> "DEFAULT"
else -> raw.uppercase(Locale.US).replace(":", "")
}
}
}
private const val NO_DEVICE_SELECTED = "n"

View file

@ -0,0 +1,114 @@
/*
* Copyright (c) 2025-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.mesh
import androidx.datastore.core.DataStore
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.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.di.MeshDataStore
import org.meshtastic.core.repository.MeshPrefs
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MeshPrefsImpl
@Inject
constructor(
@MeshDataStore private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : MeshPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
private val locationFlows = ConcurrentHashMap<Int?, StateFlow<Boolean>>()
private val storeForwardFlows = ConcurrentHashMap<String?, StateFlow<Int>>()
override val deviceAddress: StateFlow<String?> =
dataStore.data
.map { it[KEY_DEVICE_ADDRESS_PREF] ?: NO_DEVICE_SELECTED }
.stateIn(scope, SharingStarted.Eagerly, NO_DEVICE_SELECTED)
override fun setDeviceAddress(address: String?) {
scope.launch {
dataStore.edit { prefs ->
if (address == null) {
prefs.remove(KEY_DEVICE_ADDRESS_PREF)
} else {
prefs[KEY_DEVICE_ADDRESS_PREF] = address
}
}
}
}
override fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow<Boolean> = locationFlows.getOrPut(nodeNum) {
val key = booleanPreferencesKey(provideLocationKey(nodeNum))
dataStore.data.map { it[key] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
}
override fun setShouldProvideNodeLocation(nodeNum: Int?, value: Boolean) {
scope.launch { dataStore.edit { prefs -> prefs[booleanPreferencesKey(provideLocationKey(nodeNum))] = value } }
}
override fun getStoreForwardLastRequest(address: String?): StateFlow<Int> = storeForwardFlows.getOrPut(address) {
val key = intPreferencesKey(storeForwardKey(address))
dataStore.data.map { it[key] ?: 0 }.stateIn(scope, SharingStarted.Eagerly, 0)
}
override fun setStoreForwardLastRequest(address: String?, value: Int) {
scope.launch {
dataStore.edit { prefs ->
val key = intPreferencesKey(storeForwardKey(address))
if (value <= 0) {
prefs.remove(key)
} else {
prefs[key] = value
}
}
}
}
private fun provideLocationKey(nodeNum: Int?) = "provide-location-$nodeNum"
private fun storeForwardKey(address: String?): String = "store-forward-last-request-${normalizeAddress(address)}"
private fun normalizeAddress(address: String?): String {
val raw = address?.trim()?.takeIf { it.isNotEmpty() }
return when {
raw == null -> "DEFAULT"
raw.equals(NO_DEVICE_SELECTED, ignoreCase = true) -> "DEFAULT"
else -> raw.uppercase(Locale.US).replace(":", "")
}
}
companion object {
val KEY_DEVICE_ADDRESS_PREF = stringPreferencesKey("device_address")
}
}
private const val NO_DEVICE_SELECTED = "n"

View file

@ -1,56 +0,0 @@
/*
* Copyright (c) 2025-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.meshlog
import android.content.SharedPreferences
import org.meshtastic.core.prefs.PrefDelegate
import org.meshtastic.core.prefs.di.MeshLogSharedPreferences
import javax.inject.Inject
import javax.inject.Singleton
interface MeshLogPrefs {
var retentionDays: Int
var loggingEnabled: Boolean
companion object {
const val RETENTION_DAYS_KEY = "meshlog_retention_days"
const val LOGGING_ENABLED_KEY = "meshlog_logging_enabled"
const val DEFAULT_RETENTION_DAYS = 30
const val DEFAULT_LOGGING_ENABLED = true
const val MIN_RETENTION_DAYS = -1 // -1 == keep last hour
const val MAX_RETENTION_DAYS = 365
const val NEVER_CLEAR_RETENTION_DAYS = 0
const val ONE_HOUR_RETENTION_DAYS = -1
}
}
@Singleton
class MeshLogPrefsImpl @Inject constructor(@MeshLogSharedPreferences private val prefs: SharedPreferences) :
MeshLogPrefs {
override var retentionDays: Int by
PrefDelegate(
prefs = prefs,
key = MeshLogPrefs.RETENTION_DAYS_KEY,
defaultValue = MeshLogPrefs.DEFAULT_RETENTION_DAYS,
)
override var loggingEnabled: Boolean by
PrefDelegate(
prefs = prefs,
key = MeshLogPrefs.LOGGING_ENABLED_KEY,
defaultValue = MeshLogPrefs.DEFAULT_LOGGING_ENABLED,
)
}

View file

@ -0,0 +1,73 @@
/*
* Copyright (c) 2025-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.meshlog
import androidx.datastore.core.DataStore
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.di.MeshLogDataStore
import org.meshtastic.core.repository.MeshLogPrefs
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MeshLogPrefsImpl
@Inject
constructor(
@MeshLogDataStore private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : MeshLogPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val retentionDays: StateFlow<Int> =
dataStore.data
.map { it[KEY_RETENTION_DAYS_PREF] ?: DEFAULT_RETENTION_DAYS }
.stateIn(scope, SharingStarted.Eagerly, DEFAULT_RETENTION_DAYS)
override fun setRetentionDays(days: Int) {
scope.launch { dataStore.edit { it[KEY_RETENTION_DAYS_PREF] = days } }
}
override val loggingEnabled: StateFlow<Boolean> =
dataStore.data
.map { it[KEY_LOGGING_ENABLED_PREF] ?: DEFAULT_LOGGING_ENABLED }
.stateIn(scope, SharingStarted.Eagerly, DEFAULT_LOGGING_ENABLED)
override fun setLoggingEnabled(enabled: Boolean) {
scope.launch { dataStore.edit { it[KEY_LOGGING_ENABLED_PREF] = enabled } }
}
companion object {
const val RETENTION_DAYS_KEY = "meshlog_retention_days"
const val LOGGING_ENABLED_KEY = "meshlog_logging_enabled"
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)
}
}

View file

@ -1,43 +0,0 @@
/*
* Copyright (c) 2025 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.radio
import android.content.SharedPreferences
import org.meshtastic.core.prefs.NullableStringPrefDelegate
import org.meshtastic.core.prefs.di.RadioSharedPreferences
import javax.inject.Inject
import javax.inject.Singleton
interface RadioPrefs {
var devAddr: String?
}
fun RadioPrefs.isBle() = devAddr?.startsWith("x") == true
fun RadioPrefs.isSerial() = devAddr?.startsWith("s") == true
fun RadioPrefs.isMock() = devAddr?.startsWith("m") == true
fun RadioPrefs.isTcp() = devAddr?.startsWith("t") == true
fun RadioPrefs.isNoop() = devAddr?.startsWith("n") == true
@Singleton
class RadioPrefsImpl @Inject constructor(@RadioSharedPreferences prefs: SharedPreferences) : RadioPrefs {
override var devAddr: String? by NullableStringPrefDelegate(prefs, "devAddr2", null)
}

View file

@ -0,0 +1,63 @@
/*
* Copyright (c) 2025-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.radio
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
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
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.di.RadioDataStore
import org.meshtastic.core.repository.RadioPrefs
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RadioPrefsImpl
@Inject
constructor(
@RadioDataStore private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : RadioPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val devAddr: StateFlow<String?> =
dataStore.data.map { it[KEY_DEV_ADDR_PREF] }.stateIn(scope, SharingStarted.Eagerly, null)
override fun setDevAddr(address: String?) {
scope.launch {
dataStore.edit { prefs ->
if (address == null) {
prefs.remove(KEY_DEV_ADDR_PREF)
} else {
prefs[KEY_DEV_ADDR_PREF] = address
}
}
}
}
companion object {
val KEY_DEV_ADDR_PREF = stringPreferencesKey("devAddr2")
}
}

View file

@ -1,77 +0,0 @@
/*
* Copyright (c) 2025 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.ui
import android.content.SharedPreferences
import androidx.core.content.edit
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.meshtastic.core.prefs.PrefDelegate
import org.meshtastic.core.prefs.di.UiSharedPreferences
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
interface UiPrefs {
var hasShownNotPairedWarning: Boolean
var showQuickChat: Boolean
fun shouldProvideNodeLocation(nodeNum: Int): StateFlow<Boolean>
fun setShouldProvideNodeLocation(nodeNum: Int, value: Boolean)
}
@Singleton
class UiPrefsImpl @Inject constructor(@UiSharedPreferences private val prefs: SharedPreferences) : UiPrefs {
// Maps nodeNum to a flow for the for the "provide-location-nodeNum" pref
private val provideNodeLocationFlows = ConcurrentHashMap<Int, MutableStateFlow<Boolean>>()
private val sharedPreferencesListener =
SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
when (key) {
// Check if the changed key is one of our node location keys
else ->
provideNodeLocationFlows.keys.forEach { nodeNum ->
if (key == provideLocationKey(nodeNum)) {
val newValue = sharedPreferences.getBoolean(key, false)
provideNodeLocationFlows[nodeNum]?.tryEmit(newValue)
}
}
}
}
init {
prefs.registerOnSharedPreferenceChangeListener(sharedPreferencesListener)
}
override var hasShownNotPairedWarning: Boolean by PrefDelegate(prefs, "has_shown_not_paired_warning", false)
override var showQuickChat: Boolean by PrefDelegate(prefs, "show-quick-chat", false)
override fun shouldProvideNodeLocation(nodeNum: Int): StateFlow<Boolean> = provideNodeLocationFlows
.getOrPut(nodeNum) { MutableStateFlow(prefs.getBoolean(provideLocationKey(nodeNum), false)) }
.asStateFlow()
override fun setShouldProvideNodeLocation(nodeNum: Int, value: Boolean) {
prefs.edit { putBoolean(provideLocationKey(nodeNum), value) }
provideNodeLocationFlows[nodeNum]?.tryEmit(value)
}
private fun provideLocationKey(nodeNum: Int) = "provide-location-$nodeNum"
}

View file

@ -0,0 +1,81 @@
/*
* Copyright (c) 2025-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.ui
import androidx.datastore.core.DataStore
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
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.di.UiDataStore
import org.meshtastic.core.repository.UiPrefs
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class UiPrefsImpl
@Inject
constructor(
@UiDataStore private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : UiPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
// Maps nodeNum to a flow for the for the "provide-location-nodeNum" pref
private val provideNodeLocationFlows = ConcurrentHashMap<Int, StateFlow<Boolean>>()
override val hasShownNotPairedWarning: StateFlow<Boolean> =
dataStore.data
.map { it[KEY_HAS_SHOWN_NOT_PAIRED_WARNING_PREF] ?: false }
.stateIn(scope, SharingStarted.Eagerly, false)
override fun setHasShownNotPairedWarning(value: Boolean) {
scope.launch { dataStore.edit { it[KEY_HAS_SHOWN_NOT_PAIRED_WARNING_PREF] = value } }
}
override val showQuickChat: StateFlow<Boolean> =
dataStore.data.map { it[KEY_SHOW_QUICK_CHAT_PREF] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
override fun setShowQuickChat(value: Boolean) {
scope.launch { dataStore.edit { it[KEY_SHOW_QUICK_CHAT_PREF] = value } }
}
override fun shouldProvideNodeLocation(nodeNum: Int): StateFlow<Boolean> =
provideNodeLocationFlows.getOrPut(nodeNum) {
val key = booleanPreferencesKey(provideLocationKey(nodeNum))
dataStore.data.map { it[key] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
}
override fun setShouldProvideNodeLocation(nodeNum: Int, value: Boolean) {
scope.launch { dataStore.edit { it[booleanPreferencesKey(provideLocationKey(nodeNum))] = value } }
}
private fun provideLocationKey(nodeNum: Int) = "provide-location-$nodeNum"
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")
}
}

View file

@ -16,51 +16,61 @@
*/
package org.meshtastic.core.prefs.filter
import android.content.SharedPreferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.FilterPrefs
class FilterPrefsTest {
private lateinit var sharedPreferences: SharedPreferences
private lateinit var editor: SharedPreferences.Editor
@get:Rule val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build()
private lateinit var dataStore: DataStore<Preferences>
private lateinit var filterPrefs: FilterPrefs
private lateinit var dispatchers: CoroutineDispatchers
private val testDispatcher = UnconfinedTestDispatcher()
private val testScope = TestScope(testDispatcher)
@Before
fun setup() {
editor = mockk(relaxed = true)
sharedPreferences = mockk {
every { getBoolean(FilterPrefs.KEY_FILTER_ENABLED, false) } returns false
every { getStringSet(FilterPrefs.KEY_FILTER_WORDS, emptySet()) } returns emptySet()
every { edit() } returns editor
}
filterPrefs = FilterPrefsImpl(sharedPreferences)
dataStore =
PreferenceDataStoreFactory.create(
scope = testScope,
produceFile = { tmpFolder.newFile("test.preferences_pb") },
)
dispatchers = mockk { every { default } returns testDispatcher }
filterPrefs = FilterPrefsImpl(dataStore, dispatchers)
}
@Test fun `filterEnabled defaults to false`() = testScope.runTest { assertFalse(filterPrefs.filterEnabled.value) }
@Test
fun `filterWords defaults to empty set`() =
testScope.runTest { assertTrue(filterPrefs.filterWords.value.isEmpty()) }
@Test
fun `setting filterEnabled updates preference`() = testScope.runTest {
filterPrefs.setFilterEnabled(true)
assertTrue(filterPrefs.filterEnabled.value)
}
@Test
fun `filterEnabled defaults to false`() {
assertFalse(filterPrefs.filterEnabled)
}
@Test
fun `filterWords defaults to empty set`() {
assertTrue(filterPrefs.filterWords.isEmpty())
}
@Test
fun `setting filterEnabled updates preference`() {
filterPrefs.filterEnabled = true
verify { editor.putBoolean(FilterPrefs.KEY_FILTER_ENABLED, true) }
}
@Test
fun `setting filterWords updates preference`() {
fun `setting filterWords updates preference`() = testScope.runTest {
val words = setOf("test", "word")
filterPrefs.filterWords = words
verify { editor.putStringSet(FilterPrefs.KEY_FILTER_WORDS, words) }
filterPrefs.setFilterWords(words)
assertEquals(words, filterPrefs.filterWords.value)
}
}