mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-13 08:25:07 -04:00
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:
@@ -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.")
|
||||
}
|
||||
|
||||
3646
androidApp/src/main/assets/device_links.json
Normal file
3646
androidApp/src/main/assets/device_links.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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>>
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) } }
|
||||
|
||||
Reference in New Issue
Block a user