mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-16 17:59:07 -04:00
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:
5
.skills/compose-ui/strings-index.txt
generated
5
.skills/compose-ui/strings-index.txt
generated
@@ -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
|
||||
|
||||
126
androidApp/src/main/assets/marketplaces.json
Normal file
126
androidApp/src/main/assets/marketplaces.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1009
androidApp/src/main/assets/urls.json
Normal file
1009
androidApp/src/main/assets/urls.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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/...".
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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" } }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
@@ -170,6 +170,8 @@ sealed interface SettingsRoute : Route {
|
||||
|
||||
@Serializable data object NodeList : SettingsRoute
|
||||
|
||||
@Serializable data object DeviceLinks : SettingsRoute
|
||||
|
||||
@Serializable data object AppFunctionsSettings : SettingsRoute
|
||||
|
||||
// endregion
|
||||
|
||||
@@ -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>>
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 |  |
|
||||
| Distance |  |
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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) } }
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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() })
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user