mirror of
https://github.com/f-droid/fdroidclient.git
synced 2026-04-25 09:19:36 -04:00
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:
committed by
Michael Pöhn
parent
a6bce15116
commit
f6075848e7
439
libs/database/src/main/java/org/fdroid/database/App.kt
Normal file
439
libs/database/src/main/java/org/fdroid/database/App.kt
Normal 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,
|
||||
)
|
||||
539
libs/database/src/main/java/org/fdroid/database/AppDao.kt
Normal file
539
libs/database/src/main/java/org/fdroid/database/AppDao.kt
Normal 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
|
||||
|
||||
}
|
||||
51
libs/database/src/main/java/org/fdroid/database/AppPrefs.kt
Normal file
51
libs/database/src/main/java/org/fdroid/database/AppPrefs.kt
Normal 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) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(',', '_') }
|
||||
}
|
||||
}
|
||||
125
libs/database/src/main/java/org/fdroid/database/DbDiffUtils.kt
Normal file
125
libs/database/src/main/java/org/fdroid/database/DbDiffUtils.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
355
libs/database/src/main/java/org/fdroid/database/Repository.kt
Normal file
355
libs/database/src/main/java/org/fdroid/database/Repository.kt
Normal 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,
|
||||
)
|
||||
415
libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt
Normal file
415
libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt
Normal 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
|
||||
|
||||
}
|
||||
221
libs/database/src/main/java/org/fdroid/database/Version.kt
Normal file
221
libs/database/src/main/java/org/fdroid/database/Version.kt
Normal 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()
|
||||
}
|
||||
227
libs/database/src/main/java/org/fdroid/database/VersionDao.kt
Normal file
227
libs/database/src/main/java/org/fdroid/database/VersionDao.kt
Normal 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
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
103
libs/database/src/main/java/org/fdroid/index/IndexUpdater.kt
Normal file
103
libs/database/src/main/java/org/fdroid/index/IndexUpdater.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
98
libs/database/src/main/java/org/fdroid/index/RepoUpdater.kt
Normal file
98
libs/database/src/main/java/org/fdroid/index/RepoUpdater.kt
Normal 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}"))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user