[db] New AppOverviewItems AppDao methods

This commit is contained in:
Torsten Grote
2025-08-29 17:07:34 -03:00
parent a45c9763e5
commit 290cfcee34
2 changed files with 256 additions and 32 deletions

View File

@@ -1,16 +1,20 @@
package org.fdroid.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import org.fdroid.LocaleChooser.getBestLocale
import org.fdroid.database.TestUtils.getOrAwaitValue
import org.fdroid.database.TestUtils.getOrFail
import org.fdroid.index.v2.MetadataV2
import org.fdroid.test.TestAppUtils.getRandomMetadataV2
import org.fdroid.test.TestRepoUtils.getRandomRepo
import org.fdroid.test.TestUtils.getRandomString
import org.fdroid.test.TestVersionUtils.getRandomPackageVersionV2
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
@@ -19,13 +23,13 @@ import kotlin.test.assertTrue
internal class AppOverviewItemsTest : AppTest() {
@Test
fun testAntiFeatures() {
// insert one apps with without version
fun testAntiFeatures() = runBlocking {
// insert one app with without version
val repoId = repoDao.insertOrReplace(getRandomRepo())
appDao.insert(repoId, packageName, app1, locales)
// without version, anti-features are empty
appDao.getAppOverviewItems().getOrFail().let { apps ->
getItems().forEach { apps ->
assertEquals(1, apps.size)
assertNull(apps[0].antiFeatures)
}
@@ -33,7 +37,7 @@ internal class AppOverviewItemsTest : AppTest() {
// with one version, the app has those anti-features
val version = getRandomPackageVersionV2(versionCode = 42)
versionDao.insert(repoId, packageName, "1", version, true)
appDao.getAppOverviewItems().getOrFail().let { apps ->
getItems().forEach { apps ->
assertEquals(1, apps.size)
assertEquals(version.antiFeatures, apps[0].antiFeatures)
}
@@ -41,7 +45,7 @@ internal class AppOverviewItemsTest : AppTest() {
// with two versions, the app has the anti-features of the highest version
val version2 = getRandomPackageVersionV2(versionCode = 23)
versionDao.insert(repoId, packageName, "2", version2, true)
appDao.getAppOverviewItems().getOrFail().let { apps ->
getItems().forEach { apps ->
assertEquals(1, apps.size)
assertEquals(version.antiFeatures, apps[0].antiFeatures)
}
@@ -49,20 +53,20 @@ internal class AppOverviewItemsTest : AppTest() {
// with three versions, the app has the anti-features of the highest version
val version3 = getRandomPackageVersionV2(versionCode = 1337)
versionDao.insert(repoId, packageName, "3", version3, true)
appDao.getAppOverviewItems().getOrFail().let { apps ->
getItems().forEach { apps ->
assertEquals(1, apps.size)
assertEquals(version3.antiFeatures, apps[0].antiFeatures)
}
}
@Test
fun testIcons() {
fun testIcons() = runBlocking {
// insert one app
val repoId = repoDao.insertOrReplace(getRandomRepo())
appDao.insert(repoId, packageName, app1, locales)
// icon is returned correctly
appDao.getAppOverviewItems().getOrFail().let { apps ->
getItems().forEach { apps ->
assertEquals(1, apps.size)
assertEquals(app1.icon.getBestLocale(locales), apps[0].getIcon(locales))
}
@@ -72,14 +76,14 @@ internal class AppOverviewItemsTest : AppTest() {
appDao.insert(repoId2, packageName, app2, locales)
// app is still returned as before
appDao.getAppOverviewItems().getOrFail().let { apps ->
getItems().forEach { apps ->
assertEquals(1, apps.size)
assertEquals(app1.icon.getBestLocale(locales), apps[0].getIcon(locales))
}
// after preferring second repo, icon is returned from app in second repo
appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId2))
appDao.getAppOverviewItems().getOrFail().let { apps ->
getItems().forEach { apps ->
assertEquals(1, apps.size)
assertEquals(app2.icon.getBestLocale(locales), apps[0].getIcon(locales))
}
@@ -99,17 +103,18 @@ internal class AppOverviewItemsTest : AppTest() {
}
@Test
fun testIncompatibleFlag() {
fun testIncompatibleFlag() = runBlocking {
// insert two apps
val repoId = repoDao.insertOrReplace(getRandomRepo())
appDao.insert(repoId, packageName1, app1, locales)
appDao.insert(repoId, packageName2, app2, locales)
// both apps are not compatible
appDao.getAppOverviewItems().getOrFail().also {
assertEquals(2, it.size)
}.forEach {
assertFalse(it.isCompatible)
getItems().forEach { apps ->
assertEquals(2, apps.size)
apps.forEach {
assertFalse(it.isCompatible)
}
}
// both apps, in the same category, are not compatible
appDao.getAppOverviewItems("A").getOrFail().also {
@@ -128,10 +133,10 @@ internal class AppOverviewItemsTest : AppTest() {
appDao.updateCompatibility(repoId)
// now only one is not compatible
appDao.getAppOverviewItems().getOrFail().also {
assertEquals(2, it.size)
assertFalse(it[0].isCompatible)
assertTrue(it[1].isCompatible)
getItems().forEach { apps ->
assertEquals(2, apps.size)
assertFalse(apps[0].isCompatible)
assertTrue(apps[1].isCompatible)
}
appDao.getAppOverviewItems("A").getOrFail().also {
assertEquals(2, it.size)
@@ -143,14 +148,14 @@ internal class AppOverviewItemsTest : AppTest() {
}
@Test
fun testGetByRepoWeight() {
fun testGetByRepoWeight() = runBlocking {
// insert one app with one version
val repoId = repoDao.insertOrReplace(getRandomRepo())
appDao.insert(repoId, packageName, app1, locales)
versionDao.insert(repoId, packageName, "1", getRandomPackageVersionV2(2), true)
// app is returned correctly
appDao.getAppOverviewItems().getOrFail().let { apps ->
getItems().forEach { apps ->
assertEquals(1, apps.size)
assertEquals(app1, apps[0])
}
@@ -160,21 +165,21 @@ internal class AppOverviewItemsTest : AppTest() {
appDao.insert(repoId2, packageName, app2, locales)
// app is still returned as before, new repo doesn't override old one
appDao.getAppOverviewItems().getOrFail().let { apps ->
getItems().forEach { apps ->
assertEquals(1, apps.size)
assertEquals(app1, apps[0])
}
// now second app from second repo is returned after preferring it explicitly
appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId2))
appDao.getAppOverviewItems().getOrFail().let { apps ->
getItems().forEach { apps ->
assertEquals(1, apps.size)
assertEquals(app2, apps[0])
}
}
@Test
fun testGetByRepoPref() {
fun testGetByRepoPref() = runBlocking {
// insert same app into three repos (repoId1 has highest weight)
val repoId1 = repoDao.insertOrReplace(getRandomRepo())
val repoId3 = repoDao.insertOrReplace(getRandomRepo())
@@ -184,7 +189,7 @@ internal class AppOverviewItemsTest : AppTest() {
appDao.insert(repoId3, packageName, app3, locales)
// app is returned correctly from repo1
appDao.getAppOverviewItems().getOrFail().let { apps ->
getItems().forEach { apps ->
assertEquals(1, apps.size)
assertEquals(app1, apps[0])
}
@@ -195,7 +200,7 @@ internal class AppOverviewItemsTest : AppTest() {
// prefer repo3 for this app
appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId3))
appDao.getAppOverviewItems().getOrFail().let { apps ->
getItems().forEach { apps ->
assertEquals(1, apps.size)
assertEquals(app3, apps[0])
}
@@ -206,7 +211,7 @@ internal class AppOverviewItemsTest : AppTest() {
// prefer repo2 for this app
appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId2))
appDao.getAppOverviewItems().getOrFail().let { apps ->
getItems().forEach { apps ->
assertEquals(1, apps.size)
assertEquals(app2, apps[0])
}
@@ -220,7 +225,7 @@ internal class AppOverviewItemsTest : AppTest() {
// prefer repo1 for this app
appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId1))
appDao.getAppOverviewItems().getOrFail().let { apps ->
getItems().forEach { apps ->
assertEquals(1, apps.size)
assertEquals(app1, apps[0])
}
@@ -341,7 +346,7 @@ internal class AppOverviewItemsTest : AppTest() {
}
@Test
fun testOnlyFromEnabledRepos() {
fun testOnlyFromEnabledRepos() = runBlocking {
val repoId = repoDao.insertOrReplace(getRandomRepo())
appDao.insert(repoId, packageName1, app1, locales)
appDao.insert(repoId, packageName2, app2, locales)
@@ -349,18 +354,24 @@ internal class AppOverviewItemsTest : AppTest() {
appDao.insert(repoId2, packageName3, app3, locales)
// 3 apps from 2 repos
assertEquals(3, appDao.getAppOverviewItems().getOrAwaitValue()?.size)
getItems().forEach { apps ->
assertEquals(3, apps.size)
}
assertEquals(3, appDao.getAppOverviewItems("A").getOrAwaitValue()?.size)
// only 1 app after disabling first repo
repoDao.setRepositoryEnabled(repoId, false)
assertEquals(1, appDao.getAppOverviewItems().getOrAwaitValue()?.size)
getItems().forEach { apps ->
assertEquals(1, apps.size)
}
assertEquals(1, appDao.getAppOverviewItems("A").getOrAwaitValue()?.size)
assertEquals(1, appDao.getAppOverviewItems("B").getOrAwaitValue()?.size)
// no more apps after disabling all repos
repoDao.setRepositoryEnabled(repoId2, false)
assertEquals(0, appDao.getAppOverviewItems().getOrAwaitValue()?.size)
getItems().forEach { apps ->
assertEquals(0, apps.size)
}
assertEquals(0, appDao.getAppOverviewItems("A").getOrAwaitValue()?.size)
assertEquals(0, appDao.getAppOverviewItems("B").getOrAwaitValue()?.size)
}
@@ -405,6 +416,65 @@ internal class AppOverviewItemsTest : AppTest() {
assertEquals(app2, appDao.getAppOverviewItem(repoId2, packageName))
}
@Test
fun testByAuthor() = runBlocking {
val author = getRandomString()
val packageName = getRandomString()
val repoId = repoDao.insertOrReplace(getRandomRepo())
appDao.insert(repoId, packageName, getRandomMetadataV2(author), locales)
// author has only one app
assertFalse(appDao.hasAuthorMoreThanOneApp(author).getOrFail())
assertEquals(0, appDao.getAppsByAuthor("foo bar").size)
appDao.getAppsByAuthor(author).let { apps ->
assertEquals(1, apps.size)
assertEquals(packageName, apps[0].packageName)
}
// now add 49 more apps
(1 until 50).forEach { _ ->
appDao.insert(repoId, getRandomString(), getRandomMetadataV2(author), locales)
}
assertTrue(appDao.hasAuthorMoreThanOneApp(author).getOrFail())
assertEquals(50, appDao.getAppsByAuthor(author).size)
}
@Test
fun testByCategory() = runBlocking {
// insert three apps
val repoId = repoDao.insertOrReplace(getRandomRepo())
appDao.insert(repoId, packageName1, app1, locales)
appDao.insert(repoId, packageName2, app2, locales)
appDao.insert(repoId, packageName3, app3, locales)
// only two apps are in category B
appDao.getAppsByCategory("B").let { apps ->
assertEquals(2, apps.size)
assertNotEquals(packageName2, apps[0].packageName)
assertNotEquals(packageName2, apps[1].packageName)
}
// no app is in category C
assertEquals(0, appDao.getAppsByCategory("C").size)
// we'll add app1 as a variant of app2, but its repo has lower weight, so no effect
val repoId2 = repoDao.insertOrReplace(getRandomRepo())
appDao.insert(repoId2, packageName2, app1, locales)
appDao.getAppsByCategory("B").let { apps ->
assertEquals(2, apps.size)
assertNotEquals(packageName2, apps[0].packageName)
assertNotEquals(packageName2, apps[1].packageName)
}
}
private suspend fun getItems(): List<List<AppOverviewItem>> {
return listOf(
appDao.getAppOverviewItems().getOrFail(),
// manually sort the second list, so both results are comparable
appDao.getAllApps().sortedByDescending { it.lastUpdated },
)
}
private fun assertEquals(expected: MetadataV2, actual: AppOverviewItem?) {
assertNotNull(actual)
assertEquals(expected.added, actual.added)

View File

@@ -19,6 +19,8 @@ import androidx.room.RoomRawQuery
import androidx.room.RoomWarnings.Companion.QUERY_MISMATCH
import androidx.room.Transaction
import androidx.room.Update
import androidx.sqlite.SQLiteStatement
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
@@ -34,6 +36,7 @@ import org.fdroid.index.v2.LocalizedFileListV2
import org.fdroid.index.v2.LocalizedFileV2
import org.fdroid.index.v2.MetadataV2
import org.fdroid.index.v2.ReflectionDiffer.applyDiff
import java.util.concurrent.TimeUnit
public interface AppDao {
/**
@@ -83,16 +86,62 @@ public interface AppDao {
* Apps without name, icon or summary are at the end (or excluded if limit is too small).
* Includes anti-features from the version with the highest version code.
*/
@Deprecated("Use getNewAppsFlow and getRecentlyUpdatedAppsFlow instead")
public fun getAppOverviewItems(limit: Int = 200): LiveData<List<AppOverviewItem>>
/**
* Returns a limited number of apps with limited data within the given [category].
*/
@Deprecated("Use getAppsByCategory instead")
public fun getAppOverviewItems(
category: String,
limit: Int = 50,
): LiveData<List<AppOverviewItem>>
/**
* Returns all apps from the database.
*/
public suspend fun getAllApps(): List<AppOverviewItem>
/**
* Returns all apps whose author is set exactly to [authorName].
*/
public suspend fun getAppsByAuthor(authorName: String): List<AppOverviewItem>
/**
* Returns all apps that are in the category with [categoryId].
*/
public suspend fun getAppsByCategory(categoryId: String): List<AppOverviewItem>
/**
* Returns apps that are new. This means that they were added and last updated at the same time.
* @param maxAgeInDays the number of days that is still considered "new".
* Apps older than this won't be returned.
*/
public suspend fun getNewApps(maxAgeInDays: Long = 14): List<AppOverviewItem>
/**
* Get apps that were recently updated.
* This excludes apps returned by [getNewApps].
* @param limit only return that many apps and not more.
*/
public suspend fun getRecentlyUpdatedApps(limit: Int = 200): List<AppOverviewItem>
/**
* Get all apps from the repository identified by [repoId].
*/
public suspend fun getAppsByRepository(repoId: Long): List<AppOverviewItem>
/**
* Same as [getNewApps], but returns an observable [Flow].
*/
public fun getNewAppsFlow(maxAgeInDays: Long = 14): Flow<List<AppOverviewItem>>
/**
* Same as [getRecentlyUpdatedApps], but returns an observable [Flow].
*/
public fun getRecentlyUpdatedAppsFlow(limit: Int = 200): Flow<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.
@@ -413,6 +462,111 @@ internal interface AppDaoInt : AppDao {
FROM ${AppMetadata.TABLE} AS app WHERE repoId = :repoId AND packageName = :packageName""")
fun getAppOverviewItem(repoId: Long, packageName: String): AppOverviewItem?
@Transaction
override suspend fun getAllApps(): List<AppOverviewItem> {
val query = getAppsQuery("") {}
return getApps(query)
}
@Transaction
override suspend fun getAppsByAuthor(authorName: String): List<AppOverviewItem> {
val query = getAppsQuery("authorName = ?") { statement ->
statement.bindText(1, authorName)
}
return getApps(query)
}
@Transaction
override suspend fun getAppsByCategory(categoryId: String): List<AppOverviewItem> {
val query = getAppsQuery("categories LIKE '%,' || ? || ',%'") { statement ->
statement.bindText(1, categoryId)
}
return getApps(query)
}
@Transaction
override suspend fun getNewApps(maxAgeInDays: Long): List<AppOverviewItem> {
val query =
getAppsQuery("app.added = app.lastUpdated AND app.lastUpdated > ?") { statement ->
statement.bindLong(
1,
System.currentTimeMillis() - TimeUnit.DAYS.toMillis(maxAgeInDays)
)
}
return getApps(query)
}
@Transaction
override suspend fun getRecentlyUpdatedApps(limit: Int): List<AppOverviewItem> {
val query = getAppsQuery(
"app.added != app.lastUpdated ORDER BY app.lastUpdated DESC LIMIT ?"
) { statement ->
statement.bindInt(1, limit)
}
return getApps(query)
}
@Transaction
@Query("""SELECT repoId, packageName, app.added, app.lastUpdated, localizedName,
localizedSummary, categories, version.antiFeatures, app.isCompatible
FROM ${AppMetadata.TABLE} AS app
LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName)
WHERE repoId = :repoId""")
override suspend fun getAppsByRepository(repoId: Long): List<AppOverviewItem>
override fun getNewAppsFlow(maxAgeInDays: Long): Flow<List<AppOverviewItem>> {
val query =
getAppsQuery(
"app.added = app.lastUpdated AND app.lastUpdated > ? ORDER BY app.added DESC"
) { statement ->
statement.bindLong(
1,
System.currentTimeMillis() - TimeUnit.DAYS.toMillis(maxAgeInDays)
)
}
return getAppsFlow(query)
}
override fun getRecentlyUpdatedAppsFlow(limit: Int): Flow<List<AppOverviewItem>> {
val query = getAppsQuery(
"app.added != app.lastUpdated ORDER BY app.lastUpdated DESC LIMIT ?"
) { statement ->
statement.bindInt(1, limit)
}
return getAppsFlow(query)
}
private fun getAppsQuery(
whereQuery: String,
onBindStatement: (SQLiteStatement) -> Unit,
): RoomRawQuery {
val queryBuilder = StringBuilder(
"""
SELECT repoId, packageName, app.added, app.lastUpdated, localizedName,
localizedSummary, categories, version.antiFeatures, app.isCompatible
FROM ${AppMetadata.TABLE} AS app
JOIN PreferredRepo USING (packageName)
LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName)
WHERE repoId = preferredRepoId"""
)
if (whereQuery.isNotEmpty()) queryBuilder.append(" AND ").append(whereQuery)
return RoomRawQuery(
sql = queryBuilder.toString().trimIndent(),
onBindStatement = onBindStatement,
)
}
@RawQuery
suspend fun getApps(query: RoomRawQuery): List<AppOverviewItem>
@Transaction
@RawQuery(
observedEntities = [
AppMetadata::class, Version::class, Repository::class, RepositoryPreferences::class,
]
)
fun getAppsFlow(query: RoomRawQuery): Flow<List<AppOverviewItem>>
//
// AppListItems
//