Skip to content

Commit 0a3e000

Browse files
jamesarichclaude
andauthored
fix: address 2.8.0 release-audit findings (#6189)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent de37a8c commit 0a3e000

17 files changed

Lines changed: 165 additions & 36 deletions

File tree

.skills/compose-ui/strings-index.txt

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

androidApp/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ dependencies {
266266
implementation(libs.jetbrains.lifecycle.viewmodel.compose)
267267
implementation(libs.jetbrains.lifecycle.runtime.compose)
268268
implementation(libs.jetbrains.navigation3.ui)
269-
implementation(libs.ktor.client.android)
269+
implementation(libs.ktor.client.okhttp)
270270
implementation(libs.ktor.client.content.negotiation)
271271
implementation(libs.ktor.serialization.kotlinx.json)
272272
implementation(libs.ktor.client.logging)

androidApp/src/main/kotlin/org/meshtastic/app/di/NetworkModule.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import coil3.svg.SvgDecoder
3232
import coil3.util.DebugLogger
3333
import coil3.util.Logger
3434
import io.ktor.client.HttpClient
35-
import io.ktor.client.engine.android.Android
35+
import io.ktor.client.engine.okhttp.OkHttp
3636
import io.ktor.client.plugins.DefaultRequest
3737
import io.ktor.client.plugins.HttpRequestRetry
3838
import io.ktor.client.plugins.HttpTimeout
@@ -95,9 +95,15 @@ class NetworkModule {
9595
.crossfade(enable = true)
9696
.build()
9797

98+
/**
99+
* Uses the OkHttp engine, not `Android`. The `Android` engine is backed by [java.net.HttpURLConnection], which on
100+
* device routes through the platform's bundled OkHttp 2.x fork (`com.android.okhttp`). Cancelling a request while
101+
* its gzip/chunked response body is still being read trips a re-entrant `AsyncTimeout.enter()` in that fork and
102+
* crashes with "Unbalanced enter/exit" (Crashlytics 97ae5ea1, 2.8.0 only). Square's OkHttp has no such bug.
103+
*/
98104
@Single
99105
fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient =
100-
HttpClient(engineFactory = Android) {
106+
HttpClient(engineFactory = OkHttp) {
101107
install(plugin = ContentNegotiation) { json(json) }
102108
install(DefaultRequest) { url(HttpClientDefaults.API_BASE_URL) }
103109
install(plugin = HttpTimeout) {

core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,13 @@ import co.touchlab.kermit.Logger
2626
* the first time a secondary transport learns a `myNodeNum` that another transport already claimed.
2727
*
2828
* Every per-device table is unified so switching transport is seamless: messages (+FTS), reactions, contact mute/read
29-
* settings, nodes and their notes, per-node metadata, the audit log (each session's received-packet history — the
30-
* source of the telemetry timelines, position history, and traceroute results the UI reconstructs), traceroute
31-
* positions, and discovery sessions. Where the same row exists on both sides the destination is preferred (it will be
32-
* refreshed by the same radio's re-dump on connect); source-only rows and strictly newer history are brought over.
33-
* Packets/reactions in [source] and [dest] already share the same `myNodeNum` (same node), so no key remapping is
34-
* needed there; autoincrement-keyed rows (packets, discovery) are re-inserted with fresh ids to avoid collisions.
29+
* settings, nodes and their notes, per-node metadata, quick-chat actions, the audit log (each session's received-packet
30+
* history — the source of the telemetry timelines, position history, and traceroute results the UI reconstructs),
31+
* traceroute positions, and discovery sessions. Where the same row exists on both sides the destination is preferred
32+
* (it will be refreshed by the same radio's re-dump on connect); source-only rows and strictly newer history are
33+
* brought over. Packets/reactions in [source] and [dest] already share the same `myNodeNum` (same node), so no key
34+
* remapping is needed there; autoincrement-keyed rows (packets, discovery) are re-inserted with fresh ids to avoid
35+
* collisions.
3536
*/
3637
object DatabaseMerger {
3738

@@ -49,6 +50,7 @@ object DatabaseMerger {
4950
mergeContactSettings(source, dest)
5051
mergeNodes(source, dest)
5152
mergeMetadata(source, dest)
53+
mergeQuickChat(source, dest)
5254
// Logs must precede traceroute positions: the latter has a CASCADE foreign key onto log.uuid.
5355
mergeLogs(source, dest)
5456
mergeTraceroutePositions(source, dest)
@@ -110,6 +112,23 @@ object DatabaseMerger {
110112
}
111113
}
112114

115+
/**
116+
* User-authored quick-chat buttons. `uuid`/`position` are per-DB, so append the source's actions after the
117+
* destination's last one with fresh ids, skipping any the destination already has (same name + message + mode).
118+
* Without this the source's buttons are lost outright when [DatabaseManager] retires it.
119+
*/
120+
private suspend fun mergeQuickChat(source: MeshtasticDatabase, dest: MeshtasticDatabase) {
121+
val destActions = dest.quickChatActionDao().getAllSnapshot()
122+
val existing = destActions.mapTo(mutableSetOf()) { Triple(it.name, it.message, it.mode) }
123+
var nextPosition = (destActions.maxOfOrNull { it.position } ?: -1) + 1
124+
source.quickChatActionDao().getAllSnapshot().forEach { action ->
125+
if (existing.add(Triple(action.name, action.message, action.mode))) {
126+
dest.quickChatActionDao().upsert(action.copy(uuid = 0L, position = nextPosition))
127+
nextPosition++
128+
}
129+
}
130+
}
131+
113132
/**
114133
* The audit log holds each transport session's received-packet history — the source of the telemetry timelines,
115134
* position history, and node-info history the UI reconstructs per node/port. This is the only place that time

core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/QuickChatActionDao.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ interface QuickChatActionDao {
2929
@Query("Select * from quick_chat order by position asc")
3030
fun getAll(): Flow<List<QuickChatAction>>
3131

32+
/** Snapshot used by DatabaseMerger to fold one transport's DB into another for the same node. */
33+
@Query("Select * from quick_chat order by position asc")
34+
suspend fun getAllSnapshot(): List<QuickChatAction>
35+
3236
@Upsert suspend fun upsert(action: QuickChatAction)
3337

3438
@Query("Delete from quick_chat")

core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BaseRadioTransportFactory.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,15 @@ abstract class BaseRadioTransportFactory(
4242
InterfaceId.TCP.id,
4343
InterfaceId.SERIAL.id,
4444
InterfaceId.BLUETOOTH.id,
45-
InterfaceId.MOCK.id,
46-
InterfaceId.REPLAY.id,
4745
'!',
4846
-> true
4947

48+
// Virtual transports are development aids. A release build must never bind one: `connections?address=m` is
49+
// reachable from any web page through the verified meshtastic.org app link.
50+
InterfaceId.MOCK.id,
51+
InterfaceId.REPLAY.id,
52+
-> isMockTransport()
53+
5054
else -> isPlatformAddressValid(address)
5155
}
5256
}

core/resources/src/commonMain/composeResources/values/strings.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,10 @@
333333
<string name="debug_store_logs_title">Store mesh logs</string>
334334
<string name="debug_tab_app_logs">App logs</string>
335335
<string name="debug_tab_packets">Packets</string>
336+
<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>
337+
<string name="deep_link_connect_title">Connect to this device?</string>
338+
<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>
339+
<string name="deep_link_disconnect_title">Disconnect from your device?</string>
336340
<string name="default_">Default</string>
337341
<string name="default_mqtt_address" translatable="false">mqtt.meshtastic.org</string>
338342
<!-- DELETE -->

core/ui/src/androidMain/kotlin/org/meshtastic/core/ui/util/PlatformUtils.kt

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -395,10 +395,14 @@ actual fun rememberLocationPermissionState(): PermissionUiState = rememberRuntim
395395
@Composable
396396
actual fun rememberBluetoothPermissionState(): PermissionUiState {
397397
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.S) {
398-
// Pre-Android 12 has no runtime Bluetooth permission — BLE scanning is gated by the location permission, which
399-
// callers request separately (the intro Location screen, the map/Privacy location flows). Report granted here
400-
// so the Bluetooth surface itself is a no-op rather than masquerading as a location request.
401-
return rememberGrantedPermissionState()
398+
// Pre-Android 12 has no runtime Bluetooth permission — the platform gates BLE scanning on fine location
399+
// instead. Reporting "granted" here left a user who declined location with an empty device list and no way to
400+
// re-prompt, so surface the permission that actually blocks the scan. Fine specifically: API 29/30 return zero
401+
// scan results on a coarse-only grant.
402+
return rememberRuntimePermissionState(
403+
permissions = arrayOf(android.Manifest.permission.ACCESS_FINE_LOCATION),
404+
requireAll = true,
405+
)
402406
}
403407
return rememberRuntimePermissionState(
404408
permissions =

core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -361,14 +361,14 @@ private fun gatherSensors(node: Node, tempInFahrenheit: Boolean, contentColor: C
361361
items.add { PaxcountInfo(pax = "B:${pax.ble} W:${pax.wifi}", contentColor = contentColor) }
362362
}
363363

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

371+
// Humidity keeps its zero-guard: 0% RH is not physically reachable, and the humidity chart filters it too.
372372
if ((env.relative_humidity ?: 0f) != 0f) {
373373
items.add {
374374
HumidityInfo(humidity = MetricFormatter.humidity(env.relative_humidity ?: 0f), contentColor = contentColor)

feature/connections/src/commonMain/kotlin/org/meshtastic/feature/connections/domain/usecase/TcpDiscoveryHelpers.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ internal fun processTcpServices(
5252
}
5353
DeviceListEntry.Tcp(displayName, address)
5454
}
55+
// mDNS can resolve two services to one host:port (e.g. re-announce before the old record expires). The device
56+
// list keys on fullAddress, and duplicate LazyColumn keys are a hard crash.
57+
.distinctBy { it.fullAddress }
5558
.sortedBy { it.name }
5659
}
5760

0 commit comments

Comments
 (0)