refactor(node): fetch device links from the API, drop the bundled matcher (#5765)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-10 16:25:27 -05:00
committed by James Rich
parent 5b955f64f4
commit 2a45eb930a
23 changed files with 5643 additions and 1701 deletions

View File

@@ -19,6 +19,7 @@ package org.meshtastic.app.di
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkDeviceLinksResponse
import org.meshtastic.core.model.NetworkFirmwareReleases
import org.meshtastic.core.network.service.ApiService
@@ -36,6 +37,9 @@ class FDroidNetworkModule {
override suspend fun getDeviceHardware(): List<NetworkDeviceHardware> =
throw UnsupportedOperationException("getDeviceHardware is not supported on F-Droid builds.")
override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse =
throw UnsupportedOperationException("getDeviceLinks is not supported on F-Droid builds.")
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases =
throw UnsupportedOperationException("getFirmwareReleases is not supported on F-Droid builds.")
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,126 +0,0 @@
{
"rokland": {
"regions": [
"AU",
"AT",
"BE",
"CA",
"DK",
"EC",
"FR",
"DE",
"IE",
"JP",
"NL",
"NZ",
"NO",
"PK",
"ES",
"SE",
"CH",
"GB",
"US"
],
"match": "prefix"
},
"hexaspot": {
"regions": [
"AT",
"BE",
"BG",
"CY",
"CZ",
"DE",
"DK",
"EE",
"ES",
"FI",
"FR",
"GR",
"HR",
"HU",
"IE",
"IT",
"LT",
"LU",
"LV",
"MT",
"NL",
"NO",
"PL",
"PT",
"RO",
"SE",
"SI",
"SK"
],
"match": "prefix"
},
"aliexpress": {
"regions": [],
"match": "suffix"
},
"amazon": {
"regions": [
"AU",
"CA",
"FR",
"DE",
"IE",
"JP",
"NL",
"ES",
"SE",
"GB",
"US"
],
"match": "suffix"
},
"tindie": {
"regions": [
"US",
"CA",
"GB",
"DE",
"FR",
"AU",
"NL"
],
"match": "suffix"
},
"muzi": {
"regions": [
"AU",
"AT",
"BE",
"CA",
"CZ",
"DK",
"FI",
"FR",
"DE",
"HK",
"IN",
"IE",
"IL",
"IT",
"JP",
"MY",
"NL",
"NZ",
"NO",
"PL",
"PT",
"SG",
"KR",
"ES",
"SE",
"CH",
"TW",
"AE",
"GB",
"US"
],
"match": "prefix"
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
/*
* Copyright (c) 2026 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.koin.core.annotation.Single
import org.meshtastic.core.model.NetworkDeviceLink
import org.meshtastic.core.model.NetworkDeviceLinksResponse
@Single
class DeviceLinksJsonDataSourceImpl(private val application: Application) : DeviceLinksJsonDataSource {
// Tolerant parser so additional fields in the bundled snapshot don't break deserialization on older app versions.
@OptIn(ExperimentalSerializationApi::class)
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
exceptionsWithDebugInfo = false
}
@OptIn(ExperimentalSerializationApi::class)
override fun loadDeviceLinksFromJsonAsset(): List<NetworkDeviceLink> =
application.assets.open(DEVICE_LINKS_ASSET).use { inputStream ->
json.decodeFromStream<NetworkDeviceLinksResponse>(inputStream).links
}
private companion object {
const val DEVICE_LINKS_ASSET = "device_links.json"
}
}

View File

@@ -1,69 +0,0 @@
/*
* Copyright (c) 2026 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/>.
*/
@file:OptIn(ExperimentalSerializationApi::class)
package org.meshtastic.core.data.datasource
import android.app.Application
import co.touchlab.kermit.Logger
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import org.koin.core.annotation.Single
import org.meshtastic.core.model.MshToMarketplace
import org.meshtastic.core.model.MshToRoute
import org.meshtastic.core.model.MshToUrlsFile
@Single
class MshToLinksJsonDataSourceImpl(private val application: Application) : MshToLinksJsonDataSource {
// Tolerant parser: tolerate extra fields/trailing data so a stale bundled file never crashes the import.
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
exceptionsWithDebugInfo = false
}
// The bundled assets are immutable for the install's lifetime, so parse once and reuse — these are read on the
// node-detail flow's hot path (once per hardware emission).
private val routes: List<MshToRoute> by lazy {
runCatching { application.assets.open(URLS_ASSET).use { json.decodeFromStream<MshToUrlsFile>(it).routes } }
.onFailure { Logger.w(it) { "Unable to load $URLS_ASSET for device links" } }
.getOrDefault(emptyList())
}
private val marketplaces: Map<String, MshToMarketplace> by lazy {
runCatching {
application.assets.open(MARKETPLACES_ASSET).use {
json.decodeFromStream<Map<String, MshToMarketplace>>(it)
}
}
.onFailure {
Logger.w(it) { "Unable to load $MARKETPLACES_ASSET; marketplace links won't be region-filtered" }
}
.getOrDefault(emptyMap())
}
override fun loadRoutes(): List<MshToRoute> = routes
override fun loadMarketplaces(): Map<String, MshToMarketplace> = marketplaces
private companion object {
const val URLS_ASSET = "urls.json"
const val MARKETPLACES_ASSET = "marketplaces.json"
}
}

View File

@@ -16,14 +16,9 @@
*/
package org.meshtastic.core.data.datasource
import org.meshtastic.core.model.MshToMarketplace
import org.meshtastic.core.model.MshToRoute
import org.meshtastic.core.model.NetworkDeviceLink
/** Reads the bundled msh.to link data: `urls.json` (short codes) and `marketplaces.json` (region metadata). */
interface MshToLinksJsonDataSource {
/** Routes from the bundled `urls.json`, or empty if missing/malformed. */
fun loadRoutes(): List<MshToRoute>
/** Marketplace metadata from the bundled `marketplaces.json`, keyed by marketplace identifier. */
fun loadMarketplaces(): Map<String, MshToMarketplace>
/** Loads the bundled device-links snapshot (a frozen copy of the `/resource/deviceLinks` API response). */
interface DeviceLinksJsonDataSource {
fun loadDeviceLinksFromJsonAsset(): List<NetworkDeviceLink>
}

View File

@@ -139,8 +139,8 @@ class DeviceHardwareRepositoryImpl(
"DeviceHardwareRepository: network refresh timed out after ${NETWORK_REFRESH_TIMEOUT_MS}ms"
}
} else {
// Reconcile msh.to links against the freshest catalog (isVendor + orphan pruning). Runs outside
// the network timeout so a deadline can't cancel it mid-write and leave links half-reconciled.
// Refresh msh.to device links from the API after a hardware refresh. Runs outside the hardware
// network timeout so that deadline can't cancel it mid-write.
deviceLinkRepository.reconcile()
}
}

View File

@@ -1,111 +0,0 @@
/*
* Copyright (c) 2026 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 org.meshtastic.core.model.DeviceLink
/**
* Pure matching logic for associating msh.to [DeviceLink]s with a device's `platformioTarget`. Ported from the
* Meshtastic-Apple `DeviceLinksSection` (multi-tier matching: exact vendor, product variant, marketplace), so the two
* platforms surface the same links.
*/
object DeviceLinkMatcher {
/**
* Links relevant to [target], region-filtered and sorted with vendor/variant links first.
*
* @param links all imported links.
* @param marketplaceKeys known marketplace identifiers (from `marketplaces.json`).
* @param deviceTargets all known device `platformioTarget`s — used to exclude other devices' links.
* @param target the viewed device's `platformioTarget`.
* @param region the user's ISO 3166-1 alpha-2 region for marketplace filtering.
*/
fun match(
links: List<DeviceLink>,
marketplaceKeys: Set<String>,
deviceTargets: Set<String>,
target: String,
region: String,
): List<DeviceLink> {
val variants = buildTargetVariants(target)
return links
.filter { link -> matches(link, marketplaceKeys, deviceTargets, target, variants, region) }
.sortedByDescending { it.isVendor || !isMarketplaceLink(it.shortCode, marketplaceKeys) }
}
@Suppress("ReturnCount")
private fun matches(
link: DeviceLink,
marketplaceKeys: Set<String>,
deviceTargets: Set<String>,
target: String,
variants: List<String>,
region: String,
): Boolean {
val code = link.shortCode
// Exact vendor match always wins.
if (code == target) return true
// A vendor link for a different device is never shown here.
if (link.isVendor && code != target) return false
// Variant/marketplace-suffix: "<target>-..." or "<target>_...".
val matchesPrefix = variants.any { code.startsWith("${it}_") || code.startsWith("$it-") }
// Known marketplace prefix: "<marketplace>-<target>" or "<marketplace>_<target>".
val matchesMarketplacePrefix =
variants.any { variant -> marketplaceKeys.any { mp -> code == "$mp-$variant" || code == "${mp}_$variant" } }
if (!matchesPrefix && !matchesMarketplacePrefix) return false
// A prefix hit that is itself a different device's target belongs to that device, not this one.
if (matchesPrefix && code in deviceTargets && code != target) return false
// Region filter: null regions = vendor/variant (always), empty = worldwide, else must include the region.
val regions = link.regions ?: return true
if (regions.isEmpty()) return true
return region in regions
}
/** True when [code] carries a known marketplace prefix or suffix. */
fun isMarketplaceLink(code: String, marketplaceKeys: Set<String>): Boolean =
marketplaceKeyFor(code, marketplaceKeys) != null
/**
* The marketplace identifier [code] belongs to (as a delimiter-bounded prefix `mp-`/`mp_` or suffix `-mp`/`_mp`),
* or `null` if none. This is the single source of truth for "is this a marketplace link" — used for import-time
* region tagging, sort ordering, and UI prominence — so the classifications never disagree. Delimiter bounds avoid
* mis-tagging codes that merely begin with a marketplace name (e.g. `muziworks` is NOT `muzi`).
*/
fun marketplaceKeyFor(code: String, marketplaceKeys: Set<String>): String? = marketplaceKeys.firstOrNull { mp ->
code.startsWith("$mp-") || code.startsWith("${mp}_") || code.endsWith("-$mp") || code.endsWith("_$mp")
}
/**
* Alternate target strings for matching. Strips a leading `rak` (e.g. `rak4631` → `4631`) to absorb msh.to naming
* inconsistencies like `rokland-4631`.
*/
fun buildTargetVariants(target: String): List<String> {
val variants = mutableListOf(target)
if (target.startsWith("rak")) {
val stripped = target.removePrefix("rak")
if (stripped.isNotEmpty()) variants.add(stripped)
}
return variants
}
}

View File

@@ -23,92 +23,125 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
import org.meshtastic.core.data.datasource.DeviceLinkLocalDataSource
import org.meshtastic.core.data.datasource.MshToLinksJsonDataSource
import org.meshtastic.core.data.datasource.DeviceLinksJsonDataSource
import org.meshtastic.core.database.entity.asEntity
import org.meshtastic.core.database.entity.asExternalModel
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DeviceLink
import org.meshtastic.core.model.MshToMarketplace
import org.meshtastic.core.model.NetworkDeviceLink
import org.meshtastic.core.model.toDeviceLink
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.network.DeviceLinksRemoteDataSource
import org.meshtastic.core.repository.DeviceLinkRepository
import kotlin.concurrent.Volatile
/**
* Caches the resolved device-links catalog from the Meshtastic API (`/resource/deviceLinks`). The server does all the
* classification (type/targets/regions), so the client just seeds from a bundled snapshot, refreshes from the network,
* and filters the cache. Mirrors [DeviceHardwareRepositoryImpl]'s seed → single-flight refresh pattern.
*/
@Single
class DeviceLinkRepositoryImpl(
private val jsonDataSource: MshToLinksJsonDataSource,
private val remoteDataSource: DeviceLinksRemoteDataSource,
private val jsonDataSource: DeviceLinksJsonDataSource,
private val localDataSource: DeviceLinkLocalDataSource,
private val deviceHardwareLocalDataSource: DeviceHardwareLocalDataSource,
private val dispatchers: CoroutineDispatchers,
) : DeviceLinkRepository {
/** Guards the import so concurrent collectors don't run it more than once at a time. */
private val importMutex = Mutex()
/** Serializes seeding and network refreshes so concurrent collectors don't duplicate writes. */
private val writeMutex = Mutex()
@Volatile private var lastRefreshMillis = 0L
override suspend fun ensureImported() {
if (localDataSource.count() > 0) return
importMutex.withLock { if (localDataSource.count() == 0) doImport() }
ensureSeeded()
}
override suspend fun reconcile() {
importMutex.withLock { doImport() }
writeMutex.withLock {
safeCatching {
// Bound only the network call by the timeout; the DB write runs after so a deadline can't cancel it
// mid-write and leave the cache half-pruned.
val remoteLinks =
withTimeoutOrNull(NETWORK_REFRESH_TIMEOUT_MS) { remoteDataSource.getDeviceLinks() }
if (remoteLinks == null) {
Logger.w {
"DeviceLinkRepository: network refresh timed out after ${NETWORK_REFRESH_TIMEOUT_MS}ms"
}
} else {
store(remoteLinks)
lastRefreshMillis = nowMillis
}
}
.onFailure { e -> Logger.w(e) { "DeviceLinkRepository: network refresh failed" } }
}
}
override suspend fun getLinksForTarget(platformioTarget: String, regionCode: String): List<DeviceLink> {
if (platformioTarget.isBlank()) return emptyList()
ensureImported()
val links = localDataSource.getAll().map { it.asExternalModel() }
val marketplaceKeys = jsonDataSource.loadMarketplaces().keys
val deviceTargets = deviceHardwareLocalDataSource.getAllTargets().toSet()
return DeviceLinkMatcher.match(
links = links,
marketplaceKeys = marketplaceKeys,
deviceTargets = deviceTargets,
target = platformioTarget,
region = regionCode,
)
}
override suspend fun getLinksForTarget(platformioTarget: String, regionCode: String): List<DeviceLink> =
withContext(dispatchers.io) {
if (platformioTarget.isBlank()) return@withContext emptyList()
ensureSeeded()
// Deliberately non-blocking: the node-detail flow must stay snappy. Freshness arrives via reconcile(),
// triggered by the device-hardware refresh that resolves this device's hardware in the first place.
localDataSource
.getAll()
.map { it.asExternalModel() }
.filter { link -> platformioTarget in link.targets.orEmpty() }
.filter { link ->
val regions = link.regions
regions.isNullOrEmpty() || regionCode in regions
}
.sortedByDescending { it.isVendor }
}
override fun observeAllLinks(): Flow<List<DeviceLink>> = flow {
ensureImported()
ensureSeeded()
refreshIfStale()
emitAll(localDataSource.observeAll().map { entities -> entities.map { it.asExternalModel() } })
}
/** Loads bundled `urls.json`, classifies each short code, upserts, and prunes orphans. Mirrors Apple's import. */
private suspend fun doImport() {
safeCatching {
val routes = jsonDataSource.loadRoutes()
if (routes.isEmpty()) {
Logger.w { "DeviceLinkRepository: no routes in bundled urls.json; skipping import" }
return@safeCatching
/** Seeds the table from the bundled snapshot if empty (fresh install, data clear, radio switch). */
private suspend fun ensureSeeded() {
if (localDataSource.count() > 0) return
writeMutex.withLock {
if (localDataSource.count() == 0) {
safeCatching { store(jsonDataSource.loadDeviceLinksFromJsonAsset()) }
.onFailure { e -> Logger.w(e) { "DeviceLinkRepository: failed to seed from bundled JSON" } }
}
val marketplaces = jsonDataSource.loadMarketplaces()
val deviceTargets = deviceHardwareLocalDataSource.getAllTargets().toSet()
val links =
routes.map { route ->
val isVendor = route.shortCode in deviceTargets
DeviceLink(
shortCode = route.shortCode,
originalUrl = route.originalUrl,
description = route.description,
isVendor = isVendor,
regions = if (isVendor) null else marketplaceRegions(route.shortCode, marketplaces),
)
}
localDataSource.upsertAll(links.map { it.asEntity() })
localDataSource.deleteNotIn(links.map { it.shortCode })
Logger.i { "DeviceLinkRepository: imported ${links.size} msh.to links" }
}
.onFailure { Logger.w(it) { "DeviceLinkRepository: device links import failed" } }
}
/** Best-effort network refresh, gated by [CACHE_EXPIRATION_TIME_MS]. */
private suspend fun refreshIfStale() {
if (nowMillis - lastRefreshMillis > CACHE_EXPIRATION_TIME_MS) reconcile()
}
/**
* Shipping regions for a marketplace short code, or null when it is not a marketplace link. Uses the same
* delimiter-aware classifier as the matcher/UI so a code's classification (vendor/variant vs marketplace) is
* consistent everywhere — independent of the `match` hint in `marketplaces.json`, which is unreliable in practice
* (e.g. AliExpress is declared `suffix` yet most codes use the `aliexpress-<target>` prefix form).
* Maps resolved API links to the cached domain model, upserts them, and prunes short codes that no longer exist.
* Internal links (GitHub, YouTube, …) are dropped — they never belong to a device's purchase section. An empty list
* is ignored rather than wiping the cache on a bad response.
*/
private fun marketplaceRegions(code: String, marketplaces: Map<String, MshToMarketplace>): List<String>? =
DeviceLinkMatcher.marketplaceKeyFor(code, marketplaces.keys)?.let { marketplaces.getValue(it).regions }
private suspend fun store(networkLinks: List<NetworkDeviceLink>) {
val links = networkLinks.filter { it.type != NetworkDeviceLink.TYPE_INTERNAL }.map { it.toDeviceLink() }
if (links.isEmpty()) {
Logger.w { "DeviceLinkRepository: no device links to store; leaving cache untouched" }
return
}
localDataSource.upsertAll(links.map { it.asEntity() })
localDataSource.deleteNotIn(links.map { it.shortCode })
Logger.i { "DeviceLinkRepository: stored ${links.size} device links" }
}
private companion object {
private val CACHE_EXPIRATION_TIME_MS = TimeConstants.ONE_DAY.inWholeMilliseconds
/** Maximum time to wait for the remote API before falling back to cached/bundled data. */
private const val NETWORK_REFRESH_TIMEOUT_MS = 5_000L
}
}

View File

@@ -1,147 +0,0 @@
/*
* Copyright (c) 2026 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 org.meshtastic.core.model.DeviceLink
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Tests for [DeviceLinkMatcher], grounded in the acceptance scenarios of the Meshtastic-Apple `010-device-mshto-links`
* spec. Mirrors the as-built `DeviceLinksSection` matching (platformioTarget, not hwModelSlug).
*/
class DeviceLinkMatcherTest {
private val marketplaceKeys = setOf("rokland", "hexaspot", "aliexpress", "amazon", "tindie", "muzi")
private val deviceTargets =
setOf("rak4631", "heltec-v3", "seeed_solar_node", "tbeam", "rak4631_nomadstar_meteor_pro")
private fun link(shortCode: String, isVendor: Boolean = false, regions: List<String>? = null) = DeviceLink(
shortCode = shortCode,
originalUrl = "https://example.com/$shortCode",
isVendor = isVendor,
regions = regions,
)
private fun match(links: List<DeviceLink>, target: String, region: String = "US") =
DeviceLinkMatcher.match(links, marketplaceKeys, deviceTargets, target, region).map { it.shortCode }
@Test
fun exactVendorMatchIsIncluded() {
val result = match(listOf(link("heltec-v3", isVendor = true)), target = "heltec-v3")
assertEquals(listOf("heltec-v3"), result)
}
@Test
fun foreignVendorLinkIsExcluded() {
// Scenario 5: rak4631_nomadstar_meteor_pro (a different device's target) must NOT show for rak4631.
val result =
match(
listOf(link("rak4631", isVendor = true), link("rak4631_nomadstar_meteor_pro", isVendor = true)),
target = "rak4631",
)
assertEquals(listOf("rak4631"), result)
}
@Test
fun productVariantIsIncludedAndProminent() {
val result = match(listOf(link("rak4631_epaper")), target = "rak4631")
assertEquals(listOf("rak4631_epaper"), result)
}
@Test
fun marketplaceLinkIsRegionFiltered() {
val links = listOf(link("rokland-rak4631", regions = listOf("US", "CA")))
assertEquals(listOf("rokland-rak4631"), match(links, target = "rak4631", region = "US"))
assertEquals(emptyList(), match(links, target = "rak4631", region = "DE"))
}
@Test
fun rakPrefixIsStrippedForMarketplaceVariantMatch() {
// "rokland-4631" should match device "rak4631" via the rak-stripped variant "4631".
val result = match(listOf(link("rokland-4631", regions = listOf("US"))), target = "rak4631", region = "US")
assertEquals(listOf("rokland-4631"), result)
}
@Test
fun worldwideMarketplaceShowsRegardlessOfRegion() {
val links = listOf(link("rak4631_aliexpress", regions = emptyList()))
assertEquals(listOf("rak4631_aliexpress"), match(links, target = "rak4631", region = "ZZ"))
}
@Test
fun unrelatedLinksProduceEmptyResult() {
val links =
listOf(
link("github"),
link("heltec-v3", isVendor = true),
link("rokland-heltec-v3", regions = listOf("US")),
)
assertEquals(emptyList(), match(links, target = "tbeam"))
}
@Test
fun anotherDevicesTargetIsNotMatchedAsVariant() {
// "rak4631_nomadstar_meteor_pro" prefix-matches "rak4631_" but is itself a device target → excluded.
val result = match(listOf(link("rak4631_nomadstar_meteor_pro")), target = "rak4631")
assertEquals(emptyList(), result)
}
@Test
fun vendorAndVariantSortBeforeMarketplace() {
val links =
listOf(
link("rak4631_aliexpress", regions = emptyList()),
link("rak4631", isVendor = true),
link("rokland-rak4631", regions = listOf("US")),
link("rak4631_epaper"),
)
val result = match(links, target = "rak4631", region = "US")
// Vendor + variant first (order among them preserved from input), marketplace links after.
assertEquals(listOf("rak4631", "rak4631_epaper", "rak4631_aliexpress", "rokland-rak4631"), result)
}
@Test
fun buildTargetVariantsStripsRakPrefix() {
assertEquals(listOf("rak4631", "4631"), DeviceLinkMatcher.buildTargetVariants("rak4631"))
assertEquals(listOf("heltec-v3"), DeviceLinkMatcher.buildTargetVariants("heltec-v3"))
// Bare "rak" strips to empty and is not added.
assertEquals(listOf("rak"), DeviceLinkMatcher.buildTargetVariants("rak"))
}
@Test
fun isMarketplaceLinkDetectsPrefixAndSuffix() {
assertTrue(DeviceLinkMatcher.isMarketplaceLink("rokland-rak4631", marketplaceKeys))
assertTrue(DeviceLinkMatcher.isMarketplaceLink("heltec-v3_aliexpress", marketplaceKeys))
assertFalse(DeviceLinkMatcher.isMarketplaceLink("heltec-v3", marketplaceKeys))
}
@Test
fun marketplaceKeyForUsesDelimiterBounds() {
// Both prefix and suffix forms resolve to their marketplace...
assertEquals("rokland", DeviceLinkMatcher.marketplaceKeyFor("rokland-rak4631", marketplaceKeys))
assertEquals("aliexpress", DeviceLinkMatcher.marketplaceKeyFor("aliexpress-rak1921", marketplaceKeys))
assertEquals("aliexpress", DeviceLinkMatcher.marketplaceKeyFor("rak4631_aliexpress", marketplaceKeys))
// ...but a code that merely begins with a marketplace name is NOT that marketplace.
assertNull(DeviceLinkMatcher.marketplaceKeyFor("muziworks", marketplaceKeys))
assertNull(DeviceLinkMatcher.marketplaceKeyFor("heltec-v3", marketplaceKeys))
}
}

View File

@@ -18,145 +18,168 @@ package org.meshtastic.core.data.repository
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.data.datasource.DeviceHardwareLocalDataSource
import org.meshtastic.core.data.datasource.DeviceLinkLocalDataSource
import org.meshtastic.core.data.datasource.MshToLinksJsonDataSource
import org.meshtastic.core.data.datasource.DeviceLinksJsonDataSource
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.MshToMarketplace
import org.meshtastic.core.model.MshToRoute
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkDeviceLink
import org.meshtastic.core.model.NetworkDeviceLinksResponse
import org.meshtastic.core.model.NetworkFirmwareReleases
import org.meshtastic.core.network.DeviceLinksRemoteDataSource
import org.meshtastic.core.network.service.ApiService
import org.meshtastic.core.testing.FakeDatabaseProvider
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class DeviceLinkRepositoryImplTest {
private class FakeMshToLinksJsonDataSource(
var routes: List<MshToRoute>,
var marketplaces: Map<String, MshToMarketplace>,
) : MshToLinksJsonDataSource {
override fun loadRoutes(): List<MshToRoute> = routes
/** Only [getDeviceLinks] is exercised; the other endpoints are never called by the link repository. */
private class FakeApiService(var response: NetworkDeviceLinksResponse) : ApiService {
override suspend fun getDeviceHardware(): List<NetworkDeviceHardware> = error("unused")
override fun loadMarketplaces(): Map<String, MshToMarketplace> = marketplaces
override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse = response
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = error("unused")
}
private class FakeDeviceLinksJsonDataSource(var links: List<NetworkDeviceLink>) : DeviceLinksJsonDataSource {
override fun loadDeviceLinksFromJsonAsset(): List<NetworkDeviceLink> = links
}
private val dispatcher = UnconfinedTestDispatcher()
private val dispatchers = CoroutineDispatchers(main = dispatcher, io = dispatcher, default = dispatcher)
private lateinit var dbProvider: FakeDatabaseProvider
private lateinit var linkLocal: DeviceLinkLocalDataSource
private lateinit var hardwareLocal: DeviceHardwareLocalDataSource
private lateinit var json: FakeMshToLinksJsonDataSource
private lateinit var local: DeviceLinkLocalDataSource
private lateinit var api: FakeApiService
private lateinit var seed: FakeDeviceLinksJsonDataSource
private lateinit var repository: DeviceLinkRepositoryImpl
private val marketplaces =
mapOf(
"rokland" to MshToMarketplace(regions = listOf("US"), match = "prefix"),
"aliexpress" to MshToMarketplace(regions = emptyList(), match = "suffix"),
)
private fun route(shortCode: String) =
MshToRoute(shortCode = shortCode, originalUrl = "https://example.com/$shortCode", description = shortCode)
private fun link(
shortCode: String,
type: String = NetworkDeviceLink.TYPE_VENDOR,
targets: List<String>? = null,
regions: List<String>? = null,
) = NetworkDeviceLink(
shortCode = shortCode,
url = "https://msh.to/$shortCode",
description = shortCode,
type = type,
targets = targets,
regions = regions,
)
@BeforeTest
fun setup() {
dbProvider = FakeDatabaseProvider()
linkLocal = DeviceLinkLocalDataSource(dbProvider, dispatchers)
hardwareLocal = DeviceHardwareLocalDataSource(dbProvider, dispatchers)
json =
FakeMshToLinksJsonDataSource(
routes =
listOf(route("rak4631"), route("rokland-rak4631"), route("rak4631_aliexpress"), route("github")),
marketplaces = marketplaces,
local = DeviceLinkLocalDataSource(dbProvider, dispatchers)
api = FakeApiService(NetworkDeviceLinksResponse())
seed = FakeDeviceLinksJsonDataSource(emptyList())
repository =
DeviceLinkRepositoryImpl(
remoteDataSource = DeviceLinksRemoteDataSource(api, dispatchers),
jsonDataSource = seed,
localDataSource = local,
dispatchers = dispatchers,
)
repository = DeviceLinkRepositoryImpl(json, linkLocal, hardwareLocal)
}
@AfterTest fun tearDown() = dbProvider.close()
private suspend fun seedDeviceTargets(vararg targets: String) {
hardwareLocal.insertAllDeviceHardware(
targets.mapIndexed { i, t -> NetworkDeviceHardware(hwModel = i + 1, platformioTarget = t) },
)
}
@Test
fun importClassifiesVendorAndMarketplaceLinks() = runTest(dispatcher) {
seedDeviceTargets("rak4631", "heltec-v3")
repository.reconcile()
val byCode = linkLocal.getAll().associateBy { it.shortCode }
assertEquals(4, byCode.size)
// rak4631 is a known device target → vendor, no regions.
assertTrue(byCode.getValue("rak4631").isVendor)
assertNull(byCode.getValue("rak4631").regions)
// rokland-rak4631 → prefix marketplace, region-tagged.
assertTrue(!byCode.getValue("rokland-rak4631").isVendor)
assertEquals(listOf("US"), byCode.getValue("rokland-rak4631").regions)
// rak4631_aliexpress → suffix marketplace, worldwide (empty regions).
assertEquals(emptyList(), byCode.getValue("rak4631_aliexpress").regions)
// github → neither vendor nor marketplace, null regions.
assertTrue(!byCode.getValue("github").isVendor)
assertNull(byCode.getValue("github").regions)
}
@Test
fun reconcilePrunesOrphanedShortCodes() = runTest(dispatcher) {
seedDeviceTargets("rak4631")
repository.reconcile()
assertEquals(4, linkLocal.count())
// Drop "github" from the bundled file and reconcile again.
json.routes = json.routes.filterNot { it.shortCode == "github" }
repository.reconcile()
val codes = linkLocal.getAll().map { it.shortCode }.toSet()
assertEquals(setOf("rak4631", "rokland-rak4631", "rak4631_aliexpress"), codes)
}
@Test
fun aliexpressPrefixFormIsClassifiedAsWorldwideMarketplace() = runTest(dispatcher) {
// AliExpress is declared match="suffix" yet most bundled codes use the `aliexpress-<target>` prefix form;
// import must still classify it as a (worldwide) marketplace link, not a null-region variant.
json.routes = listOf(route("rak4631"), route("aliexpress-rak4631"))
seedDeviceTargets("rak4631")
repository.reconcile()
assertEquals(emptyList(), linkLocal.getAll().single { it.shortCode == "aliexpress-rak4631" }.regions)
}
@Test
fun bareMarketplaceNamePrefixIsNotMistagged() = runTest(dispatcher) {
// "muziworks" merely begins with "muzi" — delimiter bounds must keep it from inheriting muzi's regions.
json =
FakeMshToLinksJsonDataSource(
routes = listOf(route("muziworks")),
marketplaces = mapOf("muzi" to MshToMarketplace(regions = listOf("US"), match = "prefix")),
fun seedsFromBundledJsonWhenEmptyAndDropsInternalLinks() = runTest(dispatcher) {
seed.links =
listOf(
link("rak4631", targets = listOf("rak4631")),
link("github", type = NetworkDeviceLink.TYPE_INTERNAL),
)
repository = DeviceLinkRepositoryImpl(json, linkLocal, hardwareLocal)
seedDeviceTargets("rak4631")
repository.reconcile()
repository.ensureImported()
assertNull(linkLocal.getAll().single().regions)
assertEquals(setOf("rak4631"), local.getAll().map { it.shortCode }.toSet())
}
@Test
fun ensureImportedSeedsOnlyWhenEmpty() = runTest(dispatcher) {
seedDeviceTargets("rak4631")
seed.links = listOf(link("rak4631", targets = listOf("rak4631")))
repository.ensureImported()
assertEquals(4, linkLocal.count())
assertEquals(1, local.count())
// A second ensureImported with a larger bundled file must NOT re-import (table already populated).
json.routes = json.routes + route("new-code")
// A larger snapshot must NOT re-seed once the table is populated.
seed.links = seed.links + link("heltec-v3", targets = listOf("heltec-v3"))
repository.ensureImported()
assertEquals(4, linkLocal.count())
assertEquals(1, local.count())
}
@Test
fun getLinksForTargetFiltersByTargetAndRegionVendorFirst() = runTest(dispatcher) {
api.response =
NetworkDeviceLinksResponse(
links =
listOf(
link(
"rokland-rak4631",
type = NetworkDeviceLink.TYPE_MARKETPLACE,
targets = listOf("rak4631"),
regions = listOf("US"),
),
link("rak4631", targets = listOf("rak4631")),
link("heltec-v3", targets = listOf("heltec-v3")),
link(
"de-only",
type = NetworkDeviceLink.TYPE_MARKETPLACE,
targets = listOf("rak4631"),
regions = listOf("DE"),
),
),
)
repository.reconcile()
val links = repository.getLinksForTarget("rak4631", regionCode = "US")
// de-only filtered by region; heltec-v3 filtered by target; vendor sorted ahead of marketplace.
assertEquals(listOf("rak4631", "rokland-rak4631"), links.map { it.shortCode })
assertTrue(links.first().isVendor)
}
@Test
fun worldwideLinksShowRegardlessOfRegion() = runTest(dispatcher) {
api.response =
NetworkDeviceLinksResponse(
links =
listOf(
link("ww", type = NetworkDeviceLink.TYPE_MARKETPLACE, targets = listOf("t"), regions = null),
),
)
repository.reconcile()
assertEquals(listOf("ww"), repository.getLinksForTarget("t", regionCode = "ZZ").map { it.shortCode })
}
@Test
fun reconcilePrunesShortCodesNoLongerInCatalog() = runTest(dispatcher) {
api.response =
NetworkDeviceLinksResponse(
links = listOf(link("a", targets = listOf("t")), link("b", targets = listOf("t"))),
)
repository.reconcile()
assertEquals(2, local.count())
api.response = NetworkDeviceLinksResponse(links = listOf(link("a", targets = listOf("t"))))
repository.reconcile()
assertEquals(setOf("a"), local.getAll().map { it.shortCode }.toSet())
}
@Test
fun emptyResponseLeavesCacheUntouched() = runTest(dispatcher) {
api.response = NetworkDeviceLinksResponse(links = listOf(link("a", targets = listOf("t"))))
repository.reconcile()
assertEquals(1, local.count())
api.response = NetworkDeviceLinksResponse(links = emptyList())
repository.reconcile()
assertEquals(1, local.count())
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -111,8 +111,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 39, to = 40),
AutoMigration(from = 40, to = 41),
AutoMigration(from = 41, to = 42),
AutoMigration(from = 42, to = 43, spec = AutoMigration42to43::class),
],
version = 42,
version = 43,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
@@ -160,3 +161,7 @@ class AutoMigration33to34 : AutoMigrationSpec
@DeleteColumn(tableName = "packet", columnName = "retry_count")
@DeleteColumn(tableName = "reactions", columnName = "retry_count")
class AutoMigration34to35 : AutoMigrationSpec
/** Device links moved from the bundled `urls.json` to the resolved API; `original_url` is no longer stored. */
@DeleteColumn(tableName = "device_link", columnName = "original_url")
class AutoMigration42to43 : AutoMigrationSpec

View File

@@ -22,29 +22,29 @@ import androidx.room3.PrimaryKey
import kotlinx.serialization.Serializable
import org.meshtastic.core.model.DeviceLink
/** A msh.to short-link, upserted from the bundled `urls.json` during the device-hardware refresh cycle. */
/** A msh.to device link, cached from the Meshtastic API (`/resource/deviceLinks`) during the refresh cycle. */
@Serializable
@Entity(tableName = "device_link")
data class DeviceLinkEntity(
@PrimaryKey @ColumnInfo(name = "short_code") val shortCode: String,
@ColumnInfo(name = "original_url") val originalUrl: String,
@ColumnInfo(name = "link_description") val linkDescription: String? = null,
@ColumnInfo(name = "is_vendor") val isVendor: Boolean = false,
val regions: List<String>? = null,
val targets: List<String>? = null,
)
fun DeviceLink.asEntity() = DeviceLinkEntity(
shortCode = shortCode,
originalUrl = originalUrl,
linkDescription = description,
isVendor = isVendor,
regions = regions,
targets = targets,
)
fun DeviceLinkEntity.asExternalModel() = DeviceLink(
shortCode = shortCode,
originalUrl = originalUrl,
description = linkDescription,
isVendor = isVendor,
regions = regions,
targets = targets,
)

View File

@@ -19,23 +19,23 @@ package org.meshtastic.core.model
import kotlinx.serialization.Serializable
/**
* A msh.to short-link associated with a piece of hardware. Imported from the bundled `urls.json` (sourced from the
* meshtastic/msh.to repo). Every link resolves through the msh.to redirect service.
* A msh.to device link resolved by the Meshtastic API (`/resource/deviceLinks`) and cached locally. Every link routes
* through the msh.to redirect service.
*
* @param shortCode the msh.to short code, e.g. `rak_wismeshtag`, `rokland-heltec-v3`.
* @param originalUrl the destination URL recorded in `urls.json` (informational; the app links to msh.to).
* @param description human-readable label shown to the user.
* @param isVendor true when [shortCode] is itself a known device `platformioTarget` (the primary vendor link).
* @param regions marketplace shipping regions (ISO 3166-1 alpha-2). `null` = vendor/variant (not region-filtered);
* empty = worldwide marketplace; non-empty = limited to the listed countries.
* @param isVendor true for a first-party vendor link (shown more prominently than region-filtered marketplace links).
* @param regions marketplace shipping regions (ISO 3166-1 alpha-2). `null` = not region-filtered (vendor/worldwide);
* non-empty = limited to the listed countries.
* @param targets device `platformioTarget`s this link is attached to; used to match a link to the device on screen.
*/
@Serializable
data class DeviceLink(
val shortCode: String,
val originalUrl: String,
val description: String? = null,
val isVendor: Boolean = false,
val regions: List<String>? = null,
val targets: List<String>? = null,
) {
/** The user-facing link, routed through the msh.to redirect service. */
val url: String

View File

@@ -1,41 +0,0 @@
/*
* Copyright (c) 2026 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.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** Root of the bundled `urls.json` file (imported as-is from the meshtastic/msh.to repo). */
@Serializable data class MshToUrlsFile(@SerialName("Routes") val routes: List<MshToRoute> = emptyList())
/** A single short-code route in `urls.json`. */
@Serializable
data class MshToRoute(
@SerialName("ShortCode") val shortCode: String,
@SerialName("OriginalUrl") val originalUrl: String,
@SerialName("Description") val description: String? = null,
)
/**
* Marketplace metadata from the app-maintained `marketplaces.json`. Keyed by marketplace identifier (e.g. `rokland`,
* `aliexpress`).
*
* @param regions ISO 3166-1 alpha-2 shipping regions; empty = worldwide.
* @param match how the marketplace identifier appears in a short code: `"prefix"` (e.g. `rokland-heltec-v3`) or
* `"suffix"` (e.g. `heltec-v3_aliexpress`).
*/
@Serializable data class MshToMarketplace(val regions: List<String> = emptyList(), val match: String)

View File

@@ -0,0 +1,80 @@
/*
* Copyright (c) 2026 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.model
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonIgnoreUnknownKeys
/**
* Response envelope of `GET /resource/deviceLinks` on the Meshtastic API. The server resolves meshtastic/msh.to's
* catalog into fully-classified links (type + targets + regions), so the client only stores and filters them — no
* client-side matching heuristic.
*/
@Serializable
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
data class NetworkDeviceLinksResponse(
val version: Int = 1,
val generatedAt: String? = null,
val source: String? = null,
val links: List<NetworkDeviceLink> = emptyList(),
)
/**
* A single resolved device link from the Meshtastic API.
*
* @param shortCode msh.to short code, e.g. `rokland-t-deck-plus`.
* @param url the user-facing `https://msh.to/<shortCode>` link (the retailer destination is intentionally not exposed).
* @param description human-readable label.
* @param type authoritative classification: [TYPE_INTERNAL], [TYPE_VENDOR], or [TYPE_MARKETPLACE].
* @param targets device `platformioTarget`s this link is attached to; `null` = untriaged, empty = device-agnostic.
* @param hwModels `hwModel` ints derived from [targets] server-side (parallel list).
* @param marketplace retailer key (e.g. `rokland`) for marketplace links, else `null`.
* @param regions ISO 3166-1 alpha-2 shipping regions; `null` = worldwide (no region filter).
*/
@Serializable
@OptIn(ExperimentalSerializationApi::class)
@JsonIgnoreUnknownKeys
data class NetworkDeviceLink(
val shortCode: String = "",
val url: String = "",
val description: String? = null,
val type: String = TYPE_INTERNAL,
val targets: List<String>? = null,
val hwModels: List<Int>? = null,
val marketplace: String? = null,
val regions: List<String>? = null,
) {
companion object {
const val TYPE_INTERNAL = "internal"
const val TYPE_VENDOR = "vendor"
const val TYPE_MARKETPLACE = "marketplace"
}
}
/**
* Pure mapping to the cached domain model. Callers are expected to drop [TYPE_INTERNAL] links (GitHub, YouTube, …),
* which never belong to a device's purchase section.
*/
fun NetworkDeviceLink.toDeviceLink(): DeviceLink = DeviceLink(
shortCode = shortCode,
description = description,
isVendor = type == NetworkDeviceLink.TYPE_VENDOR,
regions = regions,
targets = targets,
)

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 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.network
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.NetworkDeviceLink
import org.meshtastic.core.network.service.ApiService
@Single
class DeviceLinksRemoteDataSource(private val apiService: ApiService, private val dispatchers: CoroutineDispatchers) {
suspend fun getDeviceLinks(): List<NetworkDeviceLink> =
withContext(dispatchers.io) { apiService.getDeviceLinks().links }
}

View File

@@ -21,6 +21,7 @@ import io.ktor.client.call.body
import io.ktor.client.request.get
import org.koin.core.annotation.Single
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkDeviceLinksResponse
import org.meshtastic.core.model.NetworkFirmwareReleases
/** Client for the Meshtastic public API (device hardware catalog and firmware releases). */
@@ -28,6 +29,9 @@ interface ApiService {
/** Fetches the device hardware catalog from the Meshtastic API. */
suspend fun getDeviceHardware(): List<NetworkDeviceHardware>
/** Fetches the resolved device-links catalog (msh.to purchase links) from the Meshtastic API. */
suspend fun getDeviceLinks(): NetworkDeviceLinksResponse
/** Fetches the list of available firmware releases from the Meshtastic API. */
suspend fun getFirmwareReleases(): NetworkFirmwareReleases
}
@@ -44,5 +48,7 @@ interface ApiService {
class ApiServiceImpl(private val client: HttpClient) : ApiService {
override suspend fun getDeviceHardware(): List<NetworkDeviceHardware> = client.get("resource/deviceHardware").body()
override suspend fun getDeviceLinks(): NetworkDeviceLinksResponse = client.get("resource/deviceLinks").body()
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases = client.get("github/firmware/list").body()
}

View File

@@ -21,23 +21,22 @@ import org.meshtastic.core.common.util.currentRegionCode
import org.meshtastic.core.model.DeviceLink
/**
* Provides msh.to device links imported from the bundled `urls.json`. Mirrors the Meshtastic-Apple device-links
* feature: vendor, product-variant, and region-filtered marketplace links shown on the device hardware detail view,
* plus a full directory.
* Provides msh.to device links resolved by the Meshtastic API (`/resource/deviceLinks`) and cached locally. Vendor and
* region-filtered marketplace links are shown on the device hardware detail view, plus a full directory.
*/
interface DeviceLinkRepository {
/** Seeds the link table from the bundled JSON if it is empty (covers fresh install, data clear, radio switch). */
/** Seeds the link table from the bundled snapshot if it is empty (fresh install, data clear, radio switch). */
suspend fun ensureImported()
/** Re-imports the bundled JSON: upserts all links, recomputes `isVendor`, and prunes orphaned short codes. */
/** Refreshes links from the API: upserts the resolved catalog and prunes short codes that no longer exist. */
suspend fun reconcile()
/**
* Links for a device's [platformioTarget], region-filtered and sorted with vendor/variant links first. Returns an
* Links attached to a device's [platformioTarget], region-filtered and sorted with vendor links first. Returns an
* empty list when no links match.
*/
suspend fun getLinksForTarget(platformioTarget: String, regionCode: String = currentRegionCode()): List<DeviceLink>
/** All imported links, sorted by short code — backs the Settings "Device Links" directory. */
/** All cached links, sorted by short code — backs the Settings "Device Links" directory. */
fun observeAllLinks(): Flow<List<DeviceLink>>
}

View File

@@ -36,12 +36,11 @@ import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.meshtastic.core.data.datasource.BootloaderOtaQuirksJsonDataSource
import org.meshtastic.core.data.datasource.DeviceHardwareJsonDataSource
import org.meshtastic.core.data.datasource.DeviceLinksJsonDataSource
import org.meshtastic.core.data.datasource.FirmwareReleaseJsonDataSource
import org.meshtastic.core.data.datasource.MshToLinksJsonDataSource
import org.meshtastic.core.model.BootloaderOtaQuirk
import org.meshtastic.core.model.MshToMarketplace
import org.meshtastic.core.model.MshToRoute
import org.meshtastic.core.model.NetworkDeviceHardware
import org.meshtastic.core.model.NetworkDeviceLink
import org.meshtastic.core.model.NetworkFirmwareReleases
import org.meshtastic.core.network.HttpClientDefaults
import org.meshtastic.core.network.KermitHttpLogger
@@ -273,11 +272,9 @@ private fun desktopPlatformStubsModule() = module {
}
}
single<MshToLinksJsonDataSource> {
object : MshToLinksJsonDataSource {
override fun loadRoutes(): List<MshToRoute> = emptyList()
override fun loadMarketplaces(): Map<String, MshToMarketplace> = emptyMap()
single<DeviceLinksJsonDataSource> {
object : DeviceLinksJsonDataSource {
override fun loadDeviceLinksFromJsonAsset(): List<NetworkDeviceLink> = emptyList()
}
}
}

View File

@@ -191,21 +191,21 @@ private fun DeviceLinksSectionPreview() {
listOf(
org.meshtastic.core.model.DeviceLink(
shortCode = "heltec-v3",
originalUrl = "https://heltec.org",
description = "Heltec V3",
isVendor = true,
targets = listOf("heltec-v3"),
),
org.meshtastic.core.model.DeviceLink(
shortCode = "rokland-heltec-v3",
originalUrl = "https://rokland.com",
description = "Rokland",
regions = listOf("US"),
targets = listOf("heltec-v3"),
),
org.meshtastic.core.model.DeviceLink(
shortCode = "heltec-v3_aliexpress",
originalUrl = "https://aliexpress.com",
description = "AliExpress",
regions = emptyList(),
targets = listOf("heltec-v3"),
),
)
AppTheme { Surface { DeviceLinksSection(links = links) } }