diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt index ea62007689..be2d600592 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt @@ -28,7 +28,6 @@ interface NodeInfoReadDataSource { fun getNodesFlow( sort: String, - filter: String, includeUnknown: Boolean, hopsAwayMax: Int, lastHeardMin: Int, diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt index d3678c1be0..c2378c030b 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt @@ -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, diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt index 4cc9f5af99..92634004ca 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt @@ -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> = 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() diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt index 0e2a3b0def..0060984be9 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt @@ -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, diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt index 9f794e1be1..4241fdad77 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt @@ -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) diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeTest.kt index 2da8c6b1d9..9a65b79b9e 100644 --- a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeTest.kt +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/NodeTest.kt @@ -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)) }