[db] allow setting a preferred repo per app

This commit is contained in:
Torsten Grote
2023-10-16 18:16:48 -03:00
parent f4a1d92933
commit 704234c9df
11 changed files with 1327 additions and 59 deletions

View File

File diff suppressed because it is too large Load Diff

View File

@@ -62,6 +62,28 @@ internal class AppDaoTest : AppTest() {
assertEquals(0, appDao.countLocalizedFileLists())
}
@Test
fun testAppRepoPref() {
// insert same app into three repos (repoId1 has highest weight)
val repoId2 = repoDao.insertOrReplace(getRandomRepo())
val repoId3 = repoDao.insertOrReplace(getRandomRepo())
val repoId1 = repoDao.insertOrReplace(getRandomRepo())
appDao.insert(repoId1, packageName, app1, locales)
appDao.insert(repoId2, packageName, app2, locales)
appDao.insert(repoId3, packageName, app3, locales)
// app from repo with highest weight is returned, if no prefs are set
assertEquals(app1, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort())
// prefer repo3 for this app
appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId3))
assertEquals(app3, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort())
// prefer repo1 for this app
appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId1))
assertEquals(app1, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort())
}
@Test
fun testGetSameAppFromTwoReposOneDisabled() {
// insert same app into two repos (repoId2 has highest weight)
@@ -149,6 +171,13 @@ internal class AppDaoTest : AppTest() {
assertEquals(3, appDao.getNumberOfAppsInCategory("A"))
assertEquals(2, appDao.getNumberOfAppsInCategory("B"))
assertEquals(0, appDao.getNumberOfAppsInCategory("C"))
// app1 as a variant of app2 in another repo will show one more app in B
val repoId2 = repoDao.insertOrReplace(getRandomRepo())
appDao.insert(repoId2, packageName2, app1, locales)
assertEquals(3, appDao.getNumberOfAppsInCategory("A"))
assertEquals(3, appDao.getNumberOfAppsInCategory("B"))
assertEquals(0, appDao.getNumberOfAppsInCategory("C"))
}
@Test

View File

@@ -451,6 +451,48 @@ internal class AppListItemsTest : AppTest() {
}
}
@Test
fun testFromRepoFromAppPrefs() {
// insert same app into three repos (repoId1 has highest weight)
val repoId2 = repoDao.insertOrReplace(getRandomRepo())
val repoId3 = repoDao.insertOrReplace(getRandomRepo())
val repoId1 = repoDao.insertOrReplace(getRandomRepo())
appDao.insert(repoId2, packageName, app2, locales)
appDao.insert(repoId1, packageName, app1, locales)
appDao.insert(repoId3, packageName, app3, locales)
// app from repo1 with highest weight gets returned
getItems { apps ->
assertEquals(1, apps.size)
assertEquals(packageName, apps[0].packageName)
assertEquals(app1, apps[0])
}
// prefer repo3 for this app
appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId3))
getItems { apps ->
assertEquals(1, apps.size)
assertEquals(packageName, apps[0].packageName)
assertEquals(app3, apps[0])
}
// prefer repo2 for this app
appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId2))
getItems { apps ->
assertEquals(1, apps.size)
assertEquals(packageName, apps[0].packageName)
assertEquals(app2, apps[0])
}
// prefer repo1 for this app
appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId1))
getItems { apps ->
assertEquals(1, apps.size)
assertEquals(packageName, apps[0].packageName)
assertEquals(app1, apps[0])
}
}
@Test
fun testOnlyFromGivenCategories() {
// insert three apps
@@ -476,6 +518,16 @@ internal class AppListItemsTest : AppTest() {
).forEach { apps ->
assertEquals(0, apps.size)
}
// we'll add app1 as a variant of app2, so it should be in category B as well
val repoId2 = repoDao.insertOrReplace(getRandomRepo())
appDao.insert(repoId2, packageName2, app1, locales)
listOf(
appDao.getAppListItemsByName("B").getOrFail(),
appDao.getAppListItemsByLastUpdated("B").getOrFail(),
).forEach { apps ->
assertEquals(3, apps.size) // all apps are in B now
}
}
@Test

View File

@@ -159,6 +159,63 @@ internal class AppOverviewItemsTest : AppTest() {
}
}
@Test
fun testGetByRepoPref() {
// insert same app into three repos (repoId1 has highest weight)
val repoId2 = repoDao.insertOrReplace(getRandomRepo())
val repoId3 = repoDao.insertOrReplace(getRandomRepo())
val repoId1 = repoDao.insertOrReplace(getRandomRepo())
appDao.insert(repoId1, packageName, app1, locales)
appDao.insert(repoId2, packageName, app2, locales)
appDao.insert(repoId3, packageName, app3, locales)
// app is returned correctly from repo1
appDao.getAppOverviewItems().getOrFail().let { apps ->
assertEquals(1, apps.size)
assertEquals(app1, apps[0])
}
appDao.getAppOverviewItems("A").getOrFail().let { apps ->
assertEquals(1, apps.size)
assertEquals(app1, apps[0])
}
// prefer repo3 for this app
appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId3))
appDao.getAppOverviewItems().getOrFail().let { apps ->
assertEquals(1, apps.size)
assertEquals(app3, apps[0])
}
appDao.getAppOverviewItems("B").getOrFail().let { apps ->
assertEquals(1, apps.size)
assertEquals(app3, apps[0])
}
// prefer repo2 for this app
appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId2))
appDao.getAppOverviewItems().getOrFail().let { apps ->
assertEquals(1, apps.size)
assertEquals(app2, apps[0])
}
appDao.getAppOverviewItems("A").getOrFail().let { apps ->
assertEquals(1, apps.size)
assertEquals(app2, apps[0])
}
appDao.getAppOverviewItems("B").getOrFail().let { apps ->
assertEquals(0, apps.size) // app2 is not in category B
}
// prefer repo1 for this app
appPrefsDao.update(AppPrefs(packageName, preferredRepoId = repoId1))
appDao.getAppOverviewItems().getOrFail().let { apps ->
assertEquals(1, apps.size)
assertEquals(app1, apps[0])
}
appDao.getAppOverviewItems("A").getOrFail().let { apps ->
assertEquals(1, apps.size)
assertEquals(app1, apps[0])
}
}
@Test
fun testSortOrder() {
// insert two apps with one version each

View File

@@ -10,6 +10,7 @@ import io.mockk.every
import io.mockk.mockkObject
import kotlinx.coroutines.Dispatchers
import org.fdroid.database.TestUtils.assertRepoEquals
import org.fdroid.database.TestUtils.getOrFail
import org.fdroid.database.TestUtils.toMetadataV2
import org.fdroid.database.TestUtils.toPackageVersionV2
import org.fdroid.index.v1.IndexV1StreamProcessor
@@ -111,7 +112,7 @@ internal abstract class DbTest {
packageV2.metadata,
appDao.getApp(repoId, packageName)?.toMetadataV2()?.sort()
)
val versions = versionDao.getAppVersions(repoId, packageName).map {
val versions = versionDao.getAppVersions(repoId, packageName).getOrFail().map {
it.toPackageVersionV2()
}.associateBy { it.file.sha256 }
assertEquals(packageV2.versions.size, versions.size, "number of versions")

View File

@@ -255,7 +255,7 @@ internal class RepositoryDaoTest : DbTest() {
// data is there as expected
assertEquals(1, repoDao.getRepositories().size)
assertEquals(1, appDao.getAppMetadata().size)
assertEquals(1, versionDao.getAppVersions(repoId, packageName).size)
assertEquals(1, versionDao.getAppVersions(repoId, packageName).getOrFail().size)
assertTrue(versionDao.getVersionedStrings(repoId, packageName).isNotEmpty())
// clearing the repo removes apps and versions
@@ -264,7 +264,7 @@ internal class RepositoryDaoTest : DbTest() {
assertEquals(0, appDao.countApps())
assertEquals(0, appDao.countLocalizedFiles())
assertEquals(0, appDao.countLocalizedFileLists())
assertEquals(0, versionDao.getAppVersions(repoId, packageName).size)
assertEquals(0, versionDao.getAppVersions(repoId, packageName).getOrFail().size)
assertEquals(0, versionDao.getVersionedStrings(repoId, packageName).size)
// preferences are not touched by clearing
assertEquals(repositoryPreferences, repoDao.getRepositoryPreferences(repoId))

View File

@@ -35,6 +35,22 @@ internal class VersionTest : DbTest() {
versionId2 to packageVersion2,
)
private fun getAppVersion1(repoId: Long): AppVersion {
val version = getVersion1(repoId)
return AppVersion(
version = version,
versionedStrings = packageVersion1.manifest.getVersionedStrings(version),
)
}
private fun getAppVersion2(repoId: Long): AppVersion {
val version = getVersion2(repoId)
return AppVersion(
version = version,
versionedStrings = packageVersion2.manifest.getVersionedStrings(version),
)
}
private fun getVersion1(repoId: Long) =
packageVersion1.toVersion(repoId, packageName, versionId1, isCompatible1)
@@ -55,25 +71,22 @@ internal class VersionTest : DbTest() {
appDao.insert(repoId, packageName, getRandomMetadataV2())
versionDao.insert(repoId, packageName, versionId1, packageVersion1, isCompatible1)
val appVersions = versionDao.getAppVersions(repoId, packageName)
val appVersions = versionDao.getAppVersions(repoId, packageName).getOrFail()
assertEquals(1, appVersions.size)
val appVersion = appVersions[0]
assertEquals(versionId1, appVersion.version.versionId)
assertEquals(getVersion1(repoId), appVersion.version)
val manifest = packageVersion1.manifest
assertEquals(manifest.usesPermission.toSet(), appVersion.usesPermission.toSet())
assertEquals(manifest.usesPermissionSdk23.toSet(), appVersion.usesPermissionSdk23.toSet())
assertEquals(
manifest.features.map { it.name }.toSet(),
appVersion.version.manifest.features?.toSet()
)
assertEquals(getAppVersion1(repoId), appVersions[0])
val manifest = packageVersion1.manifest
val versionedStrings = versionDao.getVersionedStrings(repoId, packageName)
val expectedSize = manifest.usesPermission.size + manifest.usesPermissionSdk23.size
assertEquals(expectedSize, versionedStrings.size)
// getting version by repo produces same result
val versionsByRepo = versionDao.getAppVersions(repoId, packageName).getOrFail()
assertEquals(1, versionsByRepo.size)
assertEquals(getAppVersion1(repoId), versionsByRepo[0])
versionDao.deleteAppVersion(repoId, packageName, versionId1)
assertEquals(0, versionDao.getAppVersions(repoId, packageName).size)
assertEquals(0, versionDao.getAppVersions(repoId, packageName).getOrFail().size)
assertEquals(0, versionDao.getVersionedStrings(repoId, packageName).size)
}
@@ -86,39 +99,29 @@ internal class VersionTest : DbTest() {
versionDao.insert(repoId, packageName, versionId2, packageVersion2, isCompatible2)
// get app versions from DB and assign them correctly
val appVersions = versionDao.getAppVersions(packageName).getOrFail()
assertEquals(2, appVersions.size)
val appVersion = if (versionId1 == appVersions[0].version.versionId) {
appVersions[0]
} else appVersions[1]
val appVersion2 = if (versionId2 == appVersions[0].version.versionId) {
appVersions[0]
} else appVersions[1]
listOf(
versionDao.getAppVersions(packageName).getOrFail(),
versionDao.getAppVersions(repoId, packageName).getOrFail(),
).forEach { appVersions ->
assertEquals(2, appVersions.size)
val appVersion = if (versionId1 == appVersions[0].version.versionId) {
appVersions[0]
} else appVersions[1]
val appVersion2 = if (versionId2 == appVersions[0].version.versionId) {
appVersions[0]
} else appVersions[1]
// check first version matches
assertEquals(getVersion1(repoId), appVersion.version)
val manifest = packageVersion1.manifest
assertEquals(manifest.usesPermission.toSet(), appVersion.usesPermission.toSet())
assertEquals(manifest.usesPermissionSdk23.toSet(), appVersion.usesPermissionSdk23.toSet())
assertEquals(
manifest.features.map { it.name }.toSet(),
appVersion.version.manifest.features?.toSet()
)
// check first version matches
assertEquals(getAppVersion1(repoId), appVersion)
// check second version matches
assertEquals(getVersion2(repoId), appVersion2.version)
val manifest2 = packageVersion2.manifest
assertEquals(manifest2.usesPermission.toSet(), appVersion2.usesPermission.toSet())
assertEquals(manifest2.usesPermissionSdk23.toSet(),
appVersion2.usesPermissionSdk23.toSet())
assertEquals(
manifest.features.map { it.name }.toSet(),
appVersion.version.manifest.features?.toSet()
)
// check second version matches
assertEquals(getAppVersion2(repoId), appVersion2)
}
// delete app and check that all associated data also gets deleted
appDao.deleteAppMetadata(repoId, packageName)
assertEquals(0, versionDao.getAppVersions(repoId, packageName).size)
assertEquals(0, versionDao.getAppVersions(packageName).getOrFail().size)
assertEquals(0, versionDao.getAppVersions(repoId, packageName).getOrFail().size)
assertEquals(0, versionDao.getVersionedStrings(repoId, packageName).size)
}
@@ -138,6 +141,10 @@ internal class VersionTest : DbTest() {
assertEquals(3, versionDao.getAppVersions(packageName).getOrFail().size)
assertEquals(3, versionDao.getVersions(listOf(packageName)).size)
// query by repo only returns the versions from each repo
assertEquals(2, versionDao.getAppVersions(repoId, packageName).getOrFail().size)
assertEquals(1, versionDao.getAppVersions(repoId2, packageName).getOrFail().size)
// disable second repo
repoDao.setRepositoryEnabled(repoId2, false)
@@ -155,8 +162,10 @@ internal class VersionTest : DbTest() {
versionDao.insert(repoId, packageName, versionId3, packageVersion3, true)
val versions1 = versionDao.getAppVersions(packageName).getOrFail()
val versions2 = versionDao.getVersions(listOf(packageName))
val versions3 = versionDao.getAppVersions(repoId, packageName).getOrFail()
assertEquals(3, versions1.size)
assertEquals(3, versions2.size)
assertEquals(3, versions3.size)
// check that they are sorted as expected
listOf(
@@ -166,6 +175,7 @@ internal class VersionTest : DbTest() {
).sortedDescending().forEachIndexed { i, versionCode ->
assertEquals(versionCode, versions1[i].version.manifest.versionCode)
assertEquals(versionCode, versions2[i].versionCode)
assertEquals(versionCode, versions3[i].version.manifest.versionCode)
}
}

View File

@@ -305,7 +305,9 @@ internal interface AppDaoInt : AppDao {
@Transaction
@Query("""SELECT ${AppMetadata.TABLE}.* FROM ${AppMetadata.TABLE}
JOIN RepositoryPreferences AS pref USING (repoId)
WHERE packageName = :packageName AND pref.enabled = 1
LEFT JOIN AppPrefs USING (packageName)
WHERE packageName = :packageName AND pref.enabled = 1 AND
COALESCE(preferredRepoId, repoId) = repoId
ORDER BY pref.weight DESC LIMIT 1""")
override fun getApp(packageName: String): LiveData<App?>
@@ -341,7 +343,8 @@ internal interface AppDaoInt : AppDao {
JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId)
LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName)
LEFT JOIN ${LocalizedIcon.TABLE} AS icon USING (repoId, packageName)
WHERE pref.enabled = 1
LEFT JOIN AppPrefs USING (packageName)
WHERE pref.enabled = 1 AND COALESCE(preferredRepoId, repoId) = repoId
GROUP BY packageName HAVING MAX(pref.weight)
ORDER BY localizedName IS NULL ASC, icon.packageName IS NULL ASC,
localizedSummary IS NULL ASC, app.lastUpdated DESC
@@ -355,7 +358,9 @@ internal interface AppDaoInt : AppDao {
JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId)
LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName)
LEFT JOIN ${LocalizedIcon.TABLE} AS icon USING (repoId, packageName)
WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%'
LEFT JOIN AppPrefs USING (packageName)
WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' AND
COALESCE(preferredRepoId, repoId) = repoId
GROUP BY packageName HAVING MAX(pref.weight)
ORDER BY localizedName IS NULL ASC, icon.packageName IS NULL ASC,
localizedSummary IS NULL ASC, app.lastUpdated DESC
@@ -440,8 +445,10 @@ internal interface AppDaoInt : AppDao {
FROM ${AppMetadata.TABLE} AS app
JOIN ${AppMetadataFts.TABLE} USING (repoId, packageName)
LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName)
LEFT JOIN AppPrefs USING (packageName)
JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId)
WHERE pref.enabled = 1 AND ${AppMetadataFts.TABLE} MATCH :searchQuery
WHERE pref.enabled = 1 AND ${AppMetadataFts.TABLE} MATCH :searchQuery AND
COALESCE(preferredRepoId, repoId) = repoId
GROUP BY packageName HAVING MAX(pref.weight)""")
fun getAppListItems(searchQuery: String): LiveData<List<AppListItem>>
@@ -455,9 +462,11 @@ internal interface AppDaoInt : AppDao {
FROM ${AppMetadata.TABLE} AS app
JOIN ${AppMetadataFts.TABLE} USING (repoId, packageName)
LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName)
LEFT JOIN AppPrefs USING (packageName)
JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId)
WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' AND
${AppMetadataFts.TABLE} MATCH :searchQuery
${AppMetadataFts.TABLE} MATCH :searchQuery AND
COALESCE(preferredRepoId, repoId) = repoId
GROUP BY packageName HAVING MAX(pref.weight)""")
fun getAppListItems(category: String, searchQuery: String): LiveData<List<AppListItem>>
@@ -485,8 +494,9 @@ internal interface AppDaoInt : AppDao {
version.antiFeatures, app.isCompatible, app.preferredSigner
FROM ${AppMetadata.TABLE} AS app
LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName)
LEFT JOIN AppPrefs USING (packageName)
JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId)
WHERE pref.enabled = 1
WHERE pref.enabled = 1 AND COALESCE(preferredRepoId, repoId) = repoId
GROUP BY packageName HAVING MAX(pref.weight)
ORDER BY localizedName COLLATE NOCASE ASC""")
fun getAppListItemsByName(): LiveData<List<AppListItem>>
@@ -498,7 +508,8 @@ internal interface AppDaoInt : AppDao {
FROM ${AppMetadata.TABLE} AS app
JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId)
LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName)
WHERE pref.enabled = 1
LEFT JOIN AppPrefs USING (packageName)
WHERE pref.enabled = 1 AND COALESCE(preferredRepoId, repoId) = repoId
GROUP BY packageName HAVING MAX(pref.weight)
ORDER BY app.lastUpdated DESC""")
fun getAppListItemsByLastUpdated(): LiveData<List<AppListItem>>
@@ -510,7 +521,9 @@ internal interface AppDaoInt : AppDao {
FROM ${AppMetadata.TABLE} AS app
JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId)
LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName)
WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%'
LEFT JOIN AppPrefs USING (packageName)
WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' AND
COALESCE(preferredRepoId, repoId) = repoId
GROUP BY packageName HAVING MAX(pref.weight)
ORDER BY app.lastUpdated DESC""")
fun getAppListItemsByLastUpdated(category: String): LiveData<List<AppListItem>>
@@ -522,7 +535,9 @@ internal interface AppDaoInt : AppDao {
FROM ${AppMetadata.TABLE} AS app
JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId)
LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName)
WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%'
LEFT JOIN AppPrefs USING (packageName)
WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' AND
COALESCE(preferredRepoId, repoId) = repoId
GROUP BY packageName HAVING MAX(pref.weight)
ORDER BY localizedName COLLATE NOCASE ASC""")
fun getAppListItemsByName(category: String): LiveData<List<AppListItem>>
@@ -556,7 +571,9 @@ internal interface AppDaoInt : AppDao {
app.isCompatible, app.preferredSigner
FROM ${AppMetadata.TABLE} AS app
JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId)
WHERE pref.enabled = 1 AND packageName IN (:packageNames)
LEFT JOIN AppPrefs USING (packageName)
WHERE pref.enabled = 1 AND packageName IN (:packageNames) AND
COALESCE(preferredRepoId, repoId) = repoId
GROUP BY packageName HAVING MAX(pref.weight)
ORDER BY localizedName COLLATE NOCASE ASC""")
fun getAppListItems(packageNames: List<String>): LiveData<List<AppListItem>>

View File

@@ -13,6 +13,7 @@ public data class AppPrefs(
@PrimaryKey
val packageName: String,
override val ignoreVersionCodeUpdate: Long = 0,
val preferredRepoId: Long? = null,
// This is named like this, because it hit a Room bug when joining with Version table
// which had exactly the same field.
internal val appPrefReleaseChannels: List<String>? = null,

View File

@@ -3,6 +3,7 @@ package org.fdroid.database
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat.getLocales
import androidx.core.os.LocaleListCompat
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@@ -14,7 +15,7 @@ import java.util.concurrent.Callable
// When bumping this version, please make sure to add one (or more) migration(s) below!
// Consider also providing tests for that migration.
// Don't forget to commit the new schema to the git repo as well.
version = 1,
version = 2,
entities = [
// repo
CoreRepository::class,
@@ -41,6 +42,7 @@ import java.util.concurrent.Callable
exportSchema = true,
autoMigrations = [
// add future migrations here (if they are easy enough to be done automatically)
AutoMigration(1, 2),
],
)
@TypeConverters(Converters::class)

View File

@@ -35,6 +35,12 @@ public interface VersionDao {
* Returns a list of versions for the given [packageName] sorting by highest version code first.
*/
public fun getAppVersions(packageName: String): LiveData<List<AppVersion>>
/**
* Returns a list of versions from the repo identified by the given [repoId]
* for the given [packageName] sorting by highest version code first.
*/
public fun getAppVersions(repoId: Long, packageName: String): LiveData<List<AppVersion>>
}
/**
@@ -161,13 +167,12 @@ internal interface VersionDaoInt : VersionDao {
ORDER BY manifest_versionCode DESC, pref.weight DESC""")
override fun getAppVersions(packageName: String): LiveData<List<AppVersion>>
/**
* Only use for testing, not sorted, does take disabled repos into account.
*/
@Transaction
@RewriteQueriesToDropUnusedColumns
@Query("""SELECT * FROM ${Version.TABLE}
WHERE repoId = :repoId AND packageName = :packageName""")
fun getAppVersions(repoId: Long, packageName: String): List<AppVersion>
WHERE repoId = :repoId AND packageName = :packageName
ORDER BY manifest_versionCode DESC""")
override fun getAppVersions(repoId: Long, packageName: String): LiveData<List<AppVersion>>
@Query("""SELECT * FROM ${Version.TABLE}
WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""")