diff --git a/app/src/main/java/com/geeksville/mesh/android/prefs/AnalyticsPrefs.kt b/app/src/main/java/com/geeksville/mesh/android/prefs/AnalyticsPrefs.kt
new file mode 100644
index 000000000..a0bc2dda9
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/android/prefs/AnalyticsPrefs.kt
@@ -0,0 +1,28 @@
+/*
+ * 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 .
+ */
+
+package com.geeksville.mesh.android.prefs
+
+import android.content.SharedPreferences
+
+interface AnalyticsPrefs {
+ var analyticsAllowed: Boolean
+}
+
+class AnalyticsPrefsImpl(prefs: SharedPreferences) : AnalyticsPrefs {
+ override var analyticsAllowed: Boolean by PrefDelegate(prefs, "allowed", true)
+}
diff --git a/app/src/main/java/com/geeksville/mesh/android/prefs/CustomEmojiPrefs.kt b/app/src/main/java/com/geeksville/mesh/android/prefs/CustomEmojiPrefs.kt
new file mode 100644
index 000000000..eb9f51eab
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/android/prefs/CustomEmojiPrefs.kt
@@ -0,0 +1,28 @@
+/*
+ * 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 .
+ */
+
+package com.geeksville.mesh.android.prefs
+
+import android.content.SharedPreferences
+
+interface CustomEmojiPrefs {
+ var customEmojiFrequency: String?
+}
+
+class CustomEmojiPrefsImpl(prefs: SharedPreferences) : CustomEmojiPrefs {
+ override var customEmojiFrequency: String? by NullableStringPrefDelegate(prefs, "pref_key_custom_emoji_freq", null)
+}
diff --git a/app/src/main/java/com/geeksville/mesh/android/prefs/MapConsentPrefs.kt b/app/src/main/java/com/geeksville/mesh/android/prefs/MapConsentPrefs.kt
new file mode 100644
index 000000000..b265f8ff9
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/android/prefs/MapConsentPrefs.kt
@@ -0,0 +1,35 @@
+/*
+ * 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 .
+ */
+
+package com.geeksville.mesh.android.prefs
+
+import android.content.SharedPreferences
+import androidx.core.content.edit
+
+interface MapConsentPrefs {
+ fun shouldReportLocation(nodeNum: Int?): Boolean
+
+ fun setShouldReportLocation(nodeNum: Int?, value: Boolean)
+}
+
+class MapConsentPrefsImpl(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) }
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/android/prefs/MapTileProviderPrefs.kt b/app/src/main/java/com/geeksville/mesh/android/prefs/MapTileProviderPrefs.kt
new file mode 100644
index 000000000..a97f5a1f6
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/android/prefs/MapTileProviderPrefs.kt
@@ -0,0 +1,28 @@
+/*
+ * 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 .
+ */
+
+package com.geeksville.mesh.android.prefs
+
+import android.content.SharedPreferences
+
+interface MapTileProviderPrefs {
+ var customTileProviders: String?
+}
+
+class MapTileProviderPrefsImpl(prefs: SharedPreferences) : MapTileProviderPrefs {
+ override var customTileProviders: String? by NullableStringPrefDelegate(prefs, "custom_tile_providers", null)
+}
diff --git a/app/src/main/java/com/geeksville/mesh/android/prefs/MeshPrefs.kt b/app/src/main/java/com/geeksville/mesh/android/prefs/MeshPrefs.kt
new file mode 100644
index 000000000..335000f1a
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/android/prefs/MeshPrefs.kt
@@ -0,0 +1,42 @@
+/*
+ * 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 .
+ */
+
+package com.geeksville.mesh.android.prefs
+
+import android.content.SharedPreferences
+import androidx.core.content.edit
+
+interface MeshPrefs {
+ var deviceAddress: String?
+
+ fun shouldProvideNodeLocation(nodeNum: Int?): Boolean
+
+ fun setShouldProvideNodeLocation(nodeNum: Int?, value: Boolean)
+}
+
+class MeshPrefsImpl(private val prefs: SharedPreferences) : MeshPrefs {
+ override var deviceAddress: String? by NullableStringPrefDelegate(prefs, "device_address", null)
+
+ override fun shouldProvideNodeLocation(nodeNum: Int?): Boolean =
+ prefs.getBoolean(provideLocationKey(nodeNum), false)
+
+ override fun setShouldProvideNodeLocation(nodeNum: Int?, value: Boolean) {
+ prefs.edit { putBoolean(provideLocationKey(nodeNum), value) }
+ }
+
+ private fun provideLocationKey(nodeNum: Int?) = "provide-location-$nodeNum"
+}
diff --git a/app/src/main/java/com/geeksville/mesh/android/prefs/NullableStringPrefDelegate.kt b/app/src/main/java/com/geeksville/mesh/android/prefs/NullableStringPrefDelegate.kt
new file mode 100644
index 000000000..9d1755b0b
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/android/prefs/NullableStringPrefDelegate.kt
@@ -0,0 +1,48 @@
+/*
+ * 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 .
+ */
+
+package com.geeksville.mesh.android.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.
+ */
+class NullableStringPrefDelegate(
+ private val prefs: SharedPreferences,
+ private val key: String,
+ private val defaultValue: String?,
+) : ReadWriteProperty {
+
+ 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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/android/prefs/PrefDelegate.kt b/app/src/main/java/com/geeksville/mesh/android/prefs/PrefDelegate.kt
new file mode 100644
index 000000000..22948a914
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/android/prefs/PrefDelegate.kt
@@ -0,0 +1,58 @@
+/*
+ * 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 .
+ */
+
+package com.geeksville.mesh.android.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.
+ */
+class PrefDelegate(private val prefs: SharedPreferences, private val key: String, private val defaultValue: T) :
+ ReadWriteProperty {
+
+ @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")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/geeksville/mesh/android/prefs/PrefsModule.kt b/app/src/main/java/com/geeksville/mesh/android/prefs/PrefsModule.kt
new file mode 100644
index 000000000..8fcd0d62b
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/android/prefs/PrefsModule.kt
@@ -0,0 +1,143 @@
+/*
+ * 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 .
+ */
+
+package com.geeksville.mesh.android.prefs
+
+import android.content.Context
+import android.content.SharedPreferences
+import dagger.Module
+import dagger.Provides
+import dagger.hilt.InstallIn
+import dagger.hilt.android.qualifiers.ApplicationContext
+import dagger.hilt.components.SingletonComponent
+import javax.inject.Qualifier
+import javax.inject.Singleton
+
+// These pref store qualifiers are private to prevent prefs stores from being injected directly.
+// Consuming code should always inject one of the prefs repositories.
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+private annotation class AnalyticsSharedPreferences
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+private annotation class CustomEmojiSharedPreferences
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+private annotation class MapConsentSharedPreferences
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+private annotation class MapTileProviderSharedPreferences
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+private annotation class MeshSharedPreferences
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+private annotation class RadioSharedPreferences
+
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+private annotation class UiSharedPreferences
+
+@Suppress("TooManyFunctions")
+@InstallIn(SingletonComponent::class)
+@Module
+object PrefsModule {
+
+ @Provides
+ @Singleton
+ @AnalyticsSharedPreferences
+ fun provideAnalyticsSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
+ context.getSharedPreferences("analytics-prefs", Context.MODE_PRIVATE)
+
+ @Provides
+ @Singleton
+ @CustomEmojiSharedPreferences
+ fun provideCustomEmojiSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
+ context.getSharedPreferences("org.geeksville.emoji.prefs", Context.MODE_PRIVATE)
+
+ @Provides
+ @Singleton
+ @MapConsentSharedPreferences
+ fun provideMapConsentSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
+ context.getSharedPreferences("map_consent_preferences", Context.MODE_PRIVATE)
+
+ @Provides
+ @Singleton
+ @MapTileProviderSharedPreferences
+ fun provideMapTileProviderSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
+ context.getSharedPreferences("map_tile_provider_prefs", Context.MODE_PRIVATE)
+
+ @Provides
+ @Singleton
+ @MeshSharedPreferences
+ fun provideMeshSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
+ context.getSharedPreferences("mesh-prefs", Context.MODE_PRIVATE)
+
+ @Provides
+ @Singleton
+ @RadioSharedPreferences
+ fun provideRadioSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
+ context.getSharedPreferences("radio-prefs", Context.MODE_PRIVATE)
+
+ @Provides
+ @Singleton
+ @UiSharedPreferences
+ fun provideUiSharedPreferences(@ApplicationContext context: Context): SharedPreferences =
+ context.getSharedPreferences("ui-prefs", Context.MODE_PRIVATE)
+
+ @Provides
+ @Singleton
+ fun provideAnalyticsPrefs(@AnalyticsSharedPreferences sharedPreferences: SharedPreferences): AnalyticsPrefs =
+ AnalyticsPrefsImpl(sharedPreferences)
+
+ @Provides
+ @Singleton
+ fun provideCustomEmojiPrefs(@CustomEmojiSharedPreferences sharedPreferences: SharedPreferences): CustomEmojiPrefs =
+ CustomEmojiPrefsImpl(sharedPreferences)
+
+ @Provides
+ @Singleton
+ fun provideMapConsentPrefs(@MapConsentSharedPreferences sharedPreferences: SharedPreferences): MapConsentPrefs =
+ MapConsentPrefsImpl(sharedPreferences)
+
+ @Provides
+ @Singleton
+ fun provideMapTileProviderPrefs(
+ @MapTileProviderSharedPreferences sharedPreferences: SharedPreferences,
+ ): MapTileProviderPrefs = MapTileProviderPrefsImpl(sharedPreferences)
+
+ @Provides
+ @Singleton
+ fun provideMeshPrefs(@MeshSharedPreferences sharedPreferences: SharedPreferences): MeshPrefs =
+ MeshPrefsImpl(sharedPreferences)
+
+ @Provides
+ @Singleton
+ fun provideRadioPrefs(@RadioSharedPreferences sharedPreferences: SharedPreferences): RadioPrefs =
+ RadioPrefsImpl(sharedPreferences)
+
+ @Provides
+ @Singleton
+ fun provideUiPrefs(@UiSharedPreferences sharedPreferences: SharedPreferences): UiPrefs =
+ UiPrefsImpl(sharedPreferences)
+}
diff --git a/app/src/main/java/com/geeksville/mesh/android/prefs/RadioPrefs.kt b/app/src/main/java/com/geeksville/mesh/android/prefs/RadioPrefs.kt
new file mode 100644
index 000000000..f53beb804
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/android/prefs/RadioPrefs.kt
@@ -0,0 +1,28 @@
+/*
+ * 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 .
+ */
+
+package com.geeksville.mesh.android.prefs
+
+import android.content.SharedPreferences
+
+interface RadioPrefs {
+ var devAddr: String?
+}
+
+class RadioPrefsImpl(prefs: SharedPreferences) : RadioPrefs {
+ override var devAddr: String? by NullableStringPrefDelegate(prefs, "devAddr2", null)
+}
diff --git a/app/src/main/java/com/geeksville/mesh/android/prefs/UiPrefs.kt b/app/src/main/java/com/geeksville/mesh/android/prefs/UiPrefs.kt
new file mode 100644
index 000000000..52d4328b7
--- /dev/null
+++ b/app/src/main/java/com/geeksville/mesh/android/prefs/UiPrefs.kt
@@ -0,0 +1,78 @@
+/*
+ * 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 .
+ */
+
+package com.geeksville.mesh.android.prefs
+
+import android.content.SharedPreferences
+import androidx.appcompat.app.AppCompatDelegate
+import androidx.core.content.edit
+import com.geeksville.mesh.model.NodeSortOption
+import com.geeksville.mesh.util.LanguageUtils
+
+interface UiPrefs {
+ var lang: String
+ var theme: Int
+ var appIntroCompleted: Boolean
+ var hasShownNotPairedWarning: Boolean
+ var nodeSortOption: Int
+ var includeUnknown: Boolean
+ var showDetails: Boolean
+ var onlyOnline: Boolean
+ var onlyDirect: Boolean
+ var showIgnored: Boolean
+ var showQuickChat: Boolean
+
+ // region Map prefs
+
+ var showOnlyFavorites: Boolean
+ var showWaypointsOnMap: Boolean
+ var showPrecisionCircleOnMap: Boolean
+ var mapStyle: Int
+
+ // endregion
+
+ fun shouldProvideNodeLocation(nodeNum: Int?): Boolean
+
+ fun setShouldProvideNodeLocation(nodeNum: Int?, value: Boolean)
+}
+
+class UiPrefsImpl(private val prefs: SharedPreferences) : UiPrefs {
+ override var lang: String by PrefDelegate(prefs, "lang", LanguageUtils.SYSTEM_DEFAULT)
+ override var theme: Int by PrefDelegate(prefs, "theme", AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
+ override var appIntroCompleted: Boolean by PrefDelegate(prefs, "app_intro_completed", false)
+ override var hasShownNotPairedWarning: Boolean by PrefDelegate(prefs, "has_shown_not_paired_warning", false)
+ override var nodeSortOption: Int by PrefDelegate(prefs, "node-sort-option", NodeSortOption.VIA_FAVORITE.ordinal)
+ override var includeUnknown: Boolean by PrefDelegate(prefs, "include-unknown", false)
+ override var showDetails: Boolean by PrefDelegate(prefs, "show-details", false)
+ override var onlyOnline: Boolean by PrefDelegate(prefs, "only-online", false)
+ override var onlyDirect: Boolean by PrefDelegate(prefs, "only-direct", false)
+ override var showIgnored: Boolean by PrefDelegate(prefs, "show-ignored", false)
+ override var showQuickChat: Boolean by PrefDelegate(prefs, "show-quick-chat", false)
+ override var showOnlyFavorites: Boolean by PrefDelegate(prefs, "only-favorites", false)
+ override var showWaypointsOnMap: Boolean by PrefDelegate(prefs, "show-waypoints-on-map", true)
+ override var showPrecisionCircleOnMap: Boolean by PrefDelegate(prefs, "show-precision-circle-on-map", true)
+ override var mapStyle: Int by PrefDelegate(prefs, "map_style_id", 0)
+
+ override fun shouldProvideNodeLocation(nodeNum: Int?): Boolean =
+ prefs.getBoolean(provideLocationKey(nodeNum), false)
+
+ override fun setShouldProvideNodeLocation(nodeNum: Int?, value: Boolean) {
+ prefs.edit { putBoolean(provideLocationKey(nodeNum), value) }
+ }
+
+ private fun provideLocationKey(nodeNum: Int?) = "provide-location-$nodeNum"
+}