feat(node): msh.to device hardware links ("I want one" section + Settings directory) (#5714)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-04 04:33:18 -05:00
parent 9947efd24e
commit 7631db63d0
38 changed files with 3551 additions and 6 deletions

View File

@@ -186,6 +186,7 @@ codec_2_enabled
codec2_sample_rate
coding_rate
collapse_chart
collapsed
communicate_off_the_grid
### COMPASS ###
compass_bearing
@@ -316,6 +317,9 @@ device_configuration
device_db_cache_limit
device_db_cache_limit_summary
device_gps
device_links
device_links_i_want_one
device_links_open_in_browser
device_metrics_label_value
device_metrics_log
device_metrics_numeric_value
@@ -433,6 +437,7 @@ event_welcome_hamvention
event_welcome_open_sauce
exchange_position
expand_chart
expanded
expires
### EXPORT ###
export_configuration

View File

@@ -0,0 +1,126 @@
{
"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

@@ -23,6 +23,8 @@ import java.util.Locale
actual fun currentLocaleCode(): String = Locale.getDefault().language
actual fun currentRegionCode(): String = Locale.getDefault().country
actual fun currentLocaleQualifier(): String {
val locale = Locale.getDefault()
val country = locale.country

View File

@@ -28,6 +28,12 @@ expect fun getSystemMeasurementSystem(): MeasurementSystem
/** Returns the device's current locale as a 2-letter ISO 639-1 language code (e.g. "en", "es", "fr"). */
expect fun currentLocaleCode(): String
/**
* Returns the device's current region as a 2-letter ISO 3166-1 alpha-2 country code (e.g. "US", "DE"), or an empty
* string when the region is unknown. Used to region-filter marketplace links.
*/
expect fun currentRegionCode(): String
/**
* Returns the device locale as a CMP resource qualifier string. Examples: "pt-rBR", "zh-rCN", "fr" (no region when not
* specified). Use this to construct locale-qualified file resource paths like "files-$qualifier/docs/...".

View File

@@ -42,6 +42,8 @@ actual fun getSystemMeasurementSystem(): MeasurementSystem = MeasurementSystem.M
actual fun currentLocaleCode(): String = "en"
actual fun currentRegionCode(): String = ""
actual fun currentLocaleQualifier(): String = "en"
actual fun String?.isValidAddress(): Boolean = false

View File

@@ -90,6 +90,8 @@ actual fun getSystemMeasurementSystem(): MeasurementSystem =
actual fun currentLocaleCode(): String = Locale.getDefault().language
actual fun currentRegionCode(): String = Locale.getDefault().country
actual fun currentLocaleQualifier(): String {
val locale = Locale.getDefault()
val country = locale.country

View File

@@ -0,0 +1,69 @@
/*
* 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

@@ -47,4 +47,7 @@ class DeviceHardwareLocalDataSource(
withContext(dispatchers.io) { deviceHardwareDao.getByModelAndTarget(hwModel, target) }
suspend fun hasAnyEntries(): Boolean = withContext(dispatchers.io) { deviceHardwareDao.count() > 0 }
/** All known `platformioTarget` values — used to determine which msh.to links are vendor links. */
suspend fun getAllTargets(): List<String> = withContext(dispatchers.io) { deviceHardwareDao.getAllTargets() }
}

View File

@@ -0,0 +1,46 @@
/*
* 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 kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.entity.DeviceLinkEntity
import org.meshtastic.core.di.CoroutineDispatchers
@Single
class DeviceLinkLocalDataSource(
private val dbManager: DatabaseProvider,
private val dispatchers: CoroutineDispatchers,
) {
private val deviceLinkDao
get() = dbManager.currentDb.value.deviceLinkDao()
fun observeAll(): Flow<List<DeviceLinkEntity>> = deviceLinkDao.observeAll()
suspend fun getAll(): List<DeviceLinkEntity> = withContext(dispatchers.io) { deviceLinkDao.getAll() }
suspend fun upsertAll(links: List<DeviceLinkEntity>) =
withContext(dispatchers.io) { deviceLinkDao.upsertAll(links) }
suspend fun deleteNotIn(keep: List<String>) = withContext(dispatchers.io) { deviceLinkDao.deleteNotIn(keep) }
suspend fun deleteAll() = withContext(dispatchers.io) { deviceLinkDao.deleteAll() }
suspend fun count(): Int = withContext(dispatchers.io) { deviceLinkDao.count() }
}

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.data.datasource
import org.meshtastic.core.model.MshToMarketplace
import org.meshtastic.core.model.MshToRoute
/** 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>
}

View File

@@ -37,6 +37,7 @@ import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.util.TimeConstants
import org.meshtastic.core.network.DeviceHardwareRemoteDataSource
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.DeviceLinkRepository
@Single
class DeviceHardwareRepositoryImpl(
@@ -44,6 +45,7 @@ class DeviceHardwareRepositoryImpl(
private val localDataSource: DeviceHardwareLocalDataSource,
private val jsonDataSource: DeviceHardwareJsonDataSource,
private val bootloaderOtaQuirksJsonDataSource: BootloaderOtaQuirksJsonDataSource,
private val deviceLinkRepository: DeviceLinkRepository,
private val dispatchers: CoroutineDispatchers,
) : DeviceHardwareRepository {
@@ -136,6 +138,10 @@ class DeviceHardwareRepositoryImpl(
Logger.w {
"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.
deviceLinkRepository.reconcile()
}
}
.onFailure { e -> Logger.w(e) { "DeviceHardwareRepository: network refresh failed" } }

View File

@@ -0,0 +1,111 @@
/*
* 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

@@ -0,0 +1,114 @@
/*
* 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 co.touchlab.kermit.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koin.core.annotation.Single
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.database.entity.asEntity
import org.meshtastic.core.database.entity.asExternalModel
import org.meshtastic.core.model.DeviceLink
import org.meshtastic.core.model.MshToMarketplace
import org.meshtastic.core.repository.DeviceLinkRepository
@Single
class DeviceLinkRepositoryImpl(
private val jsonDataSource: MshToLinksJsonDataSource,
private val localDataSource: DeviceLinkLocalDataSource,
private val deviceHardwareLocalDataSource: DeviceHardwareLocalDataSource,
) : DeviceLinkRepository {
/** Guards the import so concurrent collectors don't run it more than once at a time. */
private val importMutex = Mutex()
override suspend fun ensureImported() {
if (localDataSource.count() > 0) return
importMutex.withLock { if (localDataSource.count() == 0) doImport() }
}
override suspend fun reconcile() {
importMutex.withLock { doImport() }
}
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 fun observeAllLinks(): Flow<List<DeviceLink>> = flow {
ensureImported()
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
}
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" } }
}
/**
* 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).
*/
private fun marketplaceRegions(code: String, marketplaces: Map<String, MshToMarketplace>): List<String>? =
DeviceLinkMatcher.marketplaceKeyFor(code, marketplaces.keys)?.let { marketplaces.getValue(it).regions }
}

View File

@@ -0,0 +1,147 @@
/*
* 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

@@ -0,0 +1,162 @@
/*
* 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 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.di.CoroutineDispatchers
import org.meshtastic.core.model.MshToMarketplace
import org.meshtastic.core.model.MshToRoute
import org.meshtastic.core.model.NetworkDeviceHardware
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
override fun loadMarketplaces(): Map<String, MshToMarketplace> = marketplaces
}
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 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)
@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,
)
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")),
)
repository = DeviceLinkRepositoryImpl(json, linkLocal, hardwareLocal)
seedDeviceTargets("rak4631")
repository.reconcile()
assertNull(linkLocal.getAll().single().regions)
}
@Test
fun ensureImportedSeedsOnlyWhenEmpty() = runTest(dispatcher) {
seedDeviceTargets("rak4631")
repository.ensureImported()
assertEquals(4, linkLocal.count())
// A second ensureImported with a larger bundled file must NOT re-import (table already populated).
json.routes = json.routes + route("new-code")
repository.ensureImported()
assertEquals(4, linkLocal.count())
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@ import androidx.room3.TypeConverters
import androidx.room3.migration.AutoMigrationSpec
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.database.dao.DeviceHardwareDao
import org.meshtastic.core.database.dao.DeviceLinkDao
import org.meshtastic.core.database.dao.FirmwareReleaseDao
import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.dao.NodeInfoDao
@@ -33,6 +34,7 @@ import org.meshtastic.core.database.dao.QuickChatActionDao
import org.meshtastic.core.database.dao.TracerouteNodePositionDao
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.entity.DeviceHardwareEntity
import org.meshtastic.core.database.entity.DeviceLinkEntity
import org.meshtastic.core.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MetadataEntity
@@ -57,6 +59,7 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
ReactionEntity::class,
MetadataEntity::class,
DeviceHardwareEntity::class,
DeviceLinkEntity::class,
FirmwareReleaseEntity::class,
TracerouteNodePositionEntity::class,
],
@@ -99,8 +102,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 37, to = 38),
AutoMigration(from = 38, to = 39),
AutoMigration(from = 39, to = 40),
AutoMigration(from = 40, to = 41),
],
version = 40,
version = 41,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
@@ -117,6 +121,8 @@ abstract class MeshtasticDatabase : RoomDatabase() {
abstract fun deviceHardwareDao(): DeviceHardwareDao
abstract fun deviceLinkDao(): DeviceLinkDao
abstract fun firmwareReleaseDao(): FirmwareReleaseDao
abstract fun tracerouteNodePositionDao(): TracerouteNodePositionDao

View File

@@ -36,6 +36,9 @@ interface DeviceHardwareDao {
@Query("SELECT * FROM device_hardware WHERE hwModel = :hwModel AND platformio_target = :target")
suspend fun getByModelAndTarget(hwModel: Int, target: String): DeviceHardwareEntity?
@Query("SELECT platformio_target FROM device_hardware")
suspend fun getAllTargets(): List<String>
@Query("SELECT COUNT(*) FROM device_hardware")
suspend fun count(): Int

View File

@@ -0,0 +1,43 @@
/*
* 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.database.dao
import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.database.entity.DeviceLinkEntity
@Dao
interface DeviceLinkDao {
@Upsert suspend fun upsertAll(links: List<DeviceLinkEntity>)
@Query("SELECT * FROM device_link ORDER BY short_code")
fun observeAll(): Flow<List<DeviceLinkEntity>>
@Query("SELECT * FROM device_link")
suspend fun getAll(): List<DeviceLinkEntity>
@Query("DELETE FROM device_link WHERE short_code NOT IN (:keep)")
suspend fun deleteNotIn(keep: List<String>)
@Query("DELETE FROM device_link")
suspend fun deleteAll()
@Query("SELECT COUNT(*) FROM device_link")
suspend fun count(): Int
}

View File

@@ -0,0 +1,50 @@
/*
* 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.database.entity
import androidx.room3.ColumnInfo
import androidx.room3.Entity
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. */
@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,
)
fun DeviceLink.asEntity() = DeviceLinkEntity(
shortCode = shortCode,
originalUrl = originalUrl,
linkDescription = description,
isVendor = isVendor,
regions = regions,
)
fun DeviceLinkEntity.asExternalModel() = DeviceLink(
shortCode = shortCode,
originalUrl = originalUrl,
description = linkDescription,
isVendor = isVendor,
regions = regions,
)

View File

@@ -0,0 +1,43 @@
/*
* 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.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.
*
* @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.
*/
@Serializable
data class DeviceLink(
val shortCode: String,
val originalUrl: String,
val description: String? = null,
val isVendor: Boolean = false,
val regions: List<String>? = null,
) {
/** The user-facing link, routed through the msh.to redirect service. */
val url: String
get() = "https://msh.to/$shortCode"
}

View File

@@ -0,0 +1,41 @@
/*
* 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

@@ -170,6 +170,8 @@ sealed interface SettingsRoute : Route {
@Serializable data object NodeList : SettingsRoute
@Serializable data object DeviceLinks : SettingsRoute
@Serializable data object AppFunctionsSettings : SettingsRoute
// endregion

View File

@@ -0,0 +1,43 @@
/*
* 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.repository
import kotlinx.coroutines.flow.Flow
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.
*/
interface DeviceLinkRepository {
/** Seeds the link table from the bundled JSON if it is empty (covers fresh install, data clear, radio switch). */
suspend fun ensureImported()
/** Re-imports the bundled JSON: upserts all links, recomputes `isVendor`, and prunes orphaned short codes. */
suspend fun reconcile()
/**
* Links for a device's [platformioTarget], region-filtered and sorted with vendor/variant 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. */
fun observeAllLinks(): Flow<List<DeviceLink>>
}

View File

@@ -204,6 +204,7 @@
<string name="codec2_sample_rate">CODEC2 sample rate</string>
<string name="coding_rate">Coding Rate</string>
<string name="collapse_chart">Collapse chart</string>
<string name="collapsed">Collapsed</string>
<string name="communicate_off_the_grid">Communicate off-the-grid with your friends and community without cell service.</string>
<!-- COMPASS -->
<string name="compass_bearing">Bearing: %1$s</string>
@@ -340,6 +341,9 @@
<string name="device_db_cache_limit">Device DB cache limit</string>
<string name="device_db_cache_limit_summary">Max device databases to keep on this phone</string>
<string name="device_gps">Device GPS</string>
<string name="device_links">Device Links</string>
<string name="device_links_i_want_one">I want one</string>
<string name="device_links_open_in_browser">Open in browser</string>
<string name="device_metrics_label_value">%1$s: %2$s</string>
<string name="device_metrics_log">Device Metrics</string>
<string name="device_metrics_numeric_value">%1$s</string>
@@ -457,6 +461,7 @@
<string name="event_welcome_open_sauce">Welcome to Open Sauce! 🔧</string>
<string name="exchange_position">Exchange position</string>
<string name="expand_chart">Expand chart</string>
<string name="expanded">Expanded</string>
<string name="expires">Expires</string>
<!-- EXPORT -->
<string name="export_configuration">Export configuration</string>

View File

@@ -37,7 +37,10 @@ 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.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.NetworkFirmwareReleases
import org.meshtastic.core.network.HttpClientDefaults
@@ -267,4 +270,12 @@ private fun desktopPlatformStubsModule() = module {
override fun loadBootloaderOtaQuirksFromJsonAsset(): List<BootloaderOtaQuirk> = emptyList()
}
}
single<MshToLinksJsonDataSource> {
object : MshToLinksJsonDataSource {
override fun loadRoutes(): List<MshToRoute> = emptyList()
override fun loadMarketplaces(): Map<String, MshToMarketplace> = emptyMap()
}
}
}

View File

@@ -2,7 +2,7 @@
title: Nodes
parent: User Guide
nav_order: 4
last_updated: 2026-05-20
last_updated: 2026-06-02
description: Browse, filter, and sort mesh nodes — view details, signal quality, roles, and quick actions.
aliases:
- node-list
@@ -140,6 +140,12 @@ Inline status indicators show key metrics at a glance:
| Last heard | ![Last heard](../../assets/screenshots/nodes_last_heard.png) |
| Distance | ![Distance](../../assets/screenshots/nodes_distance_info.png) |
### Device Links ("I want one")
When a node's hardware is recognized, the detail view shows a collapsible **"I want one"** section linking to places to buy or learn more about that device: the vendor's product page, product variants, and regional marketplace listings (such as AliExpress, Amazon, and supported retailers), filtered to your country. Each link opens through the `msh.to` redirect service. Devices with no matching links don't show the section.
A full, browsable directory of every link is also available under **Settings → Device Links**.
## Related Topics
- [Node Metrics](node-metrics) — detailed telemetry dashboards for each node

View File

@@ -0,0 +1,140 @@
/*
* 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.feature.node.component
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.model.DeviceLink
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.collapsed
import org.meshtastic.core.resources.device_links_i_want_one
import org.meshtastic.core.resources.device_links_open_in_browser
import org.meshtastic.core.resources.expanded
import org.meshtastic.core.ui.icon.ExpandLess
import org.meshtastic.core.ui.icon.ExpandMore
import org.meshtastic.core.ui.icon.Language
import org.meshtastic.core.ui.icon.MeshtasticIcons
/**
* Collapsible "I want one" section listing msh.to vendor/variant and marketplace links for the viewed device. Renders
* nothing when there are no matching links. Ported from the Meshtastic-Apple `DeviceLinksSection`.
*/
@Composable
fun DeviceLinksSection(links: List<DeviceLink>, modifier: Modifier = Modifier) {
if (links.isEmpty()) return
var expanded by rememberSaveable { mutableStateOf(false) }
val title = stringResource(Res.string.device_links_i_want_one)
val expandStateDescription = stringResource(if (expanded) Res.string.expanded else Res.string.collapsed)
ElevatedCard(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.elevatedCardColors(containerColor = colorScheme.surfaceContainerHigh),
shape = MaterialTheme.shapes.extraLarge,
) {
Column(modifier = Modifier.padding(vertical = 16.dp).animateContentSize()) {
Row(
modifier =
Modifier.fillMaxWidth()
.clickable(role = Role.Button) { expanded = !expanded }
.semantics { stateDescription = expandStateDescription }
.padding(horizontal = 20.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f).semantics { heading() },
)
Icon(
imageVector = if (expanded) MeshtasticIcons.ExpandLess else MeshtasticIcons.ExpandMore,
contentDescription = null,
tint = colorScheme.primary,
)
}
if (expanded) {
links.forEach { DeviceLinkRow(it) }
}
}
}
}
@Composable
private fun DeviceLinkRow(link: DeviceLink) {
val uriHandler = LocalUriHandler.current
// Vendor and product-variant links are emphasized; marketplace links (region-tagged) are quieter.
val prominent = link.isVendor || link.regions == null
val openLabel = stringResource(Res.string.device_links_open_in_browser)
val label = link.description ?: link.shortCode
Row(
modifier =
Modifier.fillMaxWidth()
.defaultMinSize(minHeight = 48.dp)
.clickable(role = Role.Button) { uriHandler.openUri(link.url) }
.padding(horizontal = 20.dp, vertical = 8.dp)
.semantics { contentDescription = "$openLabel: $label" },
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = if (prominent) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium,
fontWeight = if (prominent) FontWeight.SemiBold else FontWeight.Normal,
color = colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Spacer(Modifier.width(8.dp))
Icon(
imageVector = MeshtasticIcons.Language,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = colorScheme.primary,
)
}
}

View File

@@ -183,3 +183,30 @@ private fun NodeDetailsSectionWithDeviceHeroPreview() {
Surface { NodeDetailsSection(node = node, deviceHardware = deviceHardware, reportedTarget = "heltec-v3") }
}
}
@PreviewLightDark
@Composable
private fun DeviceLinksSectionPreview() {
val links =
listOf(
org.meshtastic.core.model.DeviceLink(
shortCode = "heltec-v3",
originalUrl = "https://heltec.org",
description = "Heltec V3",
isVendor = true,
),
org.meshtastic.core.model.DeviceLink(
shortCode = "rokland-heltec-v3",
originalUrl = "https://rokland.com",
description = "Rokland",
regions = listOf("US"),
),
org.meshtastic.core.model.DeviceLink(
shortCode = "heltec-v3_aliexpress",
originalUrl = "https://aliexpress.com",
description = "AliExpress",
regions = emptyList(),
),
)
AppTheme { Surface { DeviceLinksSection(links = links) } }
}

View File

@@ -41,6 +41,7 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.loading
import org.meshtastic.feature.node.component.AdministrationSection
import org.meshtastic.feature.node.component.DeviceActions
import org.meshtastic.feature.node.component.DeviceLinksSection
import org.meshtastic.feature.node.component.NodeDetailsSection
import org.meshtastic.feature.node.component.NotesSection
import org.meshtastic.feature.node.model.NodeDetailAction
@@ -109,6 +110,9 @@ fun NodeDetailList(
reportedTarget = uiState.metricsState.reportedTarget,
)
}
if (uiState.metricsState.deviceLinks.isNotEmpty()) {
item { DeviceLinksSection(links = uiState.metricsState.deviceLinks) }
}
item {
DeviceActions(
node = node,

View File

@@ -22,16 +22,19 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onStart
import org.koin.core.annotation.Single
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.DeviceLink
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
import org.meshtastic.core.model.util.isDirectSignal
import org.meshtastic.core.repository.DeviceHardwareRepository
import org.meshtastic.core.repository.DeviceLinkRepository
import org.meshtastic.core.repository.FirmwareReleaseRepository
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
@@ -59,6 +62,7 @@ constructor(
private val meshLogRepository: MeshLogRepository,
private val radioConfigRepository: RadioConfigRepository,
private val deviceHardwareRepository: DeviceHardwareRepository,
private val deviceLinkRepository: DeviceLinkRepository,
private val firmwareReleaseRepository: FirmwareReleaseRepository,
private val nodeRequestActions: NodeRequestActions,
) : GetNodeDetailsUseCase {
@@ -114,8 +118,8 @@ constructor(
IdentityGroup(ourNode, myInfo, profile)
}
// 3. Device Hardware — non-blocking Flow derived from stable (hwModel, pioEnv) key.
val hardwareFlow: Flow<DeviceHardware?> =
// 3. Device Hardware (+ msh.to links) — non-blocking Flow derived from stable (hwModel, pioEnv) key.
val hardwareAndLinksFlow: Flow<Pair<DeviceHardware?, List<DeviceLink>>> =
combine(nodeFlow, identityFlow) { node, identity ->
val isLocal = node.num == identity.ourNode?.num
val pioEnv = if (isLocal) identity.myInfo?.pioEnv else null
@@ -124,6 +128,13 @@ constructor(
.distinctUntilChanged()
.flatMapLatest { key -> deviceHardwareRepository.observeDeviceHardware(key.hwModel, key.target) }
.onStart { emit(null) }
.mapLatest { hw ->
val links =
hw?.platformioTarget
?.takeIf { it.isNotBlank() }
?.let { deviceLinkRepository.getLinksForTarget(it) } ?: emptyList()
hw to links
}
// 4. Metadata & Request Timestamps
val metadataFlow =
@@ -157,7 +168,7 @@ constructor(
identityFlow,
metadataFlow,
requestsFlow,
hardwareFlow,
hardwareAndLinksFlow,
) { args: Array<Any?> ->
@Suppress("UNCHECKED_CAST")
val node = args[NODE_INDEX] as Node
@@ -165,7 +176,7 @@ constructor(
val identity = args[IDENTITY_INDEX] as IdentityGroup
val metadata = args[METADATA_INDEX] as MetadataGroup
val requests = args[REQUESTS_INDEX] as Pair<List<MeshLog>, List<MeshLog>>
val hw = args[HARDWARE_INDEX] as DeviceHardware?
val (hw, deviceLinks) = args[HARDWARE_INDEX] as Pair<DeviceHardware?, List<DeviceLink>>
val (trReqs, niReqs) = requests
val isLocal = node.num == identity.ourNode?.num
@@ -179,6 +190,7 @@ constructor(
node = node,
isLocal = isLocal,
deviceHardware = hw,
deviceLinks = deviceLinks,
reportedTarget = pioEnv,
isManaged = identity.profile.config?.security?.is_managed ?: false,
isFahrenheit =

View File

@@ -18,6 +18,7 @@ package org.meshtastic.feature.node.model
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.core.model.DeviceLink
import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.Node
import org.meshtastic.proto.Config
@@ -42,6 +43,8 @@ data class MetricsState(
val neighborInfoResults: List<MeshLog> = emptyList(),
val positionLogs: List<Position> = emptyList(),
val deviceHardware: DeviceHardware? = null,
/** msh.to vendor/marketplace links for this device's hardware, region-filtered and sorted (vendor first). */
val deviceLinks: List<DeviceLink> = emptyList(),
val firmwareEdition: FirmwareEdition? = null,
val latestStableFirmware: FirmwareRelease = FirmwareRelease(),
val latestAlphaFirmware: FirmwareRelease = FirmwareRelease(),

View File

@@ -49,6 +49,7 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.app_functions_settings
import org.meshtastic.core.resources.app_functions_settings_summary
import org.meshtastic.core.resources.bottom_nav_settings
import org.meshtastic.core.resources.device_links
import org.meshtastic.core.resources.export_configuration
import org.meshtastic.core.resources.filter_settings
import org.meshtastic.core.resources.help_and_documentation
@@ -60,6 +61,7 @@ import org.meshtastic.core.resources.wifi_devices
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.icon.Device
import org.meshtastic.core.ui.icon.FilterList
import org.meshtastic.core.ui.icon.HelpOutline
import org.meshtastic.core.ui.icon.List
@@ -272,6 +274,12 @@ fun SettingsScreen(
}
}
ExpressiveSection(title = stringResource(Res.string.device_links)) {
ListItem(text = stringResource(Res.string.device_links), leadingIcon = MeshtasticIcons.Device) {
onNavigate(SettingsRoute.DeviceLinks)
}
}
if (appFunctionsAvailable) {
ExpressiveSection(title = stringResource(Res.string.app_functions_settings)) {
ListItem(

View File

@@ -0,0 +1,72 @@
/*
* 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.feature.settings
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.device_links
import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.icon.Language
import org.meshtastic.core.ui.icon.MeshtasticIcons
/** Directory of every imported msh.to short code. Tapping a row opens `msh.to/{shortCode}` in the browser. */
@Composable
fun DeviceLinkDirectoryScreen(
viewModel: DeviceLinkDirectoryViewModel,
onNavigateUp: () -> Unit,
modifier: Modifier = Modifier,
) {
val links by viewModel.links.collectAsStateWithLifecycle()
val uriHandler = LocalUriHandler.current
Scaffold(
modifier = modifier,
topBar = {
MainAppBar(
title = stringResource(Res.string.device_links),
canNavigateUp = true,
onNavigateUp = onNavigateUp,
ourNode = null,
showNodeChip = false,
actions = {},
onClickChip = {},
)
},
) { paddingValues ->
LazyColumn(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
items(links, key = { it.shortCode }) { link ->
ListItem(
text = link.description ?: link.shortCode,
supportingText = "msh.to/${link.shortCode}",
trailingIcon = MeshtasticIcons.Language,
onClick = { uriHandler.openUri(link.url) },
)
}
}
}
}

View File

@@ -0,0 +1,30 @@
/*
* 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.feature.settings
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.StateFlow
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.model.DeviceLink
import org.meshtastic.core.repository.DeviceLinkRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
/** Backs the Settings "Device Links" directory: all imported msh.to links, sorted by short code. */
@KoinViewModel
class DeviceLinkDirectoryViewModel(deviceLinkRepository: DeviceLinkRepository) : ViewModel() {
val links: StateFlow<List<DeviceLink>> = deviceLinkRepository.observeAllLinks().stateInWhileSubscribed(emptyList())
}

View File

@@ -33,6 +33,7 @@ import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.feature.settings.AboutScreen
import org.meshtastic.feature.settings.AdministrationScreen
import org.meshtastic.feature.settings.DeviceConfigurationScreen
import org.meshtastic.feature.settings.DeviceLinkDirectoryScreen
import org.meshtastic.feature.settings.ModuleConfigurationScreen
import org.meshtastic.feature.settings.NodeListScreen
import org.meshtastic.feature.settings.SettingsViewModel
@@ -251,6 +252,12 @@ fun EntryProviderScope<NavKey>.settingsGraph(backStack: NavBackStack<NavKey>) {
)
}
entry<SettingsRoute.DeviceLinks> {
DeviceLinkDirectoryScreen(
viewModel = koinViewModel(),
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
)
}
entry<SettingsRoute.AppFunctionsSettings> {
val viewModel: AppFunctionsSettingsViewModel = koinViewModel()
AppFunctionsSettingsScreen(viewModel = viewModel, onBack = dropUnlessResumed { backStack.removeLastOrNull() })

View File

@@ -48,6 +48,7 @@ import org.meshtastic.core.resources.app_version
import org.meshtastic.core.resources.bottom_nav_settings
import org.meshtastic.core.resources.device_db_cache_limit
import org.meshtastic.core.resources.device_db_cache_limit_summary
import org.meshtastic.core.resources.device_links
import org.meshtastic.core.resources.help_and_documentation
import org.meshtastic.core.resources.info
import org.meshtastic.core.resources.modules_already_unlocked
@@ -62,6 +63,7 @@ import org.meshtastic.core.ui.component.ListItem
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.icon.ChevronRight
import org.meshtastic.core.ui.icon.Device
import org.meshtastic.core.ui.icon.FormatPaint
import org.meshtastic.core.ui.icon.HelpOutline
import org.meshtastic.core.ui.icon.Info
@@ -213,6 +215,12 @@ fun DesktopSettingsScreen(
}
}
ExpressiveSection(title = stringResource(Res.string.device_links)) {
ListItem(text = stringResource(Res.string.device_links), leadingIcon = MeshtasticIcons.Device) {
onNavigate(SettingsRoute.DeviceLinks)
}
}
ExpressiveSection(title = stringResource(Res.string.wifi_devices)) {
ListItem(text = stringResource(Res.string.wifi_devices), leadingIcon = MeshtasticIcons.Wifi) {
onNavigate(WifiProvisionRoute.WifiProvision())