Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/reusable-android-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
files: build/reports/kover/xml/report.xml
files: build/reports/kover/report.xml

- name: Upload test results to Codecov
if: ${{ !cancelled() }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/reusable-android-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ jobs:
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: meshtastic/Meshtastic-Android
files: build/reports/kover/xml/report.xml
files: build/reports/kover/report.xml

- name: Upload test results to Codecov
if: ${{ !cancelled() }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ constructor(
maxChannels = 8,
hasWifi = metadata?.hasWifi == true,
deviceId = deviceId.toStringUtf8(),
pioEnv = if (myInfo.pioEnv.isNullOrEmpty()) null else myInfo.pioEnv,
)
}
if (metadata != null && metadata != MeshProtos.DeviceMetadata.getDefaultInstance()) {
Expand Down
21 changes: 4 additions & 17 deletions core/data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,6 @@
*/
import com.android.build.api.dsl.LibraryExtension

/*
* 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/>.
*/

plugins {
alias(libs.plugins.meshtastic.android.library)
alias(libs.plugins.meshtastic.hilt)
Expand Down Expand Up @@ -61,4 +44,8 @@ dependencies {
implementation(libs.androidx.paging.common)
implementation(libs.kotlinx.serialization.json)
implementation(libs.kermit)

testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,17 @@ constructor(
) {
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 insertAllDeviceHardware(deviceHardware: List<NetworkDeviceHardware>) =
withContext(dispatchers.io) { deviceHardwareDao.insertAll(deviceHardware.map { it.asEntity() }) }

suspend fun deleteAllDeviceHardware() = withContext(dispatchers.io) { deviceHardwareDao.deleteAll() }

suspend fun getByHwModel(hwModel: Int): DeviceHardwareEntity? =
suspend fun getByHwModel(hwModel: Int): List<DeviceHardwareEntity> =
withContext(dispatchers.io) { deviceHardwareDao.getByHwModel(hwModel) }

suspend fun getByTarget(target: String): DeviceHardwareEntity? =
withContext(dispatchers.io) { deviceHardwareDao.getByTarget(target) }

suspend fun getByModelAndTarget(hwModel: Int, target: String): DeviceHardwareEntity? =
withContext(dispatchers.io) { deviceHardwareDao.getByModelAndTarget(hwModel, target) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ constructor(
) {

/**
* Retrieves device hardware information by its model ID.
* 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.
Expand All @@ -53,97 +53,151 @@ constructor(
* 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.
*/
@Suppress("LongMethod")
suspend fun getDeviceHardwareByModel(hwModel: Int, forceRefresh: Boolean = false): Result<DeviceHardware?> =
withContext(dispatchers.io) {
Logger.d {
"DeviceHardwareRepository: getDeviceHardwareByModel(hwModel=$hwModel, forceRefresh=$forceRefresh)"
}
@Suppress("LongMethod", "detekt:CyclomaticComplexMethod")
suspend fun getDeviceHardwareByModel(
hwModel: Int,
target: String? = null,
forceRefresh: Boolean = false,
): Result<DeviceHardware?> = withContext(dispatchers.io) {
Logger.d {
"DeviceHardwareRepository: getDeviceHardwareByModel(hwModel=$hwModel," +
" target=$target, forceRefresh=$forceRefresh)"
}

val quirks = loadQuirks()
val quirks = loadQuirks()

if (forceRefresh) {
Logger.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()) {
Logger.d { "DeviceHardwareRepository: using fresh cached device hardware for hwModel=$hwModel" }
return@withContext Result.success(
applyBootloaderQuirk(hwModel, cachedEntity.asExternalModel(), quirks),
)
}
Logger.d { "DeviceHardwareRepository: no fresh cache for hwModel=$hwModel, attempting remote fetch" }
}
if (forceRefresh) {
Logger.d { "DeviceHardwareRepository: forceRefresh=true, clearing local device hardware cache" }
localDataSource.deleteAllDeviceHardware()
} else {
// 1. Attempt to retrieve from cache first
var cachedEntities = localDataSource.getByHwModel(hwModel)

// 2. Fetch from remote API
runCatching {
Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" }
val remoteHardware = remoteDataSource.getAllDeviceHardware()
// Fallback to target-only lookup if hwModel-based lookup yielded nothing
if (cachedEntities.isEmpty() && target != null) {
Logger.d {
"DeviceHardwareRepository: remote API returned ${remoteHardware.size} device hardware entries"
"DeviceHardwareRepository: no cache for hwModel=$hwModel, trying target lookup for $target"
}

localDataSource.insertAllDeviceHardware(remoteHardware)
val fromDb = localDataSource.getByHwModel(hwModel)?.asExternalModel()
Logger.d {
"DeviceHardwareRepository: lookup after remote fetch for hwModel=$hwModel ${if (fromDb != null) "succeeded" else "returned null"}"
val byTarget = localDataSource.getByTarget(target)
if (byTarget != null) {
cachedEntities = listOf(byTarget)
}
fromDb
}
.onSuccess {
// Successfully fetched and found the model
return@withContext Result.success(applyBootloaderQuirk(hwModel, it, quirks))
}
.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.
val staleEntity = localDataSource.getByHwModel(hwModel)
if (staleEntity != null && !staleEntity.isIncomplete()) {
Logger.d { "DeviceHardwareRepository: using stale cached device hardware for hwModel=$hwModel" }
return@withContext Result.success(
applyBootloaderQuirk(hwModel, staleEntity.asExternalModel(), quirks),
)
}

// 4. Fallback to bundled JSON if cache is empty or incomplete
Logger.d {
"DeviceHardwareRepository: cache ${if (staleEntity == null) "empty" else "incomplete"} for hwModel=$hwModel, falling back to bundled JSON asset"
}
return@withContext loadFromBundledJson(hwModel, quirks)
}

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" }
}

private suspend fun loadFromBundledJson(hwModel: Int, quirks: List<BootloaderOtaQuirk>): Result<DeviceHardware?> =
// 2. Fetch from remote API
runCatching {
Logger.d { "DeviceHardwareRepository: loading device hardware from bundled JSON for hwModel=$hwModel" }
val jsonHardware = jsonDataSource.loadDeviceHardwareFromJsonAsset()
Logger.d { "DeviceHardwareRepository: fetching device hardware from remote API" }
val remoteHardware = remoteDataSource.getAllDeviceHardware()
Logger.d {
"DeviceHardwareRepository: bundled JSON returned ${jsonHardware.size} device hardware entries"
"DeviceHardwareRepository: remote API returned ${remoteHardware.size} device hardware 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)
}

localDataSource.insertAllDeviceHardware(jsonHardware)
val base = localDataSource.getByHwModel(hwModel)?.asExternalModel()
Logger.d {
"DeviceHardwareRepository: lookup after JSON load for hwModel=$hwModel ${if (base != null) "succeeded" else "returned null"}"
"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)
}

applyBootloaderQuirk(hwModel, base, quirks)
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)
}
}

private suspend fun loadFromBundledJson(
hwModel: Int,
target: String?,
quirks: List<BootloaderOtaQuirk>,
): Result<DeviceHardware?> = runCatching {
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"
}

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)
}
.also { result ->
result.exceptionOrNull()?.let { e ->
Logger.e(e) {
"DeviceHardwareRepository: failed to load device hardware from bundled JSON for hwModel=$hwModel"
}

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 =
Expand All @@ -168,22 +222,33 @@ constructor(
hwModel: Int,
base: DeviceHardware?,
quirks: List<BootloaderOtaQuirk>,
reportedTarget: String? = null,
): DeviceHardware? {
if (base == null) return null

val quirk = quirks.firstOrNull { it.hwModel == hwModel }
Logger.d { "DeviceHardwareRepository: applyBootloaderQuirk for hwModel=$hwModel, quirk found=${quirk != null}" }
return if (quirk != null) {
Logger.d {
"DeviceHardwareRepository: applying quirk: requiresBootloaderUpgradeForOta=${quirk.requiresBootloaderUpgradeForOta}, infoUrl=${quirk.infoUrl}"
val matchedQuirk = quirks.firstOrNull { it.hwModel == hwModel }
val result =
if (matchedQuirk != null) {
Logger.d {
"DeviceHardwareRepository: applying quirk: " +
"requiresBootloaderUpgradeForOta=${matchedQuirk.requiresBootloaderUpgradeForOta}, " +
"infoUrl=${matchedQuirk.infoUrl}"
}
base.copy(
requiresBootloaderUpgradeForOta = matchedQuirk.requiresBootloaderUpgradeForOta,
supportsUnifiedOta = matchedQuirk.supportsUnifiedOta,
bootloaderInfoUrl = matchedQuirk.infoUrl,
)
} else {
base
}
base.copy(
requiresBootloaderUpgradeForOta = quirk.requiresBootloaderUpgradeForOta,
supportsUnifiedOta = quirk.supportsUnifiedOta,
bootloaderInfoUrl = quirk.infoUrl,
)

// 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 {
base
result
}
}

Expand Down
Loading