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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .skills/compose-ui/strings-index.txt

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion androidApp/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ dependencies {
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
implementation(libs.jetbrains.lifecycle.runtime.compose)
implementation(libs.jetbrains.navigation3.ui)
implementation(libs.ktor.client.android)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktor.client.logging)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import coil3.svg.SvgDecoder
import coil3.util.DebugLogger
import coil3.util.Logger
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpTimeout
Expand Down Expand Up @@ -95,9 +95,15 @@ class NetworkModule {
.crossfade(enable = true)
.build()

/**
* Uses the OkHttp engine, not `Android`. The `Android` engine is backed by [java.net.HttpURLConnection], which on
* device routes through the platform's bundled OkHttp 2.x fork (`com.android.okhttp`). Cancelling a request while
* its gzip/chunked response body is still being read trips a re-entrant `AsyncTimeout.enter()` in that fork and
* crashes with "Unbalanced enter/exit" (Crashlytics 97ae5ea1, 2.8.0 only). Square's OkHttp has no such bug.
*/
@Single
fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient =
HttpClient(engineFactory = Android) {
HttpClient(engineFactory = OkHttp) {
install(plugin = ContentNegotiation) { json(json) }
install(DefaultRequest) { url(HttpClientDefaults.API_BASE_URL) }
install(plugin = HttpTimeout) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@ import co.touchlab.kermit.Logger
* the first time a secondary transport learns a `myNodeNum` that another transport already claimed.
*
* Every per-device table is unified so switching transport is seamless: messages (+FTS), reactions, contact mute/read
* settings, nodes and their notes, per-node metadata, the audit log (each session's received-packet history — the
* source of the telemetry timelines, position history, and traceroute results the UI reconstructs), traceroute
* positions, and discovery sessions. Where the same row exists on both sides the destination is preferred (it will be
* refreshed by the same radio's re-dump on connect); source-only rows and strictly newer history are brought over.
* Packets/reactions in [source] and [dest] already share the same `myNodeNum` (same node), so no key remapping is
* needed there; autoincrement-keyed rows (packets, discovery) are re-inserted with fresh ids to avoid collisions.
* settings, nodes and their notes, per-node metadata, quick-chat actions, the audit log (each session's received-packet
* history — the source of the telemetry timelines, position history, and traceroute results the UI reconstructs),
* traceroute positions, and discovery sessions. Where the same row exists on both sides the destination is preferred
* (it will be refreshed by the same radio's re-dump on connect); source-only rows and strictly newer history are
* brought over. Packets/reactions in [source] and [dest] already share the same `myNodeNum` (same node), so no key
* remapping is needed there; autoincrement-keyed rows (packets, discovery) are re-inserted with fresh ids to avoid
* collisions.
*/
object DatabaseMerger {

Expand All @@ -49,6 +50,7 @@ object DatabaseMerger {
mergeContactSettings(source, dest)
mergeNodes(source, dest)
mergeMetadata(source, dest)
mergeQuickChat(source, dest)
// Logs must precede traceroute positions: the latter has a CASCADE foreign key onto log.uuid.
mergeLogs(source, dest)
mergeTraceroutePositions(source, dest)
Expand Down Expand Up @@ -110,6 +112,23 @@ object DatabaseMerger {
}
}

/**
* User-authored quick-chat buttons. `uuid`/`position` are per-DB, so append the source's actions after the
* destination's last one with fresh ids, skipping any the destination already has (same name + message + mode).
* Without this the source's buttons are lost outright when [DatabaseManager] retires it.
*/
private suspend fun mergeQuickChat(source: MeshtasticDatabase, dest: MeshtasticDatabase) {
val destActions = dest.quickChatActionDao().getAllSnapshot()
val existing = destActions.mapTo(mutableSetOf()) { Triple(it.name, it.message, it.mode) }
var nextPosition = (destActions.maxOfOrNull { it.position } ?: -1) + 1
source.quickChatActionDao().getAllSnapshot().forEach { action ->
if (existing.add(Triple(action.name, action.message, action.mode))) {
dest.quickChatActionDao().upsert(action.copy(uuid = 0L, position = nextPosition))
nextPosition++
}
}
}

/**
* The audit log holds each transport session's received-packet history — the source of the telemetry timelines,
* position history, and node-info history the UI reconstructs per node/port. This is the only place that time
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ interface QuickChatActionDao {
@Query("Select * from quick_chat order by position asc")
fun getAll(): Flow<List<QuickChatAction>>

/** Snapshot used by DatabaseMerger to fold one transport's DB into another for the same node. */
@Query("Select * from quick_chat order by position asc")
suspend fun getAllSnapshot(): List<QuickChatAction>

@Upsert suspend fun upsert(action: QuickChatAction)

@Query("Delete from quick_chat")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,15 @@ abstract class BaseRadioTransportFactory(
InterfaceId.TCP.id,
InterfaceId.SERIAL.id,
InterfaceId.BLUETOOTH.id,
InterfaceId.MOCK.id,
InterfaceId.REPLAY.id,
'!',
-> true

// Virtual transports are development aids. A release build must never bind one: `connections?address=m` is
// reachable from any web page through the verified meshtastic.org app link.
InterfaceId.MOCK.id,
InterfaceId.REPLAY.id,
-> isMockTransport()

else -> isPlatformAddressValid(address)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,10 @@
<string name="debug_store_logs_title">Store mesh logs</string>
<string name="debug_tab_app_logs">App logs</string>
<string name="debug_tab_packets">Packets</string>
<string name="deep_link_connect_message">A link is asking this app to connect to the device at %1$s. Only continue if you trust where the link came from.</string>
<string name="deep_link_connect_title">Connect to this device?</string>
<string name="deep_link_disconnect_message">A link is asking this app to disconnect from your device. Only continue if you trust where the link came from.</string>
<string name="deep_link_disconnect_title">Disconnect from your device?</string>
<string name="default_">Default</string>
<string name="default_mqtt_address" translatable="false">mqtt.meshtastic.org</string>
<!-- DELETE -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -395,10 +395,14 @@ actual fun rememberLocationPermissionState(): PermissionUiState = rememberRuntim
@Composable
actual fun rememberBluetoothPermissionState(): PermissionUiState {
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) {
// Pre-Android 12 has no runtime Bluetooth permission — BLE scanning is gated by the location permission, which
// callers request separately (the intro Location screen, the map/Privacy location flows). Report granted here
// so the Bluetooth surface itself is a no-op rather than masquerading as a location request.
return rememberGrantedPermissionState()
// Pre-Android 12 has no runtime Bluetooth permission — the platform gates BLE scanning on fine location
// instead. Reporting "granted" here left a user who declined location with an empty device list and no way to
// re-prompt, so surface the permission that actually blocks the scan. Fine specifically: API 29/30 return zero
// scan results on a coarse-only grant.
return rememberRuntimePermissionState(
permissions = arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION),
requireAll = true,
)
}
return rememberRuntimePermissionState(
permissions =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -361,14 +361,14 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
items.add { PaxcountInfo(pax = "B:${pax.ble} W:${pax.wifi}", contentColor = contentColor) }
}

if ((env.temperature ?: 0f) != 0f) {
val temp = MetricFormatter.temperature(env.temperature ?: 0f, tempInFahrenheit)
items.add { TemperatureInfo(temp = temp, contentColor = contentColor) }
} else if ((aq.co2_temperature ?: 0f) != 0f) {
val temp = MetricFormatter.temperature(aq.co2_temperature ?: 0f, tempInFahrenheit)
// Temperature carries presence, so `null` already means "no sensor" — testing against 0 hid an ordinary 0 °C
// reading, which the temperature chart plots. Prefer the environment sensor, then the SCD4x CO₂ sensor's own.
(env.temperature ?: aq.co2_temperature)?.let { temperature ->
val temp = MetricFormatter.temperature(temperature, tempInFahrenheit)
items.add { TemperatureInfo(temp = temp, contentColor = contentColor) }
}

// Humidity keeps its zero-guard: 0% RH is not physically reachable, and the humidity chart filters it too.
if ((env.relative_humidity ?: 0f) != 0f) {
items.add {
HumidityInfo(humidity = MetricFormatter.humidity(env.relative_humidity ?: 0f), contentColor = contentColor)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ internal fun processTcpServices(
}
DeviceListEntry.Tcp(displayName, address)
}
// mDNS can resolve two services to one host:port (e.g. re-announce before the old record expires). The device
// list keys on fullAddress, and duplicate LazyColumn keys are a hard crash.
.distinctBy { it.fullAddress }
.sortedBy { it.name }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,26 @@
*/
package org.meshtastic.feature.connections.navigation

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import org.meshtastic.core.navigation.ConnectionsRoute
import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.cancel
import org.meshtastic.core.resources.connect
import org.meshtastic.core.resources.deep_link_connect_message
import org.meshtastic.core.resources.deep_link_connect_title
import org.meshtastic.core.resources.deep_link_disconnect_message
import org.meshtastic.core.resources.deep_link_disconnect_title
import org.meshtastic.core.resources.disconnect
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.feature.connections.NO_DEVICE_SELECTED
import org.meshtastic.feature.connections.ScannerViewModel
import org.meshtastic.feature.connections.ui.ConnectionsScreen
Expand All @@ -32,13 +45,33 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
fun EntryProviderScope<NavKey>.connectionsGraph(backStack: NavBackStack<NavKey>) {
entry<ConnectionsRoute.Connections> { key ->
val scanModel = koinViewModel<ScannerViewModel>()
// Lets a deep link (e.g. from AI/automation tooling) trigger a connection, or a disconnect via `n`, without
// manual device selection.
LaunchedEffect(key.address) {
key.address?.takeIf(String::isNotBlank)?.let { address ->
if (address == NO_DEVICE_SELECTED) scanModel.disconnect() else scanModel.changeDeviceAddress(address)
}

// A deep link (e.g. from AI/automation tooling) may name a device address, or `n` to disconnect. The
// `connections` path is a verified https://meshtastic.org app link, so any web page can fire one at us —
// always confirm before re-pointing or dropping the radio connection.
var pendingAddress by rememberSaveable(key.address) { mutableStateOf(key.address?.takeIf(String::isNotBlank)) }

pendingAddress?.let { address ->
val isDisconnect = address == NO_DEVICE_SELECTED
MeshtasticDialog(
titleRes =
if (isDisconnect) Res.string.deep_link_disconnect_title else Res.string.deep_link_connect_title,
message =
if (isDisconnect) {
stringResource(Res.string.deep_link_disconnect_message)
} else {
stringResource(Res.string.deep_link_connect_message, address)
},
confirmTextRes = if (isDisconnect) Res.string.disconnect else Res.string.connect,
onConfirm = {
if (isDisconnect) scanModel.disconnect() else scanModel.changeDeviceAddress(address)
pendingAddress = null
},
dismissTextRes = Res.string.cancel,
onDismiss = { pendingAddress = null },
)
}

ConnectionsScreen(
scanModel = scanModel,
radioConfigViewModel = koinViewModel<RadioConfigViewModel>(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,23 @@ class TcpDiscoveryHelpersTest {
result[1].name shouldBe "Zulu"
}

@Test
fun `processTcpServices dedupes services resolving to the same address`() {
// Duplicate mDNS announcements (multi-interface, re-resolution) yield the same host/port twice
val service =
DiscoveredService(
name = "Meshtastic_abcd",
hostAddress = "192.168.1.10",
port = 4403,
txt = mapOf("shortname" to "Mesh".encodeToByteArray(), "id" to "!abcd".encodeToByteArray()),
)

val result = processTcpServices(listOf(service, service.copy(name = "Meshtastic_abcd (2)")), emptyList())

result.size shouldBe 1
result[0].fullAddress shouldBe "t192.168.1.10"
}

@Test
fun `matchDiscoveredTcpNodes matches node by device id`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!1234")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,11 @@ internal fun isValidFirmwareFile(filename: String, target: String, fileExtension

val targetToken = Regex.escape(target)
val extensionToken = Regex.escape(fileExtension)
val targetPattern = Regex("(^|.*[\\-_])$targetToken(([\\-_.](v?\\d|firmware)).*$extensionToken$|$extensionToken$)")
// What follows the target must be a dotted version (`-2.7.17`, `-v2.7.17`), the literal `firmware`, or the
// extension itself. Accepting a bare digit here let digit-led sibling variants through — `tlora-v2` matched
// `firmware-tlora-v2-1-1_6-2.7.14.bin`, which belongs to `tlora-v2-1-1_6` — and flashed the wrong variant.
val versionOrFirmware = "([\\-_]v?\\d+\\.\\d+|[\\-_.]firmware)"
val targetPattern = Regex("(^|.*[\\-_])$targetToken($versionOrFirmware.*$extensionToken$|$extensionToken$)")
return targetPattern.matches(filename)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,21 @@ class IsValidFirmwareFileTest {
assertTrue(isValidFirmwareFile("firmware-nano-g1-explorer-2.8.0.bin", "nano-g1-explorer", ".bin"))
}

@Test
fun `rejects digit-led sibling variants`() {
// The variant suffix starts with a digit, so it used to be mistaken for the version and flash the wrong board.
assertFalse(isValidFirmwareFile("firmware-tlora-v2-1-1_6-2.7.14.bin", "tlora-v2", ".bin"))
assertFalse(isValidFirmwareFile("firmware-tlora-v1-3-2.7.17.bin", "tlora-v1", ".bin"))
// The variants' own targets still match.
assertTrue(isValidFirmwareFile("firmware-tlora-v2-1-1_6-2.7.14.bin", "tlora-v2-1-1_6", ".bin"))
assertTrue(isValidFirmwareFile("firmware-tlora-v1-3-2.7.17.bin", "tlora-v1-3", ".bin"))
}

@Test
fun `accepts a v-prefixed version after the target`() {
assertTrue(isValidFirmwareFile("firmware-heltec-v3-v2.7.17.bin", "heltec-v3", ".bin"))
}

@Test
fun `accepts older local firmware filename shapes`() {
// Older naming where "firmware" appears as a segment after the target.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,11 @@ import org.meshtastic.core.resources.air_quality_metrics_log
import org.meshtastic.core.resources.co2
import org.meshtastic.core.resources.co2_humidity
import org.meshtastic.core.resources.co2_temperature
import org.meshtastic.core.resources.micrograms_per_cubic_meter
import org.meshtastic.core.resources.pm10
import org.meshtastic.core.resources.pm1_0
import org.meshtastic.core.resources.pm2_5
import org.meshtastic.core.resources.ppm
import org.meshtastic.core.ui.component.Co2Severity
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.theme.GraphColors.Blue
Expand Down Expand Up @@ -304,18 +306,25 @@ private fun AirQualityMetricsCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
val ugm3 = stringResource(Res.string.micrograms_per_cubic_meter)
val ppmUnit = stringResource(Res.string.ppm)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Column {
// Present-and-zero is a valid clean-air reading; only `?.` (absent field) hides a row.
aq.pm10_standard?.let { Text("PM1.0: $it µg/m³", style = MaterialTheme.typography.bodySmall) }
aq.pm25_standard?.let { Text("PM2.5: $it µg/m³", style = MaterialTheme.typography.bodySmall) }
aq.pm100_standard?.let { Text("PM10: $it µg/m³", style = MaterialTheme.typography.bodySmall) }
listOfNotNull(
aq.pm10_standard?.let { Res.string.pm1_0 to it },
aq.pm25_standard?.let { Res.string.pm2_5 to it },
aq.pm100_standard?.let { Res.string.pm10 to it },
)
.forEach { (label, value) ->
Text("${stringResource(label)}: $value $ugm3", style = MaterialTheme.typography.bodySmall)
}
}
Column {
aq.co2?.let { co2 ->
val severity = Co2Severity.fromPpm(co2)
Text(
text = "CO₂: $co2 ppm",
text = "${stringResource(Res.string.co2)}: $co2 $ppmUnit",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
color = severity?.color ?: MaterialTheme.colorScheme.onSurface,
Expand Down
Loading