mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(BTScanModel): migrate recent IP addresses to DataStore (#2507)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
d3990f216e
commit
90d937f894
4 changed files with 340 additions and 195 deletions
|
|
@ -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<UsbManager>,
|
||||
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<MutableMap<String, DeviceListEntry>>(mutableMapOf())
|
||||
val errorText = MutableLiveData<String?>(null)
|
||||
|
||||
private val recentIpAddresses = MutableStateFlow(getRecentAddresses())
|
||||
private val recentIpAddresses = recentAddressesRepository.recentAddresses
|
||||
|
||||
private val showMockInterface: StateFlow<Boolean>
|
||||
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<String, DeviceListEntry>().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<String, DeviceListEntry>().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<String, ByteArray?>
|
||||
val shortNameBytes = txtRecords["shortname"]
|
||||
val idBytes = txtRecords["id"]
|
||||
// Include Network Service Discovery
|
||||
tcp.forEach { service ->
|
||||
val address = service.toAddressString()
|
||||
val txtRecords = service.attributes // Map<String, ByteArray?>
|
||||
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<String?> = radioInterfaceService.currentDeviceAddressFlow
|
||||
|
||||
val selectedNotNullFlow: StateFlow<String> = selectedAddressFlow
|
||||
.map { it ?: NO_DEVICE_SELECTED }
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(SHARING_STARTED_TIMEOUT_MS),
|
||||
selectedAddressFlow.value ?: NO_DEVICE_SELECTED
|
||||
)
|
||||
val selectedNotNullFlow: StateFlow<String> =
|
||||
selectedAddressFlow
|
||||
.map { it ?: NO_DEVICE_SELECTED }
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(SHARING_STARTED_TIMEOUT_MS),
|
||||
selectedAddressFlow.value ?: NO_DEVICE_SELECTED,
|
||||
)
|
||||
|
||||
val scanResult = MutableLiveData<MutableMap<String, DeviceListEntry>>(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<Pair<String, String>> {
|
||||
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<Pair<String, String>>) {
|
||||
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<Pair<String, String>>()
|
||||
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<Boolean> get() = _spinner.asStateFlow()
|
||||
val spinner: StateFlow<Boolean>
|
||||
get() = _spinner.asStateFlow()
|
||||
|
||||
// Add a new property to hold the connected node's long name
|
||||
var connectedNodeLongName: String? = null
|
||||
|
|
|
|||
|
|
@ -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<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 provideLocalConfigDataStore(@ApplicationContext appContext: Context): DataStore<LocalConfig> {
|
||||
return DataStoreFactory.create(
|
||||
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())
|
||||
corruptionHandler =
|
||||
ReplaceFileCorruptionHandler(produceNewData = { LocalConfig.getDefaultInstance() }),
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideModuleConfigDataStore(@ApplicationContext appContext: Context): DataStore<LocalModuleConfig> {
|
||||
return DataStoreFactory.create(
|
||||
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())
|
||||
corruptionHandler =
|
||||
ReplaceFileCorruptionHandler(
|
||||
produceNewData = { LocalModuleConfig.getDefaultInstance() }
|
||||
),
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideChannelSetDataStore(@ApplicationContext appContext: Context): DataStore<ChannelSet> {
|
||||
return DataStoreFactory.create(
|
||||
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())
|
||||
corruptionHandler =
|
||||
ReplaceFileCorruptionHandler(produceNewData = { ChannelSet.getDefaultInstance() }),
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <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)
|
||||
|
|
@ -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 <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.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<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
|
||||
Loading…
Add table
Add a link
Reference in a new issue