fix(node): make node-list search case-insensitive for non-ASCII names (#6753)

This commit is contained in:
James Rich authored and GitHub committed 2026-08-17 17:37:23 +00:00
1 parent 19bb682c9c
commit 2df817db7a
6 files changed
+47 -11

No files matched your search

@@ -28,7 +28,6 @@ interface NodeInfoReadDataSource {
fun getNodesFlow(
sort: String,
filter: String,
includeUnknown: Boolean,
hopsAwayMax: Int,
lastHeardMin: Int,
@@ -37,7 +37,6 @@ class SwitchingNodeInfoReadDataSource(private val dbManager: DatabaseProvider) :
override fun getNodesFlow(
sort: String,
filter: String,
includeUnknown: Boolean,
hopsAwayMax: Int,
lastHeardMin: Int,
@@ -46,7 +45,6 @@ class SwitchingNodeInfoReadDataSource(private val dbManager: DatabaseProvider) :
db.nodeInfoDao()
.getNodes(
sort = sort,
filter = filter,
includeUnknown = includeUnknown,
hopsAwayMax = hopsAwayMax,
lastHeardMin = lastHeardMin,
@@ -49,6 +49,7 @@ import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.NodeSortOption
import org.meshtastic.core.model.matchesSearch
import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.proto.DeviceMetadata
@@ -191,12 +192,11 @@ class NodeRepositoryImpl(
): Flow<List<Node>> = nodeInfoReadDataSource
.getNodesFlow(
sort = sort.sqlValue,
filter = filter,
includeUnknown = includeUnknown,
hopsAwayMax = if (onlyDirect) 0 else -1,
lastHeardMin = if (onlyOnline) onlineTimeThreshold() else -1,
)
.mapLatest { list -> list.map { it.toModel() } }
.mapLatest { list -> list.map { it.toModel() }.filter { node -> node.matchesSearch(filter) } }
.flowOn(dispatchers.io)
.conflate()
@@ -279,6 +279,8 @@ interface NodeInfoDao {
NodeWithRelations,
>
// Text search (name/id) is applied in Kotlin (NodeRepositoryImpl), not here: SQLite's LIKE/UPPER/LOWER only
// case-fold ASCII a-z/A-Z, so a WHERE-clause LIKE can't match e.g. "kolså" against "KOLSÅS" (#6750).
@Query(
"""
WITH OurNode AS (
@@ -288,11 +290,6 @@ interface NodeInfoDao {
)
SELECT * FROM nodes
WHERE (:includeUnknown = 1 OR short_name IS NOT NULL)
AND (:filter = ''
OR (long_name LIKE '%' || :filter || '%'
OR short_name LIKE '%' || :filter || '%'
OR printf('!%08x', CASE WHEN num < 0 THEN num + 4294967296 ELSE num END) LIKE '%' || :filter || '%'
OR CAST(CASE WHEN num < 0 THEN num + 4294967296 ELSE num END AS TEXT) LIKE '%' || :filter || '%'))
AND (:lastHeardMin = -1 OR last_heard >= :lastHeardMin)
AND (:hopsAwayMax = -1 OR (hops_away <= :hopsAwayMax AND hops_away >= 0) OR num = (SELECT myNodeNum FROM my_node LIMIT 1))
ORDER BY CASE
@@ -328,7 +325,6 @@ interface NodeInfoDao {
@Transaction
fun getNodes(
sort: String,
filter: String,
includeUnknown: Boolean,
hopsAwayMax: Int,
lastHeardMin: Int,
@@ -243,3 +243,21 @@ fun Config.DeviceConfig.Role?.isUnmessageableRole(): Boolean = this in
Config.DeviceConfig.Role.TRACKER,
Config.DeviceConfig.Role.TAK_TRACKER,
)
/** Offset converting a negative [Node.num] into its unsigned 32-bit decimal representation. */
private const val UNSIGNED_INT_OFFSET = 4294967296L
private val Node.unsignedNum: Long
get() = num.toLong().let { if (it < 0) it + UNSIGNED_INT_OFFSET else it }
/**
* Matches node search text (long/short name, hex id, decimal id) with Unicode-aware case folding.
*
* Must run in Kotlin, not SQL: SQLite's LIKE/UPPER/LOWER only case-fold ASCII a-z/A-Z, so a query like "kolså" can
* never match a stored name of "KOLSÅS" via a SQL WHERE clause (#6750).
*/
fun Node.matchesSearch(filter: String): Boolean = filter.isBlank() ||
user.long_name.contains(filter, ignoreCase = true) ||
user.short_name.contains(filter, ignoreCase = true) ||
user.id.contains(filter, ignoreCase = true) ||
unsignedNum.toString().contains(filter, ignoreCase = true)
@@ -137,6 +137,31 @@ class NodeTest {
assertTrue(node.mismatchKey)
}
@Test
fun matchesSearch_isCaseInsensitiveForNonAsciiLetters() {
val node = Node(num = 1, user = User(long_name = "KOLSÅS", short_name = "KOLS"))
// #6750: "kols" matched via ASCII-only SQL LIKE folding, but "kolså" did not.
assertTrue(node.matchesSearch("kols"))
assertTrue(node.matchesSearch("kolså"))
assertTrue(node.matchesSearch("KOLSÅS"))
assertFalse(node.matchesSearch("nomatch"))
}
@Test
fun matchesSearch_matchesHexAndDecimalNodeId() {
val node = Node(num = -1, user = User(id = "!ffffffff"))
assertTrue(node.matchesSearch("ffffffff"))
assertTrue(node.matchesSearch("4294967295"))
assertFalse(node.matchesSearch("1234"))
}
@Test
fun matchesSearch_blankFilterMatchesEveryNode() {
assertTrue(Node(num = 1).matchesSearch(""))
}
private fun nodeWithPosition(num: Int, latitudeI: Int, longitudeI: Int): Node =
Node(num = num, position = Position(latitude_i = latitudeI, longitude_i = longitudeI))
}