Move libraries into their own folder

and remove sharedTest symlink hack. The shared tests are now a proper gradle module to avoid issues with using the same source files in different modules.
This commit is contained in:
Torsten Grote
2022-07-20 16:06:50 -03:00
committed by Michael Pöhn
parent a6bce15116
commit f6075848e7
181 changed files with 265 additions and 433 deletions

View File

@@ -0,0 +1,439 @@
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.Fts4
import androidx.room.Ignore
import androidx.room.Relation
import org.fdroid.LocaleChooser.getBestLocale
import org.fdroid.database.Converters.fromStringToMapOfLocalizedTextV2
import org.fdroid.index.v2.FileV2
import org.fdroid.index.v2.LocalizedFileListV2
import org.fdroid.index.v2.LocalizedFileV2
import org.fdroid.index.v2.LocalizedTextV2
import org.fdroid.index.v2.MetadataV2
import org.fdroid.index.v2.Screenshots
public interface MinimalApp {
public val repoId: Long
public val packageName: String
public val name: String?
public val summary: String?
public fun getIcon(localeList: LocaleListCompat): FileV2?
}
/**
* The detailed metadata for an app.
* Almost all fields are optional.
* This largely represents [MetadataV2] in a database table.
*/
@Entity(
primaryKeys = ["repoId", "packageName"],
foreignKeys = [ForeignKey(
entity = CoreRepository::class,
parentColumns = ["repoId"],
childColumns = ["repoId"],
onDelete = ForeignKey.CASCADE,
)],
)
public data class AppMetadata(
public val repoId: Long,
public val packageName: String,
public val added: Long,
public val lastUpdated: Long,
public val name: LocalizedTextV2? = null,
public val summary: LocalizedTextV2? = null,
public val description: LocalizedTextV2? = null,
public val localizedName: String? = null,
public val localizedSummary: String? = null,
public val webSite: String? = null,
public val changelog: String? = null,
public val license: String? = null,
public val sourceCode: String? = null,
public val issueTracker: String? = null,
public val translation: String? = null,
public val preferredSigner: String? = null,
public val video: LocalizedTextV2? = null,
public val authorName: String? = null,
public val authorEmail: String? = null,
public val authorWebSite: String? = null,
public val authorPhone: String? = null,
public val donate: List<String>? = null,
public val liberapayID: String? = null,
public val liberapay: String? = null,
public val openCollective: String? = null,
public val bitcoin: String? = null,
public val litecoin: String? = null,
public val flattrID: String? = null,
public val categories: List<String>? = null,
/**
* Whether the app is compatible with the current device.
* This value will be computed and is always false until that happened.
* So to always get correct data, this MUST happen within the same transaction
* that adds the [AppMetadata].
*/
public val isCompatible: Boolean,
)
internal fun MetadataV2.toAppMetadata(
repoId: Long,
packageName: String,
isCompatible: Boolean = false,
locales: LocaleListCompat = getLocales(Resources.getSystem().configuration),
) = AppMetadata(
repoId = repoId,
packageName = packageName,
added = added,
lastUpdated = lastUpdated,
name = name,
summary = summary,
description = description,
localizedName = name.getBestLocale(locales),
localizedSummary = summary.getBestLocale(locales),
webSite = webSite,
changelog = changelog,
license = license,
sourceCode = sourceCode,
issueTracker = issueTracker,
translation = translation,
preferredSigner = preferredSigner,
video = video,
authorName = authorName,
authorEmail = authorEmail,
authorWebSite = authorWebSite,
authorPhone = authorPhone,
donate = donate,
liberapayID = liberapayID,
liberapay = liberapay,
openCollective = openCollective,
bitcoin = bitcoin,
litecoin = litecoin,
flattrID = flattrID,
categories = categories,
isCompatible = isCompatible,
)
@Entity
@Fts4(contentEntity = AppMetadata::class)
internal data class AppMetadataFts(
val repoId: Long,
val packageName: String,
@ColumnInfo(name = "localizedName")
val name: String? = null,
@ColumnInfo(name = "localizedSummary")
val summary: String? = null,
)
/**
* A class to represent all data of an App.
* It combines the metadata and localized filed such as icons and screenshots.
*/
public data class App internal constructor(
@Embedded public val metadata: AppMetadata,
@Relation(
parentColumn = "packageName",
entityColumn = "packageName",
)
private val localizedFiles: List<LocalizedFile>? = null,
@Relation(
parentColumn = "packageName",
entityColumn = "packageName",
)
private val localizedFileLists: List<LocalizedFileList>? = null,
) : MinimalApp {
public override val repoId: Long get() = metadata.repoId
override val packageName: String get() = metadata.packageName
internal val icon: LocalizedFileV2? get() = getLocalizedFile("icon")
internal val featureGraphic: LocalizedFileV2? get() = getLocalizedFile("featureGraphic")
internal val promoGraphic: LocalizedFileV2? get() = getLocalizedFile("promoGraphic")
internal val tvBanner: LocalizedFileV2? get() = getLocalizedFile("tvBanner")
internal val screenshots: Screenshots?
get() = if (localizedFileLists.isNullOrEmpty()) null else Screenshots(
phone = getLocalizedFileList("phone"),
sevenInch = getLocalizedFileList("sevenInch"),
tenInch = getLocalizedFileList("tenInch"),
wear = getLocalizedFileList("wear"),
tv = getLocalizedFileList("tv"),
).takeIf { !it.isNull }
private fun getLocalizedFile(type: String): LocalizedFileV2? {
return localizedFiles?.filter { localizedFile ->
localizedFile.repoId == metadata.repoId && localizedFile.type == type
}?.toLocalizedFileV2()
}
private fun getLocalizedFileList(type: String): LocalizedFileListV2? {
val map = HashMap<String, List<FileV2>>()
localizedFileLists?.iterator()?.forEach { file ->
if (file.repoId != metadata.repoId || file.type != type) return@forEach
val list = map.getOrPut(file.locale) { ArrayList() } as ArrayList
list.add(FileV2(
name = file.name,
sha256 = file.sha256,
size = file.size,
))
}
return map.ifEmpty { null }
}
public override val name: String? get() = metadata.localizedName
public override val summary: String? get() = metadata.localizedSummary
public fun getDescription(localeList: LocaleListCompat): String? =
metadata.description.getBestLocale(localeList)
public fun getVideo(localeList: LocaleListCompat): String? =
metadata.video.getBestLocale(localeList)
public override fun getIcon(localeList: LocaleListCompat): FileV2? =
icon.getBestLocale(localeList)
public fun getFeatureGraphic(localeList: LocaleListCompat): FileV2? =
featureGraphic.getBestLocale(localeList)
public fun getPromoGraphic(localeList: LocaleListCompat): FileV2? =
promoGraphic.getBestLocale(localeList)
public fun getTvBanner(localeList: LocaleListCompat): FileV2? =
tvBanner.getBestLocale(localeList)
public fun getPhoneScreenshots(localeList: LocaleListCompat): List<FileV2> =
screenshots?.phone.getBestLocale(localeList) ?: emptyList()
public fun getSevenInchScreenshots(localeList: LocaleListCompat): List<FileV2> =
screenshots?.sevenInch.getBestLocale(localeList) ?: emptyList()
public fun getTenInchScreenshots(localeList: LocaleListCompat): List<FileV2> =
screenshots?.tenInch.getBestLocale(localeList) ?: emptyList()
public fun getTvScreenshots(localeList: LocaleListCompat): List<FileV2> =
screenshots?.tv.getBestLocale(localeList) ?: emptyList()
public fun getWearScreenshots(localeList: LocaleListCompat): List<FileV2> =
screenshots?.wear.getBestLocale(localeList) ?: emptyList()
}
/**
* A lightweight variant of [App] with minimal data, usually used to provide an overview of apps
* without going into all details that get presented on a dedicated screen.
* The reduced data footprint helps with fast loading many items at once.
*
* It includes [antiFeatureKeys] so some clients can apply filters to them.
*/
public data class AppOverviewItem internal constructor(
public override val repoId: Long,
public override val packageName: String,
public val added: Long,
public val lastUpdated: Long,
@ColumnInfo(name = "localizedName")
public override val name: String? = null,
@ColumnInfo(name = "localizedSummary")
public override val summary: String? = null,
internal val antiFeatures: Map<String, LocalizedTextV2>? = null,
@Relation(
parentColumn = "packageName",
entityColumn = "packageName",
)
internal val localizedIcon: List<LocalizedIcon>? = null,
) : MinimalApp {
public override fun getIcon(localeList: LocaleListCompat): FileV2? {
return localizedIcon?.filter { icon ->
icon.repoId == repoId
}?.toLocalizedFileV2().getBestLocale(localeList)
}
public val antiFeatureKeys: List<String> get() = antiFeatures?.map { it.key } ?: emptyList()
}
/**
* Similar to [AppOverviewItem], this is a lightweight version of [App]
* meant to show a list of apps.
*
* There is additional information about [installedVersionCode] and [installedVersionName]
* as well as [isCompatible].
*
* It includes [antiFeatureKeys] of the highest version, so some clients can apply filters to them.
*/
public data class AppListItem internal constructor(
public override val repoId: Long,
public override val packageName: String,
@ColumnInfo(name = "localizedName")
public override val name: String? = null,
@ColumnInfo(name = "localizedSummary")
public override val summary: String? = null,
internal val antiFeatures: String?,
@Relation(
parentColumn = "packageName",
entityColumn = "packageName",
)
internal val localizedIcon: List<LocalizedIcon>?,
/**
* If true, this this app has at least one version that is compatible with this device.
*/
public val isCompatible: Boolean,
/**
* The name of the installed version, null if this app is not installed.
*/
@get:Ignore
public val installedVersionName: String? = null,
/**
* The version code of the installed version, null if this app is not installed.
*/
@get:Ignore
public val installedVersionCode: Long? = null,
) : MinimalApp {
@delegate:Ignore
private val antiFeaturesDecoded by lazy {
fromStringToMapOfLocalizedTextV2(antiFeatures)
}
public override fun getIcon(localeList: LocaleListCompat): FileV2? {
return localizedIcon?.filter { icon ->
icon.repoId == repoId
}?.toLocalizedFileV2().getBestLocale(localeList)
}
public val antiFeatureKeys: List<String>
get() = antiFeaturesDecoded?.map { it.key } ?: emptyList()
public fun getAntiFeatureReason(antiFeatureKey: String, localeList: LocaleListCompat): String? {
return antiFeaturesDecoded?.get(antiFeatureKey)?.getBestLocale(localeList)
}
}
/**
* An app that has an [update] available.
* It is meant to display available updates in the UI.
*/
public data class UpdatableApp internal constructor(
public override val repoId: Long,
public override val packageName: String,
public val installedVersionCode: Long,
public val update: AppVersion,
/**
* If true, this is not necessarily an update (contrary to the class name),
* but an app with the `KnownVuln` anti-feature.
*/
public val hasKnownVulnerability: Boolean,
public override val name: String? = null,
public override val summary: String? = null,
internal val localizedIcon: List<LocalizedIcon>? = null,
) : MinimalApp {
public override fun getIcon(localeList: LocaleListCompat): FileV2? {
return localizedIcon?.filter { icon ->
icon.repoId == update.repoId
}?.toLocalizedFileV2().getBestLocale(localeList)
}
}
internal interface IFile {
val type: String
val locale: String
val name: String
val sha256: String?
val size: Long?
}
@Entity(
primaryKeys = ["repoId", "packageName", "type", "locale"],
foreignKeys = [ForeignKey(
entity = AppMetadata::class,
parentColumns = ["repoId", "packageName"],
childColumns = ["repoId", "packageName"],
onDelete = ForeignKey.CASCADE,
)],
)
internal data class LocalizedFile(
val repoId: Long,
val packageName: String,
override val type: String,
override val locale: String,
override val name: String,
override val sha256: String? = null,
override val size: Long? = null,
) : IFile
internal fun LocalizedFileV2.toLocalizedFile(
repoId: Long,
packageName: String,
type: String,
): List<LocalizedFile> = map { (locale, file) ->
LocalizedFile(
repoId = repoId,
packageName = packageName,
type = type,
locale = locale,
name = file.name,
sha256 = file.sha256,
size = file.size,
)
}
internal fun List<IFile>.toLocalizedFileV2(): LocalizedFileV2? = associate { file ->
file.locale to FileV2(
name = file.name,
sha256 = file.sha256,
size = file.size,
)
}.ifEmpty { null }
// We can't restrict this query further (e.g. only from enabled repos or max weight),
// because we are using this via @Relation on packageName for specific repos.
// When filtering the result for only the repoId we are interested in, we'd get no icons.
@DatabaseView("SELECT * FROM LocalizedFile WHERE type='icon'")
internal data class LocalizedIcon(
val repoId: Long,
val packageName: String,
override val type: String,
override val locale: String,
override val name: String,
override val sha256: String? = null,
override val size: Long? = null,
) : IFile
@Entity(
primaryKeys = ["repoId", "packageName", "type", "locale", "name"],
foreignKeys = [ForeignKey(
entity = AppMetadata::class,
parentColumns = ["repoId", "packageName"],
childColumns = ["repoId", "packageName"],
onDelete = ForeignKey.CASCADE,
)],
)
internal data class LocalizedFileList(
val repoId: Long,
val packageName: String,
val type: String,
val locale: String,
val name: String,
val sha256: String? = null,
val size: Long? = null,
)
internal fun LocalizedFileListV2.toLocalizedFileList(
repoId: Long,
packageName: String,
type: String,
): List<LocalizedFileList> = flatMap { (locale, files) ->
files.map { file -> file.toLocalizedFileList(repoId, packageName, type, locale) }
}
internal fun FileV2.toLocalizedFileList(
repoId: Long,
packageName: String,
type: String,
locale: String,
) = LocalizedFileList(
repoId = repoId,
packageName = packageName,
type = type,
locale = locale,
name = name,
sha256 = sha256,
size = size,
)

View File

@@ -0,0 +1,539 @@
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.map
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy.REPLACE
import androidx.room.Query
import androidx.room.RoomWarnings.CURSOR_MISMATCH
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import org.fdroid.LocaleChooser.getBestLocale
import org.fdroid.database.AppListSortOrder.LAST_UPDATED
import org.fdroid.database.AppListSortOrder.NAME
import org.fdroid.database.DbDiffUtils.diffAndUpdateListTable
import org.fdroid.database.DbDiffUtils.diffAndUpdateTable
import org.fdroid.index.IndexParser.json
import org.fdroid.index.v2.FileV2
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
public interface AppDao {
/**
* Inserts an app into the DB.
* This is usually from a full index v2 via [MetadataV2].
*
* Note: The app is considered to be not compatible until [Version]s are added
* and [updateCompatibility] was called.
*
* @param locales supported by the current system configuration.
*/
public fun insert(
repoId: Long,
packageName: String,
app: MetadataV2,
locales: LocaleListCompat = getLocales(Resources.getSystem().configuration),
)
/**
* Updates the [AppMetadata.isCompatible] flag
* based on whether at least one [AppVersion] is compatible.
* This needs to run within the transaction that adds [AppMetadata] to the DB (e.g. [insert]).
* Otherwise the compatibility is wrong.
*/
public fun updateCompatibility(repoId: Long)
/**
* Gets the app from the DB. If more than one app with this [packageName] exists,
* the one from the repository with the highest weight is returned.
*/
public fun getApp(packageName: String): LiveData<App?>
/**
* Gets an app from a specific [Repository] or null,
* if none is found with the given [packageName],
*/
public fun getApp(repoId: Long, packageName: String): App?
/**
* Returns a limited number of apps with limited data.
* 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.
*/
public fun getAppOverviewItems(limit: Int = 200): LiveData<List<AppOverviewItem>>
/**
* Returns a limited number of apps with limited data within the given [category].
*/
public fun getAppOverviewItems(
category: String,
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>>
public fun getInstalledAppListItems(packageManager: PackageManager): LiveData<List<AppListItem>>
public fun getNumberOfAppsInCategory(category: String): Int
public fun getNumberOfAppsInRepository(repoId: Long): Int
}
public enum class AppListSortOrder {
LAST_UPDATED, NAME
}
/**
* A list of unknown fields in [MetadataV2] that we don't allow for [AppMetadata].
*
* We are applying reflection diffs against internal database classes
* and need to prevent the untrusted external JSON input to modify internal fields in those classes.
* This list must always hold the names of all those internal FIELDS for [AppMetadata].
*/
private val DENY_LIST = listOf("packageName", "repoId")
/**
* A list of unknown fields in [LocalizedFileV2] or [LocalizedFileListV2]
* that we don't allow for [LocalizedFile] or [LocalizedFileList].
*
* Similar to [DENY_LIST].
*/
private val DENY_FILE_LIST = listOf("packageName", "repoId", "type")
@Dao
internal interface AppDaoInt : AppDao {
@Transaction
override fun insert(
repoId: Long,
packageName: String,
app: MetadataV2,
locales: LocaleListCompat,
) {
insert(app.toAppMetadata(repoId, packageName, false, locales))
app.icon.insert(repoId, packageName, "icon")
app.featureGraphic.insert(repoId, packageName, "featureGraphic")
app.promoGraphic.insert(repoId, packageName, "promoGraphic")
app.tvBanner.insert(repoId, packageName, "tvBanner")
app.screenshots?.let {
it.phone.insert(repoId, packageName, "phone")
it.sevenInch.insert(repoId, packageName, "sevenInch")
it.tenInch.insert(repoId, packageName, "tenInch")
it.wear.insert(repoId, packageName, "wear")
it.tv.insert(repoId, packageName, "tv")
}
}
private fun LocalizedFileV2?.insert(repoId: Long, packageName: String, type: String) {
this?.toLocalizedFile(repoId, packageName, type)?.let { files ->
insert(files)
}
}
@JvmName("insertLocalizedFileListV2")
private fun LocalizedFileListV2?.insert(repoId: Long, packageName: String, type: String) {
this?.toLocalizedFileList(repoId, packageName, type)?.let { files ->
insertLocalizedFileLists(files)
}
}
@Insert(onConflict = REPLACE)
fun insert(appMetadata: AppMetadata)
@Insert(onConflict = REPLACE)
fun insert(localizedFiles: List<LocalizedFile>)
@Insert(onConflict = REPLACE)
fun insertLocalizedFileLists(localizedFiles: List<LocalizedFileList>)
@Transaction
fun updateApp(
repoId: Long,
packageName: String,
jsonObject: JsonObject?,
locales: LocaleListCompat,
) {
if (jsonObject == null) {
// this app is gone, we need to delete it
deleteAppMetadata(repoId, packageName)
return
}
val metadata = getAppMetadata(repoId, packageName)
if (metadata == null) { // new app
val metadataV2: MetadataV2 = json.decodeFromJsonElement(jsonObject)
insert(repoId, packageName, metadataV2)
} else { // diff against existing app
// ensure that diff does not include internal keys
DENY_LIST.forEach { forbiddenKey ->
if (jsonObject.containsKey(forbiddenKey)) throw SerializationException(forbiddenKey)
}
// diff metadata
val diffedApp = applyDiff(metadata, jsonObject)
val updatedApp =
if (jsonObject.containsKey("name") || jsonObject.containsKey("summary")) {
diffedApp.copy(
localizedName = diffedApp.name.getBestLocale(locales),
localizedSummary = diffedApp.summary.getBestLocale(locales),
)
} else diffedApp
updateAppMetadata(updatedApp)
// diff localizedFiles
val localizedFiles = getLocalizedFiles(repoId, packageName)
localizedFiles.diffAndUpdate(repoId, packageName, "icon", jsonObject)
localizedFiles.diffAndUpdate(repoId, packageName, "featureGraphic", jsonObject)
localizedFiles.diffAndUpdate(repoId, packageName, "promoGraphic", jsonObject)
localizedFiles.diffAndUpdate(repoId, packageName, "tvBanner", jsonObject)
// diff localizedFileLists
val screenshots = jsonObject["screenshots"]
if (screenshots is JsonNull) {
deleteLocalizedFileLists(repoId, packageName)
} else if (screenshots is JsonObject) {
diffAndUpdateLocalizedFileList(repoId, packageName, "phone", screenshots)
diffAndUpdateLocalizedFileList(repoId, packageName, "sevenInch", screenshots)
diffAndUpdateLocalizedFileList(repoId, packageName, "tenInch", screenshots)
diffAndUpdateLocalizedFileList(repoId, packageName, "wear", screenshots)
diffAndUpdateLocalizedFileList(repoId, packageName, "tv", screenshots)
}
}
}
private fun List<LocalizedFile>.diffAndUpdate(
repoId: Long,
packageName: String,
type: String,
jsonObject: JsonObject,
) = diffAndUpdateTable(
jsonObject = jsonObject,
jsonObjectKey = type,
itemList = filter { it.type == type },
itemFinder = { locale, item -> item.locale == locale },
newItem = { locale -> LocalizedFile(repoId, packageName, type, locale, "") },
deleteAll = { deleteLocalizedFiles(repoId, packageName, type) },
deleteOne = { locale -> deleteLocalizedFile(repoId, packageName, type, locale) },
insertReplace = { list -> insert(list) },
isNewItemValid = { it.name.isNotEmpty() },
keyDenyList = DENY_FILE_LIST,
)
private fun diffAndUpdateLocalizedFileList(
repoId: Long,
packageName: String,
type: String,
jsonObject: JsonObject,
) {
diffAndUpdateListTable(
jsonObject = jsonObject,
jsonObjectKey = type,
listParser = { locale, jsonArray ->
json.decodeFromJsonElement<List<FileV2>>(jsonArray).map {
it.toLocalizedFileList(repoId, packageName, type, locale)
}
},
deleteAll = { deleteLocalizedFileLists(repoId, packageName, type) },
deleteList = { locale -> deleteLocalizedFileList(repoId, packageName, type, locale) },
insertNewList = { _, fileLists -> insertLocalizedFileLists(fileLists) },
)
}
/**
* This is needed to support v1 streaming and shouldn't be used for something else.
*/
@Deprecated("Only for v1 index")
@Query("""UPDATE AppMetadata SET preferredSigner = :preferredSigner
WHERE repoId = :repoId AND packageName = :packageName""")
fun updatePreferredSigner(repoId: Long, packageName: String, preferredSigner: String?)
@Query("""UPDATE AppMetadata
SET isCompatible = (
SELECT TOTAL(isCompatible) > 0 FROM Version
WHERE repoId = :repoId AND AppMetadata.packageName = Version.packageName
)
WHERE repoId = :repoId""")
override fun updateCompatibility(repoId: Long)
@Query("""UPDATE AppMetadata SET localizedName = :name, localizedSummary = :summary
WHERE repoId = :repoId AND packageName = :packageName""")
fun updateAppMetadata(repoId: Long, packageName: String, name: String?, summary: String?)
@Update
fun updateAppMetadata(appMetadata: AppMetadata): Int
@Transaction
@Query("""SELECT AppMetadata.* FROM AppMetadata
JOIN RepositoryPreferences AS pref USING (repoId)
WHERE packageName = :packageName
ORDER BY pref.weight DESC LIMIT 1""")
override fun getApp(packageName: String): LiveData<App?>
@Transaction
@Query("""SELECT * FROM AppMetadata
WHERE repoId = :repoId AND packageName = :packageName""")
override fun getApp(repoId: Long, packageName: String): App?
/**
* Used for diffing.
*/
@Query("SELECT * FROM AppMetadata WHERE repoId = :repoId AND packageName = :packageName")
fun getAppMetadata(repoId: Long, packageName: String): AppMetadata?
/**
* Used for updating best locales.
*/
@Query("SELECT * FROM AppMetadata")
fun getAppMetadata(): List<AppMetadata>
/**
* used for diffing
*/
@Query("SELECT * FROM LocalizedFile WHERE repoId = :repoId AND packageName = :packageName")
fun getLocalizedFiles(repoId: Long, packageName: String): List<LocalizedFile>
@Transaction
@Query("""SELECT repoId, packageName, app.added, app.lastUpdated, localizedName,
localizedSummary, version.antiFeatures
FROM AppMetadata AS app
JOIN RepositoryPreferences AS pref USING (repoId)
LEFT JOIN HighestVersion AS version USING (repoId, packageName)
LEFT JOIN LocalizedIcon AS icon USING (repoId, packageName)
WHERE pref.enabled = 1
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
LIMIT :limit""")
override fun getAppOverviewItems(limit: Int): LiveData<List<AppOverviewItem>>
@Transaction
@Query("""SELECT repoId, packageName, app.added, app.lastUpdated, localizedName,
localizedSummary, version.antiFeatures
FROM AppMetadata AS app
JOIN RepositoryPreferences AS pref USING (repoId)
LEFT JOIN HighestVersion AS version USING (repoId, packageName)
LEFT JOIN LocalizedIcon AS icon USING (repoId, packageName)
WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%'
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
LIMIT :limit""")
override fun getAppOverviewItems(category: String, limit: Int): LiveData<List<AppOverviewItem>>
/**
* Used by [DbUpdateChecker] to get specific apps with available updates.
*/
@Transaction
@SuppressWarnings(CURSOR_MISMATCH) // no anti-features needed here
@Query("""SELECT repoId, packageName, added, app.lastUpdated, localizedName,
localizedSummary
FROM AppMetadata AS app WHERE repoId = :repoId AND packageName = :packageName""")
fun getAppOverviewItem(repoId: Long, packageName: String): AppOverviewItem?
//
// AppListItems
//
override fun getAppListItems(
packageManager: PackageManager,
searchQuery: String?,
sortOrder: AppListSortOrder,
): 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>> {
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(
packageManager: PackageManager,
installedPackages: Map<String, PackageInfo> = packageManager.getInstalledPackages(0)
.associateBy { packageInfo -> packageInfo.packageName },
) = map { items ->
items.map { item ->
val packageInfo = installedPackages[item.packageName]
if (packageInfo == null) item else item.copy(
installedVersionName = packageInfo.versionName,
installedVersionCode = packageInfo.getVersionCode(),
)
}
}
@Transaction
@Query("""
SELECT repoId, packageName, app.localizedName, app.localizedSummary, version.antiFeatures,
app.isCompatible
FROM AppMetadata AS app
JOIN AppMetadataFts USING (repoId, packageName)
LEFT JOIN HighestVersion AS version USING (repoId, packageName)
JOIN RepositoryPreferences AS pref USING (repoId)
WHERE pref.enabled = 1 AND AppMetadataFts MATCH '"*' || :searchQuery || '*"'
GROUP BY packageName HAVING MAX(pref.weight)""")
fun getAppListItems(searchQuery: String): LiveData<List<AppListItem>>
@Transaction
@Query("""
SELECT repoId, packageName, app.localizedName, app.localizedSummary, version.antiFeatures,
app.isCompatible
FROM AppMetadata AS app
JOIN AppMetadataFts USING (repoId, packageName)
LEFT JOIN HighestVersion AS version USING (repoId, packageName)
JOIN RepositoryPreferences AS pref USING (repoId)
WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' AND
AppMetadataFts MATCH '"*' || :searchQuery || '*"'
GROUP BY packageName HAVING MAX(pref.weight)""")
fun getAppListItems(category: String, searchQuery: String): LiveData<List<AppListItem>>
@Transaction
@Query("""
SELECT repoId, packageName, localizedName, localizedSummary, version.antiFeatures,
app.isCompatible
FROM AppMetadata AS app
LEFT JOIN HighestVersion AS version USING (repoId, packageName)
JOIN RepositoryPreferences AS pref USING (repoId)
WHERE pref.enabled = 1
GROUP BY packageName HAVING MAX(pref.weight)
ORDER BY localizedName COLLATE NOCASE ASC""")
fun getAppListItemsByName(): LiveData<List<AppListItem>>
@Transaction
@Query("""
SELECT repoId, packageName, localizedName, localizedSummary, version.antiFeatures,
app.isCompatible
FROM AppMetadata AS app
JOIN RepositoryPreferences AS pref USING (repoId)
LEFT JOIN HighestVersion AS version USING (repoId, packageName)
WHERE pref.enabled = 1
GROUP BY packageName HAVING MAX(pref.weight)
ORDER BY app.lastUpdated DESC""")
fun getAppListItemsByLastUpdated(): LiveData<List<AppListItem>>
@Transaction
@Query("""
SELECT repoId, packageName, localizedName, localizedSummary, version.antiFeatures,
app.isCompatible
FROM AppMetadata AS app
JOIN RepositoryPreferences AS pref USING (repoId)
LEFT JOIN HighestVersion AS version USING (repoId, packageName)
WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%'
GROUP BY packageName HAVING MAX(pref.weight)
ORDER BY app.lastUpdated DESC""")
fun getAppListItemsByLastUpdated(category: String): LiveData<List<AppListItem>>
@Transaction
@Query("""
SELECT repoId, packageName, localizedName, localizedSummary, version.antiFeatures,
app.isCompatible
FROM AppMetadata AS app
JOIN RepositoryPreferences AS pref USING (repoId)
LEFT JOIN HighestVersion AS version USING (repoId, packageName)
WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%'
GROUP BY packageName HAVING MAX(pref.weight)
ORDER BY localizedName COLLATE NOCASE ASC""")
fun getAppListItemsByName(category: String): LiveData<List<AppListItem>>
@Transaction
@SuppressWarnings(CURSOR_MISMATCH) // no anti-features needed here
@Query("""SELECT repoId, packageName, localizedName, localizedSummary, app.isCompatible
FROM AppMetadata AS app
JOIN RepositoryPreferences AS pref USING (repoId)
WHERE pref.enabled = 1 AND packageName IN (:packageNames)
GROUP BY packageName HAVING MAX(pref.weight)
ORDER BY localizedName COLLATE NOCASE ASC""")
fun getAppListItems(packageNames: List<String>): LiveData<List<AppListItem>>
override fun getInstalledAppListItems(
packageManager: PackageManager,
): LiveData<List<AppListItem>> {
val installedPackages = packageManager.getInstalledPackages(0)
.associateBy { packageInfo -> packageInfo.packageName }
val packageNames = installedPackages.keys.toList()
return getAppListItems(packageNames).map(packageManager, installedPackages)
}
@Query("""SELECT COUNT(DISTINCT packageName) FROM AppMetadata
JOIN RepositoryPreferences AS pref USING (repoId)
WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%'""")
override fun getNumberOfAppsInCategory(category: String): Int
@Query("SELECT COUNT(*) FROM AppMetadata WHERE repoId = :repoId")
override fun getNumberOfAppsInRepository(repoId: Long): Int
@Query("DELETE FROM AppMetadata WHERE repoId = :repoId AND packageName = :packageName")
fun deleteAppMetadata(repoId: Long, packageName: String)
@Query("""DELETE FROM LocalizedFile
WHERE repoId = :repoId AND packageName = :packageName AND type = :type""")
fun deleteLocalizedFiles(repoId: Long, packageName: String, type: String)
@Query("""DELETE FROM LocalizedFile
WHERE repoId = :repoId AND packageName = :packageName AND type = :type
AND locale = :locale""")
fun deleteLocalizedFile(repoId: Long, packageName: String, type: String, locale: String)
@Query("""DELETE FROM LocalizedFileList
WHERE repoId = :repoId AND packageName = :packageName""")
fun deleteLocalizedFileLists(repoId: Long, packageName: String)
@Query("""DELETE FROM LocalizedFileList
WHERE repoId = :repoId AND packageName = :packageName AND type = :type""")
fun deleteLocalizedFileLists(repoId: Long, packageName: String, type: String)
@Query("""DELETE FROM LocalizedFileList
WHERE repoId = :repoId AND packageName = :packageName AND type = :type
AND locale = :locale""")
fun deleteLocalizedFileList(repoId: Long, packageName: String, type: String, locale: String)
@VisibleForTesting
@Query("SELECT COUNT(*) FROM AppMetadata")
fun countApps(): Int
@VisibleForTesting
@Query("SELECT COUNT(*) FROM LocalizedFile")
fun countLocalizedFiles(): Int
@VisibleForTesting
@Query("SELECT COUNT(*) FROM LocalizedFileList")
fun countLocalizedFileLists(): Int
}

View File

@@ -0,0 +1,51 @@
package org.fdroid.database
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.fdroid.PackagePreference
/**
* User-defined preferences related to [App]s that get stored in the database,
* so they can be used for queries.
*/
@Entity
public data class AppPrefs(
@PrimaryKey
val packageName: String,
override val ignoreVersionCodeUpdate: Long = 0,
// 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,
) : PackagePreference {
public val ignoreAllUpdates: Boolean get() = ignoreVersionCodeUpdate == Long.MAX_VALUE
public override val releaseChannels: List<String> get() = appPrefReleaseChannels ?: emptyList()
public fun shouldIgnoreUpdate(versionCode: Long): Boolean =
ignoreVersionCodeUpdate >= versionCode
/**
* Returns a new instance of [AppPrefs] toggling [ignoreAllUpdates].
*/
public fun toggleIgnoreAllUpdates(): AppPrefs = copy(
ignoreVersionCodeUpdate = if (ignoreAllUpdates) 0 else Long.MAX_VALUE,
)
/**
* Returns a new instance of [AppPrefs] ignoring the given [versionCode] or stop ignoring it
* if it was already ignored.
*/
public fun toggleIgnoreVersionCodeUpdate(versionCode: Long): AppPrefs = copy(
ignoreVersionCodeUpdate = if (shouldIgnoreUpdate(versionCode)) 0 else versionCode,
)
/**
* Returns a new instance of [AppPrefs] enabling the given [releaseChannel] or disabling it
* if it was already enabled.
*/
public fun toggleReleaseChannel(releaseChannel: String): AppPrefs = copy(
appPrefReleaseChannels = if (appPrefReleaseChannels?.contains(releaseChannel) == true) {
appPrefReleaseChannels.toMutableList().apply { remove(releaseChannel) }
} else {
(appPrefReleaseChannels?.toMutableList() ?: ArrayList()).apply { add(releaseChannel) }
},
)
}

View File

@@ -0,0 +1,33 @@
package org.fdroid.database
import androidx.lifecycle.LiveData
import androidx.lifecycle.distinctUntilChanged
import androidx.lifecycle.map
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy.REPLACE
import androidx.room.Query
public interface AppPrefsDao {
public fun getAppPrefs(packageName: String): LiveData<AppPrefs>
public fun update(appPrefs: AppPrefs)
}
@Dao
internal interface AppPrefsDaoInt : AppPrefsDao {
override fun getAppPrefs(packageName: String): LiveData<AppPrefs> {
return getLiveAppPrefs(packageName).distinctUntilChanged().map { data ->
data ?: AppPrefs(packageName)
}
}
@Query("SELECT * FROM AppPrefs WHERE packageName = :packageName")
fun getLiveAppPrefs(packageName: String): LiveData<AppPrefs?>
@Query("SELECT * FROM AppPrefs WHERE packageName = :packageName")
fun getAppPrefsOrNull(packageName: String): AppPrefs?
@Insert(onConflict = REPLACE)
override fun update(appPrefs: AppPrefs)
}

View File

@@ -0,0 +1,62 @@
package org.fdroid.database
import androidx.room.TypeConverter
import kotlinx.serialization.builtins.MapSerializer
import kotlinx.serialization.builtins.serializer
import org.fdroid.index.IndexParser.json
import org.fdroid.index.v2.FileV2
import org.fdroid.index.v2.LocalizedFileV2
import org.fdroid.index.v2.LocalizedTextV2
internal object Converters {
private val localizedTextV2Serializer = MapSerializer(String.serializer(), String.serializer())
private val localizedFileV2Serializer = MapSerializer(String.serializer(), FileV2.serializer())
private val mapOfLocalizedTextV2Serializer =
MapSerializer(String.serializer(), localizedTextV2Serializer)
@TypeConverter
fun fromStringToLocalizedTextV2(value: String?): LocalizedTextV2? {
return value?.let { json.decodeFromString(localizedTextV2Serializer, it) }
}
@TypeConverter
fun localizedTextV2toString(text: LocalizedTextV2?): String? {
return text?.let { json.encodeToString(localizedTextV2Serializer, it) }
}
@TypeConverter
fun fromStringToLocalizedFileV2(value: String?): LocalizedFileV2? {
return value?.let { json.decodeFromString(localizedFileV2Serializer, it) }
}
@TypeConverter
fun localizedFileV2toString(file: LocalizedFileV2?): String? {
return file?.let { json.encodeToString(localizedFileV2Serializer, it) }
}
@TypeConverter
fun fromStringToMapOfLocalizedTextV2(value: String?): Map<String, LocalizedTextV2>? {
return value?.let { json.decodeFromString(mapOfLocalizedTextV2Serializer, it) }
}
@TypeConverter
fun mapOfLocalizedTextV2toString(text: Map<String, LocalizedTextV2>?): String? {
return text?.let { json.encodeToString(mapOfLocalizedTextV2Serializer, it) }
}
@TypeConverter
fun fromStringToListString(value: String?): List<String> {
return value?.split(',')?.filter { it.isNotEmpty() } ?: emptyList()
}
@TypeConverter
fun listStringToString(text: List<String>?): String? {
if (text.isNullOrEmpty()) return null
return text.joinToString(
prefix = ",",
separator = ",",
postfix = ",",
) { it.replace(',', '_') }
}
}

View File

@@ -0,0 +1,125 @@
package org.fdroid.database
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import org.fdroid.index.v2.ReflectionDiffer
internal object DbDiffUtils {
/**
* Applies the diff from the given [jsonObject] identified by the given [jsonObjectKey]
* to [itemList] and updates the DB as needed.
*
* @param newItem A function to produce a new [T] which typically contains the primary key(s).
*/
@Throws(SerializationException::class)
fun <T : Any> diffAndUpdateTable(
jsonObject: JsonObject,
jsonObjectKey: String,
itemList: List<T>,
itemFinder: (String, T) -> Boolean,
newItem: (String) -> T,
deleteAll: () -> Unit,
deleteOne: (String) -> Unit,
insertReplace: (List<T>) -> Unit,
isNewItemValid: (T) -> Boolean = { true },
keyDenyList: List<String>? = null,
) {
if (!jsonObject.containsKey(jsonObjectKey)) return
if (jsonObject[jsonObjectKey] == JsonNull) {
deleteAll()
} else {
val obj = jsonObject[jsonObjectKey]?.jsonObject ?: error("no $jsonObjectKey object")
val list = itemList.toMutableList()
obj.entries.forEach { (key, value) ->
if (value is JsonNull) {
list.removeAll { itemFinder(key, it) }
deleteOne(key)
} else {
value.jsonObject.checkDenyList(keyDenyList)
val index = list.indexOfFirst { itemFinder(key, it) }
val item = if (index == -1) null else list[index]
if (item == null) {
val itemToInsert =
ReflectionDiffer.applyDiff(newItem(key), value.jsonObject)
if (!isNewItemValid(itemToInsert)) throw SerializationException("$newItem")
list.add(itemToInsert)
} else {
list[index] = ReflectionDiffer.applyDiff(item, value.jsonObject)
}
}
}
insertReplace(list)
}
}
/**
* Applies a list diff from a map of lists.
* The map is identified by the given [jsonObjectKey] in the given [jsonObject].
* The diff is applied for each key
* by replacing the existing list using [deleteList] and [insertNewList].
*
* @param listParser returns a list of [T] from the given [JsonArray].
*/
@Throws(SerializationException::class)
fun <T : Any> diffAndUpdateListTable(
jsonObject: JsonObject,
jsonObjectKey: String,
listParser: (String, JsonArray) -> List<T>,
deleteAll: () -> Unit,
deleteList: (String) -> Unit,
insertNewList: (String, List<T>) -> Unit,
) {
if (!jsonObject.containsKey(jsonObjectKey)) return
if (jsonObject[jsonObjectKey] == JsonNull) {
deleteAll()
} else {
val obj = jsonObject[jsonObjectKey]?.jsonObject ?: error("no $jsonObjectKey object")
obj.entries.forEach { (key, list) ->
if (list is JsonNull) {
deleteList(key)
} else {
val newList = listParser(key, list.jsonArray)
deleteList(key)
insertNewList(key, newList)
}
}
}
}
/**
* Applies the list diff from the given [jsonObject] identified by the given [jsonObjectKey]
* by replacing an existing list using [deleteList] and [insertNewList].
*
* @param listParser returns a list of [T] from the given [JsonArray].
*/
@Throws(SerializationException::class)
fun <T : Any> diffAndUpdateListTable(
jsonObject: JsonObject,
jsonObjectKey: String,
listParser: (JsonArray) -> List<T>,
deleteList: () -> Unit,
insertNewList: (List<T>) -> Unit,
) {
if (!jsonObject.containsKey(jsonObjectKey)) return
if (jsonObject[jsonObjectKey] == JsonNull) {
deleteList()
} else {
val jsonArray = jsonObject[jsonObjectKey]?.jsonArray ?: error("no $jsonObjectKey array")
val list = listParser(jsonArray)
deleteList()
insertNewList(list)
}
}
private fun JsonObject.checkDenyList(list: List<String>?) {
list?.forEach { forbiddenKey ->
if (containsKey(forbiddenKey)) throw SerializationException(forbiddenKey)
}
}
}

View File

@@ -0,0 +1,137 @@
package org.fdroid.database
import android.annotation.SuppressLint
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.PackageManager.GET_SIGNATURES
import android.os.Build
import org.fdroid.CompatibilityCheckerImpl
import org.fdroid.PackagePreference
import org.fdroid.UpdateChecker
public class DbUpdateChecker(
db: FDroidDatabase,
private val packageManager: PackageManager,
) {
private val appDao = db.getAppDao() as AppDaoInt
private val versionDao = db.getVersionDao() as VersionDaoInt
private val appPrefsDao = db.getAppPrefsDao() as AppPrefsDaoInt
private val compatibilityChecker = CompatibilityCheckerImpl(packageManager)
private val updateChecker = UpdateChecker(compatibilityChecker)
/**
* Returns a list of apps that can be updated.
* @param releaseChannels optional list of release channels to consider on top of stable.
* If this is null or empty, only versions without channel (stable) will be considered.
*/
public fun getUpdatableApps(releaseChannels: List<String>? = null): List<UpdatableApp> {
val updatableApps = ArrayList<UpdatableApp>()
@Suppress("DEPRECATION") // we'll use this as long as it works, new one was broken
val installedPackages = packageManager.getInstalledPackages(GET_SIGNATURES)
val packageNames = installedPackages.map { it.packageName }
val versionsByPackage = HashMap<String, ArrayList<Version>>(packageNames.size)
versionDao.getVersions(packageNames).forEach { version ->
val list = versionsByPackage.getOrPut(version.packageName) { ArrayList() }
list.add(version)
}
installedPackages.iterator().forEach { packageInfo ->
val packageName = packageInfo.packageName
val versions = versionsByPackage[packageName] ?: return@forEach // continue
val version = getVersion(versions, packageName, packageInfo, null, releaseChannels)
if (version != null) {
val versionCode = packageInfo.getVersionCode()
val app = getUpdatableApp(version, versionCode)
if (app != null) updatableApps.add(app)
}
}
return updatableApps
}
/**
* Returns an [AppVersion] for the given [packageName] that is an update or new install
* or null if there is none.
* @param releaseChannels optional list of release channels to consider on top of stable.
* If this is null or empty, only versions without channel (stable) will be considered.
*/
@SuppressLint("PackageManagerGetSignatures")
public fun getSuggestedVersion(
packageName: String,
preferredSigner: String? = null,
releaseChannels: List<String>? = null,
): AppVersion? {
val versions = versionDao.getVersions(listOf(packageName))
if (versions.isEmpty()) return null
val packageInfo = try {
@Suppress("DEPRECATION")
packageManager.getPackageInfo(packageName, GET_SIGNATURES)
} catch (e: PackageManager.NameNotFoundException) {
null
}
val version = getVersion(versions, packageName, packageInfo, preferredSigner,
releaseChannels) ?: return null
val versionedStrings = versionDao.getVersionedStrings(
repoId = version.repoId,
packageName = version.packageName,
versionId = version.versionId,
)
return version.toAppVersion(versionedStrings)
}
private fun getVersion(
versions: List<Version>,
packageName: String,
packageInfo: PackageInfo?,
preferredSigner: String?,
releaseChannels: List<String>?,
): Version? {
val preferencesGetter: (() -> PackagePreference?) = {
appPrefsDao.getAppPrefsOrNull(packageName)
}
return if (packageInfo == null) {
updateChecker.getSuggestedVersion(
versions = versions,
preferredSigner = preferredSigner,
releaseChannels = releaseChannels,
preferencesGetter = preferencesGetter,
)
} else {
updateChecker.getUpdate(
versions = versions,
packageInfo = packageInfo,
releaseChannels = releaseChannels,
preferencesGetter = preferencesGetter,
)
}
}
private fun getUpdatableApp(version: Version, installedVersionCode: Long): UpdatableApp? {
val versionedStrings = versionDao.getVersionedStrings(
repoId = version.repoId,
packageName = version.packageName,
versionId = version.versionId,
)
val appOverviewItem =
appDao.getAppOverviewItem(version.repoId, version.packageName) ?: return null
return UpdatableApp(
repoId = version.repoId,
packageName = version.packageName,
installedVersionCode = installedVersionCode,
update = version.toAppVersion(versionedStrings),
hasKnownVulnerability = version.hasKnownVulnerability,
name = appOverviewItem.name,
summary = appOverviewItem.summary,
localizedIcon = appOverviewItem.localizedIcon,
)
}
}
internal fun PackageInfo.getVersionCode(): Long {
return if (Build.VERSION.SDK_INT >= 28) {
longVersionCode
} else {
@Suppress("DEPRECATION") // we use the new one above, if available
versionCode.toLong()
}
}

View File

@@ -0,0 +1,61 @@
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.IndexFormatVersion.ONE
import org.fdroid.index.v1.IndexV1StreamReceiver
import org.fdroid.index.v2.AntiFeatureV2
import org.fdroid.index.v2.CategoryV2
import org.fdroid.index.v2.MetadataV2
import org.fdroid.index.v2.PackageVersionV2
import org.fdroid.index.v2.ReleaseChannelV2
import org.fdroid.index.v2.RepoV2
/**
* Note that this class expects that its [receive] method with [RepoV2] gets called first.
* A different order of calls is not supported.
*/
@Deprecated("Use DbV2StreamReceiver instead")
internal class DbV1StreamReceiver(
private val db: FDroidDatabaseInt,
private val repoId: Long,
private val compatibilityChecker: CompatibilityChecker,
) : IndexV1StreamReceiver {
private val locales: LocaleListCompat = getLocales(Resources.getSystem().configuration)
override fun receive(repo: RepoV2, version: Long, certificate: String?) {
db.getRepositoryDao().clear(repoId)
db.getRepositoryDao().update(repoId, repo, version, ONE, certificate)
}
override fun receive(packageName: String, m: MetadataV2) {
db.getAppDao().insert(repoId, packageName, m, locales)
}
override fun receive(packageName: String, v: Map<String, PackageVersionV2>) {
db.getVersionDao().insert(repoId, packageName, v) {
compatibilityChecker.isCompatible(it.manifest)
}
}
override fun updateRepo(
antiFeatures: Map<String, AntiFeatureV2>,
categories: Map<String, CategoryV2>,
releaseChannels: Map<String, ReleaseChannelV2>,
) {
val repoDao = db.getRepositoryDao()
repoDao.insertAntiFeatures(antiFeatures.toRepoAntiFeatures(repoId))
repoDao.insertCategories(categories.toRepoCategories(repoId))
repoDao.insertReleaseChannels(releaseChannels.toRepoReleaseChannel(repoId))
db.afterUpdatingRepo(repoId)
}
override fun updateAppMetadata(packageName: String, preferredSigner: String?) {
db.getAppDao().updatePreferredSigner(repoId, packageName, preferredSigner)
}
}

View File

@@ -0,0 +1,40 @@
package org.fdroid.database
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat.getLocales
import androidx.core.os.LocaleListCompat
import kotlinx.serialization.json.JsonObject
import org.fdroid.CompatibilityChecker
import org.fdroid.index.v2.IndexV2DiffStreamReceiver
internal class DbV2DiffStreamReceiver(
private val db: FDroidDatabaseInt,
private val repoId: Long,
private val compatibilityChecker: CompatibilityChecker,
) : IndexV2DiffStreamReceiver {
private val locales: LocaleListCompat = getLocales(Resources.getSystem().configuration)
override fun receiveRepoDiff(version: Long, repoJsonObject: JsonObject) {
db.getRepositoryDao().updateRepository(repoId, version, repoJsonObject)
}
override fun receivePackageMetadataDiff(packageName: String, packageJsonObject: JsonObject?) {
db.getAppDao().updateApp(repoId, packageName, packageJsonObject, locales)
}
override fun receiveVersionsDiff(
packageName: String,
versionsDiffMap: Map<String, JsonObject?>?,
) {
db.getVersionDao().update(repoId, packageName, versionsDiffMap) {
compatibilityChecker.isCompatible(it)
}
}
@Synchronized
override fun onStreamEnded() {
db.afterUpdatingRepo(repoId)
}
}

View File

@@ -0,0 +1,69 @@
package org.fdroid.database
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat.getLocales
import androidx.core.os.LocaleListCompat
import kotlinx.serialization.SerializationException
import org.fdroid.CompatibilityChecker
import org.fdroid.index.IndexFormatVersion.TWO
import org.fdroid.index.v2.FileV2
import org.fdroid.index.v2.IndexV2StreamReceiver
import org.fdroid.index.v2.PackageV2
import org.fdroid.index.v2.RepoV2
/**
* Receives a stream of IndexV2 data and stores it in the DB.
*
* Note: This should only be used once.
* If you want to process a second stream, create a new instance.
*/
internal class DbV2StreamReceiver(
private val db: FDroidDatabaseInt,
private val repoId: Long,
private val compatibilityChecker: CompatibilityChecker,
) : IndexV2StreamReceiver {
private val locales: LocaleListCompat = getLocales(Resources.getSystem().configuration)
private var clearedRepoData = false
private val nonNullFileV2: (FileV2?) -> Unit = { fileV2 ->
if (fileV2 != null) {
if (fileV2.sha256 == null) throw SerializationException("${fileV2.name} has no sha256")
if (fileV2.size == null) throw SerializationException("${fileV2.name} has no size")
}
}
@Synchronized
override fun receive(repo: RepoV2, version: Long, certificate: String) {
repo.walkFiles(nonNullFileV2)
clearRepoDataIfNeeded()
db.getRepositoryDao().update(repoId, repo, version, TWO, certificate)
}
@Synchronized
override fun receive(packageName: String, p: PackageV2) {
p.walkFiles(nonNullFileV2)
clearRepoDataIfNeeded()
db.getAppDao().insert(repoId, packageName, p.metadata, locales)
db.getVersionDao().insert(repoId, packageName, p.versions) {
compatibilityChecker.isCompatible(it.manifest)
}
}
@Synchronized
override fun onStreamEnded() {
db.afterUpdatingRepo(repoId)
}
/**
* As it is a valid index to receive packages before the repo,
* we can not clear all repo data when receiving the repo,
* but need to do it once at the beginning.
*/
private fun clearRepoDataIfNeeded() {
if (!clearedRepoData) {
db.getRepositoryDao().clear(repoId)
clearedRepoData = true
}
}
}

View File

@@ -0,0 +1,96 @@
package org.fdroid.database
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat.getLocales
import androidx.core.os.LocaleListCompat
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import org.fdroid.LocaleChooser.getBestLocale
import java.util.Locale
@Database(
// 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,
entities = [
// repo
CoreRepository::class,
Mirror::class,
AntiFeature::class,
Category::class,
ReleaseChannel::class,
RepositoryPreferences::class,
// packages
AppMetadata::class,
AppMetadataFts::class,
LocalizedFile::class,
LocalizedFileList::class,
// versions
Version::class,
VersionedString::class,
// app user preferences
AppPrefs::class,
],
views = [
LocalizedIcon::class,
HighestVersion::class,
],
exportSchema = true,
autoMigrations = [
// add future migrations here (if they are easy enough to be done automatically)
],
)
@TypeConverters(Converters::class)
internal abstract class FDroidDatabaseInt internal constructor() : RoomDatabase(), FDroidDatabase {
abstract override fun getRepositoryDao(): RepositoryDaoInt
abstract override fun getAppDao(): AppDaoInt
abstract override fun getVersionDao(): VersionDaoInt
abstract override fun getAppPrefsDao(): AppPrefsDaoInt
override fun afterLocalesChanged(locales: LocaleListCompat) {
val appDao = getAppDao()
runInTransaction {
appDao.getAppMetadata().forEach { appMetadata ->
appDao.updateAppMetadata(
repoId = appMetadata.repoId,
packageName = appMetadata.packageName,
name = appMetadata.name.getBestLocale(locales),
summary = appMetadata.summary.getBestLocale(locales),
)
}
}
}
/**
* Call this after updating the data belonging to the given [repoId],
* so the [AppMetadata.isCompatible] can be recalculated in case new versions were added.
*/
fun afterUpdatingRepo(repoId: Long) {
getAppDao().updateCompatibility(repoId)
}
}
/**
* The F-Droid database offering methods to retrieve the various data access objects.
*/
public interface FDroidDatabase {
public fun getRepositoryDao(): RepositoryDao
public fun getAppDao(): AppDao
public fun getVersionDao(): VersionDao
public fun getAppPrefsDao(): AppPrefsDao
/**
* Call this after the system [Locale]s have changed.
* If this isn't called, the cached localized app metadata (e.g. name, summary) will be wrong.
*/
public fun afterLocalesChanged(
locales: LocaleListCompat = getLocales(Resources.getSystem().configuration),
)
/**
* Call this to run all of the given [body] inside a database transaction.
* Please run as little code as possible to keep the time the database is blocked minimal.
*/
public fun runInTransaction(body: Runnable)
}

View File

@@ -0,0 +1,93 @@
package org.fdroid.database
import android.content.Context
import android.util.Log
import androidx.annotation.GuardedBy
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
/**
* A way to pre-populate the database with a fixture.
* This can be supplied to [FDroidDatabaseHolder.getDb]
* and will then be called when a new database is created.
*/
public fun interface FDroidFixture {
/**
* Called when a new database gets created.
* Multiple DB operations should use [FDroidDatabase.runInTransaction].
*/
public fun prePopulateDb(db: FDroidDatabase)
}
/**
* A database holder using a singleton pattern to ensure
* that only one database is open at the same time.
*/
public object FDroidDatabaseHolder {
// Singleton prevents multiple instances of database opening at the same time.
@Volatile
@GuardedBy("lock")
private var INSTANCE: FDroidDatabaseInt? = null
private val lock = Object()
internal val TAG = FDroidDatabase::class.simpleName
internal val dispatcher get() = Dispatchers.IO
/**
* Give you an existing instance of [FDroidDatabase] or creates/opens a new one if none exists.
* Note: The given [name] is only used when calling this for the first time.
* Subsequent calls with a different name will return the instance created by the first call.
*/
@JvmStatic
@JvmOverloads
public fun getDb(
context: Context,
name: String = "fdroid_db",
fixture: FDroidFixture? = null,
): FDroidDatabase {
// if the INSTANCE is not null, then return it,
// if it is, then create the database
return INSTANCE ?: synchronized(lock) {
val builder = Room.databaseBuilder(
context.applicationContext,
FDroidDatabaseInt::class.java,
name,
).apply {
// We allow destructive migration (if no real migration was provided),
// so we have the option to nuke the DB in production (if that will ever be needed).
fallbackToDestructiveMigration()
// Add our [FixtureCallback] if a fixture was provided
if (fixture != null) addCallback(FixtureCallback(fixture))
}
val instance = builder.build()
INSTANCE = instance
// return instance
instance
}
}
private class FixtureCallback(private val fixture: FDroidFixture) : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
@OptIn(DelicateCoroutinesApi::class)
GlobalScope.launch(dispatcher) {
val database: FDroidDatabase
synchronized(lock) {
database = INSTANCE ?: error("DB not yet initialized")
}
fixture.prePopulateDb(database)
Log.d(TAG, "Loaded fixtures")
}
}
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
onCreate(db)
}
}
}

View File

@@ -0,0 +1,355 @@
package org.fdroid.database
import androidx.core.os.LocaleListCompat
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Ignore
import androidx.room.PrimaryKey
import androidx.room.Relation
import org.fdroid.LocaleChooser.getBestLocale
import org.fdroid.index.IndexFormatVersion
import org.fdroid.index.IndexUtils.getFingerprint
import org.fdroid.index.v2.AntiFeatureV2
import org.fdroid.index.v2.CategoryV2
import org.fdroid.index.v2.FileV2
import org.fdroid.index.v2.LocalizedFileV2
import org.fdroid.index.v2.LocalizedTextV2
import org.fdroid.index.v2.MirrorV2
import org.fdroid.index.v2.ReleaseChannelV2
import org.fdroid.index.v2.RepoV2
@Entity
internal data class CoreRepository(
@PrimaryKey(autoGenerate = true) val repoId: Long = 0,
val name: LocalizedTextV2 = emptyMap(),
val icon: LocalizedFileV2?,
val address: String,
val webBaseUrl: String? = null,
val timestamp: Long,
val version: Long?,
val formatVersion: IndexFormatVersion?,
val maxAge: Int?,
val description: LocalizedTextV2 = emptyMap(),
val certificate: String?,
)
internal fun RepoV2.toCoreRepository(
repoId: Long = 0,
version: Long,
formatVersion: IndexFormatVersion? = null,
certificate: String? = null,
) = CoreRepository(
repoId = repoId,
name = name,
icon = icon,
address = address,
webBaseUrl = webBaseUrl,
timestamp = timestamp,
version = version,
formatVersion = formatVersion,
maxAge = null,
description = description,
certificate = certificate,
)
public data class Repository internal constructor(
@Embedded internal val repository: CoreRepository,
@Relation(
parentColumn = "repoId",
entityColumn = "repoId",
)
internal val mirrors: List<Mirror>,
@Relation(
parentColumn = "repoId",
entityColumn = "repoId",
)
internal val antiFeatures: List<AntiFeature>,
@Relation(
parentColumn = "repoId",
entityColumn = "repoId",
)
internal val categories: List<Category>,
@Relation(
parentColumn = "repoId",
entityColumn = "repoId",
)
internal val releaseChannels: List<ReleaseChannel>,
@Relation(
parentColumn = "repoId",
entityColumn = "repoId",
)
internal val preferences: RepositoryPreferences,
) {
/**
* Used to create a minimal version of a [Repository].
*/
public constructor(
repoId: Long,
address: String,
timestamp: Long,
formatVersion: IndexFormatVersion,
certificate: String?,
version: Long,
weight: Int,
lastUpdated: Long,
) : this(
repository = CoreRepository(
repoId = repoId,
icon = null,
address = address,
timestamp = timestamp,
formatVersion = formatVersion,
maxAge = 42,
certificate = certificate,
version = version,
),
mirrors = emptyList(),
antiFeatures = emptyList(),
categories = emptyList(),
releaseChannels = emptyList(),
preferences = RepositoryPreferences(
repoId = repoId,
weight = weight,
lastUpdated = lastUpdated,
)
)
public val repoId: Long get() = repository.repoId
public val address: String get() = repository.address
public val webBaseUrl: String? get() = repository.webBaseUrl
public val timestamp: Long get() = repository.timestamp
public val version: Long get() = repository.version ?: 0
public val formatVersion: IndexFormatVersion? get() = repository.formatVersion
public val certificate: String? get() = repository.certificate
public fun getName(localeList: LocaleListCompat): String? =
repository.name.getBestLocale(localeList)
public fun getDescription(localeList: LocaleListCompat): String? =
repository.description.getBestLocale(localeList)
public fun getIcon(localeList: LocaleListCompat): FileV2? =
repository.icon.getBestLocale(localeList)
public fun getAntiFeatures(): Map<String, AntiFeature> {
return antiFeatures.associateBy { antiFeature -> antiFeature.id }
}
public fun getCategories(): Map<String, Category> {
return categories.associateBy { category -> category.id }
}
public fun getReleaseChannels(): Map<String, ReleaseChannel> {
return releaseChannels.associateBy { releaseChannel -> releaseChannel.id }
}
public val weight: Int get() = preferences.weight
public val enabled: Boolean get() = preferences.enabled
public val lastUpdated: Long? get() = preferences.lastUpdated
public val userMirrors: List<String> get() = preferences.userMirrors ?: emptyList()
public val disabledMirrors: List<String> get() = preferences.disabledMirrors ?: emptyList()
public val username: String? get() = preferences.username
public val password: String? get() = preferences.password
@Suppress("DEPRECATION")
@Deprecated("Only used for v1 index", ReplaceWith(""))
public val lastETag: String?
get() = preferences.lastETag
/**
* The fingerprint for the [certificate].
* This gets calculated on first call and is an expensive operation.
* Subsequent calls re-use the
*/
@delegate:Ignore
public val fingerprint: String? by lazy {
certificate?.let { getFingerprint(it) }
}
/**
* Returns official and user-added mirrors without the [disabledMirrors].
*/
public fun getMirrors(): List<org.fdroid.download.Mirror> {
return getAllMirrors(true).filter {
!disabledMirrors.contains(it.baseUrl)
}
}
/**
* Returns all mirrors, including [disabledMirrors].
*/
@JvmOverloads
public fun getAllMirrors(includeUserMirrors: Boolean = true): List<org.fdroid.download.Mirror> {
val all = mirrors.map {
it.toDownloadMirror()
} + if (includeUserMirrors) userMirrors.map {
org.fdroid.download.Mirror(it)
} else emptyList()
// whether or not the repo address is part of the mirrors is not yet standardized,
// so we may need to add it to the list ourselves
val hasCanonicalMirror = all.find { it.baseUrl == address } != null
return if (hasCanonicalMirror) all else all.toMutableList().apply {
add(0, org.fdroid.download.Mirror(address))
}
}
}
/**
* A database table to store repository mirror information.
*/
@Entity(
primaryKeys = ["repoId", "url"],
foreignKeys = [ForeignKey(
entity = CoreRepository::class,
parentColumns = ["repoId"],
childColumns = ["repoId"],
onDelete = ForeignKey.CASCADE,
)],
)
internal data class Mirror(
val repoId: Long,
val url: String,
val location: String? = null,
) {
fun toDownloadMirror(): org.fdroid.download.Mirror = org.fdroid.download.Mirror(
baseUrl = url,
location = location,
)
}
internal fun MirrorV2.toMirror(repoId: Long) = Mirror(
repoId = repoId,
url = url,
location = location,
)
/**
* An attribute belonging to a [Repository].
*/
public abstract class RepoAttribute {
public abstract val icon: FileV2?
internal abstract val name: LocalizedTextV2
internal abstract val description: LocalizedTextV2
public fun getName(localeList: LocaleListCompat): String? =
name.getBestLocale(localeList)
public fun getDescription(localeList: LocaleListCompat): String? =
description.getBestLocale(localeList)
}
/**
* An anti-feature belonging to a [Repository].
*/
@Entity(
primaryKeys = ["repoId", "id"],
foreignKeys = [ForeignKey(
entity = CoreRepository::class,
parentColumns = ["repoId"],
childColumns = ["repoId"],
onDelete = ForeignKey.CASCADE,
)],
)
public data class AntiFeature internal constructor(
internal val repoId: Long,
internal val id: String,
@Embedded(prefix = "icon_") public override val icon: FileV2? = null,
override val name: LocalizedTextV2,
override val description: LocalizedTextV2,
) : RepoAttribute()
internal fun Map<String, AntiFeatureV2>.toRepoAntiFeatures(repoId: Long) = map {
AntiFeature(
repoId = repoId,
id = it.key,
icon = it.value.icon,
name = it.value.name,
description = it.value.description,
)
}
/**
* A category of apps belonging to a [Repository].
*/
@Entity(
primaryKeys = ["repoId", "id"],
foreignKeys = [ForeignKey(
entity = CoreRepository::class,
parentColumns = ["repoId"],
childColumns = ["repoId"],
onDelete = ForeignKey.CASCADE,
)],
)
public data class Category internal constructor(
internal val repoId: Long,
public val id: String,
@Embedded(prefix = "icon_") public override val icon: FileV2? = null,
override val name: LocalizedTextV2,
override val description: LocalizedTextV2,
) : RepoAttribute()
internal fun Map<String, CategoryV2>.toRepoCategories(repoId: Long) = map {
Category(
repoId = repoId,
id = it.key,
icon = it.value.icon,
name = it.value.name,
description = it.value.description,
)
}
/**
* A release-channel for apps belonging to a [Repository].
*/
@Entity(
primaryKeys = ["repoId", "id"],
foreignKeys = [ForeignKey(
entity = CoreRepository::class,
parentColumns = ["repoId"],
childColumns = ["repoId"],
onDelete = ForeignKey.CASCADE,
)],
)
public data class ReleaseChannel(
internal val repoId: Long,
internal val id: String,
@Embedded(prefix = "icon_") public override val icon: FileV2? = null,
override val name: LocalizedTextV2,
override val description: LocalizedTextV2,
) : RepoAttribute()
internal fun Map<String, ReleaseChannelV2>.toRepoReleaseChannel(repoId: Long) = map {
ReleaseChannel(
repoId = repoId,
id = it.key,
name = it.value.name,
description = it.value.description,
)
}
@Entity
internal data class RepositoryPreferences(
@PrimaryKey internal val repoId: Long,
val weight: Int,
val enabled: Boolean = true,
val lastUpdated: Long? = System.currentTimeMillis(),
@Deprecated("Only used for indexV1") val lastETag: String? = null,
val userMirrors: List<String>? = null,
val disabledMirrors: List<String>? = null,
val username: String? = null,
val password: String? = null,
)
/**
* A reduced version of [Repository] used to pre-populate the [FDroidDatabase].
*/
public data class InitialRepository(
val name: String,
val address: String,
val description: String,
val certificate: String,
val version: Long,
val enabled: Boolean,
val weight: Int,
)

View File

@@ -0,0 +1,415 @@
package org.fdroid.database
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy.REPLACE
import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import org.fdroid.database.DbDiffUtils.diffAndUpdateListTable
import org.fdroid.database.DbDiffUtils.diffAndUpdateTable
import org.fdroid.index.IndexFormatVersion
import org.fdroid.index.IndexParser.json
import org.fdroid.index.v2.MirrorV2
import org.fdroid.index.v2.ReflectionDiffer.applyDiff
import org.fdroid.index.v2.RepoV2
public interface RepositoryDao {
/**
* Inserts a new [InitialRepository] from a fixture.
*
* @return the [Repository.repoId] of the inserted repo.
*/
public fun insert(initialRepo: InitialRepository): Long
/**
* Inserts an empty [Repository] for an initial update.
*
* @return the [Repository.repoId] of the inserted repo.
*/
public fun insertEmptyRepo(
address: String,
username: String? = null,
password: String? = null,
): Long
/**
* Returns the repository with the given [repoId] or null, if none was found with that ID.
*/
public fun getRepository(repoId: Long): Repository?
/**
* Returns a list of all [Repository]s in the database.
*/
public fun getRepositories(): List<Repository>
/**
* Same as [getRepositories], but does return a [LiveData].
*/
public fun getLiveRepositories(): LiveData<List<Repository>>
/**
* Returns a live data of all categories declared by all [Repository]s.
*/
public fun getLiveCategories(): LiveData<List<Category>>
/**
* Enables or disables the repository with the given [repoId].
* Data from disabled repositories is ignored in many queries.
*/
public fun setRepositoryEnabled(repoId: Long, enabled: Boolean)
/**
* Updates the user-defined mirrors of the repository with the given [repoId].
* The existing mirrors get overwritten with the given [mirrors].
*/
public fun updateUserMirrors(repoId: Long, mirrors: List<String>)
/**
* Updates the user name and password (for basic authentication)
* of the repository with the given [repoId].
* The existing user name and password get overwritten with the given [username] and [password].
*/
public fun updateUsernameAndPassword(repoId: Long, username: String?, password: String?)
/**
* Updates the disabled mirrors of the repository with the given [repoId].
* The existing disabled mirrors get overwritten with the given [disabledMirrors].
*/
public fun updateDisabledMirrors(repoId: Long, disabledMirrors: List<String>)
/**
* Removes a [Repository] with the given [repoId] with all associated data from the database.
*/
public fun deleteRepository(repoId: Long)
/**
* Removes all repos and their preferences.
*/
public fun clearAll()
}
@Dao
internal interface RepositoryDaoInt : RepositoryDao {
@Insert(onConflict = REPLACE)
fun insertOrReplace(repository: CoreRepository): Long
@Update
fun update(repository: CoreRepository)
@Insert(onConflict = REPLACE)
fun insertMirrors(mirrors: List<Mirror>)
@Insert(onConflict = REPLACE)
fun insertAntiFeatures(repoFeature: List<AntiFeature>)
@Insert(onConflict = REPLACE)
fun insertCategories(repoFeature: List<Category>)
@Insert(onConflict = REPLACE)
fun insertReleaseChannels(repoFeature: List<ReleaseChannel>)
@Insert(onConflict = REPLACE)
fun insert(repositoryPreferences: RepositoryPreferences)
@Transaction
override fun insert(initialRepo: InitialRepository): Long {
val repo = CoreRepository(
name = mapOf("en-US" to initialRepo.name),
address = initialRepo.address,
icon = null,
timestamp = -1,
version = initialRepo.version,
formatVersion = null,
maxAge = null,
description = mapOf("en-US" to initialRepo.description),
certificate = initialRepo.certificate,
)
val repoId = insertOrReplace(repo)
val repositoryPreferences = RepositoryPreferences(
repoId = repoId,
weight = initialRepo.weight,
lastUpdated = null,
enabled = initialRepo.enabled,
)
insert(repositoryPreferences)
return repoId
}
@Transaction
override fun insertEmptyRepo(
address: String,
username: String?,
password: String?,
): Long {
val repo = CoreRepository(
name = mapOf("en-US" to address),
icon = null,
address = address,
timestamp = -1,
version = null,
formatVersion = null,
maxAge = null,
certificate = null,
)
val repoId = insertOrReplace(repo)
val currentMaxWeight = getMaxRepositoryWeight()
val repositoryPreferences = RepositoryPreferences(
repoId = repoId,
weight = currentMaxWeight + 1,
lastUpdated = null,
username = username,
password = password,
)
insert(repositoryPreferences)
return repoId
}
@Transaction
@VisibleForTesting
fun insertOrReplace(repository: RepoV2, version: Long = 0): Long {
val repoId = insertOrReplace(repository.toCoreRepository(version = version))
val currentMaxWeight = getMaxRepositoryWeight()
val repositoryPreferences = RepositoryPreferences(repoId, currentMaxWeight + 1)
insert(repositoryPreferences)
insertRepoTables(repoId, repository)
return repoId
}
@Query("SELECT MAX(weight) FROM RepositoryPreferences")
fun getMaxRepositoryWeight(): Int
@Transaction
@Query("SELECT * FROM CoreRepository WHERE repoId = :repoId")
override fun getRepository(repoId: Long): Repository?
@Transaction
@Query("SELECT * FROM CoreRepository")
override fun getRepositories(): List<Repository>
@Transaction
@Query("SELECT * FROM CoreRepository")
override fun getLiveRepositories(): LiveData<List<Repository>>
@Query("SELECT * FROM RepositoryPreferences WHERE repoId = :repoId")
fun getRepositoryPreferences(repoId: Long): RepositoryPreferences?
@RewriteQueriesToDropUnusedColumns
@Query("""SELECT * FROM Category
JOIN RepositoryPreferences AS pref USING (repoId)
WHERE pref.enabled = 1 GROUP BY id HAVING MAX(pref.weight)""")
override fun getLiveCategories(): LiveData<List<Category>>
/**
* Updates an existing repo with new data from a full index update.
* Call [clear] first to ensure old data was removed.
*/
@Transaction
fun update(
repoId: Long,
repository: RepoV2,
version: Long,
formatVersion: IndexFormatVersion,
certificate: String?,
) {
update(repository.toCoreRepository(repoId, version, formatVersion, certificate))
insertRepoTables(repoId, repository)
}
private fun insertRepoTables(repoId: Long, repository: RepoV2) {
insertMirrors(repository.mirrors.map { it.toMirror(repoId) })
insertAntiFeatures(repository.antiFeatures.toRepoAntiFeatures(repoId))
insertCategories(repository.categories.toRepoCategories(repoId))
insertReleaseChannels(repository.releaseChannels.toRepoReleaseChannel(repoId))
}
@Update
fun updateRepository(repo: CoreRepository): Int
/**
* Updates the certificate for the [Repository] with the given [repoId].
* This should be used for V1 index updating where we only get the full cert
* after reading the entire index file.
* V2 index should use [update] instead as there the certificate is known
* before reading full index.
*/
@Query("UPDATE CoreRepository SET certificate = :certificate WHERE repoId = :repoId")
fun updateRepository(repoId: Long, certificate: String)
@Update
fun updateRepositoryPreferences(preferences: RepositoryPreferences)
/**
* Used to update an existing repository with a given [jsonObject] JSON diff.
*/
@Transaction
fun updateRepository(repoId: Long, version: Long, jsonObject: JsonObject) {
// get existing repo
val repo = getRepository(repoId) ?: error("Repo $repoId does not exist")
// update repo with JSON diff
updateRepository(applyDiff(repo.repository, jsonObject).copy(version = version))
// replace mirror list (if it is in the diff)
diffAndUpdateListTable(
jsonObject = jsonObject,
jsonObjectKey = "mirrors",
listParser = { mirrorArray ->
json.decodeFromJsonElement<List<MirrorV2>>(mirrorArray).map {
it.toMirror(repoId)
}
},
deleteList = { deleteMirrors(repoId) },
insertNewList = { mirrors -> insertMirrors(mirrors) },
)
// diff and update the antiFeatures
diffAndUpdateTable(
jsonObject = jsonObject,
jsonObjectKey = "antiFeatures",
itemList = repo.antiFeatures,
itemFinder = { key, item -> item.id == key },
newItem = { key -> AntiFeature(repoId, key, null, emptyMap(), emptyMap()) },
deleteAll = { deleteAntiFeatures(repoId) },
deleteOne = { key -> deleteAntiFeature(repoId, key) },
insertReplace = { list -> insertAntiFeatures(list) },
)
// diff and update the categories
diffAndUpdateTable(
jsonObject = jsonObject,
jsonObjectKey = "categories",
itemList = repo.categories,
itemFinder = { key, item -> item.id == key },
newItem = { key -> Category(repoId, key, null, emptyMap(), emptyMap()) },
deleteAll = { deleteCategories(repoId) },
deleteOne = { key -> deleteCategory(repoId, key) },
insertReplace = { list -> insertCategories(list) },
)
// diff and update the releaseChannels
diffAndUpdateTable(
jsonObject = jsonObject,
jsonObjectKey = "releaseChannels",
itemList = repo.releaseChannels,
itemFinder = { key, item -> item.id == key },
newItem = { key -> ReleaseChannel(repoId, key, null, emptyMap(), emptyMap()) },
deleteAll = { deleteReleaseChannels(repoId) },
deleteOne = { key -> deleteReleaseChannel(repoId, key) },
insertReplace = { list -> insertReleaseChannels(list) },
)
}
@Query("UPDATE RepositoryPreferences SET enabled = :enabled WHERE repoId = :repoId")
override fun setRepositoryEnabled(repoId: Long, enabled: Boolean)
@Query("UPDATE RepositoryPreferences SET userMirrors = :mirrors WHERE repoId = :repoId")
override fun updateUserMirrors(repoId: Long, mirrors: List<String>)
@Query("""UPDATE RepositoryPreferences SET username = :username, password = :password
WHERE repoId = :repoId""")
override fun updateUsernameAndPassword(repoId: Long, username: String?, password: String?)
@Query("""UPDATE RepositoryPreferences SET disabledMirrors = :disabledMirrors
WHERE repoId = :repoId""")
override fun updateDisabledMirrors(repoId: Long, disabledMirrors: List<String>)
@Transaction
override fun deleteRepository(repoId: Long) {
deleteCoreRepository(repoId)
// we don't use cascading delete for preferences,
// so we can replace index data on full updates
deleteRepositoryPreferences(repoId)
}
@Query("DELETE FROM CoreRepository WHERE repoId = :repoId")
fun deleteCoreRepository(repoId: Long)
@Query("DELETE FROM RepositoryPreferences WHERE repoId = :repoId")
fun deleteRepositoryPreferences(repoId: Long)
@Query("DELETE FROM CoreRepository")
fun deleteAllCoreRepositories()
@Query("DELETE FROM RepositoryPreferences")
fun deleteAllRepositoryPreferences()
/**
* Used for diffing.
*/
@Query("DELETE FROM Mirror WHERE repoId = :repoId")
fun deleteMirrors(repoId: Long)
/**
* Used for diffing.
*/
@Query("DELETE FROM AntiFeature WHERE repoId = :repoId")
fun deleteAntiFeatures(repoId: Long)
/**
* Used for diffing.
*/
@Query("DELETE FROM AntiFeature WHERE repoId = :repoId AND id = :id")
fun deleteAntiFeature(repoId: Long, id: String)
/**
* Used for diffing.
*/
@Query("DELETE FROM Category WHERE repoId = :repoId")
fun deleteCategories(repoId: Long)
/**
* Used for diffing.
*/
@Query("DELETE FROM Category WHERE repoId = :repoId AND id = :id")
fun deleteCategory(repoId: Long, id: String)
/**
* Used for diffing.
*/
@Query("DELETE FROM ReleaseChannel WHERE repoId = :repoId")
fun deleteReleaseChannels(repoId: Long)
/**
* Used for diffing.
*/
@Query("DELETE FROM ReleaseChannel WHERE repoId = :repoId AND id = :id")
fun deleteReleaseChannel(repoId: Long, id: String)
/**
* Use when replacing an existing repo with a full index.
* This removes all existing index data associated with this repo from the database,
* but does not touch repository preferences.
* @throws IllegalStateException if no repo with the given [repoId] exists.
*/
@Transaction
fun clear(repoId: Long) {
val repo = getRepository(repoId) ?: error("repo with id $repoId does not exist")
// this clears all foreign key associated data since the repo gets replaced
insertOrReplace(repo.repository)
}
@Transaction
override fun clearAll() {
deleteAllCoreRepositories()
deleteAllRepositoryPreferences()
}
@VisibleForTesting
@Query("SELECT COUNT(*) FROM Mirror")
fun countMirrors(): Int
@VisibleForTesting
@Query("SELECT COUNT(*) FROM AntiFeature")
fun countAntiFeatures(): Int
@VisibleForTesting
@Query("SELECT COUNT(*) FROM Category")
fun countCategories(): Int
@VisibleForTesting
@Query("SELECT COUNT(*) FROM ReleaseChannel")
fun countReleaseChannels(): Int
}

View File

@@ -0,0 +1,221 @@
package org.fdroid.database
import androidx.core.os.LocaleListCompat
import androidx.room.DatabaseView
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Relation
import org.fdroid.LocaleChooser.getBestLocale
import org.fdroid.database.VersionedStringType.PERMISSION
import org.fdroid.database.VersionedStringType.PERMISSION_SDK_23
import org.fdroid.index.v2.ANTI_FEATURE_KNOWN_VULNERABILITY
import org.fdroid.index.v2.FileV1
import org.fdroid.index.v2.FileV2
import org.fdroid.index.v2.LocalizedTextV2
import org.fdroid.index.v2.ManifestV2
import org.fdroid.index.v2.PackageManifest
import org.fdroid.index.v2.PackageVersion
import org.fdroid.index.v2.PackageVersionV2
import org.fdroid.index.v2.PermissionV2
import org.fdroid.index.v2.SignerV2
import org.fdroid.index.v2.UsesSdkV2
/**
* A database table entity representing the version of an [App]
* identified by its [versionCode] and [signer].
* This holds the data of [PackageVersionV2].
*/
@Entity(
primaryKeys = ["repoId", "packageName", "versionId"],
foreignKeys = [ForeignKey(
entity = AppMetadata::class,
parentColumns = ["repoId", "packageName"],
childColumns = ["repoId", "packageName"],
onDelete = ForeignKey.CASCADE,
)],
)
internal data class Version(
val repoId: Long,
val packageName: String,
val versionId: String,
val added: Long,
@Embedded(prefix = "file_") val file: FileV1,
@Embedded(prefix = "src_") val src: FileV2? = null,
@Embedded(prefix = "manifest_") val manifest: AppManifest,
override val releaseChannels: List<String>? = emptyList(),
val antiFeatures: Map<String, LocalizedTextV2>? = null,
val whatsNew: LocalizedTextV2? = null,
val isCompatible: Boolean,
) : PackageVersion {
override val versionCode: Long get() = manifest.versionCode
override val signer: SignerV2? get() = manifest.signer
override val packageManifest: PackageManifest get() = manifest
override val hasKnownVulnerability: Boolean
get() = antiFeatures?.contains(ANTI_FEATURE_KNOWN_VULNERABILITY) == true
internal fun toAppVersion(versionedStrings: List<VersionedString>): AppVersion = AppVersion(
version = this,
versionedStrings = versionedStrings,
)
}
internal fun PackageVersionV2.toVersion(
repoId: Long,
packageName: String,
versionId: String,
isCompatible: Boolean,
) = Version(
repoId = repoId,
packageName = packageName,
versionId = versionId,
added = added,
file = file,
src = src,
manifest = manifest.toManifest(),
releaseChannels = releaseChannels,
antiFeatures = antiFeatures,
whatsNew = whatsNew,
isCompatible = isCompatible,
)
/**
* A version of an [App] identified by [AppManifest.versionCode] and [AppManifest.signer].
*/
public data class AppVersion internal constructor(
@Embedded internal val version: Version,
@Relation(
parentColumn = "versionId",
entityColumn = "versionId",
)
internal val versionedStrings: List<VersionedString>?,
) {
public val repoId: Long get() = version.repoId
public val packageName: String get() = version.packageName
public val added: Long get() = version.added
public val isCompatible: Boolean get() = version.isCompatible
public val manifest: AppManifest get() = version.manifest
public val file: FileV1 get() = version.file
public val src: FileV2? get() = version.src
public val usesPermission: List<PermissionV2>
get() = versionedStrings?.getPermissions(version) ?: emptyList()
public val usesPermissionSdk23: List<PermissionV2>
get() = versionedStrings?.getPermissionsSdk23(version) ?: emptyList()
public val featureNames: List<String> get() = version.manifest.features ?: emptyList()
public val nativeCode: List<String> get() = version.manifest.nativecode ?: emptyList()
public val releaseChannels: List<String> get() = version.releaseChannels ?: emptyList()
public val antiFeatureKeys: List<String>
get() = version.antiFeatures?.map { it.key } ?: emptyList()
public fun getWhatsNew(localeList: LocaleListCompat): String? =
version.whatsNew.getBestLocale(localeList)
public fun getAntiFeatureReason(antiFeatureKey: String, localeList: LocaleListCompat): String? {
return version.antiFeatures?.get(antiFeatureKey)?.getBestLocale(localeList)
}
}
/**
* The manifest information of an [AppVersion].
*/
public data class AppManifest(
public val versionName: String,
public val versionCode: Long,
@Embedded(prefix = "usesSdk_") public val usesSdk: UsesSdkV2? = null,
public override val maxSdkVersion: Int? = null,
@Embedded(prefix = "signer_") public val signer: SignerV2? = null,
public override val nativecode: List<String>? = emptyList(),
public val features: List<String>? = emptyList(),
) : PackageManifest {
public override val minSdkVersion: Int? get() = usesSdk?.minSdkVersion
public override val featureNames: List<String>? get() = features
}
internal fun ManifestV2.toManifest() = AppManifest(
versionName = versionName,
versionCode = versionCode,
usesSdk = usesSdk,
maxSdkVersion = maxSdkVersion,
signer = signer,
nativecode = nativecode,
features = features.map { it.name },
)
@DatabaseView("""SELECT repoId, packageName, antiFeatures FROM Version
GROUP BY repoId, packageName HAVING MAX(manifest_versionCode)""")
internal class HighestVersion(
val repoId: Long,
val packageName: String,
val antiFeatures: Map<String, LocalizedTextV2>? = null,
)
internal enum class VersionedStringType {
PERMISSION,
PERMISSION_SDK_23,
}
@Entity(
primaryKeys = ["repoId", "packageName", "versionId", "type", "name"],
foreignKeys = [ForeignKey(
entity = Version::class,
parentColumns = ["repoId", "packageName", "versionId"],
childColumns = ["repoId", "packageName", "versionId"],
onDelete = ForeignKey.CASCADE,
)],
)
internal data class VersionedString(
val repoId: Long,
val packageName: String,
val versionId: String,
val type: VersionedStringType,
val name: String,
val version: Int? = null,
)
internal fun List<PermissionV2>.toVersionedString(
version: Version,
type: VersionedStringType,
) = map { permission ->
VersionedString(
repoId = version.repoId,
packageName = version.packageName,
versionId = version.versionId,
type = type,
name = permission.name,
version = permission.maxSdkVersion,
)
}
internal fun ManifestV2.getVersionedStrings(version: Version): List<VersionedString> {
return usesPermission.toVersionedString(version, PERMISSION) +
usesPermissionSdk23.toVersionedString(version, PERMISSION_SDK_23)
}
internal fun List<VersionedString>.getPermissions(version: Version) = mapNotNull { v ->
v.map(version, PERMISSION) {
PermissionV2(
name = v.name,
maxSdkVersion = v.version,
)
}
}
internal fun List<VersionedString>.getPermissionsSdk23(version: Version) = mapNotNull { v ->
v.map(version, PERMISSION_SDK_23) {
PermissionV2(
name = v.name,
maxSdkVersion = v.version,
)
}
}
private fun <T> VersionedString.map(
v: Version,
wantedType: VersionedStringType,
factory: () -> T,
): T? {
return if (repoId != v.repoId || packageName != v.packageName || versionId != v.versionId ||
type != wantedType
) null
else factory()
}

View File

@@ -0,0 +1,227 @@
package org.fdroid.database
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy.REPLACE
import androidx.room.Query
import androidx.room.RewriteQueriesToDropUnusedColumns
import androidx.room.Transaction
import androidx.room.Update
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import org.fdroid.database.VersionedStringType.PERMISSION
import org.fdroid.database.VersionedStringType.PERMISSION_SDK_23
import org.fdroid.index.IndexParser.json
import org.fdroid.index.v2.PackageManifest
import org.fdroid.index.v2.PackageVersionV2
import org.fdroid.index.v2.PermissionV2
import org.fdroid.index.v2.ReflectionDiffer
public interface VersionDao {
/**
* Inserts new versions for a given [packageName] from a full index.
*/
public fun insert(
repoId: Long,
packageName: String,
packageVersions: Map<String, PackageVersionV2>,
checkIfCompatible: (PackageVersionV2) -> Boolean,
)
/**
* Returns a list of versions for the given [packageName] sorting by highest version code first.
*/
public fun getAppVersions(packageName: String): LiveData<List<AppVersion>>
}
/**
* A list of unknown fields in [PackageVersionV2] that we don't allow for [Version].
*
* We are applying reflection diffs against internal database classes
* and need to prevent the untrusted external JSON input to modify internal fields in those classes.
* This list must always hold the names of all those internal FIELDS for [Version].
*/
private val DENY_LIST = listOf("packageName", "repoId", "versionId")
@Dao
internal interface VersionDaoInt : VersionDao {
@Transaction
override fun insert(
repoId: Long,
packageName: String,
packageVersions: Map<String, PackageVersionV2>,
checkIfCompatible: (PackageVersionV2) -> Boolean,
) {
packageVersions.entries.iterator().forEach { (versionId, packageVersion) ->
val isCompatible = checkIfCompatible(packageVersion)
insert(repoId, packageName, versionId, packageVersion, isCompatible)
}
}
@Transaction
fun insert(
repoId: Long,
packageName: String,
versionId: String,
packageVersion: PackageVersionV2,
isCompatible: Boolean,
) {
val version = packageVersion.toVersion(repoId, packageName, versionId, isCompatible)
insert(version)
insert(packageVersion.manifest.getVersionedStrings(version))
}
@Insert(onConflict = REPLACE)
fun insert(version: Version)
@Insert(onConflict = REPLACE)
fun insert(versionedString: List<VersionedString>)
@Update
fun update(version: Version)
fun update(
repoId: Long,
packageName: String,
versionsDiffMap: Map<String, JsonObject?>?,
checkIfCompatible: (PackageManifest) -> Boolean,
) {
if (versionsDiffMap == null) { // no more versions, delete all
deleteAppVersion(repoId, packageName)
} else versionsDiffMap.forEach { (versionId, jsonObject) ->
if (jsonObject == null) { // delete individual version
deleteAppVersion(repoId, packageName, versionId)
} else {
val version = getVersion(repoId, packageName, versionId)
if (version == null) { // new version, parse normally
val packageVersionV2: PackageVersionV2 =
json.decodeFromJsonElement(jsonObject)
val isCompatible = checkIfCompatible(packageVersionV2.packageManifest)
insert(repoId, packageName, versionId, packageVersionV2, isCompatible)
} else { // diff against existing version
diffVersion(version, jsonObject, checkIfCompatible)
}
}
} // end forEach
}
private fun diffVersion(
version: Version,
jsonObject: JsonObject,
checkIfCompatible: (PackageManifest) -> Boolean,
) {
// ensure that diff does not include internal keys
DENY_LIST.forEach { forbiddenKey ->
if (jsonObject.containsKey(forbiddenKey)) {
throw SerializationException(forbiddenKey)
}
}
// diff version
val diffedVersion = ReflectionDiffer.applyDiff(version, jsonObject)
val isCompatible = checkIfCompatible(diffedVersion.packageManifest)
update(diffedVersion.copy(isCompatible = isCompatible))
// diff versioned strings
val manifest = jsonObject["manifest"]
if (manifest is JsonNull) { // no more manifest, delete all versionedStrings
deleteVersionedStrings(version.repoId, version.packageName, version.versionId)
} else if (manifest is JsonObject) {
diffVersionedStrings(version, manifest, "usesPermission", PERMISSION)
diffVersionedStrings(version, manifest, "usesPermissionSdk23",
PERMISSION_SDK_23)
}
}
private fun diffVersionedStrings(
version: Version,
jsonObject: JsonObject,
key: String,
type: VersionedStringType,
) = DbDiffUtils.diffAndUpdateListTable(
jsonObject = jsonObject,
jsonObjectKey = key,
listParser = { permissionArray ->
val list: List<PermissionV2> = json.decodeFromJsonElement(permissionArray)
list.toVersionedString(version, type)
},
deleteList = {
deleteVersionedStrings(version.repoId, version.packageName, version.versionId, type)
},
insertNewList = { versionedStrings -> insert(versionedStrings) },
)
@Transaction
@RewriteQueriesToDropUnusedColumns
@Query("""SELECT * FROM Version
JOIN RepositoryPreferences AS pref USING (repoId)
WHERE pref.enabled = 1 AND packageName = :packageName
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
@Query("""SELECT * FROM Version
WHERE repoId = :repoId AND packageName = :packageName""")
fun getAppVersions(repoId: Long, packageName: String): List<AppVersion>
@Query("""SELECT * FROM Version
WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""")
fun getVersion(repoId: Long, packageName: String, versionId: String): Version?
/**
* Used for finding versions that are an update,
* so takes [AppPrefs.ignoreVersionCodeUpdate] into account.
*/
@RewriteQueriesToDropUnusedColumns
@Query("""SELECT * FROM Version
JOIN RepositoryPreferences AS pref USING (repoId)
LEFT JOIN AppPrefs USING (packageName)
WHERE pref.enabled = 1 AND
manifest_versionCode > COALESCE(AppPrefs.ignoreVersionCodeUpdate, 0) AND
packageName IN (:packageNames)
ORDER BY manifest_versionCode DESC, pref.weight DESC""")
fun getVersions(packageNames: List<String>): List<Version>
@Query("SELECT * FROM VersionedString WHERE repoId = :repoId AND packageName = :packageName")
fun getVersionedStrings(repoId: Long, packageName: String): List<VersionedString>
@Query("""SELECT * FROM VersionedString
WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""")
fun getVersionedStrings(
repoId: Long,
packageName: String,
versionId: String,
): List<VersionedString>
@Query("""DELETE FROM Version WHERE repoId = :repoId AND packageName = :packageName""")
fun deleteAppVersion(repoId: Long, packageName: String)
@Query("""DELETE FROM Version
WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""")
fun deleteAppVersion(repoId: Long, packageName: String, versionId: String)
@Query("""DELETE FROM VersionedString
WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""")
fun deleteVersionedStrings(repoId: Long, packageName: String, versionId: String)
@Query("""DELETE FROM VersionedString WHERE repoId = :repoId
AND packageName = :packageName AND versionId = :versionId AND type = :type""")
fun deleteVersionedStrings(
repoId: Long,
packageName: String,
versionId: String,
type: VersionedStringType,
)
@Query("SELECT COUNT(*) FROM Version")
fun countAppVersions(): Int
@Query("SELECT COUNT(*) FROM VersionedString")
fun countVersionedStrings(): Int
}

View File

@@ -0,0 +1,43 @@
package org.fdroid.download
import android.net.Uri
import android.util.Log
import org.fdroid.database.Repository
import java.io.File
import java.io.IOException
/**
* This is in the database library, because only that knows about the [Repository] class.
*/
public abstract class DownloaderFactory {
/**
* Same as [create], but trying canonical address first.
*
* See https://gitlab.com/fdroid/fdroidclient/-/issues/1708 for why this is still needed.
*/
@Throws(IOException::class)
public fun createWithTryFirstMirror(repo: Repository, uri: Uri, destFile: File): Downloader {
val tryFirst = repo.getMirrors().find { mirror ->
mirror.baseUrl == repo.address
}
if (tryFirst == null) {
Log.w("DownloaderFactory", "Try-first mirror not found, disabled by user?")
}
val mirrors: List<Mirror> = repo.getMirrors()
return create(repo, mirrors, uri, destFile, tryFirst)
}
@Throws(IOException::class)
public abstract fun create(repo: Repository, uri: Uri, destFile: File): Downloader
@Throws(IOException::class)
protected abstract fun create(
repo: Repository,
mirrors: List<Mirror>,
uri: Uri,
destFile: File,
tryFirst: Mirror?,
): Downloader
}

View File

@@ -0,0 +1,103 @@
package org.fdroid.index
import android.net.Uri
import org.fdroid.database.Repository
import org.fdroid.download.Downloader
import org.fdroid.download.NotFoundException
import java.io.File
import java.io.IOException
/**
* The currently known (and supported) format versions of the F-Droid index.
*/
public enum class IndexFormatVersion { ONE, TWO }
public sealed class IndexUpdateResult {
public object Unchanged : IndexUpdateResult()
public object Processed : IndexUpdateResult()
public object NotFound : IndexUpdateResult()
public class Error(public val e: Exception) : IndexUpdateResult()
}
public interface IndexUpdateListener {
public fun onDownloadProgress(repo: Repository, bytesRead: Long, totalBytes: Long)
public fun onUpdateProgress(repo: Repository, appsProcessed: Int, totalApps: Int)
}
public fun interface RepoUriBuilder {
/**
* Returns an [Uri] for downloading a file from the [Repository].
* Allowing different implementations for this is useful for exotic repository locations
* that do not allow for simple concatenation.
*/
public fun getUri(repo: Repository, vararg pathElements: String): Uri
}
internal val defaultRepoUriBuilder = RepoUriBuilder { repo, pathElements ->
val builder = Uri.parse(repo.address).buildUpon()
pathElements.forEach { builder.appendEncodedPath(it) }
builder.build()
}
public fun interface TempFileProvider {
@Throws(IOException::class)
public fun createTempFile(): File
}
/**
* A class to update information of a [Repository] in the database with a new downloaded index.
*/
public abstract class IndexUpdater {
/**
* The [IndexFormatVersion] used by this updater.
* One updater usually handles exactly one format version.
* If you need a higher level of abstraction, check [RepoUpdater].
*/
public abstract val formatVersion: IndexFormatVersion
/**
* Updates a new [repo] for the first time.
*/
public fun updateNewRepo(
repo: Repository,
expectedSigningFingerprint: String?,
): IndexUpdateResult = catchExceptions {
update(repo, null, expectedSigningFingerprint)
}
/**
* Updates an existing [repo] with a known [Repository.certificate].
*/
public fun update(
repo: Repository,
): IndexUpdateResult = catchExceptions {
require(repo.certificate != null) { "Repo ${repo.address} had no certificate" }
update(repo, repo.certificate, null)
}
private fun catchExceptions(block: () -> IndexUpdateResult): IndexUpdateResult {
return try {
block()
} catch (e: NotFoundException) {
IndexUpdateResult.NotFound
} catch (e: Exception) {
IndexUpdateResult.Error(e)
}
}
protected abstract fun update(
repo: Repository,
certificate: String?,
fingerprint: String?,
): IndexUpdateResult
}
internal fun Downloader.setIndexUpdateListener(
listener: IndexUpdateListener?,
repo: Repository,
) {
if (listener != null) setListener { bytesRead, totalBytes ->
listener.onDownloadProgress(repo, bytesRead, totalBytes)
}
}

View File

@@ -0,0 +1,98 @@
package org.fdroid.index
import mu.KotlinLogging
import org.fdroid.CompatibilityChecker
import org.fdroid.database.FDroidDatabase
import org.fdroid.database.Repository
import org.fdroid.download.DownloaderFactory
import org.fdroid.index.v1.IndexV1Updater
import org.fdroid.index.v2.IndexV2Updater
import java.io.File
import java.io.FileNotFoundException
/**
* Updates a [Repository] with a downloaded index, detects changes and chooses the right
* [IndexUpdater] automatically.
*/
public class RepoUpdater(
tempDir: File,
db: FDroidDatabase,
downloaderFactory: DownloaderFactory,
repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder,
compatibilityChecker: CompatibilityChecker,
listener: IndexUpdateListener,
) {
private val log = KotlinLogging.logger {}
private val tempFileProvider = TempFileProvider {
File.createTempFile("dl-", "", tempDir)
}
/**
* A list of [IndexUpdater]s to try, sorted by newest first.
*/
private val indexUpdater = listOf(
IndexV2Updater(
database = db,
tempFileProvider = tempFileProvider,
downloaderFactory = downloaderFactory,
repoUriBuilder = repoUriBuilder,
compatibilityChecker = compatibilityChecker,
listener = listener,
),
IndexV1Updater(
database = db,
tempFileProvider = tempFileProvider,
downloaderFactory = downloaderFactory,
repoUriBuilder = repoUriBuilder,
compatibilityChecker = compatibilityChecker,
listener = listener,
),
)
/**
* Updates the given [repo].
* If [Repository.certificate] is null,
* the repo is considered to be new this being the first update.
*/
public fun update(
repo: Repository,
fingerprint: String? = null,
): IndexUpdateResult {
return if (repo.certificate == null) {
// This is a new repo without a certificate
updateNewRepo(repo, fingerprint)
} else {
update(repo)
}
}
private fun updateNewRepo(
repo: Repository,
expectedSigningFingerprint: String?,
): IndexUpdateResult = update(repo) { updater ->
updater.updateNewRepo(repo, expectedSigningFingerprint)
}
private fun update(repo: Repository): IndexUpdateResult = update(repo) { updater ->
updater.update(repo)
}
private fun update(
repo: Repository,
doUpdate: (IndexUpdater) -> IndexUpdateResult,
): IndexUpdateResult {
indexUpdater.forEach { updater ->
// don't downgrade to older updaters if repo used new format already
val repoFormatVersion = repo.formatVersion
if (repoFormatVersion != null && repoFormatVersion > updater.formatVersion) {
val updaterVersion = updater.formatVersion.name
log.warn { "Not using updater $updaterVersion for repo ${repo.address}" }
return@forEach
}
val result = doUpdate(updater)
if (result != IndexUpdateResult.NotFound) return result
}
return IndexUpdateResult.Error(FileNotFoundException("No files found for ${repo.address}"))
}
}

View File

@@ -0,0 +1,84 @@
package org.fdroid.index.v1
import org.fdroid.CompatibilityChecker
import org.fdroid.database.DbV1StreamReceiver
import org.fdroid.database.FDroidDatabase
import org.fdroid.database.FDroidDatabaseInt
import org.fdroid.database.Repository
import org.fdroid.download.DownloaderFactory
import org.fdroid.index.IndexFormatVersion
import org.fdroid.index.IndexFormatVersion.ONE
import org.fdroid.index.IndexUpdateListener
import org.fdroid.index.IndexUpdateResult
import org.fdroid.index.IndexUpdater
import org.fdroid.index.RepoUriBuilder
import org.fdroid.index.TempFileProvider
import org.fdroid.index.defaultRepoUriBuilder
import org.fdroid.index.setIndexUpdateListener
internal const val SIGNED_FILE_NAME = "index-v1.jar"
@Suppress("DEPRECATION")
public class IndexV1Updater(
database: FDroidDatabase,
private val tempFileProvider: TempFileProvider,
private val downloaderFactory: DownloaderFactory,
private val repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder,
private val compatibilityChecker: CompatibilityChecker,
private val listener: IndexUpdateListener? = null,
) : IndexUpdater() {
public override val formatVersion: IndexFormatVersion = ONE
private val db: FDroidDatabaseInt = database as FDroidDatabaseInt
override fun update(
repo: Repository,
certificate: String?,
fingerprint: String?,
): IndexUpdateResult {
// don't allow repository downgrades
val formatVersion = repo.repository.formatVersion
require(formatVersion == null || formatVersion == ONE) {
"Format downgrade not allowed for ${repo.address}"
}
val uri = repoUriBuilder.getUri(repo, SIGNED_FILE_NAME)
val file = tempFileProvider.createTempFile()
val downloader = downloaderFactory.createWithTryFirstMirror(repo, uri, file).apply {
cacheTag = repo.lastETag
setIndexUpdateListener(listener, repo)
}
try {
downloader.download()
if (!downloader.hasChanged()) return IndexUpdateResult.Unchanged
val eTag = downloader.cacheTag
val verifier = IndexV1Verifier(file, certificate, fingerprint)
db.runInTransaction {
val (cert, _) = verifier.getStreamAndVerify { inputStream ->
listener?.onUpdateProgress(repo, 0, 0)
val streamReceiver = DbV1StreamReceiver(db, repo.repoId, compatibilityChecker)
val streamProcessor =
IndexV1StreamProcessor(streamReceiver, certificate, repo.timestamp)
streamProcessor.process(inputStream)
}
// update certificate, if we didn't have any before
val repoDao = db.getRepositoryDao()
if (certificate == null) {
repoDao.updateRepository(repo.repoId, cert)
}
// update RepositoryPreferences with timestamp and ETag (for v1)
val updatedPrefs = repo.preferences.copy(
lastUpdated = System.currentTimeMillis(),
lastETag = eTag,
)
repoDao.updateRepositoryPreferences(updatedPrefs)
}
} catch (e: OldIndexException) {
if (e.isSameTimestamp) return IndexUpdateResult.Unchanged
else throw e
} finally {
file.delete()
}
return IndexUpdateResult.Processed
}
}

View File

@@ -0,0 +1,114 @@
package org.fdroid.index.v2
import org.fdroid.CompatibilityChecker
import org.fdroid.database.DbV2DiffStreamReceiver
import org.fdroid.database.DbV2StreamReceiver
import org.fdroid.database.FDroidDatabase
import org.fdroid.database.FDroidDatabaseInt
import org.fdroid.database.Repository
import org.fdroid.download.DownloaderFactory
import org.fdroid.index.IndexFormatVersion
import org.fdroid.index.IndexFormatVersion.ONE
import org.fdroid.index.IndexFormatVersion.TWO
import org.fdroid.index.IndexParser
import org.fdroid.index.IndexUpdateListener
import org.fdroid.index.IndexUpdateResult
import org.fdroid.index.IndexUpdater
import org.fdroid.index.RepoUriBuilder
import org.fdroid.index.TempFileProvider
import org.fdroid.index.defaultRepoUriBuilder
import org.fdroid.index.parseEntryV2
import org.fdroid.index.setIndexUpdateListener
internal const val SIGNED_FILE_NAME = "entry.jar"
public class IndexV2Updater(
database: FDroidDatabase,
private val tempFileProvider: TempFileProvider,
private val downloaderFactory: DownloaderFactory,
private val repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder,
private val compatibilityChecker: CompatibilityChecker,
private val listener: IndexUpdateListener? = null,
) : IndexUpdater() {
public override val formatVersion: IndexFormatVersion = TWO
private val db: FDroidDatabaseInt = database as FDroidDatabaseInt
override fun update(
repo: Repository,
certificate: String?,
fingerprint: String?,
): IndexUpdateResult {
val (cert, entry) = getCertAndEntryV2(repo, certificate, fingerprint)
// don't process repos that we already did process in the past
if (entry.timestamp <= repo.timestamp) return IndexUpdateResult.Unchanged
// get diff, if available
val diff = entry.getDiff(repo.timestamp)
return if (diff == null || repo.formatVersion == ONE) {
// no diff found (or this is upgrade from v1 repo), so do full index update
val streamReceiver = DbV2StreamReceiver(db, repo.repoId, compatibilityChecker)
val streamProcessor = IndexV2FullStreamProcessor(streamReceiver, cert)
processStream(repo, entry.index, entry.version, streamProcessor)
} else {
// use available diff
val streamReceiver = DbV2DiffStreamReceiver(db, repo.repoId, compatibilityChecker)
val streamProcessor = IndexV2DiffStreamProcessor(streamReceiver)
processStream(repo, diff, entry.version, streamProcessor)
}
}
private fun getCertAndEntryV2(
repo: Repository,
certificate: String?,
fingerprint: String?,
): Pair<String, EntryV2> {
val uri = repoUriBuilder.getUri(repo, SIGNED_FILE_NAME)
val file = tempFileProvider.createTempFile()
val downloader = downloaderFactory.createWithTryFirstMirror(repo, uri, file).apply {
setIndexUpdateListener(listener, repo)
}
try {
downloader.download(-1L)
val verifier = EntryVerifier(file, certificate, fingerprint)
return verifier.getStreamAndVerify { inputStream ->
IndexParser.parseEntryV2(inputStream)
}
} finally {
file.delete()
}
}
private fun processStream(
repo: Repository,
entryFile: EntryFileV2,
repoVersion: Long,
streamProcessor: IndexV2StreamProcessor,
): IndexUpdateResult {
val uri = repoUriBuilder.getUri(repo, entryFile.name.trimStart('/'))
val file = tempFileProvider.createTempFile()
val downloader = downloaderFactory.createWithTryFirstMirror(repo, uri, file).apply {
setIndexUpdateListener(listener, repo)
}
try {
downloader.download(entryFile.size, entryFile.sha256)
file.inputStream().use { inputStream ->
val repoDao = db.getRepositoryDao()
db.runInTransaction {
streamProcessor.process(repoVersion, inputStream) { i ->
listener?.onUpdateProgress(repo, i, entryFile.numPackages)
}
// update RepositoryPreferences with timestamp
val repoPrefs = repoDao.getRepositoryPreferences(repo.repoId)
?: error("No repo prefs for ${repo.repoId}")
val updatedPrefs = repoPrefs.copy(
lastUpdated = System.currentTimeMillis(),
)
repoDao.updateRepositoryPreferences(updatedPrefs)
}
}
} finally {
file.delete()
}
return IndexUpdateResult.Processed
}
}