From 5a413d07e34731d08352ed1543ce32974717f87f Mon Sep 17 00:00:00 2001 From: Mac DeCourcy <49794076+mdecourcy@users.noreply.github.com> Date: Fri, 28 Nov 2025 12:45:22 -0800 Subject: [PATCH] fix: fdroid device hardware fallback using bundled JSON for incomplete cache entries (#3844) --- .../platform/FdroidPlatformAnalytics.kt | 6 +- .../repository/DeviceHardwareRepository.kt | 68 +++++++++++++++---- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/core/analytics/src/fdroid/kotlin/org/meshtastic/core/analytics/platform/FdroidPlatformAnalytics.kt b/core/analytics/src/fdroid/kotlin/org/meshtastic/core/analytics/platform/FdroidPlatformAnalytics.kt index fdfc430e2..e44a7fba9 100644 --- a/core/analytics/src/fdroid/kotlin/org/meshtastic/core/analytics/platform/FdroidPlatformAnalytics.kt +++ b/core/analytics/src/fdroid/kotlin/org/meshtastic/core/analytics/platform/FdroidPlatformAnalytics.kt @@ -30,10 +30,14 @@ import javax.inject.Inject */ class FdroidPlatformAnalytics @Inject constructor() : PlatformAnalytics { init { + // For F-Droid builds we don't initialize external analytics services. + // In debug builds we attach a DebugTree for convenient local logging. if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) + Timber.i("F-Droid platform no-op analytics initialized (DebugTree planted).") + } else { + Timber.i("F-Droid platform no-op analytics initialized.") } - Timber.i("F-Droid platform no-op analytics initialized.") } override fun setDeviceAttributes(firmwareVersion: String, model: String) { diff --git a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt index eb687a7da..1be4bab65 100644 --- a/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt +++ b/core/data/src/main/kotlin/org/meshtastic/core/data/repository/DeviceHardwareRepository.kt @@ -55,54 +55,98 @@ constructor( */ suspend fun getDeviceHardwareByModel(hwModel: Int, forceRefresh: Boolean = false): Result = withContext(Dispatchers.IO) { + Timber.d( + "DeviceHardwareRepository: getDeviceHardwareByModel(hwModel=%d, forceRefresh=%b)", + hwModel, + forceRefresh, + ) + if (forceRefresh) { + Timber.d("DeviceHardwareRepository: forceRefresh=true, clearing local device hardware cache") 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") + Timber.d("DeviceHardwareRepository: using fresh cached device hardware for hwModel=%d", hwModel) return@withContext Result.success(cachedEntity.asExternalModel()) } + Timber.d("DeviceHardwareRepository: no fresh cache for hwModel=%d, attempting remote fetch", hwModel) } // 2. Fetch from remote API runCatching { - Timber.d("Fetching device hardware from remote API.") + Timber.d("DeviceHardwareRepository: fetching device hardware from remote API") val remoteHardware = remoteDataSource.getAllDeviceHardware() + Timber.d( + "DeviceHardwareRepository: remote API returned %d device hardware entries", + remoteHardware.size, + ) localDataSource.insertAllDeviceHardware(remoteHardware) - localDataSource.getByHwModel(hwModel)?.asExternalModel() + val fromDb = localDataSource.getByHwModel(hwModel)?.asExternalModel() + Timber.d( + "DeviceHardwareRepository: lookup after remote fetch for hwModel=%d %s", + hwModel, + if (fromDb != null) "succeeded" else "returned null", + ) + fromDb } .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}") + Timber.w( + e, + "DeviceHardwareRepository: failed to fetch device hardware from server for hwModel=%d", + hwModel, + ) - // 3. Attempt to use stale cache as a fallback + // 3. Attempt to use stale cache as a fallback, but only if it looks complete. val staleEntity = localDataSource.getByHwModel(hwModel) - if (staleEntity != null) { - Timber.d("Using stale cached device hardware for model $hwModel") + if (staleEntity != null && !staleEntity.isIncomplete()) { + Timber.d("DeviceHardwareRepository: using stale cached device hardware for hwModel=%d", 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.") + // 4. Fallback to bundled JSON if cache is empty or incomplete + Timber.d( + "DeviceHardwareRepository: cache %s for hwModel=%d, falling back to bundled JSON asset", + if (staleEntity == null) "empty" else "incomplete", + hwModel, + ) return@withContext loadFromBundledJson(hwModel) } } private suspend fun loadFromBundledJson(hwModel: Int): Result = runCatching { + Timber.d("DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=%d", hwModel) val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset() + Timber.d("DeviceHardwareRepository: bundled JSON returned %d device hardware entries", jsonHardware.size) + localDataSource.insertAllDeviceHardware(jsonHardware) - localDataSource.getByHwModel(hwModel)?.asExternalModel() + val fromDb = localDataSource.getByHwModel(hwModel)?.asExternalModel() + Timber.d( + "DeviceHardwareRepository: lookup after JSON load for hwModel=%d %s", + hwModel, + if (fromDb != null) "succeeded" else "returned null", + ) + fromDb } - /** Extension function to check if the cached entity is stale. */ + /** 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 = - (System.currentTimeMillis() - this.lastUpdated) > CACHE_EXPIRATION_TIME_MS + isIncomplete() || (System.currentTimeMillis() - this.lastUpdated) > CACHE_EXPIRATION_TIME_MS companion object { private val CACHE_EXPIRATION_TIME_MS = TimeUnit.DAYS.toMillis(1)