diff --git a/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt b/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt index 295ea26a5..e7888121b 100644 --- a/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt @@ -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 diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt index bd3e838c0..166790439 100644 --- a/database/src/main/java/org/fdroid/database/App.kt +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -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( diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index cc5372a7e..ad9a560b5 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -83,14 +83,24 @@ public interface AppDao { limit: Int = 50, ): LiveData> + /** + * 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> + /** + * Like [getAppListItems], but further filter items by the given [category]. + */ public fun getAppListItems( packageManager: PackageManager, category: String, + searchQuery: String?, sortOrder: AppListSortOrder, ): LiveData> @@ -354,19 +364,25 @@ internal interface AppDaoInt : AppDao { override fun getAppListItems( packageManager: PackageManager, + searchQuery: String?, sortOrder: AppListSortOrder, - ): LiveData> = when (sortOrder) { - LAST_UPDATED -> getAppListItemsByLastUpdated().map(packageManager) - NAME -> getAppListItemsByName().map(packageManager) + ): LiveData> { + 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> = when (sortOrder) { - LAST_UPDATED -> getAppListItemsByLastUpdated(category).map(packageManager) - NAME -> getAppListItemsByName(category).map(packageManager) + ): LiveData> { + return if (searchQuery.isNullOrEmpty()) when (sortOrder) { + LAST_UPDATED -> getAppListItemsByLastUpdated(category).map(packageManager) + NAME -> getAppListItemsByName(category).map(packageManager) + } else getAppListItems(category, searchQuery) } private fun LiveData>.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> + + @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> + @Transaction @Query(""" SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures, diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index 002972eb0..9baf02486 100644 --- a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -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)