Add core data modules (#3169)

This commit is contained in:
Phil Oliver 2025-09-22 23:49:28 -04:00 committed by GitHub
parent bb2e6b9a7d
commit 53fdda3a9c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 213 additions and 193 deletions

View file

@ -28,8 +28,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.repository.bluetooth.BluetoothRepository
import com.geeksville.mesh.repository.datastore.recentaddresses.RecentAddress
import com.geeksville.mesh.repository.datastore.recentaddresses.RecentAddressesRepository
import com.geeksville.mesh.repository.network.NetworkRepository
import com.geeksville.mesh.repository.network.NetworkRepository.Companion.toAddressString
import com.geeksville.mesh.repository.radio.InterfaceId
@ -52,6 +50,8 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.strings.R
import javax.inject.Inject
@ -107,7 +107,7 @@ constructor(
private val usbManagerLazy: dagger.Lazy<UsbManager>,
private val networkRepository: NetworkRepository,
private val radioInterfaceService: RadioInterfaceService,
private val recentAddressesRepository: RecentAddressesRepository,
private val recentAddressesDataSource: RecentAddressesDataSource,
) : ViewModel(),
Logging {
private val context: Context
@ -125,7 +125,7 @@ constructor(
// Flow for discovered TCP devices, using recent addresses for potential name enrichment
private val processedDiscoveredTcpDevicesFlow: StateFlow<List<DeviceListEntry.Tcp>> =
combine(networkRepository.resolvedList, recentAddressesRepository.recentAddresses) { tcpServices, recentList ->
combine(networkRepository.resolvedList, recentAddressesDataSource.recentAddresses) { tcpServices, recentList ->
val recentMap = recentList.associateBy({ it.address }, { it.name })
tcpServices
.map { service ->
@ -149,7 +149,7 @@ constructor(
// Flow for recent TCP devices, filtered to exclude any currently discovered devices
private val filteredRecentTcpDevicesFlow: StateFlow<List<DeviceListEntry.Tcp>> =
combine(recentAddressesRepository.recentAddresses, processedDiscoveredTcpDevicesFlow) {
combine(recentAddressesDataSource.recentAddresses, processedDiscoveredTcpDevicesFlow) {
recentList,
discoveredDevices,
->
@ -328,11 +328,11 @@ constructor(
fun addRecentAddress(address: String, name: String) {
if (!address.startsWith("t")) return
viewModelScope.launch { recentAddressesRepository.add(RecentAddress(address, name)) }
viewModelScope.launch { recentAddressesDataSource.add(RecentAddress(address, name)) }
}
fun removeRecentAddress(address: String) {
viewModelScope.launch { recentAddressesRepository.remove(address) }
viewModelScope.launch { recentAddressesDataSource.remove(address) }
}
// Called by the GUI when a new device has been selected by the user

View file

@ -1,87 +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 com.geeksville.mesh.repository.datastore
import androidx.datastore.core.DataStore
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.AppOnlyProtos.ChannelSet
import com.geeksville.mesh.ChannelProtos.Channel
import com.geeksville.mesh.ChannelProtos.ChannelSettings
import com.geeksville.mesh.ConfigProtos
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import java.io.IOException
import javax.inject.Inject
/**
* Class that handles saving and retrieving [ChannelSet] data.
*/
class ChannelSetRepository @Inject constructor(
private val channelSetStore: DataStore<ChannelSet>
) : Logging {
val channelSetFlow: Flow<ChannelSet> = channelSetStore.data
.catch { exception ->
// dataStore.data throws an IOException when an error is encountered when reading data
if (exception is IOException) {
errormsg("Error reading DeviceConfig settings: ${exception.message}")
emit(ChannelSet.getDefaultInstance())
} else {
throw exception
}
}
suspend fun clearChannelSet() {
channelSetStore.updateData { preference ->
preference.toBuilder().clear().build()
}
}
suspend fun clearSettings() {
channelSetStore.updateData { preference ->
preference.toBuilder().clearSettings().build()
}
}
suspend fun addAllSettings(settingsList: List<ChannelSettings>) {
channelSetStore.updateData { preference ->
preference.toBuilder().addAllSettings(settingsList).build()
}
}
/**
* Updates the [ChannelSettings] list with the provided channel.
*/
suspend fun updateChannelSettings(channel: Channel) {
if (channel.role == Channel.Role.DISABLED) return
channelSetStore.updateData { preference ->
val builder = preference.toBuilder()
// Resize to fit channel
while (builder.settingsCount <= channel.index) {
builder.addSettings(ChannelSettings.getDefaultInstance())
}
// use setSettings() to ensure settingsList and channel indexes match
builder.setSettings(channel.index, channel.settings).build()
}
}
suspend fun setLoraConfig(config: ConfigProtos.Config.LoRaConfig) {
channelSetStore.updateData { preference ->
preference.toBuilder().setLoraConfig(config).build()
}
}
}

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 com.geeksville.mesh.repository.datastore
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.geeksville.mesh.AppOnlyProtos.ChannelSet
import com.google.protobuf.InvalidProtocolBufferException
import java.io.InputStream
import java.io.OutputStream
/**
* Serializer for the [ChannelSet] object defined in apponly.proto.
*/
@Suppress("BlockingMethodInNonBlockingContext")
object ChannelSetSerializer : Serializer<ChannelSet> {
override val defaultValue: ChannelSet = ChannelSet.getDefaultInstance()
override suspend fun readFrom(input: InputStream): ChannelSet {
try {
return ChannelSet.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(t: ChannelSet, output: OutputStream) = t.writeTo(output)
}

View file

@ -1,107 +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 com.geeksville.mesh.repository.datastore
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.dataStoreFile
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStoreFile
import com.geeksville.mesh.AppOnlyProtos.ChannelSet
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig
import com.geeksville.mesh.repository.datastore.recentaddresses.RecentAddressesRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
private const val USER_PREFERENCES_NAME = "user_preferences"
@InstallIn(SingletonComponent::class)
@Module
object DataStoreModule {
@Singleton
@Provides
fun providePreferencesDataStore(
@ApplicationContext appContext: Context
): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
corruptionHandler =
ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }),
migrations = listOf(SharedPreferencesMigration(appContext, USER_PREFERENCES_NAME)),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
produceFile = { appContext.preferencesDataStoreFile(USER_PREFERENCES_NAME) },
)
@Singleton
@Provides
fun provideRecentAddressesRepository(
@ApplicationContext context: Context,
dataStore: DataStore<Preferences>,
): RecentAddressesRepository = RecentAddressesRepository(context, dataStore)
@Singleton
@Provides
fun provideLocalConfigDataStore(
@ApplicationContext appContext: Context
): DataStore<LocalConfig> =
DataStoreFactory.create(
serializer = LocalConfigSerializer,
produceFile = { appContext.dataStoreFile("local_config.pb") },
corruptionHandler =
ReplaceFileCorruptionHandler(produceNewData = { LocalConfig.getDefaultInstance() }),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
)
@Singleton
@Provides
fun provideModuleConfigDataStore(
@ApplicationContext appContext: Context
): DataStore<LocalModuleConfig> =
DataStoreFactory.create(
serializer = ModuleConfigSerializer,
produceFile = { appContext.dataStoreFile("module_config.pb") },
corruptionHandler =
ReplaceFileCorruptionHandler(
produceNewData = { LocalModuleConfig.getDefaultInstance() }
),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
)
@Singleton
@Provides
fun provideChannelSetDataStore(@ApplicationContext appContext: Context): DataStore<ChannelSet> =
DataStoreFactory.create(
serializer = ChannelSetSerializer,
produceFile = { appContext.dataStoreFile("channel_set.pb") },
corruptionHandler =
ReplaceFileCorruptionHandler(produceNewData = { ChannelSet.getDefaultInstance() }),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
)
}

View file

@ -1,67 +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 com.geeksville.mesh.repository.datastore
import androidx.datastore.core.DataStore
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.ConfigProtos.Config
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import java.io.IOException
import javax.inject.Inject
/**
* Class that handles saving and retrieving [LocalConfig] data.
*/
class LocalConfigRepository @Inject constructor(
private val localConfigStore: DataStore<LocalConfig>,
) : Logging {
val localConfigFlow: Flow<LocalConfig> = localConfigStore.data
.catch { exception ->
// dataStore.data throws an IOException when an error is encountered when reading data
if (exception is IOException) {
errormsg("Error reading LocalConfig settings: ${exception.message}")
emit(LocalConfig.getDefaultInstance())
} else {
throw exception
}
}
suspend fun clearLocalConfig() {
localConfigStore.updateData { preference ->
preference.toBuilder().clear().build()
}
}
/**
* Updates [LocalConfig] from each [Config] oneOf.
*/
suspend fun setLocalConfig(config: Config) = localConfigStore.updateData {
val builder = it.toBuilder()
config.allFields.forEach { (field, value) ->
val localField = it.descriptorForType.findFieldByName(field.name)
if (localField != null) {
builder.setField(localField, value)
} else {
errormsg("Error writing LocalConfig settings: ${config.payloadVariantCase}")
}
}
builder.build()
}
}

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 com.geeksville.mesh.repository.datastore
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
import com.google.protobuf.InvalidProtocolBufferException
import java.io.InputStream
import java.io.OutputStream
/**
* Serializer for the [LocalConfig] object defined in localonly.proto.
*/
@Suppress("BlockingMethodInNonBlockingContext")
object LocalConfigSerializer : Serializer<LocalConfig> {
override val defaultValue: LocalConfig = LocalConfig.getDefaultInstance()
override suspend fun readFrom(input: InputStream): LocalConfig {
try {
return LocalConfig.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(t: LocalConfig, output: OutputStream) = t.writeTo(output)
}

View file

@ -1,67 +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 com.geeksville.mesh.repository.datastore
import androidx.datastore.core.DataStore
import com.geeksville.mesh.android.Logging
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig
import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import java.io.IOException
import javax.inject.Inject
/**
* Class that handles saving and retrieving [LocalModuleConfig] data.
*/
class ModuleConfigRepository @Inject constructor(
private val moduleConfigStore: DataStore<LocalModuleConfig>,
) : Logging {
val moduleConfigFlow: Flow<LocalModuleConfig> = moduleConfigStore.data
.catch { exception ->
// dataStore.data throws an IOException when an error is encountered when reading data
if (exception is IOException) {
errormsg("Error reading LocalModuleConfig settings: ${exception.message}")
emit(LocalModuleConfig.getDefaultInstance())
} else {
throw exception
}
}
suspend fun clearLocalModuleConfig() {
moduleConfigStore.updateData { preference ->
preference.toBuilder().clear().build()
}
}
/**
* Updates [LocalModuleConfig] from each [ModuleConfig] oneOf.
*/
suspend fun setLocalModuleConfig(config: ModuleConfig) = moduleConfigStore.updateData {
val builder = it.toBuilder()
config.allFields.forEach { (field, value) ->
val localField = it.descriptorForType.findFieldByName(field.name)
if (localField != null) {
builder.setField(localField, value)
} else {
errormsg("Error writing LocalModuleConfig settings: ${config.payloadVariantCase}")
}
}
builder.build()
}
}

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 com.geeksville.mesh.repository.datastore
import androidx.datastore.core.CorruptionException
import androidx.datastore.core.Serializer
import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig
import com.google.protobuf.InvalidProtocolBufferException
import java.io.InputStream
import java.io.OutputStream
/**
* Serializer for the [LocalModuleConfig] object defined in localonly.proto.
*/
@Suppress("BlockingMethodInNonBlockingContext")
object ModuleConfigSerializer : Serializer<LocalModuleConfig> {
override val defaultValue: LocalModuleConfig = LocalModuleConfig.getDefaultInstance()
override suspend fun readFrom(input: InputStream): LocalModuleConfig {
try {
return LocalModuleConfig.parseFrom(input)
} catch (exception: InvalidProtocolBufferException) {
throw CorruptionException("Cannot read proto.", exception)
}
}
override suspend fun writeTo(t: LocalModuleConfig, output: OutputStream) = t.writeTo(output)
}

View file

@ -45,6 +45,9 @@ import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import org.meshtastic.core.datastore.ChannelSetDataSource
import org.meshtastic.core.datastore.LocalConfigDataSource
import org.meshtastic.core.datastore.ModuleConfigDataSource
import javax.inject.Inject
/**
@ -56,9 +59,9 @@ class RadioConfigRepository
constructor(
private val serviceRepository: ServiceRepository,
private val nodeDB: NodeRepository,
private val channelSetRepository: ChannelSetRepository,
private val localConfigRepository: LocalConfigRepository,
private val moduleConfigRepository: ModuleConfigRepository,
private val channelSetDataSource: ChannelSetDataSource,
private val localConfigDataSource: LocalConfigDataSource,
private val moduleConfigDataSource: ModuleConfigDataSource,
) {
val meshService: IMeshService?
get() = serviceRepository.meshService
@ -104,17 +107,17 @@ constructor(
}
/** Flow representing the [ChannelSet] data store. */
val channelSetFlow: Flow<ChannelSet> = channelSetRepository.channelSetFlow
val channelSetFlow: Flow<ChannelSet> = channelSetDataSource.channelSetFlow
/** Clears the [ChannelSet] data in the data store. */
suspend fun clearChannelSet() {
channelSetRepository.clearChannelSet()
channelSetDataSource.clearChannelSet()
}
/** Replaces the [ChannelSettings] list with a new [settingsList]. */
suspend fun replaceAllSettings(settingsList: List<ChannelSettings>) {
channelSetRepository.clearSettings()
channelSetRepository.addAllSettings(settingsList)
channelSetDataSource.clearSettings()
channelSetDataSource.addAllSettings(settingsList)
}
/**
@ -124,14 +127,14 @@ constructor(
* @param channel The [Channel] provided.
* @return the index of the admin channel after the update (if not found, returns 0).
*/
suspend fun updateChannelSettings(channel: Channel) = channelSetRepository.updateChannelSettings(channel)
suspend fun updateChannelSettings(channel: Channel) = channelSetDataSource.updateChannelSettings(channel)
/** Flow representing the [LocalConfig] data store. */
val localConfigFlow: Flow<LocalConfig> = localConfigRepository.localConfigFlow
val localConfigFlow: Flow<LocalConfig> = localConfigDataSource.localConfigFlow
/** Clears the [LocalConfig] data in the data store. */
suspend fun clearLocalConfig() {
localConfigRepository.clearLocalConfig()
localConfigDataSource.clearLocalConfig()
}
/**
@ -140,16 +143,16 @@ constructor(
* @param config The [Config] to be set.
*/
suspend fun setLocalConfig(config: Config) {
localConfigRepository.setLocalConfig(config)
if (config.hasLora()) channelSetRepository.setLoraConfig(config.lora)
localConfigDataSource.setLocalConfig(config)
if (config.hasLora()) channelSetDataSource.setLoraConfig(config.lora)
}
/** Flow representing the [LocalModuleConfig] data store. */
val moduleConfigFlow: Flow<LocalModuleConfig> = moduleConfigRepository.moduleConfigFlow
val moduleConfigFlow: Flow<LocalModuleConfig> = moduleConfigDataSource.moduleConfigFlow
/** Clears the [LocalModuleConfig] data in the data store. */
suspend fun clearLocalModuleConfig() {
moduleConfigRepository.clearLocalModuleConfig()
moduleConfigDataSource.clearLocalModuleConfig()
}
/**
@ -158,7 +161,7 @@ constructor(
* @param config The [ModuleConfig] to be set.
*/
suspend fun setLocalModuleConfig(config: ModuleConfig) {
moduleConfigRepository.setLocalModuleConfig(config)
moduleConfigDataSource.setLocalModuleConfig(config)
}
/** Flow representing the combined [DeviceProfile] protobuf. */

View file

@ -1,22 +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 com.geeksville.mesh.repository.datastore.recentaddresses
import kotlinx.serialization.Serializable
@Serializable data class RecentAddress(val address: String, val name: String)

View file

@ -1,110 +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 com.geeksville.mesh.repository.datastore.recentaddresses
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.geeksville.mesh.android.Logging
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.json.JSONArray
import org.json.JSONObject
import org.meshtastic.core.strings.R
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecentAddressesRepository
@Inject
constructor(
@ApplicationContext private val context: Context,
private val dataStore: DataStore<Preferences>,
) : Logging {
private object PreferencesKeys {
val RECENT_IP_ADDRESSES = stringPreferencesKey("recent-ip-addresses")
}
val recentAddresses: Flow<List<RecentAddress>> =
dataStore.data.map { preferences ->
val jsonString = preferences[PreferencesKeys.RECENT_IP_ADDRESSES]
if (jsonString != null) {
try {
Json.decodeFromString<List<RecentAddress>>(jsonString)
} catch (e: IllegalArgumentException) {
warn("Could not parse recent addresses, falling back to legacy parsing: ${e.message}")
// Fallback to legacy parsing
parseLegacyRecentAddresses(jsonString)
} catch (e: SerializationException) {
warn("Could not parse recent addresses, falling back to legacy parsing: ${e.message}")
// Fallback to legacy parsing
parseLegacyRecentAddresses(jsonString)
}
} else {
emptyList()
}
}
private fun parseLegacyRecentAddresses(jsonAddresses: String): List<RecentAddress> {
val jsonArray = JSONArray(jsonAddresses)
return (0 until jsonArray.length()).mapNotNull { i ->
when (val item = jsonArray.get(i)) {
is JSONObject -> {
// Modern format: JSONObject with address and name
RecentAddress(address = item.getString("address"), name = item.getString("name"))
}
is String -> {
// Old format: just the address string
RecentAddress(address = item, name = context.getString(R.string.meshtastic))
}
else -> {
// Unknown format, log or handle as an error if necessary
warn("Unknown item type in recent IP addresses: $item")
null
}
}
}
}
suspend fun setRecentAddresses(addresses: List<RecentAddress>) {
dataStore.edit { preferences ->
preferences[PreferencesKeys.RECENT_IP_ADDRESSES] = Json.encodeToString(addresses)
}
}
suspend fun add(address: RecentAddress) {
val currentAddresses = recentAddresses.first()
val updatedList = mutableListOf(address)
currentAddresses.filterTo(updatedList) { it.address != address.address }
setRecentAddresses(updatedList.take(CACHE_CAPACITY))
}
suspend fun remove(address: String) {
val currentAddresses = recentAddresses.first()
val updatedList = currentAddresses.filter { it.address != address }
setRecentAddresses(updatedList)
}
}
private const val CACHE_CAPACITY = 3

View file

@ -44,15 +44,14 @@ import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
@Singleton
class MQTTRepository @Inject constructor(
private val radioConfigRepository: RadioConfigRepository,
) : Logging {
class MQTTRepository @Inject constructor(private val radioConfigRepository: RadioConfigRepository) : Logging {
companion object {
/**
* Quality of Service (QoS) levels in MQTT:
* - QoS 0: "at most once". Packets are sent once without validation if it has been received.
* - QoS 1: "at least once". Packets are sent and stored until the client receives confirmation from the server. MQTT ensures delivery, but duplicates may occur.
* - QoS 1: "at least once". Packets are sent and stored until the client receives confirmation from the server.
* MQTT ensures delivery, but duplicates may occur.
* - QoS 2: "exactly once". Similar to QoS 1, but with no duplicates.
*/
private const val DEFAULT_QOS = 1
@ -84,63 +83,72 @@ class MQTTRepository @Inject constructor(
val rootTopic = mqttConfig.root.ifEmpty { DEFAULT_TOPIC_ROOT }
val connectOptions = MqttConnectOptions().apply {
userName = mqttConfig.username
password = mqttConfig.password.toCharArray()
isAutomaticReconnect = true
if (mqttConfig.tlsEnabled) {
socketFactory = sslContext.socketFactory
}
}
val bufferOptions = DisconnectedBufferOptions().apply {
isBufferEnabled = true
bufferSize = 512
isPersistBuffer = false
isDeleteOldestMessages = true
}
val callback = object : MqttCallbackExtended {
override fun connectComplete(reconnect: Boolean, serverURI: String) {
info("MQTT connectComplete: $serverURI reconnect: $reconnect")
channelSet.subscribeList.ifEmpty { return }.forEach { globalId ->
subscribe("$rootTopic$DEFAULT_TOPIC_LEVEL$globalId/+")
if (mqttConfig.jsonEnabled) subscribe("$rootTopic$JSON_TOPIC_LEVEL$globalId/+")
val connectOptions =
MqttConnectOptions().apply {
userName = mqttConfig.username
password = mqttConfig.password.toCharArray()
isAutomaticReconnect = true
if (mqttConfig.tlsEnabled) {
socketFactory = sslContext.socketFactory
}
subscribe("$rootTopic${DEFAULT_TOPIC_LEVEL}PKI/+")
}
override fun connectionLost(cause: Throwable) {
info("MQTT connectionLost cause: $cause")
if (cause is IllegalArgumentException) close(cause)
val bufferOptions =
DisconnectedBufferOptions().apply {
isBufferEnabled = true
bufferSize = 512
isPersistBuffer = false
isDeleteOldestMessages = true
}
override fun messageArrived(topic: String, message: MqttMessage) {
trySend(mqttClientProxyMessage {
this.topic = topic
data = ByteString.copyFrom(message.payload)
retained = message.isRetained
})
}
val callback =
object : MqttCallbackExtended {
override fun connectComplete(reconnect: Boolean, serverURI: String) {
info("MQTT connectComplete: $serverURI reconnect: $reconnect")
channelSet.subscribeList
.ifEmpty {
return
}
.forEach { globalId ->
subscribe("$rootTopic$DEFAULT_TOPIC_LEVEL$globalId/+")
if (mqttConfig.jsonEnabled) subscribe("$rootTopic$JSON_TOPIC_LEVEL$globalId/+")
}
subscribe("$rootTopic${DEFAULT_TOPIC_LEVEL}PKI/+")
}
override fun deliveryComplete(token: IMqttDeliveryToken?) {
info("MQTT deliveryComplete messageId: ${token?.messageId}")
override fun connectionLost(cause: Throwable) {
info("MQTT connectionLost cause: $cause")
if (cause is IllegalArgumentException) close(cause)
}
override fun messageArrived(topic: String, message: MqttMessage) {
trySend(
mqttClientProxyMessage {
this.topic = topic
data = ByteString.copyFrom(message.payload)
retained = message.isRetained
},
)
}
override fun deliveryComplete(token: IMqttDeliveryToken?) {
info("MQTT deliveryComplete messageId: ${token?.messageId}")
}
}
}
val scheme = if (mqttConfig.tlsEnabled) "ssl" else "tcp"
val (host, port) = mqttConfig.address.ifEmpty { DEFAULT_SERVER_ADDRESS }
.split(":", limit = 2).let { it[0] to (it.getOrNull(1)?.toIntOrNull() ?: -1) }
val (host, port) =
mqttConfig.address
.ifEmpty { DEFAULT_SERVER_ADDRESS }
.split(":", limit = 2)
.let { it[0] to (it.getOrNull(1)?.toIntOrNull() ?: -1) }
mqttClient = MqttAsyncClient(
URI(scheme, null, host, port, "", "", "").toString(),
ownerId,
MemoryPersistence(),
).apply {
setCallback(callback)
setBufferOpts(bufferOptions)
connect(connectOptions)
}
mqttClient =
MqttAsyncClient(URI(scheme, null, host, port, "", "", "").toString(), ownerId, MemoryPersistence()).apply {
setCallback(callback)
setBufferOpts(bufferOptions)
connect(connectOptions)
}
awaitClose { disconnect() }
}