Skip to content

Commit 8874352

Browse files
jamesarichclaude
andcommitted
fix: resolve release/2.8.0 branch-review findings (car hosts, AI node IDs, discovery abort, AQ zeros) (#5813)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bfe3440 commit 8874352

10 files changed

Lines changed: 289 additions & 45 deletions

File tree

core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ class AiFunctionProviderImpl(
130130
val nodes =
131131
nodeMap.values.map { node ->
132132
NodeSummary(
133-
id = "!${node.num.toString(HEX_RADIX)}",
133+
id = NodeAddress.numToDefaultId(node.num),
134134
name = node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${node.num}",
135135
batteryLevel = node.deviceMetrics.battery_level?.coerceIn(0, MAX_BATTERY_LEVEL),
136136
lastHeard = node.lastHeard.toLong() * MS_PER_SEC,
@@ -201,8 +201,11 @@ class AiFunctionProviderImpl(
201201
try {
202202
val node =
203203
if (nodeId.startsWith("!")) {
204-
// Hex format: extract number and search
205-
val nodeNum = nodeId.drop(1).toInt(HEX_RADIX)
204+
// Canonical hex node ID (e.g. "!ffffffff"). idToNum parses the full unsigned 32-bit
205+
// range and returns null for malformed input, which we surface as NotFound.
206+
val nodeNum =
207+
NodeAddress.idToNum(nodeId)
208+
?: return@withTimeout GetNodeDetailsResult.NotFound("Node not found: $nodeId")
206209
nodeRepository.nodeDBbyNum.first()[nodeNum]
207210
} else {
208211
// User ID format
@@ -218,7 +221,7 @@ class AiFunctionProviderImpl(
218221

219222
val details =
220223
NodeDetails(
221-
id = "!${node.num.toString(HEX_RADIX)}",
224+
id = NodeAddress.numToDefaultId(node.num),
222225
userId = node.user.id,
223226
name = node.user.long_name.takeIf { it.isNotBlank() } ?: "Node ${node.num}",
224227
batteryLevel = node.deviceMetrics.battery_level?.coerceIn(0, MAX_BATTERY_LEVEL),
@@ -502,7 +505,6 @@ class AiFunctionProviderImpl(
502505
companion object {
503506
private val OPERATION_TIMEOUT = 5.seconds
504507
private const val MAX_BATTERY_LEVEL = 100
505-
private const val HEX_RADIX = 16
506508
private const val MS_PER_SEC = 1000L
507509
private const val HEALTH_SCORE_BASE = 50
508510
private const val HEALTH_SCORE_ONLINE_RATIO = 50

core/data/src/commonTest/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImplTest.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,22 @@ class AiFunctionProviderImplTest {
137137
assertEquals(true, isHandled)
138138
}
139139

140+
@Test
141+
fun getNodeDetails_round_trips_high_bit_node_num() = runTest {
142+
// A node num with the high bit set (-1 == 0xFFFFFFFF) must format and parse as the canonical
143+
// "!ffffffff", not the signed "!-1" — regression guard for the node-ID hex fix.
144+
val testNode = Node(num = -1, user = User(id = "!ffffffff", long_name = "HighBit", short_name = "HB"))
145+
val nodeMap = MutableStateFlow(mapOf(-1 to testNode))
146+
every { nodeRepository.nodeDBbyNum } returns nodeMap
147+
148+
val provider = createProvider()
149+
val result = provider.getNodeDetails("!ffffffff")
150+
151+
assertIs<GetNodeDetailsResult.Success>(result)
152+
assertEquals("!ffffffff", result.node.id)
153+
assertEquals("HighBit", result.node.name)
154+
}
155+
140156
// --- getMeshMetrics tests ---
141157

142158
@Test
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.core.database.dao
18+
19+
import androidx.room3.Room
20+
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
21+
import androidx.test.core.app.ApplicationProvider
22+
import androidx.test.ext.junit.runners.AndroidJUnit4
23+
import kotlinx.coroutines.test.runTest
24+
import org.junit.After
25+
import org.junit.Before
26+
import org.junit.Test
27+
import org.junit.runner.RunWith
28+
import org.meshtastic.core.common.util.nowMillis
29+
import org.meshtastic.core.database.MeshtasticDatabase
30+
import org.meshtastic.core.database.MeshtasticDatabaseConstructor
31+
import org.meshtastic.core.database.entity.MyNodeEntity
32+
import org.meshtastic.core.database.entity.Packet
33+
import org.meshtastic.core.model.DataPacket
34+
import org.meshtastic.core.model.NodeAddress
35+
import org.meshtastic.proto.PortNum
36+
import org.robolectric.annotation.Config
37+
import kotlin.test.assertEquals
38+
import kotlin.test.assertTrue
39+
40+
/**
41+
* Verifies FTS5 full-text message search (#5373) and the historical-message backfill.
42+
*
43+
* [backfillMessageTexts_makesHistoricalMessagesSearchable] is the regression guard for the original `json_extract(data,
44+
* '$.text')` backfill, which silently matched nothing: `DataPacket.text` is a computed property and is never serialized
45+
* into the stored JSON, so historical messages stayed permanently unsearchable.
46+
*/
47+
@RunWith(AndroidJUnit4::class)
48+
@Config(sdk = [34])
49+
class PacketFtsSearchTest {
50+
private lateinit var database: MeshtasticDatabase
51+
private lateinit var packetDao: PacketDao
52+
private lateinit var nodeInfoDao: NodeInfoDao
53+
54+
private val myNodeNum = 42424242
55+
56+
private val myNodeInfo =
57+
MyNodeEntity(
58+
myNodeNum = myNodeNum,
59+
model = null,
60+
firmwareVersion = null,
61+
couldUpdate = false,
62+
shouldUpdate = false,
63+
currentPacketId = 1L,
64+
messageTimeoutMsec = 5 * 60 * 1000,
65+
minAppVersion = 1,
66+
maxChannels = 8,
67+
hasWifi = false,
68+
)
69+
70+
@Before
71+
fun createDb(): Unit = runTest {
72+
val context = ApplicationProvider.getApplicationContext<android.content.Context>()
73+
database =
74+
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(
75+
context = context,
76+
factory = { MeshtasticDatabaseConstructor.initialize() },
77+
)
78+
.setDriver(BundledSQLiteDriver())
79+
.build()
80+
nodeInfoDao = database.nodeInfoDao().apply { setMyNodeInfo(myNodeInfo) }
81+
packetDao = database.packetDao()
82+
}
83+
84+
@After
85+
fun closeDb() {
86+
database.close()
87+
}
88+
89+
@Test
90+
fun searchMessages_matchesIndexedText_ignoresNonMatches() = runTest {
91+
insertTextPacket(contactKey = CONTACT, text = "the quick brown fox", messageText = "the quick brown fox")
92+
93+
assertEquals(1, packetDao.searchMessages("brown").size, "a term in the indexed message should match")
94+
assertTrue(packetDao.searchMessages("zebra").isEmpty(), "an absent term should not match")
95+
}
96+
97+
@Test
98+
fun searchMessagesInConversation_scopesToContact() = runTest {
99+
insertTextPacket(contactKey = CONTACT, text = "shared keyword here", messageText = "shared keyword here")
100+
insertTextPacket(contactKey = OTHER_CONTACT, text = "shared keyword here", messageText = "shared keyword here")
101+
102+
assertEquals(2, packetDao.searchMessages("keyword").size, "both conversations match the global search")
103+
assertEquals(1, packetDao.searchMessagesInConversation("keyword", CONTACT).size, "scoped to one contact")
104+
}
105+
106+
@Test
107+
fun backfillMessageTexts_makesHistoricalMessagesSearchable() = runTest {
108+
// A pre-v39 packet: the payload carries the text, but message_text was never populated, so it is unindexed.
109+
insertTextPacket(contactKey = CONTACT, text = "historical needle", messageText = "")
110+
111+
assertTrue(packetDao.searchMessages("needle").isEmpty(), "the historical message is unindexed before backfill")
112+
assertEquals(1, packetDao.countPacketsNeedingBackfill(), "the empty-message_text packet needs backfill")
113+
114+
val updated = packetDao.backfillMessageTexts()
115+
packetDao.rebuildFtsIndex()
116+
117+
assertEquals(1, updated, "the historical text packet should be backfilled")
118+
assertEquals(1, packetDao.searchMessages("needle").size, "the backfilled message should now be searchable")
119+
assertEquals(0, packetDao.countPacketsNeedingBackfill(), "nothing left to backfill")
120+
}
121+
122+
private suspend fun insertTextPacket(contactKey: String, text: String, messageText: String) {
123+
packetDao.insert(
124+
Packet(
125+
uuid = 0L,
126+
myNodeNum = myNodeNum,
127+
port_num = PortNum.TEXT_MESSAGE_APP.value,
128+
contact_key = contactKey,
129+
received_time = nowMillis,
130+
read = false,
131+
data = DataPacket(to = NodeAddress.ID_BROADCAST, channel = 0, text = text),
132+
messageText = messageText,
133+
),
134+
)
135+
}
136+
137+
companion object {
138+
private const val CONTACT = "0!aaaa1111"
139+
private const val OTHER_CONTACT = "0!bbbb2222"
140+
}
141+
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -310,9 +310,10 @@ open class DatabaseManager(
310310
}
311311

312312
/**
313-
* Backfills [Packet.messageText] for existing text-message packets that predate the FTS5 schema. Uses a single SQL
314-
* UPDATE with json_extract to avoid loading all packets into memory, then rebuilds the FTS index so search covers
315-
* historical messages.
313+
* Backfills [Packet.messageText] for existing text-message packets that predate the FTS5 schema, then rebuilds the
314+
* FTS index so search covers historical messages. The text is decoded in Kotlin from each packet's payload (see
315+
* [PacketDao.backfillMessageTexts]); it cannot be read in SQL because the message body is stored as serialized
316+
* `bytes`, not a `text` JSON field.
316317
*/
317318
private suspend fun backfillSearchIndexIfNeeded(db: MeshtasticDatabase) {
318319
val needsBackfill = db.packetDao().countPacketsNeedingBackfill() > 0

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

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -568,19 +568,31 @@ interface PacketDao {
568568
@Query("UPDATE packet SET message_text = :text WHERE uuid = :uuid")
569569
suspend fun updateMessageText(uuid: Long, text: String)
570570

571-
@Query(
572-
"SELECT COUNT(*) FROM packet " +
573-
"WHERE port_num = 1 AND (message_text IS NULL OR message_text = '') " +
574-
"AND json_extract(data, '\$.text') IS NOT NULL",
575-
)
571+
@Query("SELECT COUNT(*) FROM packet WHERE port_num = 1 AND (message_text IS NULL OR message_text = '')")
576572
suspend fun countPacketsNeedingBackfill(): Int
577573

578-
@Query(
579-
"UPDATE packet SET message_text = json_extract(data, '\$.text') " +
580-
"WHERE port_num = 1 AND (message_text IS NULL OR message_text = '') " +
581-
"AND json_extract(data, '\$.text') IS NOT NULL",
582-
)
583-
suspend fun backfillMessageTexts(): Int
574+
@Query("SELECT * FROM packet WHERE port_num = 1 AND (message_text IS NULL OR message_text = '')")
575+
suspend fun getPacketsNeedingBackfill(): List<Packet>
576+
577+
/**
578+
* Populates [Packet.messageText] for historical text packets that predate the FTS5 schema (v39) so they become
579+
* searchable. The text is decoded in Kotlin from each packet's [DataPacket.text]; it cannot be read with a SQL
580+
* `json_extract(data, '$.text')` because [DataPacket.text] is a computed property that is never serialized into the
581+
* stored JSON (the payload is persisted as `bytes`). Returns the number of rows updated; the caller rebuilds the
582+
* FTS index via [rebuildFtsIndex] when this is greater than zero.
583+
*/
584+
@Transaction
585+
suspend fun backfillMessageTexts(): Int {
586+
var updated = 0
587+
getPacketsNeedingBackfill().forEach { packet ->
588+
val text = packet.data.text
589+
if (!text.isNullOrEmpty()) {
590+
updateMessageText(packet.uuid, text)
591+
updated++
592+
}
593+
}
594+
return updated
595+
}
584596

585597
@Query("INSERT INTO packet_fts(packet_fts) VALUES('rebuild')")
586598
suspend fun rebuildFtsIndex()

core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeRadioController.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ class FakeRadioController :
4646
val sentPackets = mutableListOf<DataPacket>()
4747
val favoritedNodes = mutableListOf<Int>()
4848
val sentSharedContacts = mutableListOf<Int>()
49+
50+
/** Every [setLocalConfig] call, in order — lets tests assert e.g. that a scan restored the home LoRa preset. */
51+
val localConfigs = mutableListOf<Config>()
52+
val lastLocalConfig: Config?
53+
get() = localConfigs.lastOrNull()
54+
4955
var throwOnSend: Boolean = false
5056
var lastSetDeviceAddress: String? = null
5157
var editSettingsCalled = false
@@ -57,6 +63,7 @@ class FakeRadioController :
5763
sentPackets.clear()
5864
favoritedNodes.clear()
5965
sentSharedContacts.clear()
66+
localConfigs.clear()
6067
throwOnSend = false
6168
lastSetDeviceAddress = null
6269
editSettingsCalled = false
@@ -93,7 +100,9 @@ class FakeRadioController :
93100

94101
override suspend fun refreshMetadata(destNum: Int) {}
95102

96-
override suspend fun setLocalConfig(config: Config) {}
103+
override suspend fun setLocalConfig(config: Config) {
104+
localConfigs.add(config)
105+
}
97106

98107
override suspend fun setLocalChannel(channel: Channel) {}
99108

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<resources>
3-
<string-array name="car_hosts_allowlist">
4-
<!-- Android Auto host (Google) -->
5-
<item>com.google.android.projection.gearhead</item>
6-
<!-- Android Automotive OS system host -->
7-
<item>com.android.car.carlauncher</item>
8-
<!-- Android Auto for Phone Screens (testing) -->
9-
<item>com.google.android.apps.auto</item>
3+
<!--
4+
Hosts allowed to bind to the Meshtastic CarAppService.
5+
Each entry MUST be "sha256SigningCertDigest,packageName" — androidx.car.app's HostValidator
6+
rejects bare package names (it splits on ',' and throws IllegalArgumentException unless the
7+
entry yields exactly two parts). Values are copied verbatim from androidx.car.app:1.9.0-alpha01's
8+
bundled hosts_allowlist_sample (Google's official Android Auto / AAOS signing-cert digests);
9+
re-sync them whenever the car-app library version is bumped.
10+
-->
11+
<string-array name="car_hosts_allowlist" translatable="false">
12+
<!-- Android Auto (com.google.android.projection.gearhead) -->
13+
<item>fdb00c43dbde8b51cb312aa81d3b5fa17713adb94b28f598d77f8eb89daceedf,com.google.android.projection.gearhead</item>
14+
<item>70811a3eacfd2e83e18da9bfede52df16ce91f2e69a44d21f18ab66991130771,com.google.android.projection.gearhead</item>
15+
<item>1975b2f17177bc89a5dff31f9e64a6cae281a53dc1d1d59b1d147fe1c82afa00,com.google.android.projection.gearhead</item>
16+
<!-- Android Automotive OS template host (com.google.android.apps.automotive.templates.host) -->
17+
<item>c241ffbc8e287c4e9a4ad19632ba1b1351ad361d5177b7d7b29859bd2b7fc631,com.google.android.apps.automotive.templates.host</item>
18+
<item>dd66deaf312d8daec7adbe85a218ecc8c64f3b152f9b5998d5b29300c2623f61,com.google.android.apps.automotive.templates.host</item>
19+
<item>50e603d333c6049a37bd751375d08f3bd0abebd33facd30bd17b64b89658b421,com.google.android.apps.automotive.templates.host</item>
1020
</string-array>
1121
</resources>

feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -321,10 +321,16 @@ class DiscoveryScanEngine(
321321
/** Common cleanup path when a scan step fails mid-loop. */
322322
private suspend fun pauseAndAbort() {
323323
_scanState.value = DiscoveryScanState.Failed("Connection lost during scan")
324-
cancelScanInternal()
325-
restoreHomePreset()
324+
// pauseAndAbort runs inside the runScanLoop coroutine, which is a child of scanScope.
325+
// cancelScanInternal() cancels scanScope (and therefore this coroutine), so it must run LAST:
326+
// any suspend after it — finalizeSession or restoreHomePreset — would throw CancellationException
327+
// and silently skip cleanup, stranding the radio on the scan modem preset. So finalize and reach
328+
// the terminal state first, then restore the home preset on applicationScope (which outlives
329+
// scanScope), mirroring stopScan().
326330
finalizeSession("failed")
327331
_scanState.value = DiscoveryScanState.Complete(DiscoveryScanState.CompletionOutcome.Failed)
332+
applicationScope.launch { restoreHomePreset() }
333+
cancelScanInternal()
328334
}
329335

330336
private suspend fun shiftPreset(preset: ChannelOption) {

0 commit comments

Comments
 (0)