From 89a2a50f7c2d42400643f08c5e2b2a71ac1ba497 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 11 Apr 2022 16:02:09 -0300 Subject: [PATCH] [db] store localized name and summary in DB otherwise we can't really sort *all* apps by name in an efficient manner --- .../java/org/fdroid/database/AppTest.kt | 8 +- .../java/org/fdroid/database/DbTest.kt | 4 + .../src/main/java/org/fdroid/database/App.kt | 51 ++++---- .../main/java/org/fdroid/database/AppDao.kt | 112 +++++++++++++++--- .../org/fdroid/database/DbStreamReceiver.kt | 9 +- .../org/fdroid/database/DbV1StreamReceiver.kt | 9 +- .../org/fdroid/database/FDroidDatabase.kt | 19 +++ 7 files changed, 159 insertions(+), 53 deletions(-) diff --git a/database/src/androidTest/java/org/fdroid/database/AppTest.kt b/database/src/androidTest/java/org/fdroid/database/AppTest.kt index 9bcea19ed..295fc1672 100644 --- a/database/src/androidTest/java/org/fdroid/database/AppTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/AppTest.kt @@ -8,6 +8,7 @@ import org.fdroid.test.TestAppUtils.getRandomMetadataV2 import org.fdroid.test.TestRepoUtils.getRandomFileV2 import org.fdroid.test.TestRepoUtils.getRandomRepo import org.fdroid.test.TestUtils.getRandomString +import org.fdroid.test.TestVersionUtils.getRandomPackageVersionV2 import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -64,8 +65,10 @@ internal class AppTest : DbTest() { val app1 = getRandomMetadataV2().copy(name = name1, icon = icons1) val app2 = getRandomMetadataV2().copy(name = name2, icon = icons2) val app3 = getRandomMetadataV2().copy(name = name3, icon = null) - appDao.insert(repoId, packageId1, app1) - appDao.insert(repoId, packageId2, app2) + appDao.insert(repoId, packageId1, app1, locales) + appDao.insert(repoId, packageId2, app2, locales) + versionDao.insert(repoId, packageId1, "1", getRandomPackageVersionV2(), true) + versionDao.insert(repoId, packageId2, "2", getRandomPackageVersionV2(), true) // icons of both apps are returned correctly val apps = appDao.getAppOverviewItems().getOrAwaitValue() ?: fail() @@ -77,6 +80,7 @@ internal class AppTest : DbTest() { // app without icon is not returned appDao.insert(repoId, packageId3, app3) + versionDao.insert(repoId, packageId3, "3", getRandomPackageVersionV2(), true) val apps3 = appDao.getAppOverviewItems().getOrAwaitValue() ?: fail() assertEquals(2, apps3.size) assertEquals(icons1, diff --git a/database/src/androidTest/java/org/fdroid/database/DbTest.kt b/database/src/androidTest/java/org/fdroid/database/DbTest.kt index 116d7104f..66e1dc813 100644 --- a/database/src/androidTest/java/org/fdroid/database/DbTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/DbTest.kt @@ -1,6 +1,7 @@ package org.fdroid.database import android.content.Context +import androidx.core.os.LocaleListCompat import androidx.room.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -13,6 +14,7 @@ import org.junit.After import org.junit.Before import org.junit.runner.RunWith import java.io.IOException +import java.util.Locale @RunWith(AndroidJUnit4::class) @OptIn(ExperimentalCoroutinesApi::class) @@ -24,6 +26,8 @@ internal abstract class DbTest { internal lateinit var db: FDroidDatabaseInt private val testCoroutineDispatcher = Dispatchers.Unconfined + protected val locales = LocaleListCompat.create(Locale.US) + @Before open fun createDb() { val context = ApplicationProvider.getApplicationContext() diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt index 78175da12..14e98431d 100644 --- a/database/src/main/java/org/fdroid/database/App.kt +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -1,13 +1,15 @@ package org.fdroid.database +import android.content.res.Resources +import androidx.core.os.ConfigurationCompat.getLocales import androidx.core.os.LocaleListCompat +import androidx.room.ColumnInfo import androidx.room.DatabaseView import androidx.room.Embedded import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Ignore import androidx.room.Relation -import org.fdroid.database.Converters.fromStringToLocalizedTextV2 import org.fdroid.database.Converters.fromStringToMapOfLocalizedTextV2 import org.fdroid.index.v2.Author import org.fdroid.index.v2.Donation @@ -61,6 +63,7 @@ internal fun MetadataV2.toAppMetadata( repoId: Long, packageId: String, isCompatible: Boolean = false, + locales: LocaleListCompat = getLocales(Resources.getSystem().configuration), ) = AppMetadata( repoId = repoId, packageId = packageId, @@ -69,6 +72,8 @@ internal fun MetadataV2.toAppMetadata( name = name, summary = summary, description = description, + localizedName = name.getBestLocale(locales), + localizedSummary = summary.getBestLocale(locales), webSite = webSite, changelog = changelog, license = license, @@ -91,12 +96,8 @@ public data class App( val tvBanner: LocalizedFileV2? = null, val screenshots: Screenshots? = null, ) { - public fun getName(localeList: LocaleListCompat): String? = - metadata.name.getBestLocale(localeList) - - public fun getSummary(localeList: LocaleListCompat): String? = - metadata.summary.getBestLocale(localeList) - + public fun getName(): String? = metadata.localizedName + public fun getSummary(): String? = metadata.localizedSummary public fun getDescription(localeList: LocaleListCompat): String? = metadata.description.getBestLocale(localeList) @@ -135,25 +136,30 @@ public data class AppOverviewItem( public val packageId: String, public val added: Long, public val lastUpdated: Long, - internal val name: LocalizedTextV2? = null, - internal val summary: LocalizedTextV2? = null, + @ColumnInfo(name = "localizedName") + public val name: String? = null, + @ColumnInfo(name = "localizedSummary") + public val summary: String? = null, + internal val antiFeatures: Map? = null, @Relation( parentColumn = "packageId", entityColumn = "packageId", ) internal val localizedIcon: List? = null, ) { - public fun getName(localeList: LocaleListCompat): String? = name.getBestLocale(localeList) - public fun getSummary(localeList: LocaleListCompat): String? = summary.getBestLocale(localeList) public fun getIcon(localeList: LocaleListCompat): String? = localizedIcon?.toLocalizedFileV2().getBestLocale(localeList)?.name + + val antiFeatureNames: List get() = antiFeatures?.map { it.key } ?: emptyList() } -public data class AppListItem @JvmOverloads constructor( +public data class AppListItem constructor( public val repoId: Long, public val packageId: String, - internal val name: String?, - internal val summary: String?, + @ColumnInfo(name = "localizedName") + public val name: String? = null, + @ColumnInfo(name = "localizedSummary") + public val summary: String? = null, internal val antiFeatures: String?, @Relation( parentColumn = "packageId", @@ -167,21 +173,11 @@ public data class AppListItem @JvmOverloads constructor( /** * The name of the installed version, null if this app is not installed. */ - @Ignore + @get:Ignore public val installedVersionName: String? = null, - @Ignore + @get:Ignore public val installedVersionCode: Long? = null, ) { - public fun getName(localeList: LocaleListCompat): String? { - // queries for this class return a larger number, so we convert on demand - return fromStringToLocalizedTextV2(name).getBestLocale(localeList) - } - - public fun getSummary(localeList: LocaleListCompat): String? { - // queries for this class return a larger number, so we convert on demand - return fromStringToLocalizedTextV2(summary).getBestLocale(localeList) - } - public fun getAntiFeatureNames(): List { return fromStringToMapOfLocalizedTextV2(antiFeatures)?.map { it.key } ?: emptyList() } @@ -194,7 +190,7 @@ public data class UpdatableApp( public val packageId: String, public val installedVersionCode: Long, public val upgrade: AppVersion, - internal val name: LocalizedTextV2? = null, + public val name: String? = null, public val summary: String? = null, @Relation( parentColumn = "packageId", @@ -202,7 +198,6 @@ public data class UpdatableApp( ) internal val localizedIcon: List? = null, ) { - public fun getName(localeList: LocaleListCompat): String? = name.getBestLocale(localeList) public fun getIcon(localeList: LocaleListCompat): FileV2? = localizedIcon?.toLocalizedFileV2().getBestLocale(localeList) } diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index d9511743d..363fac2bd 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -2,7 +2,10 @@ package org.fdroid.database import android.content.pm.PackageInfo import android.content.pm.PackageManager +import android.content.res.Resources import androidx.annotation.VisibleForTesting +import androidx.core.os.ConfigurationCompat.getLocales +import androidx.core.os.LocaleListCompat import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.distinctUntilChanged @@ -22,7 +25,12 @@ import org.fdroid.index.v2.MetadataV2 import org.fdroid.index.v2.Screenshots public interface AppDao { - public fun insert(repoId: Long, packageId: String, app: MetadataV2) + public fun insert( + repoId: Long, + packageId: String, + app: MetadataV2, + locales: LocaleListCompat = getLocales(Resources.getSystem().configuration), + ) /** * Gets the app from the DB. If more than one app with this [packageId] exists, @@ -36,10 +44,15 @@ public interface AppDao { limit: Int = 50, ): LiveData> - public fun getAppListItems(packageManager: PackageManager): LiveData> + public fun getAppListItems( + packageManager: PackageManager, + sortOrder: AppListSortOrder, + ): LiveData> + public fun getAppListItems( packageManager: PackageManager, category: String, + sortOrder: AppListSortOrder, ): LiveData> public fun getInstalledAppListItems(packageManager: PackageManager): LiveData> @@ -47,11 +60,20 @@ public interface AppDao { public fun getNumberOfAppsInCategory(category: String): Int } +public enum class AppListSortOrder { + LAST_UPDATED, NAME +} + @Dao internal interface AppDaoInt : AppDao { @Transaction - override fun insert(repoId: Long, packageId: String, app: MetadataV2) { + override fun insert( + repoId: Long, + packageId: String, + app: MetadataV2, + locales: LocaleListCompat, + ) { insert(app.toAppMetadata(repoId, packageId, false)) app.icon.insert(repoId, packageId, "icon") app.featureGraphic.insert(repoId, packageId, "featureGraphic") @@ -109,6 +131,10 @@ internal interface AppDaoInt : AppDao { WHERE repoId = :repoId""") fun updateCompatibility(repoId: Long) + @Query("""UPDATE AppMetadata SET localizedName = :name, localizedSummary = :summary + WHERE repoId = :repoId AND packageId = :packageId""") + fun updateAppMetadata(repoId: Long, packageId: String, name: String?, summary: String?) + override fun getApp(packageId: String): LiveData { return getRepoIdForPackage(packageId).distinctUntilChanged().switchMap { repoId -> if (repoId == null) MutableLiveData(null) @@ -181,28 +207,40 @@ internal interface AppDaoInt : AppDao { fun getLocalizedFileLists(): List @Transaction - @Query("""SELECT repoId, packageId, added, app.lastUpdated, app.name, summary + @Query("""SELECT repoId, packageId, app.added, app.lastUpdated, localizedName, + localizedSummary, version.antiFeatures FROM AppMetadata AS app JOIN RepositoryPreferences AS pref USING (repoId) + JOIN Version AS version USING (repoId, packageId) JOIN LocalizedIcon AS icon USING (repoId, packageId) - WHERE pref.enabled = 1 GROUP BY packageId - ORDER BY app.name IS NULL ASC, summary IS NULL ASC, app.lastUpdated DESC, added ASC + WHERE pref.enabled = 1 + GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) + ORDER BY localizedName IS NULL ASC, localizedSummary IS NULL ASC, app.lastUpdated DESC, app.added ASC LIMIT :limit""") override fun getAppOverviewItems(limit: Int): LiveData> @Transaction // TODO maybe it makes sense to split categories into their own table for this? - @Query("""SELECT repoId, packageId, added, app.lastUpdated, app.name, summary + @Query("""SELECT repoId, packageId, app.added, app.lastUpdated, localizedName, + localizedSummary, version.antiFeatures FROM AppMetadata AS app JOIN RepositoryPreferences AS pref USING (repoId) + JOIN Version AS version USING (repoId, packageId) JOIN LocalizedIcon AS icon USING (repoId, packageId) - WHERE pref.enabled = 1 AND categories LIKE '%' || :category || '%' GROUP BY packageId - ORDER BY app.name IS NULL ASC, summary IS NULL ASC, app.lastUpdated DESC, added ASC + WHERE pref.enabled = 1 AND categories LIKE '%' || :category || '%' + GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) + ORDER BY localizedName IS NULL ASC, localizedSummary IS NULL ASC, app.lastUpdated DESC, app.added ASC LIMIT :limit""") override fun getAppOverviewItems(category: String, limit: Int): LiveData> - override fun getAppListItems(packageManager: PackageManager): LiveData> { - return getAppListItems().map(packageManager) + override fun getAppListItems( + packageManager: PackageManager, + sortOrder: AppListSortOrder, + ): LiveData> { + return when (sortOrder) { + AppListSortOrder.LAST_UPDATED -> getAppListItemsByLastUpdated().map(packageManager) + AppListSortOrder.NAME -> getAppListItemsByName().map(packageManager) + } } private fun LiveData>.map( @@ -221,41 +259,75 @@ internal interface AppDaoInt : AppDao { @Transaction @Query(""" - SELECT repoId, packageId, app.name, summary, version.antiFeatures, app.isCompatible + SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures, + app.isCompatible + FROM AppMetadata AS app + JOIN Version AS version USING (repoId, packageId) + JOIN RepositoryPreferences AS pref USING (repoId) + WHERE pref.enabled = 1 + GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) + ORDER BY localizedName COLLATE NOCASE ASC""") + fun getAppListItemsByName(): LiveData> + + @Transaction + @Query(""" + SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures, + app.isCompatible FROM AppMetadata AS app JOIN Version AS version USING (repoId, packageId) JOIN RepositoryPreferences AS pref USING (repoId) WHERE pref.enabled = 1 GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) ORDER BY app.lastUpdated DESC""") - fun getAppListItems(): LiveData> + fun getAppListItemsByLastUpdated(): LiveData> override fun getAppListItems( packageManager: PackageManager, category: String, + sortOrder: AppListSortOrder, ): LiveData> { - return getAppListItems(category).map(packageManager) + return when (sortOrder) { + AppListSortOrder.LAST_UPDATED -> { + getAppListItemsByLastUpdated(category).map(packageManager) + } + AppListSortOrder.NAME -> getAppListItemsByName(category).map(packageManager) + } } // TODO maybe it makes sense to split categories into their own table for this? @Transaction @Query(""" - SELECT repoId, packageId, app.name, summary, version.antiFeatures, app.isCompatible + SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures, + app.isCompatible FROM AppMetadata AS app JOIN Version AS version USING (repoId, packageId) JOIN RepositoryPreferences AS pref USING (repoId) WHERE pref.enabled = 1 AND categories LIKE '%' || :category || '%' GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) ORDER BY app.lastUpdated DESC""") - fun getAppListItems(category: String): LiveData> + fun getAppListItemsByLastUpdated(category: String): LiveData> + + // TODO maybe it makes sense to split categories into their own table for this? + @Transaction + @Query(""" + SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures, + app.isCompatible + FROM AppMetadata AS app + JOIN Version AS version USING (repoId, packageId) + JOIN RepositoryPreferences AS pref USING (repoId) + WHERE pref.enabled = 1 AND categories LIKE '%' || :category || '%' + GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) + ORDER BY localizedName COLLATE NOCASE ASC""") + fun getAppListItemsByName(category: String): LiveData> @Transaction @SuppressWarnings(CURSOR_MISMATCH) // no anti-features needed here - @Query("""SELECT repoId, packageId, app.name, summary, app.isCompatible + @Query("""SELECT repoId, packageId, localizedName, localizedSummary, app.isCompatible FROM AppMetadata AS app JOIN RepositoryPreferences AS pref USING (repoId) WHERE pref.enabled = 1 AND packageId IN (:packageNames) - GROUP BY packageId HAVING MAX(pref.weight)""") + GROUP BY packageId HAVING MAX(pref.weight) + ORDER BY localizedName COLLATE NOCASE ASC""") fun getAppListItems(packageNames: List): LiveData> override fun getInstalledAppListItems( @@ -276,7 +348,9 @@ internal interface AppDaoInt : AppDao { * Used by [UpdateChecker] to get specific apps with available updates. */ @Transaction - @Query("""SELECT repoId, packageId, added, app.lastUpdated, app.name, summary + @SuppressWarnings(CURSOR_MISMATCH) // no anti-features needed here + @Query("""SELECT repoId, packageId, added, app.lastUpdated, localizedName, + localizedSummary FROM AppMetadata AS app WHERE repoId = :repoId AND packageId = :packageId""") fun getAppOverviewItem(repoId: Long, packageId: String): AppOverviewItem? diff --git a/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt index 4fdcddaf1..b5304505e 100644 --- a/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt @@ -1,5 +1,8 @@ package org.fdroid.database +import android.content.res.Resources +import androidx.core.os.ConfigurationCompat.getLocales +import androidx.core.os.LocaleListCompat import org.fdroid.CompatibilityChecker import org.fdroid.index.v2.IndexStreamReceiver import org.fdroid.index.v2.PackageV2 @@ -10,19 +13,21 @@ internal class DbStreamReceiver( private val compatibilityChecker: CompatibilityChecker, ) : IndexStreamReceiver { + private val locales: LocaleListCompat = getLocales(Resources.getSystem().configuration) + override fun receive(repoId: Long, repo: RepoV2, version: Int, certificate: String?) { db.getRepositoryDao().replace(repoId, repo, version, certificate) } override fun receive(repoId: Long, packageId: String, p: PackageV2) { - db.getAppDao().insert(repoId, packageId, p.metadata) + db.getAppDao().insert(repoId, packageId, p.metadata, locales) db.getVersionDao().insert(repoId, packageId, p.versions) { compatibilityChecker.isCompatible(it.manifest) } } override fun onStreamEnded(repoId: Long) { - db.getAppDao().updateCompatibility(repoId) + db.afterUpdatingRepo(repoId) } } diff --git a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt index a77c8326c..84cd5231a 100644 --- a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt @@ -1,5 +1,8 @@ package org.fdroid.database +import android.content.res.Resources +import androidx.core.os.ConfigurationCompat.getLocales +import androidx.core.os.LocaleListCompat import org.fdroid.CompatibilityChecker import org.fdroid.index.v1.IndexV1StreamReceiver import org.fdroid.index.v2.AntiFeatureV2 @@ -14,12 +17,14 @@ internal class DbV1StreamReceiver( private val compatibilityChecker: CompatibilityChecker, ) : IndexV1StreamReceiver { + private val locales: LocaleListCompat = getLocales(Resources.getSystem().configuration) + override fun receive(repoId: Long, repo: RepoV2, version: Int, certificate: String?) { db.getRepositoryDao().replace(repoId, repo, version, certificate) } override fun receive(repoId: Long, packageId: String, m: MetadataV2) { - db.getAppDao().insert(repoId, packageId, m) + db.getAppDao().insert(repoId, packageId, m, locales) } override fun receive(repoId: Long, packageId: String, v: Map) { @@ -39,7 +44,7 @@ internal class DbV1StreamReceiver( repoDao.insertCategories(categories.toRepoCategories(repoId)) repoDao.insertReleaseChannels(releaseChannels.toRepoReleaseChannel(repoId)) - db.getAppDao().updateCompatibility(repoId) + db.afterUpdatingRepo(repoId) } override fun updateAppMetadata(repoId: Long, packageId: String, preferredSigner: String?) { diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index bb63c3864..5e36cc911 100644 --- a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -1,7 +1,9 @@ package org.fdroid.database import android.content.Context +import android.content.res.Resources import android.util.Log +import androidx.core.os.ConfigurationCompat.getLocales import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase @@ -42,12 +44,29 @@ internal abstract class FDroidDatabaseInt internal constructor() : RoomDatabase( abstract override fun getRepositoryDao(): RepositoryDaoInt abstract override fun getAppDao(): AppDaoInt abstract override fun getVersionDao(): VersionDaoInt + fun afterUpdatingRepo(repoId: Long) { + getAppDao().updateCompatibility(repoId) + } } public interface FDroidDatabase { public fun getRepositoryDao(): RepositoryDao public fun getAppDao(): AppDao public fun getVersionDao(): VersionDao + public fun afterLocalesChanged() { + val appDao = getAppDao() as AppDaoInt + val locales = getLocales(Resources.getSystem().configuration) + runInTransaction { + appDao.getAppMetadata().forEach { appMetadata -> + appDao.updateAppMetadata( + repoId = appMetadata.repoId, + packageId = appMetadata.packageId, + name = appMetadata.name.getBestLocale(locales), + summary = appMetadata.summary.getBestLocale(locales), + ) + } + } + } public fun runInTransaction(body: Runnable) }