Move some repo classes to :core:data (#3214)

This commit is contained in:
Phil Oliver 2025-09-26 17:45:11 -04:00 committed by GitHub
parent af8e1daa5d
commit 3e83e61a1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 159 additions and 140 deletions

View file

@ -0,0 +1,33 @@
/*
* 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.data.datasource
import android.app.Application
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import org.meshtastic.core.model.NetworkDeviceHardware
import javax.inject.Inject
class DeviceHardwareJsonDataSource @Inject constructor(private val application: Application) {
@OptIn(ExperimentalSerializationApi::class)
fun loadDeviceHardwareFromJsonAsset(): List<NetworkDeviceHardware> {
val inputStream = application.assets.open("device_hardware.json")
return Json.decodeFromStream<List<NetworkDeviceHardware>>(inputStream)
}
}

View file

@ -0,0 +1,40 @@
/*
* 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.data.datasource
import dagger.Lazy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.dao.DeviceHardwareDao
import org.meshtastic.core.database.entity.DeviceHardwareEntity
import org.meshtastic.core.database.entity.asEntity
import org.meshtastic.core.model.NetworkDeviceHardware
import javax.inject.Inject
class DeviceHardwareLocalDataSource @Inject constructor(private val deviceHardwareDaoLazy: Lazy<DeviceHardwareDao>) {
private val deviceHardwareDao by lazy { deviceHardwareDaoLazy.get() }
suspend fun insertAllDeviceHardware(deviceHardware: List<NetworkDeviceHardware>) = withContext(Dispatchers.IO) {
deviceHardware.forEach { deviceHardware -> deviceHardwareDao.insert(deviceHardware.asEntity()) }
}
suspend fun deleteAllDeviceHardware() = withContext(Dispatchers.IO) { deviceHardwareDao.deleteAll() }
suspend fun getByHwModel(hwModel: Int): DeviceHardwareEntity? =
withContext(Dispatchers.IO) { deviceHardwareDao.getByHwModel(hwModel) }
}

View file

@ -0,0 +1,34 @@
/*
* 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.data.datasource
import android.app.Application
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import org.meshtastic.core.model.NetworkFirmwareReleases
import javax.inject.Inject
class FirmwareReleaseJsonDataSource @Inject constructor(private val application: Application) {
@OptIn(ExperimentalSerializationApi::class)
fun loadFirmwareReleaseFromJsonAsset(): NetworkFirmwareReleases {
val inputStream = application.assets.open("firmware_releases.json")
val result = inputStream.use { Json.decodeFromStream<NetworkFirmwareReleases>(inputStream) }
return result
}
}

View file

@ -0,0 +1,58 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.datasource
import dagger.Lazy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.dao.FirmwareReleaseDao
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.database.entity.asDeviceVersion
import org.meshtastic.core.database.entity.asEntity
import org.meshtastic.core.model.NetworkFirmwareRelease
import javax.inject.Inject
class FirmwareReleaseLocalDataSource @Inject constructor(private val firmwareReleaseDaoLazy: Lazy<FirmwareReleaseDao>) {
private val firmwareReleaseDao by lazy { firmwareReleaseDaoLazy.get() }
suspend fun insertFirmwareReleases(
firmwareReleases: List<NetworkFirmwareRelease>,
releaseType: FirmwareReleaseType,
) = withContext(Dispatchers.IO) {
if (firmwareReleases.isNotEmpty()) {
firmwareReleaseDao.deleteAll()
firmwareReleases.forEach { firmwareRelease ->
firmwareReleaseDao.insert(firmwareRelease.asEntity(releaseType))
}
}
}
suspend fun deleteAllFirmwareReleases() = withContext(Dispatchers.IO) { firmwareReleaseDao.deleteAll() }
suspend fun getLatestRelease(releaseType: FirmwareReleaseType): FirmwareReleaseEntity? =
withContext(Dispatchers.IO) {
val releases = firmwareReleaseDao.getReleasesByType(releaseType)
if (releases.isEmpty()) {
return@withContext null
} else {
val latestRelease = releases.maxBy { it.asDeviceVersion() }
return@withContext latestRelease
}
}
}

View file

@ -0,0 +1,110 @@
/*
* 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.data.repository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
import org.meshtastic.core.database.entity.DeviceHardwareEntity
import org.meshtastic.core.database.entity.asExternalModel
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
// Annotating with Singleton to ensure a single instance manages the cache
@Singleton
class DeviceHardwareRepository
@Inject
constructor(
private val remoteDataSource: DeviceHardwareRemoteDataSource,
private val localDataSource: DeviceHardwareLocalDataSource,
private val jsonDataSource: DeviceHardwareJsonDataSource,
) {
/**
* Retrieves device hardware information by its model ID.
*
* This function implements a cache-aside pattern with a fallback mechanism:
* 1. Check for a valid, non-expired local cache entry.
* 2. If not found or expired, fetch fresh data from the remote API.
* 3. If the remote fetch fails, attempt to use stale data from the cache.
* 4. If the cache is empty, fall back to loading data from a bundled JSON asset.
*
* @param hwModel The hardware model identifier.
* @param forceRefresh If true, the local cache will be invalidated and data will be fetched remotely.
* @return A [Result] containing the [DeviceHardware] on success (or null if not found), or an exception on failure.
*/
suspend fun getDeviceHardwareByModel(hwModel: Int, forceRefresh: Boolean = false): Result<DeviceHardware?> =
withContext(Dispatchers.IO) {
if (forceRefresh) {
localDataSource.deleteAllDeviceHardware()
} else {
// 1. Attempt to retrieve from cache first
val cachedEntity = localDataSource.getByHwModel(hwModel)
if (cachedEntity != null && !cachedEntity.isStale()) {
Timber.d("Using fresh cached device hardware for model $hwModel")
return@withContext Result.success(cachedEntity.asExternalModel())
}
}
// 2. Fetch from remote API
runCatching {
Timber.d("Fetching device hardware from remote API.")
val remoteHardware = remoteDataSource.getAllDeviceHardware()
localDataSource.insertAllDeviceHardware(remoteHardware)
localDataSource.getByHwModel(hwModel)?.asExternalModel()
}
.onSuccess {
// Successfully fetched and found the model
return@withContext Result.success(it)
}
.onFailure { e ->
Timber.w("Failed to fetch device hardware from server: ${e.message}")
// 3. Attempt to use stale cache as a fallback
val staleEntity = localDataSource.getByHwModel(hwModel)
if (staleEntity != null) {
Timber.d("Using stale cached device hardware for model $hwModel")
return@withContext Result.success(staleEntity.asExternalModel())
}
// 4. Fallback to bundled JSON if cache is empty
Timber.d("Cache is empty, falling back to bundled JSON asset.")
return@withContext loadFromBundledJson(hwModel)
}
}
private suspend fun loadFromBundledJson(hwModel: Int): Result<DeviceHardware?> = runCatching {
val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
localDataSource.insertAllDeviceHardware(jsonHardware)
localDataSource.getByHwModel(hwModel)?.asExternalModel()
}
/** Extension function to check if the cached entity is stale. */
private fun DeviceHardwareEntity.isStale(): Boolean =
(System.currentTimeMillis() - this.lastUpdated) > CACHE_EXPIRATION_TIME_MS
companion object {
private val CACHE_EXPIRATION_TIME_MS = TimeUnit.DAYS.toMillis(1)
}
}

View file

@ -0,0 +1,133 @@
/*
* 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.data.repository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource
import org.meshtastic.core.data.datasource.FirmwareReleaseLocalDataSource
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.FirmwareReleaseType
import org.meshtastic.core.database.entity.asExternalModel
import org.meshtastic.core.network.FirmwareReleaseRemoteDataSource
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class FirmwareReleaseRepository
@Inject
constructor(
private val remoteDataSource: FirmwareReleaseRemoteDataSource,
private val localDataSource: FirmwareReleaseLocalDataSource,
private val jsonDataSource: FirmwareReleaseJsonDataSource,
) {
/**
* A flow that provides the latest STABLE firmware release. It follows a "cache-then-network" strategy:
* 1. Immediately emits the cached version (if any).
* 2. If the cached version is stale, triggers a network fetch in the background.
* 3. Emits the updated version upon successful fetch. Collectors should use `.distinctUntilChanged()` to avoid
* redundant UI updates.
*/
val stableRelease: Flow<FirmwareRelease?> = getLatestFirmware(FirmwareReleaseType.STABLE)
/**
* A flow that provides the latest ALPHA firmware release.
*
* @see stableRelease for behavior details.
*/
val alphaRelease: Flow<FirmwareRelease?> = getLatestFirmware(FirmwareReleaseType.ALPHA)
private fun getLatestFirmware(
releaseType: FirmwareReleaseType,
forceRefresh: Boolean = false,
): Flow<FirmwareRelease?> = flow {
if (forceRefresh) {
invalidateCache()
}
// 1. Emit cached data first, regardless of staleness.
// This gives the UI something to show immediately.
val cachedRelease = localDataSource.getLatestRelease(releaseType)
cachedRelease?.let {
Timber.d("Emitting cached firmware for $releaseType (isStale=${it.isStale()})")
emit(it.asExternalModel())
}
// 2. If the cache was fresh and we are not forcing a refresh, we're done.
if (cachedRelease != null && !cachedRelease.isStale() && !forceRefresh) {
return@flow
}
// 3. Cache is stale, empty, or refresh is forced. Fetch new data.
updateCacheFromSources()
// 4. Emit the final, updated value from the cache.
// The `distinctUntilChanged()` operator on the collector side will prevent
// re-emitting the same data if the cache wasn't actually updated.
val finalRelease = localDataSource.getLatestRelease(releaseType)
Timber.d("Emitting final firmware for $releaseType from cache.")
emit(finalRelease?.asExternalModel())
}
/**
* Updates the local cache by fetching from the remote API, with a fallback to a bundled JSON asset if the remote
* fetch fails.
*
* This method is efficient because it fetches and caches all release types (stable, alpha, etc.) in a single
* operation.
*/
private suspend fun updateCacheFromSources() {
val remoteFetchSuccess =
runCatching {
Timber.d("Fetching fresh firmware releases from remote API.")
val networkReleases = remoteDataSource.getFirmwareReleases()
// The API fetches all release types, so we cache them all at once.
localDataSource.insertFirmwareReleases(networkReleases.releases.stable, FirmwareReleaseType.STABLE)
localDataSource.insertFirmwareReleases(networkReleases.releases.alpha, FirmwareReleaseType.ALPHA)
}
.isSuccess
// If remote fetch failed, try the JSON fallback as a last resort.
if (!remoteFetchSuccess) {
Timber.w("Remote fetch failed, attempting to cache from bundled JSON.")
runCatching {
val jsonReleases = jsonDataSource.loadFirmwareReleaseFromJsonAsset()
localDataSource.insertFirmwareReleases(jsonReleases.releases.stable, FirmwareReleaseType.STABLE)
localDataSource.insertFirmwareReleases(jsonReleases.releases.alpha, FirmwareReleaseType.ALPHA)
}
.onFailure { Timber.w("Failed to cache from JSON: ${it.message}") }
}
}
suspend fun invalidateCache() {
localDataSource.deleteAllFirmwareReleases()
}
/** Extension function to check if the cached entity is stale. */
private fun FirmwareReleaseEntity.isStale(): Boolean =
(System.currentTimeMillis() - this.lastUpdated) > CACHE_EXPIRATION_TIME_MS
companion object {
private val CACHE_EXPIRATION_TIME_MS = TimeUnit.HOURS.toMillis(1)
}
}

View file

@ -0,0 +1,148 @@
/*
* 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.data.repository
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.MeshProtos.MeshPacket
import com.geeksville.mesh.Portnums
import com.geeksville.mesh.TelemetryProtos.Telemetry
import dagger.Lazy
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.di.annotation.IoDispatcher
import javax.inject.Inject
@Suppress("TooManyFunctions")
class MeshLogRepository
@Inject
constructor(
private val meshLogDaoLazy: Lazy<MeshLogDao>,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) {
private val meshLogDao by lazy { meshLogDaoLazy.get() }
fun getAllLogs(maxItems: Int = MAX_ITEMS): Flow<List<MeshLog>> =
meshLogDao.getAllLogs(maxItems).flowOn(ioDispatcher).conflate()
fun getAllLogsInReceiveOrder(maxItems: Int = MAX_ITEMS): Flow<List<MeshLog>> =
meshLogDao.getAllLogsInReceiveOrder(maxItems).flowOn(ioDispatcher).conflate()
private fun parseTelemetryLog(log: MeshLog): Telemetry? = runCatching {
Telemetry.parseFrom(log.fromRadio.packet.decoded.payload)
.toBuilder()
.apply {
if (hasEnvironmentMetrics()) {
// Handle float metrics that default to 0.0f when not explicitly set or when 0.0f means no
// data
if (!environmentMetrics.hasTemperature()) {
environmentMetrics = environmentMetrics.toBuilder().setTemperature(Float.NaN).build()
}
if (!environmentMetrics.hasRelativeHumidity()) {
environmentMetrics =
environmentMetrics.toBuilder().setRelativeHumidity(Float.NaN).build()
}
if (!environmentMetrics.hasSoilTemperature()) {
environmentMetrics =
environmentMetrics.toBuilder().setSoilTemperature(Float.NaN).build()
}
if (!environmentMetrics.hasBarometricPressure()) {
environmentMetrics =
environmentMetrics.toBuilder().setBarometricPressure(Float.NaN).build()
}
if (!environmentMetrics.hasGasResistance()) {
environmentMetrics = environmentMetrics.toBuilder().setGasResistance(Float.NaN).build()
}
if (!environmentMetrics.hasVoltage()) {
environmentMetrics = environmentMetrics.toBuilder().setVoltage(Float.NaN).build()
}
if (!environmentMetrics.hasCurrent()) {
environmentMetrics = environmentMetrics.toBuilder().setCurrent(Float.NaN).build()
}
if (!environmentMetrics.hasLux()) {
environmentMetrics = environmentMetrics.toBuilder().setLux(Float.NaN).build()
}
if (!environmentMetrics.hasUvLux()) {
environmentMetrics = environmentMetrics.toBuilder().setUvLux(Float.NaN).build()
}
// Handle uint32 metrics that default to 0 when not explicitly set or when 0 means no data
if (!environmentMetrics.hasIaq()) {
environmentMetrics = environmentMetrics.toBuilder().setIaq(Int.MIN_VALUE).build()
}
if (!environmentMetrics.hasSoilMoisture()) {
environmentMetrics =
environmentMetrics.toBuilder().setSoilMoisture(Int.MIN_VALUE).build()
}
}
// Leaving in case we have need of nulling any in device metrics.
// if (hasDeviceMetrics()) {
// deviceMetrics =
// deviceMetrics.toBuilder().setBatteryLevel(Int.MIN_VALUE).build()
// }
}
.setTime((log.received_date / MILLIS_TO_SECONDS).toInt())
.build()
}
.getOrNull()
fun getTelemetryFrom(nodeNum: Int): Flow<List<Telemetry>> = meshLogDao
.getLogsFrom(nodeNum, Portnums.PortNum.TELEMETRY_APP_VALUE, MAX_MESH_PACKETS)
.distinctUntilChanged()
.mapLatest { list -> list.mapNotNull(::parseTelemetryLog) }
.flowOn(ioDispatcher)
fun getLogsFrom(
nodeNum: Int,
portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE,
maxItem: Int = MAX_MESH_PACKETS,
): Flow<List<MeshLog>> =
meshLogDao.getLogsFrom(nodeNum, portNum, maxItem).distinctUntilChanged().flowOn(ioDispatcher)
/*
* Retrieves MeshPackets matching 'nodeNum' and 'portNum'.
* If 'portNum' is not specified, returns all MeshPackets. Otherwise, filters by 'portNum'.
*/
fun getMeshPacketsFrom(nodeNum: Int, portNum: Int = Portnums.PortNum.UNKNOWN_APP_VALUE): Flow<List<MeshPacket>> =
getLogsFrom(nodeNum, portNum).mapLatest { list -> list.map { it.fromRadio.packet } }.flowOn(ioDispatcher)
fun getMyNodeInfo(): Flow<MeshProtos.MyNodeInfo?> = getLogsFrom(0, 0)
.mapLatest { list -> list.firstOrNull { it.myNodeInfo != null }?.myNodeInfo }
.flowOn(ioDispatcher)
suspend fun insert(log: MeshLog) = withContext(ioDispatcher) { meshLogDao.insert(log) }
suspend fun deleteAll() = withContext(ioDispatcher) { meshLogDao.deleteAll() }
suspend fun deleteLog(uuid: String) = withContext(ioDispatcher) { meshLogDao.deleteLog(uuid) }
suspend fun deleteLogs(nodeNum: Int, portNum: Int) =
withContext(ioDispatcher) { meshLogDao.deleteLogs(nodeNum, portNum) }
companion object {
private const val MAX_ITEMS = 500
private const val MAX_MESH_PACKETS = 10000
private const val MILLIS_TO_SECONDS = 1000
}
}

View file

@ -0,0 +1,157 @@
/*
* 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.data.repository
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
import com.geeksville.mesh.MeshProtos
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.dao.NodeInfoDao
import org.meshtastic.core.database.entity.MetadataEntity
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.entity.NodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.di.annotation.IoDispatcher
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.onlineTimeThreshold
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.collections.map
@Singleton
@Suppress("TooManyFunctions")
class NodeRepository
@Inject
constructor(
processLifecycle: Lifecycle,
private val nodeInfoDao: NodeInfoDao,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) {
// hardware info about our local device (can be null)
val myNodeInfo: StateFlow<MyNodeEntity?> =
nodeInfoDao
.getMyNodeInfo()
.flowOn(ioDispatcher)
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, null)
// our node info
private val _ourNodeInfo = MutableStateFlow<Node?>(null)
val ourNodeInfo: StateFlow<Node?>
get() = _ourNodeInfo
// The unique userId of our node
private val _myId = MutableStateFlow<String?>(null)
val myId: StateFlow<String?>
get() = _myId
fun getNodeDBbyNum() = nodeInfoDao.nodeDBbyNum().map { map -> map.mapValues { (_, it) -> it.toEntity() } }
// A map from nodeNum to Node
val nodeDBbyNum: StateFlow<Map<Int, Node>> =
nodeInfoDao
.nodeDBbyNum()
.mapLatest { map -> map.mapValues { (_, it) -> it.toModel() } }
.onEach {
val ourNodeInfo = it.values.firstOrNull()
_ourNodeInfo.value = ourNodeInfo
_myId.value = ourNodeInfo?.user?.id
}
.flowOn(ioDispatcher)
.conflate()
.stateIn(processLifecycle.coroutineScope, SharingStarted.Eagerly, emptyMap())
fun getNode(userId: String): Node = nodeDBbyNum.value.values.find { it.user.id == userId }
?: Node(num = DataPacket.idToDefaultNodeNum(userId) ?: 0, user = getUser(userId))
fun getUser(nodeNum: Int): MeshProtos.User = getUser(DataPacket.nodeNumToDefaultId(nodeNum))
fun getUser(userId: String): MeshProtos.User = nodeDBbyNum.value.values.find { it.user.id == userId }?.user
?: MeshProtos.User.newBuilder()
.setId(userId)
.setLongName("Meshtastic ${userId.takeLast(n = 4)}")
.setShortName(userId.takeLast(n = 4))
.setHwModel(MeshProtos.HardwareModel.UNSET)
.build()
fun getNodes(
sort: NodeSortOption = NodeSortOption.LAST_HEARD,
filter: String = "",
includeUnknown: Boolean = true,
onlyOnline: Boolean = false,
onlyDirect: Boolean = false,
) = nodeInfoDao
.getNodes(
sort = sort.sqlValue,
filter = filter,
includeUnknown = includeUnknown,
hopsAwayMax = if (onlyDirect) 0 else -1,
lastHeardMin = if (onlyOnline) onlineTimeThreshold() else -1,
)
.mapLatest { list -> list.map { it.toModel() } }
.flowOn(ioDispatcher)
.conflate()
suspend fun upsert(node: NodeEntity) = withContext(ioDispatcher) { nodeInfoDao.upsert(node) }
suspend fun installConfig(mi: MyNodeEntity, nodes: List<NodeEntity>) =
withContext(ioDispatcher) { nodeInfoDao.installConfig(mi, nodes) }
suspend fun clearNodeDB() = withContext(ioDispatcher) { nodeInfoDao.clearNodeInfo() }
suspend fun deleteNode(num: Int) = withContext(ioDispatcher) {
nodeInfoDao.deleteNode(num)
nodeInfoDao.deleteMetadata(num)
}
suspend fun deleteNodes(nodeNums: List<Int>) = withContext(ioDispatcher) {
nodeInfoDao.deleteNodes(nodeNums)
nodeNums.forEach { nodeInfoDao.deleteMetadata(it) }
}
suspend fun getNodesOlderThan(lastHeard: Int): List<NodeEntity> =
withContext(ioDispatcher) { nodeInfoDao.getNodesOlderThan(lastHeard) }
suspend fun getUnknownNodes(): List<NodeEntity> = withContext(ioDispatcher) { nodeInfoDao.getUnknownNodes() }
suspend fun insertMetadata(metadata: MetadataEntity) = withContext(ioDispatcher) { nodeInfoDao.upsert(metadata) }
val onlineNodeCount: Flow<Int> =
nodeInfoDao
.nodeDBbyNum()
.mapLatest { map -> map.values.count { it.node.lastHeard > onlineTimeThreshold() } }
.flowOn(ioDispatcher)
.conflate()
val totalNodeCount: Flow<Int> =
nodeInfoDao.nodeDBbyNum().mapLatest { map -> map.values.count() }.flowOn(ioDispatcher).conflate()
suspend fun setNodeNotes(num: Int, notes: String) =
withContext(ioDispatcher) { nodeInfoDao.setNodeNotes(num, notes) }
}

View file

@ -0,0 +1,104 @@
/*
* 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.data.repository
import com.geeksville.mesh.Portnums.PortNum
import dagger.Lazy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.dao.PacketDao
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.entity.Packet
import org.meshtastic.core.database.entity.ReactionEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MessageStatus
import javax.inject.Inject
class PacketRepository @Inject constructor(private val packetDaoLazy: Lazy<PacketDao>) {
private val packetDao by lazy { packetDaoLazy.get() }
fun getWaypoints(): Flow<List<Packet>> = packetDao.getAllPackets(PortNum.WAYPOINT_APP_VALUE)
fun getContacts(): Flow<Map<String, Packet>> = packetDao.getContactKeys()
suspend fun getMessageCount(contact: String): Int =
withContext(Dispatchers.IO) { packetDao.getMessageCount(contact) }
suspend fun getUnreadCount(contact: String): Int = withContext(Dispatchers.IO) { packetDao.getUnreadCount(contact) }
suspend fun clearUnreadCount(contact: String, timestamp: Long) =
withContext(Dispatchers.IO) { packetDao.clearUnreadCount(contact, timestamp) }
suspend fun getQueuedPackets(): List<DataPacket>? = withContext(Dispatchers.IO) { packetDao.getQueuedPackets() }
suspend fun insert(packet: Packet) = withContext(Dispatchers.IO) { packetDao.insert(packet) }
suspend fun getMessagesFrom(contact: String, getNode: suspend (String?) -> Node) = withContext(Dispatchers.IO) {
packetDao.getMessagesFrom(contact).mapLatest { packets ->
packets.map { packet ->
val message = packet.toMessage(getNode)
message.replyId
.takeIf { it != null && it != 0 }
?.let { getPacketByPacketId(it) }
?.toMessage(getNode)
?.let { originalMessage -> message.copy(originalMessage = originalMessage) } ?: message
}
}
}
suspend fun updateMessageStatus(d: DataPacket, m: MessageStatus) =
withContext(Dispatchers.IO) { packetDao.updateMessageStatus(d, m) }
suspend fun updateMessageId(d: DataPacket, id: Int) =
withContext(Dispatchers.IO) { packetDao.updateMessageId(d, id) }
suspend fun getPacketById(requestId: Int) = withContext(Dispatchers.IO) { packetDao.getPacketById(requestId) }
suspend fun getPacketByPacketId(packetId: Int) =
withContext(Dispatchers.IO) { packetDao.getPacketByPacketId(packetId) }
suspend fun deleteMessages(uuidList: List<Long>) = withContext(Dispatchers.IO) {
for (chunk in uuidList.chunked(500)) { // limit number of UUIDs per query
packetDao.deleteMessages(chunk)
}
}
suspend fun deleteContacts(contactList: List<String>) =
withContext(Dispatchers.IO) { packetDao.deleteContacts(contactList) }
suspend fun deleteWaypoint(id: Int) = withContext(Dispatchers.IO) { packetDao.deleteWaypoint(id) }
suspend fun delete(packet: Packet) = withContext(Dispatchers.IO) { packetDao.delete(packet) }
suspend fun update(packet: Packet) = withContext(Dispatchers.IO) { packetDao.update(packet) }
fun getContactSettings(): Flow<Map<String, ContactSettings>> = packetDao.getContactSettings()
suspend fun getContactSettings(contact: String) =
withContext(Dispatchers.IO) { packetDao.getContactSettings(contact) ?: ContactSettings(contact) }
suspend fun setMuteUntil(contacts: List<String>, until: Long) =
withContext(Dispatchers.IO) { packetDao.setMuteUntil(contacts, until) }
suspend fun insertReaction(reaction: ReactionEntity) = withContext(Dispatchers.IO) { packetDao.insert(reaction) }
suspend fun clearPacketDB() = withContext(Dispatchers.IO) { packetDao.deleteAll() }
}

View file

@ -0,0 +1,47 @@
/*
* 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.data.repository
import dagger.Lazy
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import org.meshtastic.core.database.dao.QuickChatActionDao
import org.meshtastic.core.database.entity.QuickChatAction
import org.meshtastic.core.di.annotation.IoDispatcher
import javax.inject.Inject
class QuickChatActionRepository
@Inject
constructor(
private val quickChatDaoLazy: Lazy<QuickChatActionDao>,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) {
private val quickChatActionDao by lazy { quickChatDaoLazy.get() }
fun getAllActions() = quickChatActionDao.getAll().flowOn(ioDispatcher)
suspend fun upsert(action: QuickChatAction) = withContext(ioDispatcher) { quickChatActionDao.upsert(action) }
suspend fun deleteAll() = withContext(ioDispatcher) { quickChatActionDao.deleteAll() }
suspend fun delete(action: QuickChatAction) = withContext(ioDispatcher) { quickChatActionDao.delete(action) }
suspend fun setItemPosition(uuid: Long, newPos: Int) =
withContext(ioDispatcher) { quickChatActionDao.updateActionPosition(uuid, newPos) }
}

View file

@ -0,0 +1,129 @@
/*
* 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.data.repository
import com.geeksville.mesh.AppOnlyProtos.ChannelSet
import com.geeksville.mesh.ChannelProtos.Channel
import com.geeksville.mesh.ChannelProtos.ChannelSettings
import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile
import com.geeksville.mesh.ConfigProtos.Config
import com.geeksville.mesh.LocalOnlyProtos.LocalConfig
import com.geeksville.mesh.LocalOnlyProtos.LocalModuleConfig
import com.geeksville.mesh.ModuleConfigProtos.ModuleConfig
import com.geeksville.mesh.deviceProfile
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import org.meshtastic.core.datastore.ChannelSetDataSource
import org.meshtastic.core.datastore.LocalConfigDataSource
import org.meshtastic.core.datastore.ModuleConfigDataSource
import org.meshtastic.core.model.util.getChannelUrl
import javax.inject.Inject
/**
* Class responsible for radio configuration data. Combines access to [nodeDB], [ChannelSet], [LocalConfig] &
* [LocalModuleConfig].
*/
class RadioConfigRepository
@Inject
constructor(
private val nodeDB: NodeRepository,
private val channelSetDataSource: ChannelSetDataSource,
private val localConfigDataSource: LocalConfigDataSource,
private val moduleConfigDataSource: ModuleConfigDataSource,
) {
/** Flow representing the [ChannelSet] data store. */
val channelSetFlow: Flow<ChannelSet> = channelSetDataSource.channelSetFlow
/** Clears the [ChannelSet] data in the data store. */
suspend fun clearChannelSet() {
channelSetDataSource.clearChannelSet()
}
/** Replaces the [ChannelSettings] list with a new [settingsList]. */
suspend fun replaceAllSettings(settingsList: List<ChannelSettings>) {
channelSetDataSource.clearSettings()
channelSetDataSource.addAllSettings(settingsList)
}
/**
* Updates the [ChannelSettings] list with the provided channel and returns the index of the admin channel after the
* update (if not found, returns 0).
*
* @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) = channelSetDataSource.updateChannelSettings(channel)
/** Flow representing the [LocalConfig] data store. */
val localConfigFlow: Flow<LocalConfig> = localConfigDataSource.localConfigFlow
/** Clears the [LocalConfig] data in the data store. */
suspend fun clearLocalConfig() {
localConfigDataSource.clearLocalConfig()
}
/**
* Updates [LocalConfig] from each [Config] oneOf.
*
* @param config The [Config] to be set.
*/
suspend fun setLocalConfig(config: Config) {
localConfigDataSource.setLocalConfig(config)
if (config.hasLora()) channelSetDataSource.setLoraConfig(config.lora)
}
/** Flow representing the [LocalModuleConfig] data store. */
val moduleConfigFlow: Flow<LocalModuleConfig> = moduleConfigDataSource.moduleConfigFlow
/** Clears the [LocalModuleConfig] data in the data store. */
suspend fun clearLocalModuleConfig() {
moduleConfigDataSource.clearLocalModuleConfig()
}
/**
* Updates [LocalModuleConfig] from each [ModuleConfig] oneOf.
*
* @param config The [ModuleConfig] to be set.
*/
suspend fun setLocalModuleConfig(config: ModuleConfig) {
moduleConfigDataSource.setLocalModuleConfig(config)
}
/** Flow representing the combined [DeviceProfile] protobuf. */
val deviceProfileFlow: Flow<DeviceProfile> =
combine(nodeDB.ourNodeInfo, channelSetFlow, localConfigFlow, moduleConfigFlow) {
node,
channels,
localConfig,
localModuleConfig,
->
deviceProfile {
node?.user?.let {
longName = it.longName
shortName = it.shortName
}
channelUrl = channels.getChannelUrl().toString()
config = localConfig
moduleConfig = localModuleConfig
if (node != null && localConfig.position.fixedPosition) {
fixedPosition = node.position
}
}
}
}