diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/database/GroupTableTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/database/GroupTableTest.kt index 0ca921ede5..ccea2189e4 100644 --- a/app/src/androidTest/java/org/thoughtcrime/securesms/database/GroupTableTest.kt +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/database/GroupTableTest.kt @@ -167,8 +167,8 @@ class GroupTableTest { @Test fun givenTwoGroupsWithoutMembers_whenIQueryThem_thenIExpectEach() { - val g1 = insertPushGroup(listOf()) - val g2 = insertPushGroup(listOf()) + val g1 = insertPushGroup(members = emptyList()) + val g2 = insertPushGroup(members = emptyList()) val gr1 = groupTable.getGroup(g1) val gr2 = groupTable.getGroup(g2) @@ -195,6 +195,85 @@ class GroupTableTest { assertEquals(groups[0].id, groupInCommon) } + @Test + fun givenTwoGroupsWithANameThatSharesAToken_whenISearchForTheSharedToken_thenIExpectBothGroups() { + insertPushGroup("Group Alice") + insertPushGroup("Group Bob") + + SignalDatabase.groups.queryGroupsByTitle( + inputQuery = "Group", + includeInactive = false, + excludeV1 = false, + excludeMms = false + ).use { + assertEquals(2, it.cursor?.count) + + val firstGroup = it.getNext() + val secondGroup = it.getNext() + + assertEquals("Group Alice", firstGroup?.title) + assertEquals("Group Bob", secondGroup?.title) + } + } + + @Test + fun givenTwoGroupsWithANameThatSharesAToken_whenISearchForAnUnsharedToken_thenIExpectOneGroup() { + insertPushGroup("Group Alice") + insertPushGroup("Group Bob") + + SignalDatabase.groups.queryGroupsByTitle( + inputQuery = "Alice", + includeInactive = false, + excludeV1 = false, + excludeMms = false + ).use { + assertEquals(1, it.cursor?.count) + + val firstGroup = it.getNext() + + assertEquals("Group Alice", firstGroup?.title) + } + } + + @Test + fun givenAGroupWithThreeTokens_whenISearchForTheFirstAndLastToken_thenIExpectThatGroup() { + insertPushGroup("Group & Alice") + + SignalDatabase.groups.queryGroupsByTitle( + inputQuery = "Group Alice", + includeInactive = false, + excludeV1 = false, + excludeMms = false + ).use { + assertEquals(1, it.cursor?.count) + + val firstGroup = it.getNext() + + assertEquals("Group & Alice", firstGroup?.title) + } + } + + @Test + fun givenTwoGroupsWithSharedTokens_whenISearchForAnExactMatch_thenIExpectThatGroupFirst() { + insertPushGroup("Group Alice Bob") + insertPushGroup("Group Bob") + + SignalDatabase.groups.queryGroupsByTitle( + inputQuery = "Group Bob", + includeInactive = false, + excludeV1 = false, + excludeMms = false + ).use { + assertEquals(2, it.cursor?.count) + + val firstGroup = it.getNext() + val second = it.getNext() + + assertEquals("Group Bob", firstGroup?.title) + assertEquals("Group Alice Bob", second?.title) + } + } + private fun insertThread(groupId: GroupId): Long { val groupRecipient = SignalDatabase.recipients.getByGroupId(groupId).get() return SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(groupRecipient)) @@ -214,6 +293,7 @@ class GroupTableTest { } private fun insertPushGroup( + title: String = "Test Group", members: List = listOf( DecryptedMember.Builder() .aciBytes(harness.self.requireAci().toByteString()) @@ -229,6 +309,7 @@ class GroupTableTest { ): GroupId { val groupMasterKey = GroupMasterKey(Random.nextBytes(GroupMasterKey.SIZE)) val decryptedGroupState = DecryptedGroup.Builder() + .title(title) .members(members) .revision(0) .build() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt index 993bb0eab7..bb59278237 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/GroupTable.kt @@ -74,6 +74,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT private val TAG = Log.tag(GroupTable::class.java) const val MEMBER_GROUP_CONCAT = "member_group_concat" + const val TITLE_SEARCH_RANK = "title_search_rank" const val THREAD_DATE = "thread_date" const val TABLE_NAME = "groups" @@ -154,18 +155,6 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT .map { columnName: String -> "$TABLE_NAME.$columnName" } .toList() - //language=sql - private const val JOINED_GROUP_SELECT = """ - SELECT - DISTINCT $TABLE_NAME.*, - ( - SELECT GROUP_CONCAT(${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID}) - FROM ${MembershipTable.TABLE_NAME} - WHERE ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} = $TABLE_NAME.$GROUP_ID - ) as $MEMBER_GROUP_CONCAT - FROM $TABLE_NAME - """ - val CREATE_TABLES = arrayOf(CREATE_TABLE, MembershipTable.CREATE_TABLE) } @@ -203,7 +192,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT private fun getGroup(query: SqlUtil.Query): Optional { //language=sql - val select = "$JOINED_GROUP_SELECT WHERE ${query.where}" + val select = "${joinedGroupSelect()} WHERE ${query.where}" readableDatabase .query(select, query.whereArgs) @@ -356,9 +345,9 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT val query = getGroupQueryWhereStatement(inputQuery, includeInactive, excludeV1, excludeMms) //language=sql val statement = """ - $JOINED_GROUP_SELECT + ${joinedGroupSelect(inputQuery)} WHERE ${query.where} - ORDER BY $TITLE COLLATE NOCASE ASC + ORDER BY $TITLE_SEARCH_RANK DESC, $TITLE COLLATE NOCASE ASC """ val cursor = databaseHelper.signalReadableDatabase.query(statement, query.whereArgs) @@ -368,10 +357,10 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT private fun queryGroupsByRecency(groupQuery: GroupQuery): Reader { val query = getGroupQueryWhereStatement(groupQuery.searchQuery, groupQuery.includeInactive, !groupQuery.includeV1, !groupQuery.includeMms) val sql = """ - $JOINED_GROUP_SELECT + ${joinedGroupSelect(groupQuery.searchQuery)} INNER JOIN ${ThreadTable.TABLE_NAME} ON ${ThreadTable.TABLE_NAME}.${ThreadTable.RECIPIENT_ID} = $TABLE_NAME.$RECIPIENT_ID WHERE ${query.where} - ORDER BY ${ThreadTable.TABLE_NAME}.${ThreadTable.DATE} DESC + ORDER BY $TITLE_SEARCH_RANK DESC, ${ThreadTable.TABLE_NAME}.${ThreadTable.DATE} DESC """ return Reader(databaseHelper.signalReadableDatabase.rawQuery(sql, query.whereArgs)) @@ -388,16 +377,25 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT private fun getGroupQueryWhereStatement(inputQuery: String, includeInactive: Boolean, excludeV1: Boolean, excludeMms: Boolean): SqlUtil.Query { var query: String val queryArgs: Array - val caseInsensitiveQuery = buildCaseInsensitiveGlobPattern(inputQuery) + val tokens = inputQuery.split(" ").filter { it.isNotEmpty() }.map { buildCaseInsensitiveGlobPattern(it) } + val tokenSearchQuery = tokens.joinToString(" AND ") { "$TITLE GLOB ?" } - if (includeInactive) { - query = "$TITLE GLOB ? AND ($TABLE_NAME.$ACTIVE = ? OR $TABLE_NAME.$RECIPIENT_ID IN (SELECT ${ThreadTable.RECIPIENT_ID} FROM ${ThreadTable.TABLE_NAME} WHERE ${ThreadTable.TABLE_NAME}.${ThreadTable.ACTIVE} = 1))" - queryArgs = buildArgs(caseInsensitiveQuery, 1) - } else { - query = "$TITLE GLOB ? AND $TABLE_NAME.$ACTIVE = ?" - queryArgs = buildArgs(caseInsensitiveQuery, 1) + val searchQuery = tokenSearchQuery.ifEmpty { + "$TITLE GLOB ?" } + val searchTokens = tokens.ifEmpty { + listOf(buildCaseInsensitiveGlobPattern(inputQuery)) + } + + query = if (includeInactive) { + "($searchQuery) AND ($TABLE_NAME.$ACTIVE = ? OR $TABLE_NAME.$RECIPIENT_ID IN (SELECT ${ThreadTable.RECIPIENT_ID} FROM ${ThreadTable.TABLE_NAME} WHERE ${ThreadTable.TABLE_NAME}.${ThreadTable.ACTIVE} = 1))" + } else { + "($searchQuery) AND $TABLE_NAME.$ACTIVE = ?" + } + + queryArgs = buildArgs(*searchTokens.toTypedArray(), 1) + if (excludeV1) { query += " AND $EXPECTED_V2_ID IS NULL" } @@ -494,7 +492,7 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT } fun getGroups(): Reader { - val cursor = readableDatabase.query(JOINED_GROUP_SELECT) + val cursor = readableDatabase.query(joinedGroupSelect()) return Reader(cursor) } @@ -1270,6 +1268,27 @@ class GroupTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT } } + //language=sql + private fun joinedGroupSelect(titleSearchQuery: String? = null): String { + val titleSearchRankColumn = if (titleSearchQuery == null) { + "" + } else { + val glob = buildCaseInsensitiveGlobPattern(titleSearchQuery) + ", ($TITLE GLOB \"$glob\") as $TITLE_SEARCH_RANK" + } + + return """ + SELECT + DISTINCT $TABLE_NAME.*, + ( + SELECT GROUP_CONCAT(${MembershipTable.TABLE_NAME}.${MembershipTable.RECIPIENT_ID}) + FROM ${MembershipTable.TABLE_NAME} + WHERE ${MembershipTable.TABLE_NAME}.${MembershipTable.GROUP_ID} = $TABLE_NAME.$GROUP_ID + ) as $MEMBER_GROUP_CONCAT $titleSearchRankColumn + FROM $TABLE_NAME + """ + } + enum class MemberSet(val includeSelf: Boolean, val includePending: Boolean) { FULL_MEMBERS_INCLUDING_SELF(true, false), FULL_MEMBERS_EXCLUDING_SELF(false, false)