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
113 changed files with 1790 additions and 1320 deletions

View File

@@ -25,7 +25,7 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.prefs.filter.FilterPrefs
import org.meshtastic.core.repository.FilterPrefs
import org.meshtastic.core.repository.MessageFilter
import javax.inject.Inject
@@ -46,8 +46,8 @@ class MessageFilterIntegrationTest {
@Test
fun filterPrefsIntegration() = runTest {
filterPrefs.filterEnabled = true
filterPrefs.filterWords = setOf("test", "spam")
filterPrefs.setFilterEnabled(true)
filterPrefs.setFilterWords(setOf("test", "spam"))
filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("this is a test message"))

View File

@@ -44,8 +44,8 @@ import kotlinx.coroutines.withTimeout
import no.nordicsemi.kotlin.ble.core.android.AndroidEnvironment
import org.meshtastic.core.common.ContextServices
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.core.repository.MeshPrefs
import javax.inject.Inject
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
@@ -114,7 +114,7 @@ open class MeshUtilApplication :
// Initialize DatabaseManager asynchronously with current device address so DAO consumers have an active DB
val entryPoint = EntryPointAccessors.fromApplication(this, AppEntryPoint::class.java)
applicationScope.launch { entryPoint.databaseManager().init(entryPoint.meshPrefs().deviceAddress) }
applicationScope.launch { entryPoint.databaseManager().init(entryPoint.meshPrefs().deviceAddress.value) }
}
override fun onTerminate() {

View File

@@ -42,7 +42,6 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getString
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.data.repository.FirmwareReleaseRepository
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.MeshActivity
@@ -52,6 +51,7 @@ import org.meshtastic.core.model.TracerouteMapAvailability
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.service.TracerouteResponse
import org.meshtastic.core.model.util.dispatchMeshtasticUri
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository

View File

@@ -51,8 +51,8 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.InterfaceId
import org.meshtastic.core.model.MeshActivity
import org.meshtastic.core.model.util.anonymize
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.proto.Heartbeat
import org.meshtastic.proto.ToRadio
import javax.inject.Inject
@@ -92,7 +92,7 @@ constructor(
val connectionError: SharedFlow<String> = _connectionError.asSharedFlow()
// Thread-safe StateFlow for tracking device address changes
private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr)
private val _currentDeviceAddressFlow = MutableStateFlow(radioPrefs.devAddr.value)
override val currentDeviceAddressFlow: StateFlow<String?> = _currentDeviceAddressFlow.asStateFlow()
private val logSends = false
@@ -192,7 +192,7 @@ constructor(
*/
override fun getDeviceAddress(): String? {
// If the user has unpaired our device, treat things as if we don't have one
var address = radioPrefs.devAddr
var address = radioPrefs.devAddr.value
// If we are running on the emulator we default to the mock interface, so we can have some data to show to the
// user
@@ -352,7 +352,7 @@ constructor(
Logger.d { "Setting bonded device to ${address.anonymize}" }
// Stores the address if non-null, otherwise removes the pref
radioPrefs.devAddr = address
radioPrefs.setDevAddr(address)
_currentDeviceAddressFlow.value = address
// Force the service to reconnect

View File

@@ -23,10 +23,10 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.proto.LocalConfig
import javax.inject.Inject
@@ -50,11 +50,11 @@ constructor(
val ourNodeInfo: StateFlow<Node?> = nodeRepository.ourNodeInfo
private val _hasShownNotPairedWarning = MutableStateFlow(uiPrefs.hasShownNotPairedWarning)
private val _hasShownNotPairedWarning = MutableStateFlow(uiPrefs.hasShownNotPairedWarning.value)
val hasShownNotPairedWarning: StateFlow<Boolean> = _hasShownNotPairedWarning.asStateFlow()
fun suppressNoPairedWarning() {
_hasShownNotPairedWarning.value = true
uiPrefs.hasShownNotPairedWarning = true
uiPrefs.setHasShownNotPairedWarning(true)
}
}

View File

@@ -27,8 +27,8 @@ import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.core.repository.MeshLogRepository
@HiltWorker
class MeshLogCleanupWorker
@@ -53,14 +53,14 @@ constructor(
@Suppress("TooGenericExceptionCaught")
override suspend fun doWork(): Result = try {
val retentionDays = meshLogPrefs.retentionDays
if (!meshLogPrefs.loggingEnabled) {
val retentionDays = meshLogPrefs.retentionDays.value
if (!meshLogPrefs.loggingEnabled.value) {
logger.i { "Skipping cleanup because mesh log storage is disabled" }
} else if (retentionDays == MeshLogPrefs.NEVER_CLEAR_RETENTION_DAYS) {
} else if (retentionDays == 0) {
logger.i { "Skipping cleanup because retention is set to never delete" }
} else {
val retentionLabel =
if (retentionDays == MeshLogPrefs.ONE_HOUR_RETENTION_DAYS) {
if (retentionDays == -1) {
"1 hour"
} else {
"$retentionDays days"

View File

@@ -27,6 +27,7 @@ plugins {
dependencies {
implementation(projects.core.prefs)
implementation(projects.core.repository)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.lifecycle.process)

View File

@@ -58,7 +58,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.analytics.BuildConfig
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
import org.meshtastic.core.repository.AnalyticsPrefs
import javax.inject.Inject
import co.touchlab.kermit.Logger as KermitLogger
@@ -109,11 +109,10 @@ constructor(
KermitLogger.setMinSeverity(if (BuildConfig.DEBUG) Severity.Debug else Severity.Info)
// Initial consent state
updateAnalyticsConsent(analyticsPrefs.analyticsAllowed)
updateAnalyticsConsent(analyticsPrefs.analyticsAllowed.value)
// Subscribe to analytics preference changes
analyticsPrefs
.getAnalyticsAllowedChangesFlow()
analyticsPrefs.analyticsAllowed
.onEach { allowed -> updateAnalyticsConsent(allowed) }
.launchIn(ProcessLifecycleOwner.get().lifecycleScope)
}
@@ -122,7 +121,7 @@ constructor(
* Ensures that Datadog and Firebase SDKs are initialized if allowed. This is called lazily when consent is granted.
*/
private fun ensureInitialized() {
if (!analyticsPrefs.analyticsAllowed || isInTestLab) return
if (!analyticsPrefs.analyticsAllowed.value || isInTestLab) return
if (!Datadog.isInitialized()) {
initDatadog(context as Application)

View File

@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
package org.meshtastic.core.common.database
import kotlinx.coroutines.flow.StateFlow

View File

@@ -31,6 +31,8 @@ dependencies {
implementation(projects.core.common)
implementation(projects.core.database)
implementation(projects.core.datastore)
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
implementation(projects.core.di)
implementation(projects.core.model)
implementation(projects.core.network)

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,7 +14,6 @@
* 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.data.repository
import co.touchlab.kermit.Logger
@@ -26,7 +25,7 @@ import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.meshtastic.core.data.model.CustomTileProviderConfig
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.map.MapTileProviderPrefs
import org.meshtastic.core.repository.MapTileProviderPrefs
import javax.inject.Inject
import javax.inject.Singleton
@@ -82,7 +81,7 @@ constructor(
customTileProvidersStateFlow.value.find { it.id == configId }
private fun loadDataFromPrefs() {
val jsonString = mapTileProviderPrefs.customTileProviders
val jsonString = mapTileProviderPrefs.customTileProviders.value
if (jsonString != null) {
try {
customTileProvidersStateFlow.value = json.decodeFromString<List<CustomTileProviderConfig>>(jsonString)
@@ -99,7 +98,7 @@ constructor(
withContext(dispatchers.io) {
try {
val jsonString = json.encodeToString(providers)
mapTileProviderPrefs.customTileProviders = jsonString
mapTileProviderPrefs.setCustomTileProviders(jsonString)
} catch (e: SerializationException) {
Logger.e(e) { "Error serializing tile providers" }
}

View File

@@ -14,12 +14,13 @@
* 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.datastore.di
package org.meshtastic.core.data.di
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.core.okio.OkioStorage
import androidx.datastore.dataStoreFile
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
@@ -34,6 +35,8 @@ import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import okio.FileSystem
import okio.Path.Companion.toOkioPath
import org.meshtastic.core.datastore.KEY_APP_INTRO_COMPLETED
import org.meshtastic.core.datastore.KEY_INCLUDE_UNKNOWN
import org.meshtastic.core.datastore.KEY_NODE_SORT
@@ -102,8 +105,12 @@ object DataStoreModule {
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore<LocalConfig> = DataStoreFactory.create(
serializer = LocalConfigSerializer,
produceFile = { appContext.dataStoreFile("local_config.pb") },
storage =
OkioStorage(
fileSystem = FileSystem.SYSTEM,
serializer = LocalConfigSerializer,
producePath = { appContext.dataStoreFile("local_config.pb").toOkioPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalConfig() }),
scope = scope,
)
@@ -114,8 +121,12 @@ object DataStoreModule {
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore<LocalModuleConfig> = DataStoreFactory.create(
serializer = ModuleConfigSerializer,
produceFile = { appContext.dataStoreFile("module_config.pb") },
storage =
OkioStorage(
fileSystem = FileSystem.SYSTEM,
serializer = ModuleConfigSerializer,
producePath = { appContext.dataStoreFile("module_config.pb").toOkioPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalModuleConfig() }),
scope = scope,
)
@@ -126,8 +137,12 @@ object DataStoreModule {
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore<ChannelSet> = DataStoreFactory.create(
serializer = ChannelSetSerializer,
produceFile = { appContext.dataStoreFile("channel_set.pb") },
storage =
OkioStorage(
fileSystem = FileSystem.SYSTEM,
serializer = ChannelSetSerializer,
producePath = { appContext.dataStoreFile("channel_set.pb").toOkioPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { ChannelSet() }),
scope = scope,
)
@@ -138,8 +153,12 @@ object DataStoreModule {
@ApplicationContext appContext: Context,
@DataStoreScope scope: CoroutineScope,
): DataStore<LocalStats> = DataStoreFactory.create(
serializer = LocalStatsSerializer,
produceFile = { appContext.dataStoreFile("local_stats.pb") },
storage =
OkioStorage(
fileSystem = FileSystem.SYSTEM,
serializer = LocalStatsSerializer,
producePath = { appContext.dataStoreFile("local_stats.pb").toOkioPath() },
),
corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { LocalStats() }),
scope = scope,
)

View File

@@ -20,13 +20,15 @@ import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import org.meshtastic.core.database.DatabaseManager
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
interface DatabaseModule {
@Binds @Singleton
fun bindDatabaseManager(impl: DatabaseManager): org.meshtastic.core.repository.DatabaseManager
@Binds
@Singleton
fun bindDatabaseManager(
impl: org.meshtastic.core.database.DatabaseManager,
): org.meshtastic.core.common.database.DatabaseManager
}

View File

@@ -38,6 +38,7 @@ import org.meshtastic.core.data.manager.NodeManagerImpl
import org.meshtastic.core.data.manager.PacketHandlerImpl
import org.meshtastic.core.data.manager.TracerouteHandlerImpl
import org.meshtastic.core.data.repository.DeviceHardwareRepositoryImpl
import org.meshtastic.core.data.repository.MeshLogRepositoryImpl
import org.meshtastic.core.data.repository.NodeRepositoryImpl
import org.meshtastic.core.data.repository.PacketRepositoryImpl
import org.meshtastic.core.data.repository.RadioConfigRepositoryImpl
@@ -51,6 +52,7 @@ import org.meshtastic.core.repository.MeshConfigFlowManager
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.MeshConnectionManager
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.MessageFilter
@@ -85,6 +87,10 @@ abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindPacketRepository(packetRepositoryImpl: PacketRepositoryImpl): PacketRepository
@Binds
@Singleton
abstract fun bindMeshLogRepository(meshLogRepositoryImpl: MeshLogRepositoryImpl): MeshLogRepository
@Binds @Singleton
abstract fun bindNodeManager(nodeManagerImpl: NodeManagerImpl): NodeManager

View File

@@ -18,8 +18,8 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.repository.HistoryManager
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.proto.Data
import org.meshtastic.proto.MeshPacket
@@ -71,7 +71,7 @@ constructor(
}
private fun activeDeviceAddress(): String? =
meshPrefs.deviceAddress?.takeIf { !it.equals(NO_DEVICE_SELECTED, ignoreCase = true) && it.isNotBlank() }
meshPrefs.deviceAddress.value?.takeIf { !it.equals(NO_DEVICE_SELECTED, ignoreCase = true) && it.isNotBlank() }
override fun requestHistoryReplay(
trigger: String,
@@ -86,7 +86,7 @@ constructor(
return
}
val lastRequest = meshPrefs.getStoreForwardLastRequest(address)
val lastRequest = meshPrefs.getStoreForwardLastRequest(address).value
val (window, max) =
resolveHistoryRequestParameters(
storeForwardConfig?.history_return_window ?: 0,
@@ -116,7 +116,7 @@ constructor(
override fun updateStoreForwardLastRequest(source: String, lastRequest: Int, transport: String) {
if (lastRequest <= 0) return
val address = activeDeviceAddress() ?: return
val current = meshPrefs.getStoreForwardLastRequest(address)
val current = meshPrefs.getStoreForwardLastRequest(address).value
if (lastRequest != current) {
meshPrefs.setStoreForwardLastRequest(address, lastRequest)
historyLog(

View File

@@ -23,6 +23,7 @@ import kotlinx.coroutines.SupervisorJob
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.analytics.DataPair
import org.meshtastic.core.analytics.platform.PlatformAnalytics
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.ignoreException
import org.meshtastic.core.common.util.nowMillis
@@ -32,12 +33,11 @@ import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.Reaction
import org.meshtastic.core.model.service.ServiceAction
import org.meshtastic.core.prefs.mesh.MeshPrefs
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.DatabaseManager
import org.meshtastic.core.repository.MeshActionHandler
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshPrefs
import org.meshtastic.core.repository.MeshServiceNotifications
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.PacketRepository
@@ -197,7 +197,7 @@ constructor(
override fun handleRequestPosition(destNum: Int, position: Position, myNodeNum: Int) {
if (destNum != myNodeNum) {
val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum)
val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum).value
val currentPosition =
when {
provideLocation && position.isValid() -> position
@@ -343,9 +343,9 @@ constructor(
}
override fun handleUpdateLastAddress(deviceAddr: String?) {
val currentAddr = meshPrefs.deviceAddress
val currentAddr = meshPrefs.deviceAddress.value
if (deviceAddr != currentAddr) {
meshPrefs.deviceAddress = deviceAddr
meshPrefs.setDeviceAddress(deviceAddr)
scope.handledLaunch {
nodeManager.clear()
messageProcessor.get().clearEarlyPackets()

View File

@@ -35,7 +35,6 @@ import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HistoryManager
@@ -52,6 +51,7 @@ import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.connected
import org.meshtastic.core.resources.connecting

View File

@@ -27,11 +27,11 @@ import kotlinx.coroutines.flow.onEach
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.isLora
import org.meshtastic.core.repository.FromRadioPacketHandler
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.MeshMessageProcessor
import org.meshtastic.core.repository.MeshRouter
import org.meshtastic.core.repository.NodeManager

View File

@@ -17,7 +17,7 @@
package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import org.meshtastic.core.prefs.filter.FilterPrefs
import org.meshtastic.core.repository.FilterPrefs
import org.meshtastic.core.repository.MessageFilter
import java.util.regex.PatternSyntaxException
import javax.inject.Inject
@@ -33,7 +33,7 @@ class MessageFilterImpl @Inject constructor(private val filterPrefs: FilterPrefs
}
override fun shouldFilter(message: String, isFilteringDisabled: Boolean): Boolean {
if (!filterPrefs.filterEnabled || compiledPatterns.isEmpty() || isFilteringDisabled) {
if (!filterPrefs.filterEnabled.value || compiledPatterns.isEmpty() || isFilteringDisabled) {
return false
}
val textToCheck = message.take(MAX_CHECK_LENGTH)
@@ -42,7 +42,7 @@ class MessageFilterImpl @Inject constructor(private val filterPrefs: FilterPrefs
override fun rebuildPatterns() {
compiledPatterns =
filterPrefs.filterWords.mapNotNull { word ->
filterPrefs.filterWords.value.mapNotNull { word ->
try {
if (word.startsWith(REGEX_PREFIX)) {
Regex(word.removePrefix(REGEX_PREFIX), RegexOption.IGNORE_CASE)

View File

@@ -28,7 +28,6 @@ import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
@@ -36,6 +35,7 @@ import org.meshtastic.core.model.MessageStatus
import org.meshtastic.core.model.RadioNotConnectedException
import org.meshtastic.core.model.util.toOneLineString
import org.meshtastic.core.model.util.toPIIString
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.PacketHandler
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioInterfaceService

View File

@@ -30,7 +30,9 @@ import org.meshtastic.core.data.datasource.NodeInfoReadDataSource
import org.meshtastic.core.database.DatabaseManager
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.MeshLogRepository.Companion.DEFAULT_MAX_LOGS
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.PortNum
@@ -39,48 +41,48 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Repository for managing and retrieving logs from the local database.
* Repository implementation for managing and retrieving logs from the local database.
*
* This repository provides methods for inserting, deleting, and querying logs, including specialized methods for
* telemetry and traceroute data.
*/
@Suppress("TooManyFunctions")
@Singleton
class MeshLogRepository
class MeshLogRepositoryImpl
@Inject
constructor(
private val dbManager: DatabaseManager,
private val dispatchers: CoroutineDispatchers,
private val meshLogPrefs: MeshLogPrefs,
private val nodeInfoReadDataSource: NodeInfoReadDataSource,
) {
) : MeshLogRepository {
/** Retrieves all [MeshLog]s in the database, up to [maxItem]. */
fun getAllLogs(maxItem: Int = MAX_MESH_PACKETS): Flow<List<MeshLog>> =
override fun getAllLogs(maxItem: Int): Flow<List<MeshLog>> =
dbManager.currentDb.flatMapLatest { it.meshLogDao().getAllLogs(maxItem) }.flowOn(dispatchers.io)
/** Retrieves all [MeshLog]s in the database in the order they were received. */
fun getAllLogsInReceiveOrder(maxItem: Int = MAX_MESH_PACKETS): Flow<List<MeshLog>> =
override fun getAllLogsInReceiveOrder(maxItem: Int): Flow<List<MeshLog>> =
dbManager.currentDb.flatMapLatest { it.meshLogDao().getAllLogsInReceiveOrder(maxItem) }.flowOn(dispatchers.io)
/** Retrieves all [MeshLog]s in the database without any limit. */
fun getAllLogsUnbounded(): Flow<List<MeshLog>> = getAllLogs(Int.MAX_VALUE)
override fun getAllLogsUnbounded(): Flow<List<MeshLog>> = getAllLogs(Int.MAX_VALUE)
/** Retrieves all [MeshLog]s associated with a specific [nodeNum] and [portNum]. */
fun getLogsFrom(nodeNum: Int, portNum: Int): Flow<List<MeshLog>> = dbManager.currentDb
.flatMapLatest { it.meshLogDao().getLogsFrom(nodeNum, portNum, MAX_MESH_PACKETS) }
override fun getLogsFrom(nodeNum: Int, portNum: Int): Flow<List<MeshLog>> = dbManager.currentDb
.flatMapLatest { it.meshLogDao().getLogsFrom(nodeNum, portNum, DEFAULT_MAX_LOGS) }
.distinctUntilChanged()
.flowOn(dispatchers.io)
/** Retrieves all [MeshLog]s containing [MeshPacket]s for a specific [nodeNum]. */
fun getMeshPacketsFrom(nodeNum: Int, portNum: Int = -1): Flow<List<MeshPacket>> =
override fun getMeshPacketsFrom(nodeNum: Int, portNum: Int): Flow<List<MeshPacket>> =
getLogsFrom(nodeNum, portNum).map { list -> list.mapNotNull { it.fromRadio.packet } }.flowOn(dispatchers.io)
/** Retrieves telemetry history for a specific node, automatically handling local node redirection. */
fun getTelemetryFrom(nodeNum: Int): Flow<List<Telemetry>> = effectiveLogId(nodeNum)
override fun getTelemetryFrom(nodeNum: Int): Flow<List<Telemetry>> = effectiveLogId(nodeNum)
.flatMapLatest { logId ->
dbManager.currentDb
.flatMapLatest { it.meshLogDao().getLogsFrom(logId, PortNum.TELEMETRY_APP.value, MAX_MESH_PACKETS) }
.flatMapLatest { it.meshLogDao().getLogsFrom(logId, PortNum.TELEMETRY_APP.value, DEFAULT_MAX_LOGS) }
.distinctUntilChanged()
.mapLatest { list -> list.mapNotNull(::parseTelemetryLog) }
}
@@ -91,8 +93,8 @@ constructor(
*
* A request log is defined as an outgoing packet (`fromNum = 0`) where `want_response` is true.
*/
fun getRequestLogs(targetNodeNum: Int, portNum: PortNum): Flow<List<MeshLog>> = dbManager.currentDb
.flatMapLatest { it.meshLogDao().getLogsFrom(MeshLog.NODE_NUM_LOCAL, portNum.value, MAX_MESH_PACKETS) }
override fun getRequestLogs(targetNodeNum: Int, portNum: PortNum): Flow<List<MeshLog>> = dbManager.currentDb
.flatMapLatest { it.meshLogDao().getLogsFrom(MeshLog.NODE_NUM_LOCAL, portNum.value, DEFAULT_MAX_LOGS) }
.map { list ->
list.filter { log ->
val packet = log.fromRadio.packet ?: return@filter false
@@ -140,26 +142,27 @@ constructor(
.distinctUntilChanged()
/** Returns the cached [MyNodeInfo] from the system logs. */
fun getMyNodeInfo(): Flow<MyNodeInfo?> = dbManager.currentDb
.flatMapLatest { db -> db.meshLogDao().getLogsFrom(MeshLog.NODE_NUM_LOCAL, 0, MAX_MESH_PACKETS) }
override fun getMyNodeInfo(): Flow<MyNodeInfo?> = dbManager.currentDb
.flatMapLatest { db -> db.meshLogDao().getLogsFrom(MeshLog.NODE_NUM_LOCAL, 0, DEFAULT_MAX_LOGS) }
.mapLatest { list -> list.firstOrNull { it.myNodeInfo != null }?.myNodeInfo }
.flowOn(dispatchers.io)
/** Persists a new log entry to the database if logging is enabled in preferences. */
suspend fun insert(log: MeshLog) = withContext(dispatchers.io) {
if (!meshLogPrefs.loggingEnabled) return@withContext
override suspend fun insert(log: MeshLog) = withContext(dispatchers.io) {
if (!meshLogPrefs.loggingEnabled.value) return@withContext
dbManager.currentDb.value.meshLogDao().insert(log)
}
/** Clears all logs from the database. */
suspend fun deleteAll() = withContext(dispatchers.io) { dbManager.currentDb.value.meshLogDao().deleteAll() }
override suspend fun deleteAll() =
withContext(dispatchers.io) { dbManager.currentDb.value.meshLogDao().deleteAll() }
/** Deletes a specific log entry by its [uuid]. */
suspend fun deleteLog(uuid: String) =
override suspend fun deleteLog(uuid: String) =
withContext(dispatchers.io) { dbManager.currentDb.value.meshLogDao().deleteLog(uuid) }
/** Deletes all logs associated with a specific [nodeNum] and [portNum]. */
suspend fun deleteLogs(nodeNum: Int, portNum: Int) = withContext(dispatchers.io) {
override suspend fun deleteLogs(nodeNum: Int, portNum: Int) = withContext(dispatchers.io) {
val myNodeNum = nodeInfoReadDataSource.myNodeInfoFlow().firstOrNull()?.myNodeNum
val logId = if (nodeNum == myNodeNum) MeshLog.NODE_NUM_LOCAL else nodeNum
dbManager.currentDb.value.meshLogDao().deleteLogs(logId, portNum)
@@ -167,13 +170,12 @@ constructor(
/** Prunes the log database based on the configured [retentionDays]. */
@Suppress("MagicNumber")
suspend fun deleteLogsOlderThan(retentionDays: Int) = withContext(dispatchers.io) {
override suspend fun deleteLogsOlderThan(retentionDays: Int) = withContext(dispatchers.io) {
val cutoffTime = nowMillis - (retentionDays.toLong() * 24 * 60 * 60 * 1000)
dbManager.currentDb.value.meshLogDao().deleteOlderThan(cutoffTime)
}
companion object {
private const val MAX_MESH_PACKETS = 5000
private const val MILLIS_PER_SEC = 1000L
}
}

View File

@@ -36,7 +36,6 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.repository.AppWidgetUpdater
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.HistoryManager
@@ -52,6 +51,7 @@ import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.resources.getString
import org.meshtastic.proto.Config
import org.meshtastic.proto.LocalConfig

View File

@@ -18,34 +18,39 @@ package org.meshtastic.core.data.manager
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableStateFlow
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.prefs.filter.FilterPrefs
import org.meshtastic.core.repository.FilterPrefs
class MessageFilterImplTest {
private lateinit var filterPrefs: FilterPrefs
private lateinit var filterEnabledFlow: MutableStateFlow<Boolean>
private lateinit var filterWordsFlow: MutableStateFlow<Set<String>>
private lateinit var filterService: MessageFilterImpl
@Before
fun setup() {
filterEnabledFlow = MutableStateFlow(true)
filterWordsFlow = MutableStateFlow(setOf("spam", "bad"))
filterPrefs = mockk {
every { filterEnabled } returns true
every { filterWords } returns setOf("spam", "bad")
every { filterEnabled } returns filterEnabledFlow
every { filterWords } returns filterWordsFlow
}
filterService = MessageFilterImpl(filterPrefs)
}
@Test
fun `shouldFilter returns false when filter is disabled`() {
every { filterPrefs.filterEnabled } returns false
filterEnabledFlow.value = false
assertFalse(filterService.shouldFilter("spam message"))
}
@Test
fun `shouldFilter returns false when filter words is empty`() {
every { filterPrefs.filterWords } returns emptySet()
filterWordsFlow.value = emptySet()
filterService.rebuildPatterns()
assertFalse(filterService.shouldFilter("any message"))
}
@@ -70,7 +75,7 @@ class MessageFilterImplTest {
@Test
fun `shouldFilter supports regex patterns`() {
every { filterPrefs.filterWords } returns setOf("regex:test\\d+")
filterWordsFlow.value = setOf("regex:test\\d+")
filterService.rebuildPatterns()
assertTrue(filterService.shouldFilter("this is test123"))
assertFalse(filterService.shouldFilter("this is test"))
@@ -78,7 +83,7 @@ class MessageFilterImplTest {
@Test
fun `shouldFilter handles invalid regex gracefully`() {
every { filterPrefs.filterWords } returns setOf("regex:[invalid")
filterWordsFlow.value = setOf("regex:[invalid")
filterService.rebuildPatterns()
assertFalse(filterService.shouldFilter("any message"))
}

View File

@@ -26,9 +26,9 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.ServiceBroadcasts

View File

@@ -36,7 +36,7 @@ import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.proto.Data
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.FromRadio
@@ -55,7 +55,7 @@ class MeshLogRepositoryTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = testDispatcher, io = testDispatcher, default = testDispatcher)
private val repository = MeshLogRepository(dbManager, dispatchers, meshLogPrefs, nodeInfoReadDataSource)
private val repository = MeshLogRepositoryImpl(dbManager, dispatchers, meshLogPrefs, nodeInfoReadDataSource)
init {
every { dbManager.currentDb } returns MutableStateFlow(appDatabase)
@@ -81,7 +81,7 @@ class MeshLogRepositoryTest {
)
// Using reflection to test private method parseTelemetryLog
val method = MeshLogRepository::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java)
val method = MeshLogRepositoryImpl::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java)
method.isAccessible = true
val result = method.invoke(repository, meshLog) as Telemetry?
@@ -107,7 +107,7 @@ class MeshLogRepositoryTest {
fromRadio = FromRadio(packet = meshPacket),
)
val method = MeshLogRepository::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java)
val method = MeshLogRepositoryImpl::class.java.getDeclaredMethod("parseTelemetryLog", MeshLog::class.java)
method.isAccessible = true
val result = method.invoke(repository, meshLog) as Telemetry?

View File

@@ -32,7 +32,7 @@ kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.androidx.sqlite.bundled)
implementation(projects.core.repository)
api(projects.core.common)
implementation(projects.core.di)
api(projects.core.model)

View File

@@ -40,7 +40,7 @@ import org.meshtastic.core.di.CoroutineDispatchers
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
import org.meshtastic.core.repository.DatabaseManager as SharedDatabaseManager
import org.meshtastic.core.common.database.DatabaseManager as SharedDatabaseManager
/** Manages per-device Room database instances for node data, with LRU eviction. */
@Singleton

View File

@@ -14,38 +14,29 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import com.android.build.api.dsl.LibraryExtension
/*
* 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/>.
*/
plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.hilt)
alias(libs.plugins.meshtastic.kmp.library)
alias(libs.plugins.meshtastic.kotlinx.serialization)
alias(libs.plugins.kotlin.parcelize)
alias(libs.plugins.devtools.ksp)
}
configure<LibraryExtension> { namespace = "org.meshtastic.core.datastore" }
kotlin {
android { namespace = "org.meshtastic.core.datastore" }
dependencies {
implementation(projects.core.proto)
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kermit)
sourceSets {
commonMain.dependencies {
implementation(projects.core.proto)
implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kermit)
}
androidMain.dependencies {
implementation(libs.hilt.android)
implementation(libs.javax.inject)
}
}
}
dependencies { "kspAndroid"(libs.hilt.compiler) }

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,7 +14,6 @@
* 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.datastore
import androidx.datastore.core.DataStore

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,7 +14,6 @@
* 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.datastore
import androidx.datastore.core.DataStore

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,7 +14,6 @@
* 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.datastore
import androidx.datastore.core.DataStore
@@ -33,16 +32,16 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
import javax.inject.Singleton
internal const val KEY_APP_INTRO_COMPLETED = "app_intro_completed"
internal const val KEY_THEME = "theme"
const val KEY_APP_INTRO_COMPLETED = "app_intro_completed"
const val KEY_THEME = "theme"
// Node list filters/sort
internal const val KEY_NODE_SORT = "node-sort-option"
internal const val KEY_INCLUDE_UNKNOWN = "include-unknown"
internal const val KEY_EXCLUDE_INFRASTRUCTURE = "exclude-infrastructure"
internal const val KEY_ONLY_ONLINE = "only-online"
internal const val KEY_ONLY_DIRECT = "only-direct"
internal const val KEY_SHOW_IGNORED = "show-ignored"
const val KEY_NODE_SORT = "node-sort-option"
const val KEY_INCLUDE_UNKNOWN = "include-unknown"
const val KEY_EXCLUDE_INFRASTRUCTURE = "exclude-infrastructure"
const val KEY_ONLY_ONLINE = "only-online"
const val KEY_ONLY_DIRECT = "only-direct"
const val KEY_SHOW_IGNORED = "show-ignored"
@Singleton
class UiPreferencesDataSource @Inject constructor(private val dataStore: DataStore<Preferences>) {

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,7 +14,6 @@
* 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.datastore.model
import kotlinx.serialization.Serializable

View File

@@ -17,24 +17,25 @@
package org.meshtastic.core.datastore.serializer
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import androidx.datastore.core.okio.OkioSerializer
import okio.BufferedSink
import okio.BufferedSource
import okio.IOException
import org.meshtastic.proto.ChannelSet
import java.io.InputStream
import java.io.OutputStream
/** Serializer for the [ChannelSet] object defined in apponly.proto. */
@Suppress("BlockingMethodInNonBlockingContext")
object ChannelSetSerializer : Serializer<ChannelSet> {
object ChannelSetSerializer : OkioSerializer<ChannelSet> {
override val defaultValue: ChannelSet = ChannelSet()
override suspend fun readFrom(input: InputStream): ChannelSet {
override suspend fun readFrom(source: BufferedSource): ChannelSet {
try {
return ChannelSet.ADAPTER.decode(input)
return ChannelSet.ADAPTER.decode(source)
} catch (exception: IOException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(t: ChannelSet, output: OutputStream) = ChannelSet.ADAPTER.encode(output, t)
override suspend fun writeTo(t: ChannelSet, sink: BufferedSink) {
ChannelSet.ADAPTER.encode(sink, t)
}
}

View File

@@ -17,24 +17,25 @@
package org.meshtastic.core.datastore.serializer
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import androidx.datastore.core.okio.OkioSerializer
import okio.BufferedSink
import okio.BufferedSource
import okio.IOException
import org.meshtastic.proto.LocalConfig
import java.io.InputStream
import java.io.OutputStream
/** Serializer for the [LocalConfig] object defined in localonly.proto. */
@Suppress("BlockingMethodInNonBlockingContext")
object LocalConfigSerializer : Serializer<LocalConfig> {
object LocalConfigSerializer : OkioSerializer<LocalConfig> {
override val defaultValue: LocalConfig = LocalConfig()
override suspend fun readFrom(input: InputStream): LocalConfig {
override suspend fun readFrom(source: BufferedSource): LocalConfig {
try {
return LocalConfig.ADAPTER.decode(input)
return LocalConfig.ADAPTER.decode(source)
} catch (exception: IOException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(t: LocalConfig, output: OutputStream) = LocalConfig.ADAPTER.encode(output, t)
override suspend fun writeTo(t: LocalConfig, sink: BufferedSink) {
LocalConfig.ADAPTER.encode(sink, t)
}
}

View File

@@ -17,24 +17,25 @@
package org.meshtastic.core.datastore.serializer
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import androidx.datastore.core.okio.OkioSerializer
import okio.BufferedSink
import okio.BufferedSource
import okio.IOException
import org.meshtastic.proto.LocalStats
import java.io.InputStream
import java.io.OutputStream
/** Serializer for the [LocalStats] object defined in telemetry.proto. */
@Suppress("BlockingMethodInNonBlockingContext")
object LocalStatsSerializer : Serializer<LocalStats> {
object LocalStatsSerializer : OkioSerializer<LocalStats> {
override val defaultValue: LocalStats = LocalStats()
override suspend fun readFrom(input: InputStream): LocalStats {
override suspend fun readFrom(source: BufferedSource): LocalStats {
try {
return LocalStats.ADAPTER.decode(input)
return LocalStats.ADAPTER.decode(source)
} catch (exception: IOException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(t: LocalStats, output: OutputStream) = LocalStats.ADAPTER.encode(output, t)
override suspend fun writeTo(t: LocalStats, sink: BufferedSink) {
LocalStats.ADAPTER.encode(sink, t)
}
}

View File

@@ -17,25 +17,25 @@
package org.meshtastic.core.datastore.serializer
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import androidx.datastore.core.okio.OkioSerializer
import okio.BufferedSink
import okio.BufferedSource
import okio.IOException
import org.meshtastic.proto.LocalModuleConfig
import java.io.InputStream
import java.io.OutputStream
/** Serializer for the [LocalModuleConfig] object defined in localonly.proto. */
@Suppress("BlockingMethodInNonBlockingContext")
object ModuleConfigSerializer : Serializer<LocalModuleConfig> {
object ModuleConfigSerializer : OkioSerializer<LocalModuleConfig> {
override val defaultValue: LocalModuleConfig = LocalModuleConfig()
override suspend fun readFrom(input: InputStream): LocalModuleConfig {
override suspend fun readFrom(source: BufferedSource): LocalModuleConfig {
try {
return LocalModuleConfig.ADAPTER.decode(input)
return LocalModuleConfig.ADAPTER.decode(source)
} catch (exception: IOException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(t: LocalModuleConfig, output: OutputStream) =
LocalModuleConfig.ADAPTER.encode(output, t)
override suspend fun writeTo(t: LocalModuleConfig, sink: BufferedSink) {
LocalModuleConfig.ADAPTER.encode(sink, t)
}
}

View File

@@ -29,8 +29,6 @@ dependencies {
implementation(projects.core.proto)
implementation(projects.core.common)
implementation(projects.core.database)
implementation(projects.core.prefs)
implementation(projects.core.data)
implementation(projects.core.datastore)
implementation(projects.core.resources)

View File

@@ -18,9 +18,9 @@ package org.meshtastic.core.domain.usecase.settings
import android.icu.text.SimpleDateFormat
import kotlinx.coroutines.flow.first
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.positionToMeter
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.proto.PortNum
import java.io.BufferedWriter

View File

@@ -23,12 +23,12 @@ import kotlinx.coroutines.flow.flowOf
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.prefs.radio.isBle
import org.meshtastic.core.prefs.radio.isSerial
import org.meshtastic.core.prefs.radio.isTcp
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.isBle
import org.meshtastic.core.repository.isSerial
import org.meshtastic.core.repository.isTcp
import javax.inject.Inject
/** Use case to determine if the currently connected device is capable of over-the-air (OTA) updates. */

View File

@@ -16,8 +16,8 @@
*/
package org.meshtastic.core.domain.usecase.settings
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.repository.DatabaseManager
import javax.inject.Inject
/** Use case for setting the database cache limit. */

View File

@@ -16,8 +16,8 @@
*/
package org.meshtastic.core.domain.usecase.settings
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.core.repository.MeshLogRepository
import javax.inject.Inject
/** Use case for managing mesh log settings. */
@@ -34,7 +34,7 @@ constructor(
*/
suspend fun setRetentionDays(days: Int) {
val clamped = days.coerceIn(MeshLogPrefs.MIN_RETENTION_DAYS, MeshLogPrefs.MAX_RETENTION_DAYS)
meshLogPrefs.retentionDays = clamped
meshLogPrefs.setRetentionDays(clamped)
meshLogRepository.deleteLogsOlderThan(clamped)
}
@@ -44,11 +44,11 @@ constructor(
* @param enabled True to enable logging, false to disable.
*/
suspend fun setLoggingEnabled(enabled: Boolean) {
meshLogPrefs.loggingEnabled = enabled
meshLogPrefs.setLoggingEnabled(enabled)
if (!enabled) {
meshLogRepository.deleteAll()
} else {
meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays)
meshLogRepository.deleteLogsOlderThan(meshLogPrefs.retentionDays.value)
}
}
}

View File

@@ -16,7 +16,7 @@
*/
package org.meshtastic.core.domain.usecase.settings
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.repository.UiPrefs
import javax.inject.Inject
/** Use case for setting whether to provide the node location to the mesh. */

View File

@@ -16,12 +16,12 @@
*/
package org.meshtastic.core.domain.usecase.settings
import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
import org.meshtastic.core.repository.AnalyticsPrefs
import javax.inject.Inject
/** Use case for toggling the analytics preference. */
open class ToggleAnalyticsUseCase @Inject constructor(private val analyticsPrefs: AnalyticsPrefs) {
operator fun invoke() {
analyticsPrefs.analyticsAllowed = !analyticsPrefs.analyticsAllowed
analyticsPrefs.setAnalyticsAllowed(!analyticsPrefs.analyticsAllowed.value)
}
}

View File

@@ -16,12 +16,12 @@
*/
package org.meshtastic.core.domain.usecase.settings
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
import org.meshtastic.core.repository.HomoglyphPrefs
import javax.inject.Inject
/** Use case for toggling the homoglyph encoding preference. */
open class ToggleHomoglyphEncodingUseCase @Inject constructor(private val homoglyphEncodingPrefs: HomoglyphPrefs) {
operator fun invoke() {
homoglyphEncodingPrefs.homoglyphEncodingEnabled = !homoglyphEncodingPrefs.homoglyphEncodingEnabled
homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(!homoglyphEncodingPrefs.homoglyphEncodingEnabled.value)
}
}

View File

@@ -81,7 +81,7 @@ class SendMessageUseCaseTest {
val ourNode = mockk<Node>(relaxed = true)
every { ourNode.user.id } returns "!1234"
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled.value } returns false
// Act
useCase("Hello broadcast", "0${DataPacket.ID_BROADCAST}", null)
@@ -110,7 +110,7 @@ class SendMessageUseCaseTest {
every { destNode.num } returns 12345
every { nodeRepository.getNode("!dest") } returns destNode
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled.value } returns false
every { anyConstructed<Capabilities>().canSendVerifiedContacts } returns false
// Act
@@ -139,7 +139,7 @@ class SendMessageUseCaseTest {
every { destNode.num } returns 67890
every { nodeRepository.getNode("!dest") } returns destNode
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled.value } returns false
every { anyConstructed<Capabilities>().canSendVerifiedContacts } returns true
// Act
@@ -158,7 +158,7 @@ class SendMessageUseCaseTest {
// Arrange
val ourNode = mockk<Node>(relaxed = true)
every { nodeRepository.ourNodeInfo } returns MutableStateFlow(ourNode)
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns true
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled.value } returns true
val originalText = "\u0410pple" // Cyrillic A

View File

@@ -26,9 +26,9 @@ import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.proto.Data
import org.meshtastic.proto.FromRadio

View File

@@ -29,9 +29,9 @@ import org.junit.Test
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioPrefs
class IsOtaCapableUseCaseTest {
@@ -82,7 +82,7 @@ class IsOtaCapableUseCaseTest {
val node = mockk<Node>(relaxed = true)
ourNodeInfoFlow.value = node
connectionStateFlow.value = ConnectionState.Connected
every { radioPrefs.devAddr } returns "m123" // Mock
every { radioPrefs.devAddr } returns MutableStateFlow("m123") // Mock
useCase().test {
assertFalse(awaitItem())
@@ -95,7 +95,7 @@ class IsOtaCapableUseCaseTest {
val node = mockk<Node>(relaxed = true)
ourNodeInfoFlow.value = node
connectionStateFlow.value = ConnectionState.Connected
every { radioPrefs.devAddr } returns "x123" // BLE
every { radioPrefs.devAddr } returns MutableStateFlow("x123") // BLE
val hw = mockk<org.meshtastic.core.model.DeviceHardware> { every { requiresDfu } returns true }
coEvery { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.success(hw)
@@ -111,7 +111,7 @@ class IsOtaCapableUseCaseTest {
val node = mockk<Node>(relaxed = true)
ourNodeInfoFlow.value = node
connectionStateFlow.value = ConnectionState.Connected
every { radioPrefs.devAddr } returns "x123" // BLE
every { radioPrefs.devAddr } returns MutableStateFlow("x123") // BLE
val hw = mockk<org.meshtastic.core.model.DeviceHardware> { every { requiresDfu } returns false }
coEvery { deviceHardwareRepository.getDeviceHardwareByModel(any()) } returns Result.success(hw)

View File

@@ -20,8 +20,8 @@ import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.repository.DatabaseManager
class SetDatabaseCacheLimitUseCaseTest {

View File

@@ -23,8 +23,8 @@ import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.prefs.meshlog.MeshLogPrefs
import org.meshtastic.core.repository.MeshLogPrefs
import org.meshtastic.core.repository.MeshLogRepository
class SetMeshLogSettingsUseCaseTest {
@@ -45,20 +45,20 @@ class SetMeshLogSettingsUseCaseTest {
useCase.setRetentionDays(MeshLogPrefs.MIN_RETENTION_DAYS - 1)
// Assert
verify { meshLogPrefs.retentionDays = MeshLogPrefs.MIN_RETENTION_DAYS }
verify { meshLogPrefs.setRetentionDays(MeshLogPrefs.MIN_RETENTION_DAYS) }
coVerify { meshLogRepository.deleteLogsOlderThan(MeshLogPrefs.MIN_RETENTION_DAYS) }
}
@Test
fun `setLoggingEnabled true triggers cleanup`() = runTest {
// Arrange
every { meshLogPrefs.retentionDays } returns 30
every { meshLogPrefs.retentionDays.value } returns 30
// Act
useCase.setLoggingEnabled(true)
// Assert
verify { meshLogPrefs.loggingEnabled = true }
verify { meshLogPrefs.setLoggingEnabled(true) }
coVerify { meshLogRepository.deleteLogsOlderThan(30) }
}
@@ -68,7 +68,7 @@ class SetMeshLogSettingsUseCaseTest {
useCase.setLoggingEnabled(false)
// Assert
verify { meshLogPrefs.loggingEnabled = false }
verify { meshLogPrefs.setLoggingEnabled(false) }
coVerify { meshLogRepository.deleteAll() }
}
}

View File

@@ -20,7 +20,7 @@ import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.prefs.ui.UiPrefs
import org.meshtastic.core.repository.UiPrefs
class SetProvideLocationUseCaseTest {

View File

@@ -21,7 +21,7 @@ import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.prefs.analytics.AnalyticsPrefs
import org.meshtastic.core.repository.AnalyticsPrefs
class ToggleAnalyticsUseCaseTest {
@@ -37,24 +37,24 @@ class ToggleAnalyticsUseCaseTest {
@Test
fun `invoke toggles analytics from false to true`() {
// Arrange
every { analyticsPrefs.analyticsAllowed } returns false
every { analyticsPrefs.analyticsAllowed.value } returns false
// Act
useCase()
// Assert
verify { analyticsPrefs.analyticsAllowed = true }
verify { analyticsPrefs.setAnalyticsAllowed(true) }
}
@Test
fun `invoke toggles analytics from true to false`() {
// Arrange
every { analyticsPrefs.analyticsAllowed } returns true
every { analyticsPrefs.analyticsAllowed.value } returns true
// Act
useCase()
// Assert
verify { analyticsPrefs.analyticsAllowed = false }
verify { analyticsPrefs.setAnalyticsAllowed(false) }
}
}

View File

@@ -21,7 +21,7 @@ import io.mockk.mockk
import io.mockk.verify
import org.junit.Before
import org.junit.Test
import org.meshtastic.core.prefs.homoglyph.HomoglyphPrefs
import org.meshtastic.core.repository.HomoglyphPrefs
class ToggleHomoglyphEncodingUseCaseTest {
@@ -37,24 +37,24 @@ class ToggleHomoglyphEncodingUseCaseTest {
@Test
fun `invoke toggles homoglyph encoding from false to true`() {
// Arrange
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns false
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled.value } returns false
// Act
useCase()
// Assert
verify { homoglyphEncodingPrefs.homoglyphEncodingEnabled = true }
verify { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(true) }
}
@Test
fun `invoke toggles homoglyph encoding from true to false`() {
// Arrange
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled } returns true
every { homoglyphEncodingPrefs.homoglyphEncodingEnabled.value } returns true
// Act
useCase()
// Assert
verify { homoglyphEncodingPrefs.homoglyphEncodingEnabled = false }
verify { homoglyphEncodingPrefs.setHomoglyphEncodingEnabled(false) }
}
}

View File

@@ -26,8 +26,13 @@ configure<LibraryExtension> { namespace = "org.meshtastic.core.prefs" }
dependencies {
implementation(projects.core.repository)
implementation(projects.core.common)
implementation(projects.core.di)
implementation(libs.androidx.datastore.preferences)
implementation(libs.kotlinx.coroutines.core)
googleImplementation(libs.maps.compose)
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
}

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)
}
}

View File

@@ -26,6 +26,7 @@ kotlin {
api(projects.core.model)
api(projects.core.proto)
implementation(projects.core.common)
implementation(projects.core.database)
implementation(libs.kotlinx.coroutines.core)
implementation(libs.kermit)

View File

@@ -0,0 +1,173 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
import kotlinx.coroutines.flow.StateFlow
/** Reactive interface for analytics-related preferences. */
interface AnalyticsPrefs {
val analyticsAllowed: StateFlow<Boolean>
fun setAnalyticsAllowed(allowed: Boolean)
val installId: StateFlow<String>
}
/** Reactive interface for homoglyph encoding preferences. */
interface HomoglyphPrefs {
val homoglyphEncodingEnabled: StateFlow<Boolean>
fun setHomoglyphEncodingEnabled(enabled: Boolean)
}
/** Reactive interface for message filtering preferences. */
interface FilterPrefs {
val filterEnabled: StateFlow<Boolean>
fun setFilterEnabled(enabled: Boolean)
val filterWords: StateFlow<Set<String>>
fun setFilterWords(words: Set<String>)
}
/** Reactive interface for mesh log preferences. */
interface MeshLogPrefs {
val retentionDays: StateFlow<Int>
fun setRetentionDays(days: Int)
val loggingEnabled: StateFlow<Boolean>
fun setLoggingEnabled(enabled: Boolean)
companion object {
const val DEFAULT_RETENTION_DAYS = 30
const val MIN_RETENTION_DAYS = -1
const val MAX_RETENTION_DAYS = 365
}
}
/** Reactive interface for emoji preferences. */
interface CustomEmojiPrefs {
val customEmojiFrequency: StateFlow<String?>
fun setCustomEmojiFrequency(frequency: String?)
}
/** Reactive interface for general UI preferences. */
interface UiPrefs {
val hasShownNotPairedWarning: StateFlow<Boolean>
fun setHasShownNotPairedWarning(shown: Boolean)
val showQuickChat: StateFlow<Boolean>
fun setShowQuickChat(show: Boolean)
fun shouldProvideNodeLocation(nodeNum: Int): StateFlow<Boolean>
fun setShouldProvideNodeLocation(nodeNum: Int, provide: Boolean)
}
/** Reactive interface for general map preferences. */
interface MapPrefs {
val mapStyle: StateFlow<Int>
fun setMapStyle(style: Int)
val showOnlyFavorites: StateFlow<Boolean>
fun setShowOnlyFavorites(show: Boolean)
val showWaypointsOnMap: StateFlow<Boolean>
fun setShowWaypointsOnMap(show: Boolean)
val showPrecisionCircleOnMap: StateFlow<Boolean>
fun setShowPrecisionCircleOnMap(show: Boolean)
val lastHeardFilter: StateFlow<Long>
fun setLastHeardFilter(seconds: Long)
val lastHeardTrackFilter: StateFlow<Long>
fun setLastHeardTrackFilter(seconds: Long)
}
/** Reactive interface for map consent. */
interface MapConsentPrefs {
fun shouldReportLocation(nodeNum: Int?): StateFlow<Boolean>
fun setShouldReportLocation(nodeNum: Int?, report: Boolean)
}
/** Reactive interface for map tile provider settings. */
interface MapTileProviderPrefs {
val customTileProviders: StateFlow<String?>
fun setCustomTileProviders(providers: String?)
}
/** Reactive interface for radio settings. */
interface RadioPrefs {
val devAddr: StateFlow<String?>
fun setDevAddr(address: String?)
}
fun RadioPrefs.isBle() = devAddr.value?.startsWith("x") == true
fun RadioPrefs.isSerial() = devAddr.value?.startsWith("s") == true
fun RadioPrefs.isMock() = devAddr.value?.startsWith("m") == true
fun RadioPrefs.isTcp() = devAddr.value?.startsWith("t") == true
fun RadioPrefs.isNoop() = devAddr.value?.startsWith("n") == true
/** Reactive interface for mesh connection settings. */
interface MeshPrefs {
val deviceAddress: StateFlow<String?>
fun setDeviceAddress(address: String?)
fun shouldProvideNodeLocation(nodeNum: Int?): StateFlow<Boolean>
fun setShouldProvideNodeLocation(nodeNum: Int?, provide: Boolean)
fun getStoreForwardLastRequest(address: String?): StateFlow<Int>
fun setStoreForwardLastRequest(address: String?, timestamp: Int)
}
/** Consolidated interface for all application preferences. */
interface AppPreferences {
val analytics: AnalyticsPrefs
val homoglyph: HomoglyphPrefs
val filter: FilterPrefs
val meshLog: MeshLogPrefs
val emoji: CustomEmojiPrefs
val ui: UiPrefs
val map: MapPrefs
val mapConsent: MapConsentPrefs
val mapTileProvider: MapTileProviderPrefs
val radio: RadioPrefs
val mesh: MeshPrefs
}

View File

@@ -1,21 +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.repository
interface HomoglyphPrefs {
val homoglyphEncodingEnabled: Boolean
}

View File

@@ -0,0 +1,82 @@
/*
* 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.repository
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.MyNodeInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
/**
* Repository interface for managing and retrieving logs from the database.
*
* This component provides access to the application's message log, telemetry history, and debug records. It supports
* reactive queries for packets, telemetry data, and node-specific logs.
*
* This interface is shared across platforms via Kotlin Multiplatform (KMP).
*/
@Suppress("TooManyFunctions")
interface MeshLogRepository {
/** Retrieves all [MeshLog]s in the database, up to [maxItem]. */
fun getAllLogs(maxItem: Int = DEFAULT_MAX_LOGS): Flow<List<MeshLog>>
/** Retrieves all [MeshLog]s in the database in the order they were received. */
fun getAllLogsInReceiveOrder(maxItem: Int = DEFAULT_MAX_LOGS): Flow<List<MeshLog>>
/** Retrieves all [MeshLog]s in the database without any limit. */
fun getAllLogsUnbounded(): Flow<List<MeshLog>>
/** Retrieves all [MeshLog]s associated with a specific [nodeNum] and [portNum]. */
fun getLogsFrom(nodeNum: Int, portNum: Int): Flow<List<MeshLog>>
/** Retrieves all [MeshLog]s containing [MeshPacket]s for a specific [nodeNum]. */
fun getMeshPacketsFrom(nodeNum: Int, portNum: Int = -1): Flow<List<MeshPacket>>
/** Retrieves telemetry history for a specific node, automatically handling local node redirection. */
fun getTelemetryFrom(nodeNum: Int): Flow<List<Telemetry>>
/**
* Retrieves all outgoing request logs for a specific [targetNodeNum] and [portNum].
*
* A request log is defined as an outgoing packet where `want_response` is true.
*/
fun getRequestLogs(targetNodeNum: Int, portNum: PortNum): Flow<List<MeshLog>>
/** Returns the cached [MyNodeInfo] from the system logs. */
fun getMyNodeInfo(): Flow<MyNodeInfo?>
/** Persists a new log entry to the database. */
suspend fun insert(log: MeshLog)
/** Clears all logs from the database. */
suspend fun deleteAll()
/** Deletes a specific log entry by its [uuid]. */
suspend fun deleteLog(uuid: String)
/** Deletes all logs associated with a specific [nodeNum] and [portNum]. */
suspend fun deleteLogs(nodeNum: Int, portNum: Int)
/** Prunes the log database based on the configured [retentionDays]. */
suspend fun deleteLogsOlderThan(retentionDays: Int)
companion object {
const val DEFAULT_MAX_LOGS = 5000
}
}

View File

@@ -89,7 +89,7 @@ class SendMessageUseCase(
// Apply homoglyph encoding
val finalMessageText =
if (homoglyphEncodingPrefs.homoglyphEncodingEnabled) {
if (homoglyphEncodingPrefs.homoglyphEncodingEnabled.value) {
HomoglyphCharacterStringTransformer.optimizeUtf8StringWithHomoglyphs(text)
} else {
text

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,20 +14,19 @@
* 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.ui.emoji
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import org.meshtastic.core.prefs.emoji.CustomEmojiPrefs
import org.meshtastic.core.repository.CustomEmojiPrefs
import javax.inject.Inject
@HiltViewModel
class EmojiPickerViewModel @Inject constructor(private val customEmojiPrefs: CustomEmojiPrefs) : ViewModel() {
var customEmojiFrequency: String?
get() = customEmojiPrefs.customEmojiFrequency
get() = customEmojiPrefs.customEmojiFrequency.value
set(value) {
customEmojiPrefs.customEmojiFrequency = value
customEmojiPrefs.setCustomEmojiFrequency(value)
}
}

View File

@@ -20,10 +20,10 @@ import android.net.Uri
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.prefs.radio.isBle
import org.meshtastic.core.prefs.radio.isSerial
import org.meshtastic.core.prefs.radio.isTcp
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.isBle
import org.meshtastic.core.repository.isSerial
import org.meshtastic.core.repository.isTcp
import org.meshtastic.feature.firmware.ota.Esp32OtaUpdateHandler
import java.io.File
import javax.inject.Inject
@@ -90,7 +90,7 @@ constructor(
private fun getTarget(address: String): String = when {
radioPrefs.isSerial() -> ""
radioPrefs.isBle() -> address
radioPrefs.isTcp() -> extractIpFromAddress(radioPrefs.devAddr) ?: ""
radioPrefs.isTcp() -> extractIpFromAddress(radioPrefs.devAddr.value) ?: ""
else -> ""
}

View File

@@ -45,12 +45,12 @@ import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.radio.RadioPrefs
import org.meshtastic.core.prefs.radio.isBle
import org.meshtastic.core.prefs.radio.isSerial
import org.meshtastic.core.prefs.radio.isTcp
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioPrefs
import org.meshtastic.core.repository.isBle
import org.meshtastic.core.repository.isSerial
import org.meshtastic.core.repository.isTcp
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.firmware_update_battery_low
import org.meshtastic.core.resources.firmware_update_copying
@@ -157,7 +157,7 @@ constructor(
_state.value = FirmwareUpdateState.Checking
runCatching {
val ourNode = nodeRepository.myNodeInfo.value
val address = radioPrefs.devAddr?.drop(1)
val address = radioPrefs.devAddr.value?.drop(1)
if (address == null || ourNode == null) {
_state.value = FirmwareUpdateState.Error(getString(Res.string.firmware_update_no_device))
return@launch

View File

@@ -26,7 +26,7 @@ import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
@@ -52,9 +52,9 @@ constructor(
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
var mapStyleId: Int
get() = mapPrefs.mapStyle
get() = mapPrefs.mapStyle.value
set(value) {
mapPrefs.mapStyle = value
mapPrefs.setMapStyle(value)
}
val localConfig = radioConfigRepository.localConfigFlow.stateInWhileSubscribed(initialValue = LocalConfig())

View File

@@ -49,7 +49,7 @@ import org.meshtastic.core.datastore.UiPreferencesDataSource
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.navigation.MapRoutes
import org.meshtastic.core.prefs.map.GoogleMapsPrefs
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
@@ -96,9 +96,9 @@ constructor(
val selectedWaypointId: StateFlow<Int?> = _selectedWaypointId.asStateFlow()
private val targetLatLng =
googleMapsPrefs.cameraTargetLat
googleMapsPrefs.cameraTargetLat.value
.takeIf { it != 0.0 }
?.let { lat -> googleMapsPrefs.cameraTargetLng.takeIf { it != 0.0 }?.let { lng -> LatLng(lat, lng) } }
?.let { lat -> googleMapsPrefs.cameraTargetLng.value.takeIf { it != 0.0 }?.let { lng -> LatLng(lat, lng) } }
?: ourNodeInfo.value?.position?.toLatLng()
?: LatLng(0.0, 0.0)
@@ -107,9 +107,9 @@ constructor(
position =
CameraPosition(
targetLatLng,
googleMapsPrefs.cameraZoom,
googleMapsPrefs.cameraTilt,
googleMapsPrefs.cameraBearing,
googleMapsPrefs.cameraZoom.value,
googleMapsPrefs.cameraTilt.value,
googleMapsPrefs.cameraBearing.value,
),
)
@@ -222,7 +222,7 @@ constructor(
) {
_selectedCustomTileProviderUrl.value = null
// Also clear from prefs
googleMapsPrefs.selectedCustomTileUrl = null
googleMapsPrefs.setSelectedCustomTileUrl(null)
}
if (configToRemove.localUri != null) {
@@ -238,28 +238,28 @@ constructor(
if (!config.isLocal && !isValidTileUrlTemplate(config.urlTemplate)) {
Logger.withTag("MapViewModel").w("Attempted to select invalid URL template: ${config.urlTemplate}")
_selectedCustomTileProviderUrl.value = null
googleMapsPrefs.selectedCustomTileUrl = null
googleMapsPrefs.setSelectedCustomTileUrl(null)
return
}
// Use localUri if present, otherwise urlTemplate
val selectedUrl = config.localUri ?: config.urlTemplate
_selectedCustomTileProviderUrl.value = selectedUrl
_selectedGoogleMapType.value = MapType.NONE
googleMapsPrefs.selectedCustomTileUrl = selectedUrl
googleMapsPrefs.selectedGoogleMapType = null
googleMapsPrefs.setSelectedCustomTileUrl(selectedUrl)
googleMapsPrefs.setSelectedGoogleMapType(null)
} else {
_selectedCustomTileProviderUrl.value = null
_selectedGoogleMapType.value = MapType.NORMAL
googleMapsPrefs.selectedCustomTileUrl = null
googleMapsPrefs.selectedGoogleMapType = MapType.NORMAL.name
googleMapsPrefs.setSelectedCustomTileUrl(null)
googleMapsPrefs.setSelectedGoogleMapType(MapType.NORMAL.name)
}
}
fun setSelectedGoogleMapType(mapType: MapType) {
_selectedGoogleMapType.value = mapType
_selectedCustomTileProviderUrl.value = null // Clear custom selection
googleMapsPrefs.selectedGoogleMapType = mapType.name
googleMapsPrefs.selectedCustomTileUrl = null
googleMapsPrefs.setSelectedGoogleMapType(mapType.name)
googleMapsPrefs.setSelectedCustomTileUrl(null)
}
private var currentTileProvider: TileProvider? = null
@@ -354,16 +354,16 @@ constructor(
fun saveCameraPosition(cameraPosition: CameraPosition) {
viewModelScope.launch {
googleMapsPrefs.cameraTargetLat = cameraPosition.target.latitude
googleMapsPrefs.cameraTargetLng = cameraPosition.target.longitude
googleMapsPrefs.cameraZoom = cameraPosition.zoom
googleMapsPrefs.cameraTilt = cameraPosition.tilt
googleMapsPrefs.cameraBearing = cameraPosition.bearing
googleMapsPrefs.setCameraTargetLat(cameraPosition.target.latitude)
googleMapsPrefs.setCameraTargetLng(cameraPosition.target.longitude)
googleMapsPrefs.setCameraZoom(cameraPosition.zoom)
googleMapsPrefs.setCameraTilt(cameraPosition.tilt)
googleMapsPrefs.setCameraBearing(cameraPosition.bearing)
}
}
private fun loadPersistedMapType() {
val savedCustomUrl = googleMapsPrefs.selectedCustomTileUrl
val savedCustomUrl = googleMapsPrefs.selectedCustomTileUrl.value
if (savedCustomUrl != null) {
// Check if this custom provider still exists
if (
@@ -375,18 +375,18 @@ constructor(
MapType.NONE // MapType.NONE to hide google basemap when using custom provider
} else {
// The saved custom URL is no longer valid or doesn't exist, remove preference
googleMapsPrefs.selectedCustomTileUrl = null
googleMapsPrefs.setSelectedCustomTileUrl(null)
// Fallback to default Google Map type
_selectedGoogleMapType.value = MapType.NORMAL
}
} else {
val savedGoogleMapTypeName = googleMapsPrefs.selectedGoogleMapType
val savedGoogleMapTypeName = googleMapsPrefs.selectedGoogleMapType.value
try {
_selectedGoogleMapType.value = MapType.valueOf(savedGoogleMapTypeName ?: MapType.NORMAL.name)
} catch (e: IllegalArgumentException) {
Logger.e(e) { "Invalid saved Google Map type: $savedGoogleMapTypeName" }
_selectedGoogleMapType.value = MapType.NORMAL // Fallback in case of invalid stored name
googleMapsPrefs.selectedGoogleMapType = null
googleMapsPrefs.setSelectedGoogleMapType(null)
}
}
}
@@ -399,7 +399,7 @@ constructor(
val persistedLayerFiles = layersDir.listFiles()
if (persistedLayerFiles != null) {
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
val loadedItems =
persistedLayerFiles.mapNotNull { file ->
if (file.isFile) {
@@ -429,7 +429,7 @@ constructor(
}
val networkItems =
googleMapsPrefs.networkMapLayers.mapNotNull { networkString ->
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
try {
val parts = networkString.split("|:|")
if (parts.size == 3) {
@@ -532,7 +532,7 @@ constructor(
_mapLayers.value = _mapLayers.value + newItem
val networkLayerString = "${newItem.id}|:|${newItem.name}|:|${newItem.uri}"
googleMapsPrefs.networkMapLayers = googleMapsPrefs.networkMapLayers + networkLayerString
googleMapsPrefs.setNetworkMapLayers(googleMapsPrefs.networkMapLayers.value + networkLayerString)
} catch (e: Exception) {
_errorFlow.emit("Invalid URL.")
}
@@ -572,9 +572,9 @@ constructor(
toggledLayer?.let {
if (it.isVisible) {
googleMapsPrefs.hiddenLayerUrls -= it.uri.toString()
googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - it.uri.toString())
} else {
googleMapsPrefs.hiddenLayerUrls += it.uri.toString()
googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value + it.uri.toString())
}
}
}
@@ -584,12 +584,13 @@ constructor(
val layerToRemove = _mapLayers.value.find { it.id == layerId }
layerToRemove?.uri?.let { uri ->
if (layerToRemove.isNetwork) {
googleMapsPrefs.networkMapLayers =
googleMapsPrefs.networkMapLayers.filterNot { it.startsWith("$layerId|:|") }.toSet()
googleMapsPrefs.setNetworkMapLayers(
googleMapsPrefs.networkMapLayers.value.filterNot { it.startsWith("$layerId|:|") }.toSet(),
)
} else {
deleteFileToInternalStorage(uri)
}
googleMapsPrefs.hiddenLayerUrls -= uri.toString()
googleMapsPrefs.setHiddenLayerUrls(googleMapsPrefs.hiddenLayerUrls.value - uri.toString())
}
_mapLayers.value = _mapLayers.value.filterNot { it.id == layerId }
}

View File

@@ -30,7 +30,7 @@ import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.resources.Res
@@ -90,47 +90,48 @@ abstract class BaseMapViewModel(
}
.stateInWhileSubscribed(initialValue = emptyMap())
private val showOnlyFavorites = MutableStateFlow(mapPrefs.showOnlyFavorites)
private val showOnlyFavorites = MutableStateFlow(mapPrefs.showOnlyFavorites.value)
val showOnlyFavoritesOnMap = showOnlyFavorites
fun toggleOnlyFavorites() {
val newValue = !showOnlyFavorites.value
showOnlyFavorites.value = newValue
mapPrefs.showOnlyFavorites = newValue
mapPrefs.setShowOnlyFavorites(newValue)
}
private val showWaypoints = MutableStateFlow(mapPrefs.showWaypointsOnMap)
private val showWaypoints = MutableStateFlow(mapPrefs.showWaypointsOnMap.value)
val showWaypointsOnMap = showWaypoints
fun toggleShowWaypointsOnMap() {
val newValue = !showWaypoints.value
showWaypoints.value = newValue
mapPrefs.showWaypointsOnMap = newValue
mapPrefs.setShowWaypointsOnMap(newValue)
}
private val showPrecisionCircle = MutableStateFlow(mapPrefs.showPrecisionCircleOnMap)
private val showPrecisionCircle = MutableStateFlow(mapPrefs.showPrecisionCircleOnMap.value)
val showPrecisionCircleOnMap = showPrecisionCircle
fun toggleShowPrecisionCircleOnMap() {
val newValue = !showPrecisionCircle.value
showPrecisionCircle.value = newValue
mapPrefs.showPrecisionCircleOnMap = newValue
mapPrefs.setShowPrecisionCircleOnMap(newValue)
}
private val lastHeardFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter))
private val lastHeardFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardFilter.value))
val lastHeardFilter = lastHeardFilterValue
fun setLastHeardFilter(filter: LastHeardFilter) {
lastHeardFilterValue.value = filter
mapPrefs.lastHeardFilter = filter.seconds
mapPrefs.setLastHeardFilter(filter.seconds)
}
private val lastHeardTrackFilterValue = MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardTrackFilter))
private val lastHeardTrackFilterValue =
MutableStateFlow(LastHeardFilter.fromSeconds(mapPrefs.lastHeardTrackFilter.value))
val lastHeardTrackFilter = lastHeardTrackFilterValue
fun setLastHeardTrackFilter(filter: LastHeardFilter) {
lastHeardTrackFilterValue.value = filter
mapPrefs.lastHeardTrackFilter = filter.seconds
mapPrefs.setLastHeardTrackFilter(filter.seconds)
}
abstract fun getUser(userId: String?): org.meshtastic.proto.User

View File

@@ -28,10 +28,10 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.toList
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.data.repository.MeshLogRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.prefs.map.MapPrefs
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
@@ -81,5 +81,5 @@ constructor(
.stateInWhileSubscribed(initialValue = emptyList())
val tileSource
get() = CustomTileSource.getTileSource(mapPrefs.mapStyle)
get() = CustomTileSource.getTileSource(mapPrefs.mapStyle.value)
}

Some files were not shown because too many files have changed in this diff Show More