Support repos on file:// URIs

This commit is contained in:
Torsten Grote
2025-10-21 11:12:26 -03:00
parent 05efce1161
commit 4b8ff9678f
22 changed files with 146 additions and 72 deletions

View File

@@ -15,7 +15,7 @@ import java.io.File
class InitialData(val context: Context) : FDroidFixture {
override fun prePopulateDb(db: FDroidDatabase) {
addPreloadedRepositories(db, context.packageName)
RepoUpdateWorker.Companion.updateNow(context)
RepoUpdateWorker.updateNow(context)
}
@OptIn(ExperimentalSerializationApi::class)
@@ -24,7 +24,6 @@ class InitialData(val context: Context) : FDroidFixture {
Json.decodeFromStream<List<DefaultRepository>>(inputStream)
}
addRepositories(db.getRepositoryDao(), defaultRepos)
// TODO support file:/// Uri for repo and test additional_repos.json
// "system" can be removed when minSdk is 28
for (root in listOf("/system", "/system_ext", "/product", "/vendor")) {
val romReposFile = File("$root/etc/$packageName/additional_repos.json")

View File

@@ -1,5 +1,6 @@
package org.fdroid.download
import android.content.ContentResolver.SCHEME_FILE
import android.net.Uri
import org.fdroid.IndexFile
import org.fdroid.database.Repository
@@ -36,21 +37,13 @@ class DownloaderFactoryImpl @Inject constructor(
tryFirstMirror = tryFirst,
)
val v1OrUnknown = repo.formatVersion == null || repo.formatVersion == IndexFormatVersion.ONE
return if (v1OrUnknown) {
return if (uri.scheme == SCHEME_FILE) {
LocalFileDownloader(uri, indexFile, destFile)
} else if (v1OrUnknown) {
@Suppress("DEPRECATION") // v1 only
HttpDownloader(httpManager, request, destFile)
} else {
HttpDownloaderV2(httpManager, request, destFile)
}
}
}
// TODO move to a better place
fun IndexFile.getDownloadRequest(repository: Repository?): DownloadRequest? {
return DownloadRequest(
indexFile = this,
mirrors = repository?.getMirrors() ?: return null,
proxy = null,
username = repository.username,
password = repository.password,
)
}

View File

@@ -0,0 +1,42 @@
package org.fdroid.download
import android.net.Uri
import androidx.core.net.toUri
import org.fdroid.IndexFile
import org.fdroid.database.Repository
fun IndexFile.getImageModel(repository: Repository?): Any? {
if (repository == null) return null
val address = repository.address
if (address.startsWith("content://") || address.startsWith("file://")) {
return getUri(address, this)
}
return DownloadRequest(
indexFile = this,
mirrors = repository.getMirrors(),
proxy = null, // TODO proxy support
username = repository.username,
password = repository.password,
)
}
fun getUri(repoAddress: String, indexFile: IndexFile): Uri {
val pathElements = indexFile.name.split("/")
if (repoAddress.startsWith("content://")) {
// This is a hack that won't work with most ContentProviders
// as they don't expose the path in the Uri.
// However, it works for local file storage.
val result = StringBuilder(repoAddress)
for (element in pathElements) {
result.append("%2F")
result.append(element)
}
return result.toString().toUri()
} else { // Normal URL
val result = repoAddress.toUri().buildUpon()
for (element in pathElements) {
result.appendPath(element)
}
return result.build()
}
}

View File

@@ -0,0 +1,49 @@
package org.fdroid.download
import android.net.Uri
import org.fdroid.IndexFile
import java.io.File
import java.io.FileNotFoundException
import java.io.InputStream
/**
* "Downloads" files from `file:///` [Uri]s. Even though it is
* obviously unnecessary to download a file that is locally available, this
* class is here so that the whole security-sensitive installation process is
* the same, no matter where the files are downloaded from. Also, for things
* like icons and graphics, it makes sense to have them copied to the cache so
* that they are available even after removable storage is no longer present.
*/
class LocalFileDownloader(
uri: Uri,
indexFile: IndexFile,
destFile: File,
) : Downloader(indexFile, destFile) {
private val sourceFile: File = File(uri.path ?: error("Uri had no path"))
override fun getInputStream(resumable: Boolean): InputStream = sourceFile.inputStream()
override fun close() {}
@Deprecated("Only for v1 repos")
override fun hasChanged(): Boolean = true
override fun totalDownloadSize(): Long = sourceFile.length()
override fun download() {
if (!sourceFile.exists()) {
throw FileNotFoundException("$sourceFile does not exist")
}
var resumable = false
val contentLength = sourceFile.length()
val fileLength = outputFile.length()
if (fileLength > contentLength) {
outputFile.delete()
} else if (fileLength == contentLength && outputFile.isFile()) {
return // already have it!
} else if (fileLength > 0) {
resumable = true
}
downloadFromStream(resumable)
}
}

View File

@@ -5,7 +5,6 @@ import android.content.Context
import android.content.Intent
import android.content.Intent.ACTION_DELETE
import android.graphics.Bitmap
import android.net.Uri
import androidx.activity.result.ActivityResult
import androidx.annotation.UiThread
import androidx.annotation.WorkerThread
@@ -36,6 +35,7 @@ import org.fdroid.database.AppVersion
import org.fdroid.database.Repository
import org.fdroid.download.DownloadRequest
import org.fdroid.download.DownloaderFactory
import org.fdroid.download.getUri
import org.fdroid.getCacheKey
import org.fdroid.utils.IoDispatcher
import java.io.File
@@ -107,9 +107,10 @@ class AppInstallManager @Inject constructor(
version: AppVersion,
currentVersionName: String?,
repo: Repository,
iconDownloadRequest: DownloadRequest?,
iconModel: Any?,
): InstallState {
val packageName = appMetadata.packageName
val iconDownloadRequest = iconModel as? DownloadRequest
val job = scope.async {
installInt(appMetadata, version, currentVersionName, repo, iconDownloadRequest)
}
@@ -178,8 +179,8 @@ class AppInstallManager @Inject constructor(
coroutineContext.ensureActive()
// download file
val file = File(context.cacheDir, version.file.sha256)
val downloader =
downloaderFactory.create(repo, Uri.EMPTY, version.file, file)
val uri = getUri(repo.address, version.file)
val downloader = downloaderFactory.create(repo, uri, version.file, file)
val now = System.currentTimeMillis()
downloader.setListener { bytesRead, totalBytes ->
coroutineContext.ensureActive()

View File

@@ -13,6 +13,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.R
import org.fdroid.download.DownloadRequest
import org.fdroid.download.PackageName
import org.fdroid.fdroid.ui.theme.FDroidContent
import org.fdroid.ui.utils.AsyncShimmerImage
@@ -28,7 +29,7 @@ fun InstalledAppRow(
ListItem(
leadingContent = {
AsyncShimmerImage(
model = PackageName(app.packageName, app.iconDownloadRequest),
model = PackageName(app.packageName, app.iconModel as? DownloadRequest),
error = painterResource(R.drawable.ic_repo_app_default),
contentDescription = null,
modifier = Modifier.size(48.dp),

View File

@@ -33,7 +33,7 @@ fun InstallingAppRow(
ListItem(
leadingContent = {
AsyncShimmerImage(
model = PackageName(app.packageName, app.iconDownloadRequest, false),
model = PackageName(app.packageName, app.iconModel, false),
error = painterResource(R.drawable.ic_repo_app_default),
contentDescription = null,
modifier = Modifier.size(48.dp),

View File

@@ -8,7 +8,7 @@ sealed class MyAppItem {
abstract val packageName: String
abstract val name: String
abstract val lastUpdated: Long
abstract val iconDownloadRequest: DownloadRequest?
abstract val iconModel: Any?
}
data class InstallingAppItem(
@@ -17,7 +17,7 @@ data class InstallingAppItem(
) : MyAppItem() {
override val name: String = installState.name
override val lastUpdated: Long = installState.lastUpdated
override val iconDownloadRequest: DownloadRequest? = installState.iconDownloadRequest
override val iconModel: DownloadRequest? = installState.iconDownloadRequest
}
data class AppUpdateItem(
@@ -27,7 +27,7 @@ data class AppUpdateItem(
val installedVersionName: String,
val update: PackageVersion,
val whatsNew: String?,
override val iconDownloadRequest: DownloadRequest? = null,
override val iconModel: Any? = null,
) : MyAppItem() {
override val lastUpdated: Long = update.added
}
@@ -37,5 +37,5 @@ data class InstalledAppItem(
override val name: String,
val installedVersionName: String,
override val lastUpdated: Long,
override val iconDownloadRequest: DownloadRequest? = null,
override val iconModel: Any? = null,
) : MyAppItem()

View File

@@ -20,7 +20,7 @@ import mu.KotlinLogging
import org.fdroid.database.AppListItem
import org.fdroid.database.AppListSortOrder
import org.fdroid.database.FDroidDatabase
import org.fdroid.download.getDownloadRequest
import org.fdroid.download.getImageModel
import org.fdroid.index.RepoManager
import org.fdroid.install.AppInstallManager
import org.fdroid.install.InstallState
@@ -55,8 +55,8 @@ class MyAppsViewModel @Inject constructor(
name = app.name ?: "Unknown app",
installedVersionName = app.installedVersionName ?: "???",
lastUpdated = app.lastUpdated,
iconDownloadRequest = repoManager.getRepository(app.repoId)?.let { repo ->
app.getIcon(localeList)?.getDownloadRequest(repo)
iconModel = repoManager.getRepository(app.repoId)?.let { repo ->
app.getIcon(localeList)?.getImageModel(repo)
},
)
}

View File

@@ -30,6 +30,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.fdroid.R
import org.fdroid.download.DownloadRequest
import org.fdroid.download.PackageName
import org.fdroid.fdroid.ui.theme.FDroidContent
import org.fdroid.ui.utils.AsyncShimmerImage
@@ -53,7 +54,7 @@ fun UpdatableAppRow(
)
}) {
AsyncShimmerImage(
model = PackageName(app.packageName, app.iconDownloadRequest),
model = PackageName(app.packageName, app.iconModel as? DownloadRequest),
error = painterResource(R.drawable.ic_repo_app_default),
contentDescription = null,
modifier = Modifier.size(48.dp),

View File

@@ -10,8 +10,7 @@ import org.fdroid.database.AppMetadata
import org.fdroid.database.AppPrefs
import org.fdroid.database.AppVersion
import org.fdroid.database.Repository
import org.fdroid.download.DownloadRequest
import org.fdroid.download.getDownloadRequest
import org.fdroid.download.getImageModel
import org.fdroid.index.RELEASE_CHANNEL_BETA
import org.fdroid.index.v2.PackageVersion
import org.fdroid.install.InstallState
@@ -35,9 +34,9 @@ data class AppDetailsItem(
val name: String,
val summary: String? = null,
val description: String? = null,
val icon: DownloadRequest? = null,
val featureGraphic: DownloadRequest? = null,
val phoneScreenshots: List<DownloadRequest> = emptyList(),
val icon: Any? = null,
val featureGraphic: Any? = null,
val phoneScreenshots: List<Any> = emptyList(),
val categories: List<CategoryItem>? = null,
val versions: List<PackageVersion>? = null,
val installedVersion: PackageVersion? = null,
@@ -90,10 +89,10 @@ data class AppDetailsItem(
name = dbApp.name ?: "Unknown App",
summary = dbApp.summary,
description = getHtmlDescription(dbApp.getDescription(localeList)),
icon = dbApp.getIcon(localeList)?.getDownloadRequest(repository),
featureGraphic = dbApp.getFeatureGraphic(localeList)?.getDownloadRequest(repository),
icon = dbApp.getIcon(localeList)?.getImageModel(repository),
featureGraphic = dbApp.getFeatureGraphic(localeList)?.getImageModel(repository),
phoneScreenshots = dbApp.getPhoneScreenshots(localeList).mapNotNull {
it.getDownloadRequest(repository)
it.getImageModel(repository)
},
categories = dbApp.metadata.categories?.mapNotNull { categoryId ->
val category = repository.getCategories()[categoryId] ?: return@mapNotNull null
@@ -189,7 +188,7 @@ data class AppDetailsItem(
}
class AppDetailsActions(
val installAction: (AppMetadata, AppVersion, DownloadRequest?) -> Unit,
val installAction: (AppMetadata, AppVersion, Any?) -> Unit,
val requestUserConfirmation: (String, InstallState.UserConfirmationNeeded) -> Unit,
/**
* A workaround for Android 10, 11, 12 and 13 where tapping outside the confirmation dialog
@@ -218,7 +217,7 @@ enum class MainButtonState {
data class AntiFeature(
val id: String,
val icon: DownloadRequest? = null,
val icon: Any? = null,
val name: String = id,
val reason: String? = null,
)
@@ -231,7 +230,7 @@ private fun AppVersion?.getAntiFeatures(
val antiFeature = repository.getAntiFeatures()[key] ?: return@mapNotNull null
AntiFeature(
id = key,
icon = antiFeature.getIcon(localeList)?.getDownloadRequest(repository),
icon = antiFeature.getIcon(localeList)?.getImageModel(repository),
name = antiFeature.getName(localeList) ?: key,
reason = getAntiFeatureReason(key, localeList),
)

View File

@@ -24,7 +24,6 @@ import org.fdroid.UpdateChecker
import org.fdroid.database.AppMetadata
import org.fdroid.database.AppVersion
import org.fdroid.database.FDroidDatabase
import org.fdroid.download.DownloadRequest
import org.fdroid.index.RELEASE_CHANNEL_BETA
import org.fdroid.index.RepoManager
import org.fdroid.install.AppInstallManager
@@ -82,18 +81,14 @@ class AppDetailsViewModel @Inject constructor(
}
@UiThread
fun install(
appMetadata: AppMetadata,
version: AppVersion,
iconDownloadRequest: DownloadRequest?,
) {
fun install(appMetadata: AppMetadata, version: AppVersion, iconModel: Any?) {
scope.launch(Dispatchers.Main) {
val result = appInstallManager.install(
appMetadata = appMetadata,
version = version,
currentVersionName = packageInfoFlow.value?.packageInfo?.versionName,
repo = repoManager.getRepository(version.repoId) ?: return@launch, // TODO
iconDownloadRequest = iconDownloadRequest,
iconModel = iconModel,
)
if (result is InstallState.Installed) {
// to reload packageInfoFlow with fresh packageInfo

View File

@@ -74,7 +74,7 @@ fun AppBox(app: AppDiscoverItem, onAppTap: (AppDiscoverItem) -> Unit) {
.clickable { onAppTap(app) },
) {
AsyncShimmerImage(
model = app.iconDownloadRequest,
model = app.imageModel,
contentDescription = null,
contentScale = ContentScale.Fit,
modifier = Modifier

View File

@@ -1,10 +1,8 @@
package org.fdroid.ui.discover
import org.fdroid.download.DownloadRequest
class AppDiscoverItem(
val packageName: String,
val name: String,
val iconDownloadRequest: DownloadRequest? = null,
val imageModel: Any? = null,
val lastUpdated: Long = -1,
)

View File

@@ -22,7 +22,7 @@ import org.fdroid.LocaleChooser.getBestLocale
import org.fdroid.database.AppOverviewItem
import org.fdroid.database.FDroidDatabase
import org.fdroid.database.Repository
import org.fdroid.download.getDownloadRequest
import org.fdroid.download.getImageModel
import org.fdroid.index.RepoManager
import org.fdroid.ui.categories.CategoryItem
import org.fdroid.ui.lists.AppListItem
@@ -114,8 +114,7 @@ class DiscoverViewModel @Inject constructor(
summary = it.summary.getBestLocale(localeList) ?: "",
lastUpdated = it.lastUpdated,
isCompatible = true, // doesn't matter here, as we don't filter
iconDownloadRequest = it.getIcon(localeList)
?.getDownloadRequest(repository),
iconModel = it.getIcon(localeList)?.getImageModel(repository),
categoryIds = it.categories?.toSet(),
)
}
@@ -146,6 +145,6 @@ class DiscoverViewModel @Inject constructor(
packageName = packageName,
name = getName(localeList) ?: "Unknown App",
lastUpdated = lastUpdated,
iconDownloadRequest = getIcon(localeList)?.getDownloadRequest(repository),
imageModel = getIcon(localeList)?.getImageModel(repository),
)
}

View File

@@ -1,7 +1,5 @@
package org.fdroid.ui.lists
import org.fdroid.download.DownloadRequest
data class AppListItem(
val repoId: Long,
val packageName: String,
@@ -9,6 +7,6 @@ data class AppListItem(
val summary: String,
val lastUpdated: Long,
val isCompatible: Boolean,
val iconDownloadRequest: DownloadRequest? = null,
val iconModel: Any? = null,
val categoryIds: Set<String>? = null,
)

View File

@@ -28,7 +28,7 @@ fun AppListRow(
supportingContent = { Text(item.summary) },
leadingContent = {
AsyncShimmerImage(
model = item.iconDownloadRequest,
model = item.iconModel,
error = painterResource(R.drawable.ic_repo_app_default),
contentDescription = null,
modifier = Modifier.size(48.dp),

View File

@@ -23,7 +23,7 @@ import kotlinx.coroutines.launch
import org.fdroid.R
import org.fdroid.database.AppListSortOrder
import org.fdroid.database.FDroidDatabase
import org.fdroid.download.getDownloadRequest
import org.fdroid.download.getImageModel
import org.fdroid.index.RepoManager
import org.fdroid.settings.OnboardingManager
import org.fdroid.settings.SettingsManager
@@ -119,7 +119,7 @@ class AppListViewModel @Inject constructor(
summary = it.getSummary(localeList) ?: "Unknown",
lastUpdated = it.lastUpdated,
isCompatible = it.isCompatible,
iconDownloadRequest = it.getIcon(localeList)?.getDownloadRequest(repository),
iconModel = it.getIcon(localeList)?.getImageModel(repository),
categoryIds = it.categories?.toSet(),
)
}

View File

@@ -6,13 +6,13 @@ import androidx.compose.ui.res.painterResource
import androidx.core.os.LocaleListCompat
import org.fdroid.R
import org.fdroid.database.Repository
import org.fdroid.download.getDownloadRequest
import org.fdroid.download.getImageModel
import org.fdroid.ui.utils.AsyncShimmerImage
@Composable
fun RepoIcon(repo: Repository, modifier: Modifier = Modifier) {
AsyncShimmerImage(
model = repo.getIcon(LocaleListCompat.getDefault())?.getDownloadRequest(repo),
model = repo.getIcon(LocaleListCompat.getDefault())?.getImageModel(repo),
contentDescription = null,
error = painterResource(R.drawable.ic_repo_app_default),
modifier = modifier,

View File

@@ -2,14 +2,13 @@ package org.fdroid.ui.repositories
import androidx.core.os.LocaleListCompat
import org.fdroid.database.Repository
import org.fdroid.download.DownloadRequest
import org.fdroid.download.getDownloadRequest
import org.fdroid.download.getImageModel
data class RepositoryItem(
val repoId: Long,
val address: String,
val name: String,
val icon: DownloadRequest? = null,
val icon: Any? = null,
val timestamp: Long,
val lastUpdated: Long?,
val weight: Int,
@@ -19,7 +18,7 @@ data class RepositoryItem(
repoId = repo.repoId,
address = repo.address,
name = repo.getName(localeList) ?: "Unknown Repo",
icon = repo.getIcon(localeList)?.getDownloadRequest(repo),
icon = repo.getIcon(localeList)?.getImageModel(repo),
timestamp = repo.timestamp,
lastUpdated = repo.lastUpdated,
weight = repo.weight,

View File

@@ -20,7 +20,7 @@ import androidx.compose.ui.unit.dp
import androidx.core.os.LocaleListCompat
import org.fdroid.R
import org.fdroid.database.MinimalApp
import org.fdroid.download.getDownloadRequest
import org.fdroid.download.getImageModel
import org.fdroid.fdroid.ui.theme.FDroidContent
import org.fdroid.index.v2.FileV2
import org.fdroid.repo.FetchResult.IsNewRepoAndNewMirror
@@ -88,7 +88,7 @@ fun AddRepoPreviewScreen(
packageName = app.packageName,
name = app.name ?: "Unknown app",
summary = app.summary ?: "",
iconDownloadRequest = app.getIcon(localeList)?.getDownloadRequest(repo),
iconModel = app.getIcon(localeList)?.getImageModel(repo),
lastUpdated = 1L,
isCompatible = true,
)

View File

@@ -13,7 +13,7 @@ import mu.KotlinLogging
import org.fdroid.database.AppVersion
import org.fdroid.database.DbUpdateChecker
import org.fdroid.database.FDroidDatabase
import org.fdroid.download.getDownloadRequest
import org.fdroid.download.getImageModel
import org.fdroid.index.RepoManager
import org.fdroid.install.AppInstallManager
import org.fdroid.ui.apps.AppUpdateItem
@@ -66,8 +66,8 @@ class UpdatesManager @Inject constructor(
installedVersionName = update.installedVersionName,
update = update.update,
whatsNew = update.update.getWhatsNew(localeList),
iconDownloadRequest = repoManager.getRepository(update.repoId)?.let { repo ->
update.getIcon(localeList)?.getDownloadRequest(repo)
iconModel = repoManager.getRepository(update.repoId)?.let { repo ->
update.getIcon(localeList)?.getImageModel(repo)
},
)
}
@@ -102,7 +102,7 @@ class UpdatesManager @Inject constructor(
version = update.update as AppVersion,
currentVersionName = update.installedVersionName,
repo = repoManager.getRepository(update.repoId) ?: return,
iconDownloadRequest = update.iconDownloadRequest
iconModel = update.iconModel
)
}
}