From 90d937f89418e84e90f42ff21ef7a8f94a7f0562 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Tue, 22 Jul 2025 15:28:06 -0500 Subject: [PATCH] refactor(BTScanModel): migrate recent IP addresses to DataStore (#2507) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../com/geeksville/mesh/model/BTScanModel.kt | 325 ++++++++---------- .../repository/datastore/DataStoreModule.kt | 71 ++-- .../recentaddresses/RecentAddress.kt | 22 ++ .../RecentAddressesRepository.kt | 117 +++++++ 4 files changed, 340 insertions(+), 195 deletions(-) create mode 100644 app/src/main/java/com/geeksville/mesh/repository/datastore/recentaddresses/RecentAddress.kt create mode 100644 app/src/main/java/com/geeksville/mesh/repository/datastore/recentaddresses/RecentAddressesRepository.kt diff --git a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt index 384cfe6d8..028af4acd 100644 --- a/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/BTScanModel.kt @@ -21,16 +21,16 @@ import android.annotation.SuppressLint import android.app.Application import android.bluetooth.BluetoothDevice import android.content.Context -import android.content.SharedPreferences import android.hardware.usb.UsbManager import android.os.RemoteException -import androidx.core.content.edit import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.geeksville.mesh.R 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 @@ -41,6 +41,7 @@ import com.geeksville.mesh.service.ServiceRepository import com.geeksville.mesh.util.anonymize import com.hoho.android.usbserial.driver.UsbSerialDriver import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -52,13 +53,13 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn -import org.json.JSONArray -import org.json.JSONObject -import javax.inject.Inject +import kotlinx.coroutines.launch @HiltViewModel @Suppress("LongParameterList", "TooManyFunctions") -class BTScanModel @Inject constructor( +class BTScanModel +@Inject +constructor( private val application: Application, private val serviceRepository: ServiceRepository, private val bluetoothRepository: BluetoothRepository, @@ -66,120 +67,133 @@ class BTScanModel @Inject constructor( private val usbManagerLazy: dagger.Lazy, private val networkRepository: NetworkRepository, private val radioInterfaceService: RadioInterfaceService, - private val preferences: SharedPreferences, + private val recentAddressesRepository: RecentAddressesRepository, ) : ViewModel(), Logging { + private val context: Context + get() = application.applicationContext - private val context: Context get() = application.applicationContext val devices = MutableLiveData>(mutableMapOf()) val errorText = MutableLiveData(null) - private val recentIpAddresses = MutableStateFlow(getRecentAddresses()) + private val recentIpAddresses = recentAddressesRepository.recentAddresses private val showMockInterface: StateFlow - get() = - MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow() + get() = MutableStateFlow(radioInterfaceService.isMockInterface()).asStateFlow() init { combine( - bluetoothRepository.state, - networkRepository.resolvedList, - recentIpAddresses.asStateFlow(), - usbRepository.serialDevicesWithDrivers, - showMockInterface, - ) { ble, tcp, recent, usb, showMockInterface -> - devices.value = mutableMapOf().apply { - fun addDevice(entry: DeviceListEntry) { - this[entry.fullAddress] = entry - } + bluetoothRepository.state, + networkRepository.resolvedList, + recentIpAddresses, + usbRepository.serialDevicesWithDrivers, + showMockInterface, + ) { ble, tcp, recent, usb, showMockInterface -> + devices.value = + mutableMapOf().apply { + fun addDevice(entry: DeviceListEntry) { + this[entry.fullAddress] = entry + } - // Include a placeholder for "None" - addDevice( - DeviceListEntry( - context.getString(R.string.none), - NO_DEVICE_SELECTED, - true - ) - ) + // Include a placeholder for "None" + addDevice( + DeviceListEntry( + context.getString(R.string.none), + NO_DEVICE_SELECTED, + true, + ) + ) - if (showMockInterface) { - addDevice(DeviceListEntry("Demo Mode", "m", true)) - } + if (showMockInterface) { + addDevice(DeviceListEntry("Demo Mode", "m", true)) + } - // Include paired Bluetooth devices - ble.bondedDevices.map(::BLEDeviceListEntry).sortedBy { it.name } - .forEach(::addDevice) + // Include paired Bluetooth devices + ble.bondedDevices + .map(::BLEDeviceListEntry) + .sortedBy { it.name } + .forEach(::addDevice) - // Include Network Service Discovery - tcp.forEach { service -> - val address = service.toAddressString() - val txtRecords = service.attributes // Map - val shortNameBytes = txtRecords["shortname"] - val idBytes = txtRecords["id"] + // Include Network Service Discovery + tcp.forEach { service -> + val address = service.toAddressString() + val txtRecords = service.attributes // Map + val shortNameBytes = txtRecords["shortname"] + val idBytes = txtRecords["id"] - val shortName = shortNameBytes?.let { String(it, Charsets.UTF_8) } - ?: context.getString(R.string.meshtastic) - val deviceId = - idBytes?.let { String(it, Charsets.UTF_8) }?.replace("!", "") - var displayName = shortName - if (deviceId != null) { - displayName += "_$deviceId" + val shortName = + shortNameBytes?.let { String(it, Charsets.UTF_8) } + ?: context.getString(R.string.meshtastic) + val deviceId = + idBytes?.let { String(it, Charsets.UTF_8) }?.replace("!", "") + var displayName = shortName + if (deviceId != null) { + displayName += "_$deviceId" + } + addDevice(DeviceListEntry(displayName, "t$address", true)) + } + + // Include saved IP connections + recent.forEach { addDevice(DeviceListEntry(it.name, it.address, true)) } + + usb.forEach { (_, d) -> + addDevice( + USBDeviceListEntry(radioInterfaceService, usbManagerLazy.get(), d) + ) + } } - addDevice(DeviceListEntry(displayName, "t$address", true)) - } - - // Include saved IP connections - recent.forEach { (address, name) -> - addDevice(DeviceListEntry(name, address, true)) - } - - usb.forEach { (_, d) -> - addDevice(USBDeviceListEntry(radioInterfaceService, usbManagerLazy.get(), d)) - } } - }.launchIn(viewModelScope) - - serviceRepository.statusMessage - .onEach { errorText.value = it } .launchIn(viewModelScope) + serviceRepository.statusMessage.onEach { errorText.value = it }.launchIn(viewModelScope) + debug("BTScanModel created") } - /** - * @param fullAddress Interface [prefix] + [address] (example: "x7C:9E:BD:F0:BE:BE") - */ + /** @param fullAddress Interface [prefix] + [address] (example: "x7C:9E:BD:F0:BE:BE") */ open class DeviceListEntry(val name: String, val fullAddress: String, val bonded: Boolean) { - val prefix get() = fullAddress[0] - val address get() = fullAddress.substring(1) + val prefix + get() = fullAddress[0] - override fun toString(): String { - return "DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded)" - } + val address + get() = fullAddress.substring(1) - val isBLE: Boolean get() = prefix == 'x' - val isUSB: Boolean get() = prefix == 's' - val isTCP: Boolean get() = prefix == 't' + override fun toString(): String = + "DeviceListEntry(name=${name.anonymize}, addr=${address.anonymize}, bonded=$bonded)" - val isMock: Boolean get() = prefix == 'm' - val isDisconnect: Boolean get() = prefix == 'n' + val isBLE: Boolean + get() = prefix == 'x' + + val isUSB: Boolean + get() = prefix == 's' + + val isTCP: Boolean + get() = prefix == 't' + + val isMock: Boolean + get() = prefix == 'm' + + val isDisconnect: Boolean + get() = prefix == 'n' } @SuppressLint("MissingPermission") - class BLEDeviceListEntry(device: BluetoothDevice) : DeviceListEntry( - device.name ?: "unnamed-${device.address}", // some devices might not have a name - "x${device.address}", - device.bondState == BluetoothDevice.BOND_BONDED - ) + class BLEDeviceListEntry(device: BluetoothDevice) : + DeviceListEntry( + device.name ?: "unnamed-${device.address}", // some devices might not have a name + "x${device.address}", + device.bondState == BluetoothDevice.BOND_BONDED, + ) class USBDeviceListEntry( radioInterfaceService: RadioInterfaceService, usbManager: UsbManager, val usb: UsbSerialDriver, - ) : DeviceListEntry( - usb.device.deviceName, - radioInterfaceService.toInterfaceAddress(InterfaceId.SERIAL, usb.device.deviceName), - usbManager.hasPermission(usb.device), - ) + ) : + DeviceListEntry( + usb.device.deviceName, + radioInterfaceService.toInterfaceAddress(InterfaceId.SERIAL, usb.device.deviceName), + usbManager.hasPermission(usb.device), + ) override fun onCleared() { super.onCleared() @@ -194,13 +208,14 @@ class BTScanModel @Inject constructor( val selectedAddressFlow: StateFlow = radioInterfaceService.currentDeviceAddressFlow - val selectedNotNullFlow: StateFlow = selectedAddressFlow - .map { it ?: NO_DEVICE_SELECTED } - .stateIn( - viewModelScope, - SharingStarted.WhileSubscribed(SHARING_STARTED_TIMEOUT_MS), - selectedAddressFlow.value ?: NO_DEVICE_SELECTED - ) + val selectedNotNullFlow: StateFlow = + selectedAddressFlow + .map { it ?: NO_DEVICE_SELECTED } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(SHARING_STARTED_TIMEOUT_MS), + selectedAddressFlow.value ?: NO_DEVICE_SELECTED, + ) val scanResult = MutableLiveData>(mutableMapOf()) @@ -215,7 +230,9 @@ class BTScanModel @Inject constructor( try { scanJob?.cancel() } catch (ex: Throwable) { - warn("Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}") + warn( + "Ignoring error stopping scan, probably BT adapter was disabled suddenly: ${ex.message}" + ) } finally { scanJob = null } @@ -228,25 +245,32 @@ class BTScanModel @Inject constructor( debug("starting classic scan") _spinner.value = true - scanJob = bluetoothRepository.scan() - .onEach { result -> - val fullAddress = radioInterfaceService.toInterfaceAddress( - InterfaceId.BLUETOOTH, - result.device.address - ) - // prevent log spam because we'll get lots of redundant scan results - val isBonded = result.device.bondState == BluetoothDevice.BOND_BONDED - val oldDevs = scanResult.value!! - val oldEntry = oldDevs[fullAddress] - // Don't spam the GUI with endless updates for non changing nodes - if (oldEntry == null || oldEntry.bonded != isBonded) { - val entry = DeviceListEntry(result.device.name, fullAddress, isBonded) - oldDevs[entry.fullAddress] = entry - scanResult.value = oldDevs + scanJob = + bluetoothRepository + .scan() + .onEach { result -> + val fullAddress = + radioInterfaceService.toInterfaceAddress( + InterfaceId.BLUETOOTH, + result.device.address, + ) + // prevent log spam because we'll get lots of redundant scan results + val isBonded = result.device.bondState == BluetoothDevice.BOND_BONDED + val oldDevs = scanResult.value!! + val oldEntry = oldDevs[fullAddress] + // Don't spam the GUI with endless updates for non changing nodes + if (oldEntry == null || oldEntry.bonded != isBonded) { + val entry = DeviceListEntry(result.device.name, fullAddress, isBonded) + oldDevs[entry.fullAddress] = entry + scanResult.value = oldDevs + } } - }.catch { ex -> - serviceRepository.setErrorMessage("Unexpected Bluetooth scan failure: ${ex.message}") - }.launchIn(viewModelScope) + .catch { ex -> + serviceRepository.setErrorMessage( + "Unexpected Bluetooth scan failure: ${ex.message}" + ) + } + .launchIn(viewModelScope) } private fun changeDeviceAddress(address: String) { @@ -266,7 +290,8 @@ class BTScanModel @Inject constructor( val device = bluetoothRepository.getRemoteDevice(it.address) ?: return info("Starting bonding for ${device.anonymize}") - bluetoothRepository.createBond(device) + bluetoothRepository + .createBond(device) .onEach { state -> debug("Received bond state changed $state") if (state != BluetoothDevice.BOND_BONDING) { @@ -278,14 +303,17 @@ class BTScanModel @Inject constructor( setErrorText(context.getString(R.string.pairing_failed_try_again)) } } - }.catch { ex -> + } + .catch { ex -> // We ignore missing BT adapters, because it lets us run on the emulator warn("Failed creating Bluetooth bond: ${ex.message}") - }.launchIn(viewModelScope) + } + .launchIn(viewModelScope) } private fun requestPermission(it: USBDeviceListEntry) { - usbRepository.requestPermission(it.usb.device) + usbRepository + .requestPermission(it.usb.device) .onEach { granted -> if (granted) { info("User approved USB access") @@ -293,71 +321,21 @@ class BTScanModel @Inject constructor( } else { errormsg("USB permission denied for device ${it.address}") } - }.launchIn(viewModelScope) - } - - private fun getRecentAddresses(): List> { - val jsonAddresses = preferences.getString("recent-ip-addresses", "[]") ?: "[]" - val jsonArray = JSONArray(jsonAddresses) - var needsMigration = false - - val listAddresses = (0 until jsonArray.length()).mapNotNull { i -> - when (val item = jsonArray.get(i)) { - is JSONObject -> { - // Modern format: JSONObject with address and name - item.getString("address") to item.getString("name") - } - - is String -> { - // Old format: just the address string - needsMigration = true - item to context.getString(R.string.meshtastic) // [3] - } - - else -> { - // Unknown format, log or handle as an error if necessary - warn("Unknown item type in recent IP addresses: $item") - null - } } - } - - // If migration was needed for any item, rewrite the entire list in the new format - if (needsMigration) { - setRecentAddresses(listAddresses) - } - return listAddresses - } - - private fun setRecentAddresses(addresses: List>) { - val jsonArray = JSONArray() - addresses.forEach { (address, name) -> - val obj = JSONObject() - obj.put("address", address) - obj.put("name", name) - jsonArray.put(obj) - } - preferences.edit { - putString("recent-ip-addresses", jsonArray.toString()) - } - recentIpAddresses.value = addresses + .launchIn(viewModelScope) } // Remove 'name' parameter from addRecentAddress and related logic fun addRecentAddress(address: String, overrideName: String? = null) { if (!address.startsWith("t")) return - val existingItems = getRecentAddresses() - val updatedList = mutableListOf>() - val displayName = overrideName ?: context.getString(R.string.meshtastic) - updatedList.add(address to displayName) - updatedList.addAll(existingItems.filter { it.first != address }.take(2)) - setRecentAddresses(updatedList) + viewModelScope.launch { + val displayName = overrideName ?: context.getString(R.string.meshtastic) + recentAddressesRepository.add(RecentAddress(address, displayName)) + } } fun removeRecentAddress(address: String) { - val existingItems = getRecentAddresses() - val updatedList = existingItems.filter { it.first != address } - setRecentAddresses(updatedList) + viewModelScope.launch { recentAddressesRepository.remove(address) } } // Called by the GUI when a new device has been selected by the user @@ -385,7 +363,8 @@ class BTScanModel @Inject constructor( } private val _spinner = MutableStateFlow(false) - val spinner: StateFlow get() = _spinner.asStateFlow() + val spinner: StateFlow + get() = _spinner.asStateFlow() // Add a new property to hold the connected node's long name var connectedNodeLongName: String? = null diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/DataStoreModule.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/DataStoreModule.kt index 705b38d0d..0ab3f112e 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/datastore/DataStoreModule.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/DataStoreModule.kt @@ -22,59 +22,86 @@ 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 -import javax.inject.Singleton + +private const val USER_PREFERENCES_NAME = "user_preferences" @InstallIn(SingletonComponent::class) @Module object DataStoreModule { + @Singleton + @Provides + fun providePreferencesDataStore( + @ApplicationContext appContext: Context + ): DataStore = + 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 provideLocalConfigDataStore(@ApplicationContext appContext: Context): DataStore { - return DataStoreFactory.create( + fun provideRecentAddressesRepository( + @ApplicationContext context: Context, + dataStore: DataStore, + ): RecentAddressesRepository = RecentAddressesRepository(context, dataStore) + + @Singleton + @Provides + fun provideLocalConfigDataStore( + @ApplicationContext appContext: Context + ): DataStore = + DataStoreFactory.create( serializer = LocalConfigSerializer, produceFile = { appContext.dataStoreFile("local_config.pb") }, - corruptionHandler = ReplaceFileCorruptionHandler( - produceNewData = { LocalConfig.getDefaultInstance() } - ), - scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + corruptionHandler = + ReplaceFileCorruptionHandler(produceNewData = { LocalConfig.getDefaultInstance() }), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), ) - } @Singleton @Provides - fun provideModuleConfigDataStore(@ApplicationContext appContext: Context): DataStore { - return DataStoreFactory.create( + fun provideModuleConfigDataStore( + @ApplicationContext appContext: Context + ): DataStore = + DataStoreFactory.create( serializer = ModuleConfigSerializer, produceFile = { appContext.dataStoreFile("module_config.pb") }, - corruptionHandler = ReplaceFileCorruptionHandler( - produceNewData = { LocalModuleConfig.getDefaultInstance() } - ), - scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + corruptionHandler = + ReplaceFileCorruptionHandler( + produceNewData = { LocalModuleConfig.getDefaultInstance() } + ), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), ) - } @Singleton @Provides - fun provideChannelSetDataStore(@ApplicationContext appContext: Context): DataStore { - return DataStoreFactory.create( + fun provideChannelSetDataStore(@ApplicationContext appContext: Context): DataStore = + DataStoreFactory.create( serializer = ChannelSetSerializer, produceFile = { appContext.dataStoreFile("channel_set.pb") }, - corruptionHandler = ReplaceFileCorruptionHandler( - produceNewData = { ChannelSet.getDefaultInstance() } - ), - scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + corruptionHandler = + ReplaceFileCorruptionHandler(produceNewData = { ChannelSet.getDefaultInstance() }), + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()), ) - } } diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/recentaddresses/RecentAddress.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/recentaddresses/RecentAddress.kt new file mode 100644 index 000000000..e5c6039e4 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/recentaddresses/RecentAddress.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.repository.datastore.recentaddresses + +import kotlinx.serialization.Serializable + +@Serializable data class RecentAddress(val address: String, val name: String) diff --git a/app/src/main/java/com/geeksville/mesh/repository/datastore/recentaddresses/RecentAddressesRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/datastore/recentaddresses/RecentAddressesRepository.kt new file mode 100644 index 000000000..efd2c6708 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/repository/datastore/recentaddresses/RecentAddressesRepository.kt @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2025 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.geeksville.mesh.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.R +import com.geeksville.mesh.android.Logging +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +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 + +@Singleton +class RecentAddressesRepository +@Inject +constructor( + @ApplicationContext private val context: Context, + private val dataStore: DataStore, +) : Logging { + private object PreferencesKeys { + val RECENT_IP_ADDRESSES = stringPreferencesKey("recent-ip-addresses") + } + + val recentAddresses: Flow> = + dataStore.data.map { preferences -> + val jsonString = preferences[PreferencesKeys.RECENT_IP_ADDRESSES] + if (jsonString != null) { + try { + Json.decodeFromString>(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 { + 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) { + 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