mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-14 09:46:48 -04:00
feat(api): add hasAnyEntries method to local data sources and improve… (#5406)
This commit is contained in:
@@ -45,4 +45,6 @@ class DeviceHardwareLocalDataSource(
|
||||
|
||||
suspend fun getByModelAndTarget(hwModel: Int, target: String): DeviceHardwareEntity? =
|
||||
withContext(dispatchers.io) { deviceHardwareDao.getByModelAndTarget(hwModel, target) }
|
||||
|
||||
suspend fun hasAnyEntries(): Boolean = withContext(dispatchers.io) { deviceHardwareDao.count() > 0 }
|
||||
}
|
||||
|
||||
@@ -55,4 +55,6 @@ class FirmwareReleaseLocalDataSource(
|
||||
return@withContext latestRelease
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun hasAnyEntries(): Boolean = withContext(dispatchers.io) { firmwareReleaseDao.count() > 0 }
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ import org.meshtastic.core.model.util.TimeConstants
|
||||
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
|
||||
import org.meshtastic.core.repository.DeviceHardwareRepository
|
||||
|
||||
// Annotating with Singleton to ensure a single instance manages the cache
|
||||
@Single
|
||||
class DeviceHardwareRepositoryImpl(
|
||||
private val remoteDataSource: DeviceHardwareRemoteDataSource,
|
||||
@@ -46,18 +45,11 @@ class DeviceHardwareRepositoryImpl(
|
||||
/**
|
||||
* Retrieves device hardware information by its model ID and optional target string.
|
||||
*
|
||||
* 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 target Optional PlatformIO target environment name to disambiguate multiple variants.
|
||||
* @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.
|
||||
* Pipeline:
|
||||
* 1. If the local DB is empty, seed it from the bundled JSON asset (instant baseline).
|
||||
* 2. If the cached entry is stale or missing, refresh from the remote API.
|
||||
* 3. Return the best available data from the DB.
|
||||
*/
|
||||
@Suppress("LongMethod", "detekt:CyclomaticComplexMethod")
|
||||
override suspend fun getDeviceHardwareByModel(
|
||||
hwModel: Int,
|
||||
target: String?,
|
||||
@@ -71,146 +63,65 @@ class DeviceHardwareRepositoryImpl(
|
||||
val quirks = loadQuirks()
|
||||
|
||||
if (forceRefresh) {
|
||||
Logger.d { "DeviceHardwareRepository: forceRefresh=true, clearing local device hardware cache" }
|
||||
Logger.d { "DeviceHardwareRepository: forceRefresh=true, clearing cache" }
|
||||
localDataSource.deleteAllDeviceHardware()
|
||||
} else {
|
||||
// 1. Attempt to retrieve from cache first
|
||||
var cachedEntities = localDataSource.getByHwModel(hwModel)
|
||||
|
||||
// Fallback to target-only lookup if hwModel-based lookup yielded nothing
|
||||
if (cachedEntities.isEmpty() && target != null) {
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: no cache for hwModel=$hwModel, trying target lookup for $target"
|
||||
}
|
||||
val byTarget = localDataSource.getByTarget(target)
|
||||
if (byTarget != null) {
|
||||
cachedEntities = listOf(byTarget)
|
||||
}
|
||||
}
|
||||
|
||||
if (cachedEntities.isNotEmpty() && cachedEntities.all { !it.isStale() }) {
|
||||
Logger.d { "DeviceHardwareRepository: using fresh cached device hardware for hwModel=$hwModel" }
|
||||
val matched = disambiguate(cachedEntities, target)
|
||||
return@withContext Result.success(
|
||||
applyBootloaderQuirk(hwModel, matched?.asExternalModel(), quirks, target),
|
||||
)
|
||||
}
|
||||
Logger.d { "DeviceHardwareRepository: no fresh cache for hwModel=$hwModel, attempting remote fetch" }
|
||||
}
|
||||
|
||||
// 2. Fetch from remote API
|
||||
// 1. Seed from bundled JSON if the DB is completely empty (first launch or post-wipe).
|
||||
if (!localDataSource.hasAnyEntries()) {
|
||||
seedCacheFromBundledJson()
|
||||
}
|
||||
|
||||
// 2. Check cache; refresh from network if stale, empty, or forced.
|
||||
var entities = lookupEntities(hwModel, target)
|
||||
if (forceRefresh || entities.isEmpty() || entities.any { it.isStale() }) {
|
||||
refreshFromNetwork()
|
||||
entities = lookupEntities(hwModel, target)
|
||||
}
|
||||
|
||||
// 3. Resolve and return the best available data.
|
||||
val matched = disambiguate(entities, target)
|
||||
Result.success(applyBootloaderQuirk(hwModel, matched?.asExternalModel(), quirks, target))
|
||||
}
|
||||
|
||||
/** Looks up entities by hwModel, falling back to a target-only lookup when needed. */
|
||||
private suspend fun lookupEntities(hwModel: Int, target: String?): List<DeviceHardwareEntity> =
|
||||
localDataSource.getByHwModel(hwModel).ifEmpty {
|
||||
target?.let { listOfNotNull(localDataSource.getByTarget(it)) } ?: emptyList()
|
||||
}
|
||||
|
||||
private suspend fun seedCacheFromBundledJson() {
|
||||
safeCatching {
|
||||
Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" }
|
||||
Logger.d { "DeviceHardwareRepository: seeding cache from bundled JSON" }
|
||||
val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
|
||||
localDataSource.insertAllDeviceHardware(jsonHardware)
|
||||
}
|
||||
.onFailure { e -> Logger.w(e) { "DeviceHardwareRepository: failed to seed cache from bundled JSON" } }
|
||||
}
|
||||
|
||||
private suspend fun refreshFromNetwork() {
|
||||
safeCatching {
|
||||
Logger.d { "DeviceHardwareRepository: fetching from remote API" }
|
||||
val remoteHardware = remoteDataSource.getAllDeviceHardware()
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: remote API returned ${remoteHardware.size} device hardware entries"
|
||||
}
|
||||
|
||||
Logger.d { "DeviceHardwareRepository: remote returned ${remoteHardware.size} entries" }
|
||||
localDataSource.insertAllDeviceHardware(remoteHardware)
|
||||
var fromDb = localDataSource.getByHwModel(hwModel)
|
||||
|
||||
// Fallback to target lookup after remote fetch
|
||||
if (fromDb.isEmpty() && target != null) {
|
||||
val byTarget = localDataSource.getByTarget(target)
|
||||
if (byTarget != null) fromDb = listOf(byTarget)
|
||||
}
|
||||
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: lookup after remote fetch for hwModel=$hwModel returned" +
|
||||
" ${fromDb.size} entries"
|
||||
}
|
||||
disambiguate(fromDb, target)?.asExternalModel()
|
||||
}
|
||||
.onSuccess {
|
||||
// Successfully fetched and found the model
|
||||
return@withContext Result.success(applyBootloaderQuirk(hwModel, it, quirks, target))
|
||||
}
|
||||
.onFailure { e ->
|
||||
Logger.w(e) {
|
||||
"DeviceHardwareRepository: failed to fetch device hardware from server for hwModel=$hwModel"
|
||||
}
|
||||
|
||||
// 3. Attempt to use stale cache as a fallback, but only if it looks complete.
|
||||
var staleEntities = localDataSource.getByHwModel(hwModel)
|
||||
if (staleEntities.isEmpty() && target != null) {
|
||||
val byTarget = localDataSource.getByTarget(target)
|
||||
if (byTarget != null) staleEntities = listOf(byTarget)
|
||||
}
|
||||
|
||||
if (staleEntities.isNotEmpty() && staleEntities.all { !it.isIncomplete() }) {
|
||||
Logger.d { "DeviceHardwareRepository: using stale cached device hardware for hwModel=$hwModel" }
|
||||
val matched = disambiguate(staleEntities, target)
|
||||
return@withContext Result.success(
|
||||
applyBootloaderQuirk(hwModel, matched?.asExternalModel(), quirks, target),
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Fallback to bundled JSON if cache is empty or incomplete
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: cache ${if (staleEntities.isEmpty()) "empty" else "incomplete"} " +
|
||||
"for hwModel=$hwModel, falling back to bundled JSON asset"
|
||||
}
|
||||
return@withContext loadFromBundledJson(hwModel, target, quirks)
|
||||
}
|
||||
.onFailure { e -> Logger.w(e) { "DeviceHardwareRepository: network refresh failed" } }
|
||||
}
|
||||
|
||||
private suspend fun loadFromBundledJson(
|
||||
hwModel: Int,
|
||||
target: String?,
|
||||
quirks: List<BootloaderOtaQuirk>,
|
||||
): Result<DeviceHardware?> = safeCatching {
|
||||
Logger.d { "DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=$hwModel" }
|
||||
val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: bundled JSON returned ${jsonHardware.size} device hardware entries"
|
||||
private fun disambiguate(entities: List<DeviceHardwareEntity>, target: String?): DeviceHardwareEntity? =
|
||||
when (target) {
|
||||
null -> entities.firstOrNull()
|
||||
|
||||
else ->
|
||||
entities.find { it.platformioTarget == target }
|
||||
?: entities.find { it.platformioTarget.equals(target, ignoreCase = true) }
|
||||
?: entities.firstOrNull()
|
||||
}
|
||||
|
||||
localDataSource.insertAllDeviceHardware(jsonHardware)
|
||||
var baseList = localDataSource.getByHwModel(hwModel)
|
||||
|
||||
// Fallback to target lookup after JSON load
|
||||
if (baseList.isEmpty() && target != null) {
|
||||
val byTarget = localDataSource.getByTarget(target)
|
||||
if (byTarget != null) baseList = listOf(byTarget)
|
||||
}
|
||||
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: lookup after JSON load for hwModel=$hwModel returned ${baseList.size} entries"
|
||||
}
|
||||
|
||||
val matched = disambiguate(baseList, target)
|
||||
applyBootloaderQuirk(hwModel, matched?.asExternalModel(), quirks, target)
|
||||
}
|
||||
.also { result ->
|
||||
result.exceptionOrNull()?.let { e ->
|
||||
Logger.e(e) {
|
||||
"DeviceHardwareRepository: failed to load device hardware from bundled JSON for hwModel=$hwModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun disambiguate(entities: List<DeviceHardwareEntity>, target: String?): DeviceHardwareEntity? = when {
|
||||
entities.isEmpty() -> null
|
||||
|
||||
target == null -> entities.first()
|
||||
|
||||
else -> {
|
||||
entities.find { it.platformioTarget == target }
|
||||
?: entities.find { it.platformioTarget.equals(target, ignoreCase = true) }
|
||||
?: entities.first()
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if the cached entity is missing important fields and should be refreshed. */
|
||||
private fun DeviceHardwareEntity.isIncomplete(): Boolean =
|
||||
displayName.isBlank() || platformioTarget.isBlank() || images.isNullOrEmpty()
|
||||
|
||||
/**
|
||||
* Extension function to check if the cached entity is stale.
|
||||
*
|
||||
* We treat entries with missing critical fields (e.g., no images or target) as stale so that they can be
|
||||
* automatically healed from newer JSON snapshots even if their timestamp is recent.
|
||||
*/
|
||||
private fun DeviceHardwareEntity.isStale(): Boolean =
|
||||
isIncomplete() || (nowMillis - this.lastUpdated) > CACHE_EXPIRATION_TIME_MS
|
||||
|
||||
@@ -225,32 +136,25 @@ class DeviceHardwareRepositoryImpl(
|
||||
base: DeviceHardware?,
|
||||
quirks: List<BootloaderOtaQuirk>,
|
||||
reportedTarget: String? = null,
|
||||
): DeviceHardware? {
|
||||
if (base == null) return null
|
||||
|
||||
): DeviceHardware? = base?.let { hw ->
|
||||
val matchedQuirk = quirks.firstOrNull { it.hwModel == hwModel }
|
||||
val result =
|
||||
val withQuirk =
|
||||
if (matchedQuirk != null) {
|
||||
Logger.d {
|
||||
"DeviceHardwareRepository: applying quirk: " +
|
||||
"requiresBootloaderUpgradeForOta=${matchedQuirk.requiresBootloaderUpgradeForOta}, " +
|
||||
"infoUrl=${matchedQuirk.infoUrl}"
|
||||
}
|
||||
base.copy(
|
||||
hw.copy(
|
||||
requiresBootloaderUpgradeForOta = matchedQuirk.requiresBootloaderUpgradeForOta,
|
||||
bootloaderInfoUrl = matchedQuirk.infoUrl,
|
||||
)
|
||||
} else {
|
||||
base
|
||||
hw
|
||||
}
|
||||
|
||||
// If the device reported a specific build environment via pio_env, trust it for firmware retrieval
|
||||
return if (reportedTarget != null) {
|
||||
Logger.d { "DeviceHardwareRepository: using reported target $reportedTarget for hardware info" }
|
||||
result.copy(platformioTarget = reportedTarget)
|
||||
} else {
|
||||
result
|
||||
}
|
||||
reportedTarget?.let { withQuirk.copy(platformioTarget = it) } ?: withQuirk
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -40,11 +40,14 @@ open class FirmwareReleaseRepositoryImpl(
|
||||
) : FirmwareReleaseRepository {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* A flow that provides the latest STABLE firmware release.
|
||||
*
|
||||
* Pipeline:
|
||||
* 1. If the local DB is empty, seed it from the bundled JSON asset (instant baseline).
|
||||
* 2. Immediately emit the cached version.
|
||||
* 3. If the cached version is stale, refresh from the remote API and re-emit.
|
||||
*
|
||||
* Collectors should use `.distinctUntilChanged()` to avoid redundant UI updates.
|
||||
*/
|
||||
override val stableRelease: Flow<FirmwareRelease?> = getLatestFirmware(FirmwareReleaseType.STABLE)
|
||||
|
||||
@@ -55,76 +58,52 @@ open class FirmwareReleaseRepositoryImpl(
|
||||
*/
|
||||
override val alphaRelease: Flow<FirmwareRelease?> = getLatestFirmware(FirmwareReleaseType.ALPHA)
|
||||
|
||||
private fun getLatestFirmware(
|
||||
releaseType: FirmwareReleaseType,
|
||||
forceRefresh: Boolean = false,
|
||||
): Flow<FirmwareRelease?> = flow {
|
||||
if (forceRefresh) {
|
||||
invalidateCache()
|
||||
private fun getLatestFirmware(releaseType: FirmwareReleaseType): Flow<FirmwareRelease?> = flow {
|
||||
// 1. Seed from bundled JSON if the DB is completely empty (first launch or post-wipe).
|
||||
if (!localDataSource.hasAnyEntries()) {
|
||||
seedCacheFromBundledJson()
|
||||
}
|
||||
|
||||
// 1. Emit cached data first, regardless of staleness.
|
||||
// This gives the UI something to show immediately.
|
||||
// 2. Emit cached data immediately.
|
||||
val cachedRelease = localDataSource.getLatestRelease(releaseType)
|
||||
if (cachedRelease != null) {
|
||||
Logger.d { "Emitting cached firmware for $releaseType (isStale=${cachedRelease.isStale()})" }
|
||||
emit(cachedRelease.asExternalModel())
|
||||
} else {
|
||||
emit(null)
|
||||
}
|
||||
emit(cachedRelease?.asExternalModel())
|
||||
|
||||
// 2. If the cache was fresh and we are not forcing a refresh, we're done.
|
||||
if (cachedRelease != null && !cachedRelease.isStale() && !forceRefresh) {
|
||||
// 3. If fresh, we're done.
|
||||
if (cachedRelease?.isStale() == false) {
|
||||
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.
|
||||
// 4. Cache is stale or empty — refresh from network and re-emit.
|
||||
refreshFromNetwork()
|
||||
val finalRelease = localDataSource.getLatestRelease(releaseType)
|
||||
Logger.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 =
|
||||
safeCatching {
|
||||
Logger.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) {
|
||||
Logger.w { "Remote fetch failed, attempting to cache from bundled JSON." }
|
||||
safeCatching {
|
||||
val jsonReleases = jsonDataSource.loadFirmwareReleaseFromJsonAsset()
|
||||
localDataSource.insertFirmwareReleases(jsonReleases.releases.stable, FirmwareReleaseType.STABLE)
|
||||
localDataSource.insertFirmwareReleases(jsonReleases.releases.alpha, FirmwareReleaseType.ALPHA)
|
||||
}
|
||||
.onFailure { Logger.w { "Failed to cache from JSON: ${it.message}" } }
|
||||
private suspend fun refreshFromNetwork() {
|
||||
safeCatching {
|
||||
Logger.d { "Fetching fresh firmware releases from remote API." }
|
||||
val networkReleases = remoteDataSource.getFirmwareReleases()
|
||||
localDataSource.insertFirmwareReleases(networkReleases.releases.stable, FirmwareReleaseType.STABLE)
|
||||
localDataSource.insertFirmwareReleases(networkReleases.releases.alpha, FirmwareReleaseType.ALPHA)
|
||||
}
|
||||
.onFailure { e -> Logger.w(e) { "FirmwareReleaseRepository: network refresh failed" } }
|
||||
}
|
||||
|
||||
override suspend fun invalidateCache() {
|
||||
localDataSource.deleteAllFirmwareReleases()
|
||||
}
|
||||
|
||||
/** Extension function to check if the cached entity is stale. */
|
||||
private suspend fun seedCacheFromBundledJson() {
|
||||
safeCatching {
|
||||
Logger.d { "FirmwareReleaseRepository: seeding cache from bundled JSON" }
|
||||
val jsonReleases = jsonDataSource.loadFirmwareReleaseFromJsonAsset()
|
||||
localDataSource.insertFirmwareReleases(jsonReleases.releases.stable, FirmwareReleaseType.STABLE)
|
||||
localDataSource.insertFirmwareReleases(jsonReleases.releases.alpha, FirmwareReleaseType.ALPHA)
|
||||
}
|
||||
.onFailure { e -> Logger.w(e) { "FirmwareReleaseRepository: failed to seed cache from bundled JSON" } }
|
||||
}
|
||||
|
||||
private fun FirmwareReleaseEntity.isStale(): Boolean = (nowMillis - this.lastUpdated) > CACHE_EXPIRATION_TIME_MS
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -36,6 +36,9 @@ interface DeviceHardwareDao {
|
||||
@Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel AND platformio_target = :target")
|
||||
suspend fun getByModelAndTarget(hwModel: Int, target: String): DeviceHardwareEntity?
|
||||
|
||||
@Query("SELECT COUNT(*) FROM device_hardware")
|
||||
suspend fun count(): Int
|
||||
|
||||
@Query("DELETE FROM device_hardware")
|
||||
suspend fun deleteAll()
|
||||
}
|
||||
|
||||
@@ -34,4 +34,7 @@ interface FirmwareReleaseDao {
|
||||
|
||||
@Query("SELECT * FROM firmware_release WHERE release_type = :releaseType")
|
||||
suspend fun getReleasesByType(releaseType: FirmwareReleaseType): List<FirmwareReleaseEntity>
|
||||
|
||||
@Query("SELECT COUNT(*) FROM firmware_release")
|
||||
suspend fun count(): Int
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user