From 10c5b5db2e5b23c52a3a5689205bfc42285b7b0a Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 11 May 2026 19:34:41 -0500 Subject: [PATCH] =?UTF-8?q?feat(api):=20add=20hasAnyEntries=20method=20to?= =?UTF-8?q?=20local=20data=20sources=20and=20improve=E2=80=A6=20(#5406)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DeviceHardwareLocalDataSource.kt | 2 + .../FirmwareReleaseLocalDataSource.kt | 2 + .../DeviceHardwareRepositoryImpl.kt | 204 +++++------------- .../FirmwareReleaseRepositoryImpl.kt | 91 +++----- .../core/database/dao/DeviceHardwareDao.kt | 3 + .../core/database/dao/FirmwareReleaseDao.kt | 3 + 6 files changed, 99 insertions(+), 206 deletions(-) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt index eaca1ea7b..852ac0898 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/DeviceHardwareLocalDataSource.kt @@ -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 } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseLocalDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseLocalDataSource.kt index 77d90a421..31f1845b7 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseLocalDataSource.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/FirmwareReleaseLocalDataSource.kt @@ -55,4 +55,6 @@ class FirmwareReleaseLocalDataSource( return@withContext latestRelease } } + + suspend fun hasAnyEntries(): Boolean = withContext(dispatchers.io) { firmwareReleaseDao.count() > 0 } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt index 1ff565704..c7c805ee1 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepositoryImpl.kt @@ -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 = + 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, - ): Result = 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, 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, 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, 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 { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt index cfb045227..8739d0844 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/FirmwareReleaseRepositoryImpl.kt @@ -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 = getLatestFirmware(FirmwareReleaseType.STABLE) @@ -55,76 +58,52 @@ open class FirmwareReleaseRepositoryImpl( */ override val alphaRelease: Flow = getLatestFirmware(FirmwareReleaseType.ALPHA) - private fun getLatestFirmware( - releaseType: FirmwareReleaseType, - forceRefresh: Boolean = false, - ): Flow = flow { - if (forceRefresh) { - invalidateCache() + private fun getLatestFirmware(releaseType: FirmwareReleaseType): Flow = 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 { diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt index a6ca6e65a..01f61e3ee 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DeviceHardwareDao.kt @@ -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() } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt index e17a0af1a..dfd5d3962 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/FirmwareReleaseDao.kt @@ -34,4 +34,7 @@ interface FirmwareReleaseDao { @Query("SELECT * FROM firmware_release WHERE release_type = :releaseType") suspend fun getReleasesByType(releaseType: FirmwareReleaseType): List + + @Query("SELECT COUNT(*) FROM firmware_release") + suspend fun count(): Int }