[db] add search support with FTS4

FTS5 isn't supported by Room because old Android devices ship only with sqlite with FTS4
This commit is contained in:
Torsten Grote
2022-06-29 14:57:22 -03:00
committed by Michael Pöhn
parent 01381c268d
commit 28fa5fd46e
4 changed files with 167 additions and 11 deletions

View File

@@ -34,6 +34,107 @@ internal class AppListItemsTest : AppTest() {
Pair(packageName3, app3),
)
@Test
fun testSearchQuery() {
val app1 = app1.copy(name = mapOf("en-US" to "One"), summary = mapOf("en-US" to "Onearry"))
val app2 = app2.copy(name = mapOf("en-US" to "Two"), summary = mapOf("de" to "Zfassung"))
val app3 = app3.copy(name = mapOf("de-DE" to "Drei"), summary = mapOf("de" to "Zfassung"))
// insert three apps in a random order
val repoId = repoDao.insertOrReplace(getRandomRepo())
appDao.insert(repoId, packageName1, app1, locales)
appDao.insert(repoId, packageName3, app3, locales)
appDao.insert(repoId, packageName2, app2, locales)
// nothing is installed
every { pm.getInstalledPackages(0) } returns emptyList()
// get first app by search, sort order doesn't matter
appDao.getAppListItems(pm, "One", LAST_UPDATED).getOrFail().let { apps ->
assertEquals(1, apps.size)
assertEquals(app1, apps[0])
}
// get second app by search, sort order doesn't matter
appDao.getAppListItems(pm, "Two", NAME).getOrFail().let { apps ->
assertEquals(1, apps.size)
assertEquals(app2, apps[0])
}
// get second and third app by searching for summary
appDao.getAppListItems(pm, "Zfassung", LAST_UPDATED).getOrFail().let { apps ->
assertEquals(2, apps.size)
// sort-order isn't fixes, yet
if (apps[0].packageId == packageName2) {
assertEquals(app2, apps[0])
assertEquals(app3, apps[1])
} else {
assertEquals(app3, apps[0])
assertEquals(app2, apps[1])
}
}
// empty search for unknown search term
appDao.getAppListItems(pm, "foo bar", LAST_UPDATED).getOrFail().let { apps ->
assertEquals(0, apps.size)
}
}
@Test
fun testSearchQueryInCategory() {
val app1 = app1.copy(name = mapOf("en-US" to "One"), summary = mapOf("en-US" to "Onearry"))
val app2 = app2.copy(name = mapOf("en-US" to "Two"), summary = mapOf("de" to "Zfassung"))
val app3 = app3.copy(name = mapOf("de-DE" to "Drei"), summary = mapOf("de" to "Zfassung"))
// insert three apps in a random order
val repoId = repoDao.insertOrReplace(getRandomRepo())
appDao.insert(repoId, packageName1, app1, locales)
appDao.insert(repoId, packageName3, app3, locales)
appDao.insert(repoId, packageName2, app2, locales)
// nothing is installed
every { pm.getInstalledPackages(0) } returns emptyList()
// get first app by search, sort order doesn't matter
appDao.getAppListItems(pm, "A", "One", LAST_UPDATED).getOrFail().let { apps ->
assertEquals(1, apps.size)
assertEquals(app1, apps[0])
}
// get second app by search, sort order doesn't matter
appDao.getAppListItems(pm, "A", "Two", NAME).getOrFail().let { apps ->
assertEquals(1, apps.size)
assertEquals(app2, apps[0])
}
// get second and third app by searching for summary
appDao.getAppListItems(pm, "A", "Zfassung", LAST_UPDATED).getOrFail().let { apps ->
assertEquals(2, apps.size)
// sort-order isn't fixes, yet
if (apps[0].packageId == packageName2) {
assertEquals(app2, apps[0])
assertEquals(app3, apps[1])
} else {
assertEquals(app3, apps[0])
assertEquals(app2, apps[1])
}
}
// get third app by searching for summary in category B only
appDao.getAppListItems(pm, "B", "Zfassung", LAST_UPDATED).getOrFail().let { apps ->
assertEquals(1, apps.size)
assertEquals(app3, apps[0])
}
// empty search for unknown category
appDao.getAppListItems(pm, "C", "Zfassung", LAST_UPDATED).getOrFail().let { apps ->
assertEquals(0, apps.size)
}
// empty search for unknown search term
appDao.getAppListItems(pm, "A", "foo bar", LAST_UPDATED).getOrFail().let { apps ->
assertEquals(0, apps.size)
}
}
@Test
fun testSortOrderByLastUpdated() {
// insert three apps in a random order
@@ -46,7 +147,7 @@ internal class AppListItemsTest : AppTest() {
every { pm.getInstalledPackages(0) } returns emptyList()
// get apps sorted by last updated
appDao.getAppListItems(pm, LAST_UPDATED).getOrFail().let { apps ->
appDao.getAppListItems(pm, "", LAST_UPDATED).getOrFail().let { apps ->
assertEquals(3, apps.size)
// we expect apps to be sorted by last updated descending
appPairs.sortedByDescending { (_, metadataV2) ->
@@ -70,7 +171,7 @@ internal class AppListItemsTest : AppTest() {
every { pm.getInstalledPackages(0) } returns emptyList()
// get apps sorted by name ascending
appDao.getAppListItems(pm, NAME).getOrFail().let { apps ->
appDao.getAppListItems(pm, null, NAME).getOrFail().let { apps ->
assertEquals(3, apps.size)
// we expect apps to be sorted by last updated descending
appPairs.sortedBy { (_, metadataV2) ->
@@ -100,8 +201,8 @@ internal class AppListItemsTest : AppTest() {
// get apps sorted by name and last update, test on both lists
listOf(
appDao.getAppListItems(pm, NAME).getOrFail(),
appDao.getAppListItems(pm, LAST_UPDATED).getOrFail(),
appDao.getAppListItems(pm, "", NAME).getOrFail(),
appDao.getAppListItems(pm, null, LAST_UPDATED).getOrFail(),
).forEach { apps ->
assertEquals(2, apps.size)
// the installed app should have app data

View File

@@ -8,6 +8,7 @@ import androidx.room.DatabaseView
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Fts4
import androidx.room.Ignore
import androidx.room.Relation
import org.fdroid.LocaleChooser.getBestLocale
@@ -105,6 +106,17 @@ internal fun MetadataV2.toAppMetadata(
isCompatible = isCompatible,
)
@Entity
@Fts4(contentEntity = AppMetadata::class)
internal data class AppMetadataFts(
val repoId: Long,
val packageId: String,
@ColumnInfo(name = "localizedName")
val name: String? = null,
@ColumnInfo(name = "localizedSummary")
val summary: String? = null,
)
public data class App internal constructor(
@Embedded val metadata: AppMetadata,
@Relation(

View File

@@ -83,14 +83,24 @@ public interface AppDao {
limit: Int = 50,
): LiveData<List<AppOverviewItem>>
/**
* Returns a list of all [AppListItem] sorted by the given [sortOrder],
* or a subset of [AppListItem]s filtered by the given [searchQuery] if it is non-null.
* In the later case, the [sortOrder] gets ignored.
*/
public fun getAppListItems(
packageManager: PackageManager,
searchQuery: String?,
sortOrder: AppListSortOrder,
): LiveData<List<AppListItem>>
/**
* Like [getAppListItems], but further filter items by the given [category].
*/
public fun getAppListItems(
packageManager: PackageManager,
category: String,
searchQuery: String?,
sortOrder: AppListSortOrder,
): LiveData<List<AppListItem>>
@@ -354,19 +364,25 @@ internal interface AppDaoInt : AppDao {
override fun getAppListItems(
packageManager: PackageManager,
searchQuery: String?,
sortOrder: AppListSortOrder,
): LiveData<List<AppListItem>> = when (sortOrder) {
LAST_UPDATED -> getAppListItemsByLastUpdated().map(packageManager)
NAME -> getAppListItemsByName().map(packageManager)
): LiveData<List<AppListItem>> {
return if (searchQuery.isNullOrEmpty()) when (sortOrder) {
LAST_UPDATED -> getAppListItemsByLastUpdated().map(packageManager)
NAME -> getAppListItemsByName().map(packageManager)
} else getAppListItems(searchQuery)
}
override fun getAppListItems(
packageManager: PackageManager,
category: String,
searchQuery: String?,
sortOrder: AppListSortOrder,
): LiveData<List<AppListItem>> = when (sortOrder) {
LAST_UPDATED -> getAppListItemsByLastUpdated(category).map(packageManager)
NAME -> getAppListItemsByName(category).map(packageManager)
): LiveData<List<AppListItem>> {
return if (searchQuery.isNullOrEmpty()) when (sortOrder) {
LAST_UPDATED -> getAppListItemsByLastUpdated(category).map(packageManager)
NAME -> getAppListItemsByName(category).map(packageManager)
} else getAppListItems(category, searchQuery)
}
private fun LiveData<List<AppListItem>>.map(
@@ -383,6 +399,31 @@ internal interface AppDaoInt : AppDao {
}
}
@Transaction
@Query("""
SELECT repoId, packageId, app.localizedName, app.localizedSummary, version.antiFeatures,
app.isCompatible
FROM AppMetadata AS app
JOIN AppMetadataFts USING (repoId, packageId)
LEFT JOIN HighestVersion AS version USING (repoId, packageId)
JOIN RepositoryPreferences AS pref USING (repoId)
WHERE pref.enabled = 1 AND AppMetadataFts MATCH '"*' || :searchQuery || '*"'
GROUP BY packageId HAVING MAX(pref.weight)""")
fun getAppListItems(searchQuery: String): LiveData<List<AppListItem>>
@Transaction
@Query("""
SELECT repoId, packageId, app.localizedName, app.localizedSummary, version.antiFeatures,
app.isCompatible
FROM AppMetadata AS app
JOIN AppMetadataFts USING (repoId, packageId)
LEFT JOIN HighestVersion AS version USING (repoId, packageId)
JOIN RepositoryPreferences AS pref USING (repoId)
WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' AND
AppMetadataFts MATCH '"*' || :searchQuery || '*"'
GROUP BY packageId HAVING MAX(pref.weight)""")
fun getAppListItems(category: String, searchQuery: String): LiveData<List<AppListItem>>
@Transaction
@Query("""
SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures,

View File

@@ -18,7 +18,7 @@ import kotlinx.coroutines.launch
import org.fdroid.LocaleChooser.getBestLocale
@Database(
version = 8, // TODO set version to 1 before release and wipe old schemas
version = 9, // TODO set version to 1 before release and wipe old schemas
entities = [
// repo
CoreRepository::class,
@@ -29,6 +29,7 @@ import org.fdroid.LocaleChooser.getBestLocale
RepositoryPreferences::class,
// packages
AppMetadata::class,
AppMetadataFts::class,
LocalizedFile::class,
LocalizedFileList::class,
// versions
@@ -50,6 +51,7 @@ import org.fdroid.LocaleChooser.getBestLocale
AutoMigration(from = 5, to = 6),
AutoMigration(from = 6, to = 7),
AutoMigration(from = 7, to = 8),
AutoMigration(from = 8, to = 9),
],
)
@TypeConverters(Converters::class)