mirror of
https://github.com/f-droid/fdroidclient.git
synced 2026-04-24 16:57:15 -04:00
[db] Add plumping for fetching and adding a new repo
This commit is contained in:
@@ -8,6 +8,7 @@ import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import org.fdroid.LocaleChooser.getBestLocale
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.Callable
|
||||
|
||||
@Database(
|
||||
// When bumping this version, please make sure to add one (or more) migration(s) below!
|
||||
@@ -101,6 +102,11 @@ public interface FDroidDatabase {
|
||||
*/
|
||||
public fun runInTransaction(body: Runnable)
|
||||
|
||||
/**
|
||||
* Like [runInTransaction], but can return something.
|
||||
*/
|
||||
public fun <V> runInTransaction(body: Callable<V>): V
|
||||
|
||||
/**
|
||||
* Removes all apps and associated data (such as versions) from all repositories.
|
||||
* The repository data and app preferences are kept as-is.
|
||||
|
||||
@@ -93,6 +93,7 @@ public data class Repository internal constructor(
|
||||
/**
|
||||
* Used to create a minimal version of a [Repository].
|
||||
*/
|
||||
@JvmOverloads
|
||||
public constructor(
|
||||
repoId: Long,
|
||||
address: String,
|
||||
@@ -102,6 +103,8 @@ public data class Repository internal constructor(
|
||||
version: Long,
|
||||
weight: Int,
|
||||
lastUpdated: Long,
|
||||
username: String? = null,
|
||||
password: String? = null,
|
||||
) : this(
|
||||
repository = CoreRepository(
|
||||
repoId = repoId,
|
||||
@@ -121,6 +124,8 @@ public data class Repository internal constructor(
|
||||
repoId = repoId,
|
||||
weight = weight,
|
||||
lastUpdated = lastUpdated,
|
||||
username = username,
|
||||
password = password,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -401,3 +406,16 @@ private fun validateCertificate(certificate: String?) {
|
||||
certificate.chunked(2).find { it.toIntOrNull(16) == null } == null
|
||||
) { "Invalid certificate: $certificate" }
|
||||
}
|
||||
|
||||
/**
|
||||
* A reduced version of [Repository] used to add new repositories.
|
||||
*/
|
||||
public data class NewRepository(
|
||||
val name: LocalizedTextV2,
|
||||
val icon: LocalizedFileV2,
|
||||
val address: String,
|
||||
val formatVersion: IndexFormatVersion?,
|
||||
val certificate: String,
|
||||
val username: String? = null,
|
||||
val password: String? = null,
|
||||
)
|
||||
|
||||
@@ -28,11 +28,17 @@ public interface RepositoryDao {
|
||||
*/
|
||||
public fun insert(initialRepo: InitialRepository): Long
|
||||
|
||||
/**
|
||||
* Inserts a new repository into the database.
|
||||
*/
|
||||
public fun insert(newRepository: NewRepository): Long
|
||||
|
||||
/**
|
||||
* Inserts an empty [Repository] for an initial update.
|
||||
*
|
||||
* @return the [Repository.repoId] of the inserted repo.
|
||||
*/
|
||||
@Deprecated("Use insert instead")
|
||||
public fun insertEmptyRepo(
|
||||
address: String,
|
||||
username: String? = null,
|
||||
@@ -145,6 +151,32 @@ internal interface RepositoryDaoInt : RepositoryDao {
|
||||
}
|
||||
|
||||
@Transaction
|
||||
override fun insert(newRepository: NewRepository): Long {
|
||||
val repo = CoreRepository(
|
||||
name = newRepository.name,
|
||||
icon = newRepository.icon,
|
||||
address = newRepository.address,
|
||||
timestamp = -1,
|
||||
version = null,
|
||||
formatVersion = newRepository.formatVersion,
|
||||
maxAge = null,
|
||||
certificate = newRepository.certificate,
|
||||
)
|
||||
val repoId = insertOrReplace(repo)
|
||||
val currentMaxWeight = getMaxRepositoryWeight()
|
||||
val repositoryPreferences = RepositoryPreferences(
|
||||
repoId = repoId,
|
||||
weight = currentMaxWeight + 1,
|
||||
lastUpdated = null,
|
||||
username = newRepository.username,
|
||||
password = newRepository.password,
|
||||
)
|
||||
insert(repositoryPreferences)
|
||||
return repoId
|
||||
}
|
||||
|
||||
@Transaction
|
||||
@Deprecated("Use insert instead")
|
||||
override fun insertEmptyRepo(
|
||||
address: String,
|
||||
username: String?,
|
||||
@@ -191,6 +223,10 @@ internal interface RepositoryDaoInt : RepositoryDao {
|
||||
@Query("SELECT * FROM ${CoreRepository.TABLE} WHERE repoId = :repoId")
|
||||
override fun getRepository(repoId: Long): Repository?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM ${CoreRepository.TABLE} WHERE certificate = :certificate COLLATE NOCASE")
|
||||
fun getRepository(certificate: String): Repository?
|
||||
|
||||
@Transaction
|
||||
@Query("SELECT * FROM ${CoreRepository.TABLE}")
|
||||
override fun getRepositories(): List<Repository>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package org.fdroid.index
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.AnyThread
|
||||
import androidx.annotation.UiThread
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.asLiveData
|
||||
@@ -13,23 +16,45 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fdroid.database.FDroidDatabase
|
||||
import org.fdroid.database.Repository
|
||||
import org.fdroid.download.DownloaderFactory
|
||||
import org.fdroid.download.HttpManager
|
||||
import org.fdroid.repo.AddRepoState
|
||||
import org.fdroid.repo.RepoAdder
|
||||
import java.io.File
|
||||
import java.net.Proxy
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
public class RepoManager @JvmOverloads constructor(
|
||||
context: Context,
|
||||
db: FDroidDatabase,
|
||||
downloaderFactory: DownloaderFactory,
|
||||
httpManager: HttpManager,
|
||||
private val coroutineContext: CoroutineContext = Dispatchers.IO,
|
||||
) {
|
||||
|
||||
private val repositoryDao = db.getRepositoryDao()
|
||||
private val tempFileProvider = TempFileProvider {
|
||||
File.createTempFile("dl-", "", context.cacheDir)
|
||||
}
|
||||
private val repoAdder = RepoAdder(
|
||||
context = context,
|
||||
db = db,
|
||||
tempFileProvider = tempFileProvider,
|
||||
downloaderFactory = downloaderFactory,
|
||||
httpManager = httpManager,
|
||||
coroutineContext = coroutineContext,
|
||||
)
|
||||
|
||||
private val _repositoriesState: MutableStateFlow<List<Repository>> =
|
||||
MutableStateFlow(emptyList())
|
||||
public val repositoriesState: StateFlow<List<Repository>> = _repositoriesState.asStateFlow()
|
||||
|
||||
public val liveRepositories: LiveData<List<Repository>> = _repositoriesState.asLiveData()
|
||||
|
||||
public val addRepoState: StateFlow<AddRepoState> = repoAdder.addRepoState.asStateFlow()
|
||||
public val liveAddRepoState: LiveData<AddRepoState> = repoAdder.addRepoState.asLiveData()
|
||||
|
||||
/**
|
||||
* Used internally as a mechanism to wait until repositories are loaded from the DB.
|
||||
* This happens quite fast and the load is triggered at construction time.
|
||||
@@ -92,4 +117,42 @@ public class RepoManager @JvmOverloads constructor(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a preview of the repository at the given [url]
|
||||
* with the intention of possibly adding it to the database.
|
||||
* Progress can be observed via [addRepoState] or [liveAddRepoState].
|
||||
*/
|
||||
@AnyThread
|
||||
@JvmOverloads
|
||||
public fun fetchRepositoryPreview(
|
||||
url: String,
|
||||
username: String? = null,
|
||||
password: String? = null,
|
||||
proxy: Proxy? = null,
|
||||
) {
|
||||
repoAdder.fetchRepository(url, username, password, proxy)
|
||||
}
|
||||
|
||||
/**
|
||||
* When [addRepoState] is in [org.fdroid.repo.Fetched],
|
||||
* you can call this to actually add the repo to the DB.
|
||||
* @throws IllegalStateException if [addRepoState] is currently in any other state.
|
||||
*/
|
||||
@AnyThread
|
||||
public fun addFetchedRepository() {
|
||||
GlobalScope.launch(coroutineContext) {
|
||||
repoAdder.addFetchedRepository()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts the process of fetching a [Repository] preview,
|
||||
* e.g. when the user leaves the UI flow or wants to cancel the preview process.
|
||||
* Note that this won't work after [addFetchedRepository] has already been called.
|
||||
*/
|
||||
@UiThread
|
||||
public fun abortAddingRepository() {
|
||||
repoAdder.abortAddingRepo()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
281
libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt
Normal file
281
libs/database/src/main/java/org/fdroid/repo/RepoAdder.kt
Normal file
@@ -0,0 +1,281 @@
|
||||
package org.fdroid.repo
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.UserManager
|
||||
import android.os.UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES
|
||||
import android.os.UserManager.DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.core.content.ContextCompat.getSystemService
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import mu.KotlinLogging
|
||||
import org.fdroid.database.AppOverviewItem
|
||||
import org.fdroid.database.FDroidDatabase
|
||||
import org.fdroid.database.MinimalApp
|
||||
import org.fdroid.database.NewRepository
|
||||
import org.fdroid.database.Repository
|
||||
import org.fdroid.database.RepositoryDaoInt
|
||||
import org.fdroid.download.DownloaderFactory
|
||||
import org.fdroid.download.HttpManager
|
||||
import org.fdroid.download.NotFoundException
|
||||
import org.fdroid.index.IndexFormatVersion
|
||||
import org.fdroid.index.SigningException
|
||||
import org.fdroid.index.TempFileProvider
|
||||
import org.fdroid.repo.AddRepoError.ErrorType.INVALID_FINGERPRINT
|
||||
import org.fdroid.repo.AddRepoError.ErrorType.INVALID_INDEX
|
||||
import org.fdroid.repo.AddRepoError.ErrorType.IO_ERROR
|
||||
import org.fdroid.repo.AddRepoError.ErrorType.UNKNOWN_SOURCES_DISALLOWED
|
||||
import java.io.IOException
|
||||
import java.net.Proxy
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
internal const val REPO_ID = 0L
|
||||
|
||||
public sealed class AddRepoState
|
||||
|
||||
public object None : AddRepoState()
|
||||
|
||||
public class Fetching(
|
||||
public val repo: Repository?,
|
||||
public val apps: List<MinimalApp>,
|
||||
public val fetchResult: FetchResult?,
|
||||
/**
|
||||
* true if fetching is complete.
|
||||
*/
|
||||
public val done: Boolean = false,
|
||||
) : AddRepoState() {
|
||||
/**
|
||||
* true if the repository can be added (be it as new [Repository] or new mirror).
|
||||
*/
|
||||
public val canAdd: Boolean = repo != null &&
|
||||
(fetchResult != null && fetchResult !is FetchResult.IsExistingRepository)
|
||||
}
|
||||
|
||||
public object Adding : AddRepoState()
|
||||
|
||||
public class Added(
|
||||
public val repo: Repository,
|
||||
) : AddRepoState()
|
||||
|
||||
public data class AddRepoError(
|
||||
public val errorType: ErrorType,
|
||||
public val exception: Exception? = null,
|
||||
) : AddRepoState() {
|
||||
public enum class ErrorType {
|
||||
UNKNOWN_SOURCES_DISALLOWED,
|
||||
INVALID_FINGERPRINT,
|
||||
INVALID_INDEX,
|
||||
IO_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class FetchResult {
|
||||
public object IsNewRepository : FetchResult()
|
||||
public data class IsNewMirror(
|
||||
internal val existingRepoId: Long,
|
||||
internal val newMirrorUrl: String,
|
||||
) : FetchResult()
|
||||
|
||||
public object IsExistingRepository : FetchResult()
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
internal class RepoAdder(
|
||||
private val context: Context,
|
||||
private val db: FDroidDatabase,
|
||||
private val tempFileProvider: TempFileProvider,
|
||||
private val downloaderFactory: DownloaderFactory,
|
||||
private val httpManager: HttpManager,
|
||||
private val repoUriGetter: RepoUriGetter = RepoUriGetter,
|
||||
private val coroutineContext: CoroutineContext = Dispatchers.IO,
|
||||
) {
|
||||
private val log = KotlinLogging.logger {}
|
||||
private val repositoryDao = db.getRepositoryDao() as RepositoryDaoInt
|
||||
|
||||
internal val addRepoState: MutableStateFlow<AddRepoState> = MutableStateFlow(None)
|
||||
|
||||
private var fetchJob: Job? = null
|
||||
|
||||
internal fun fetchRepository(url: String, username: String?, password: String?, proxy: Proxy?) {
|
||||
fetchJob = GlobalScope.launch(coroutineContext) {
|
||||
fetchRepositoryInt(url, username, password, proxy)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
@VisibleForTesting
|
||||
internal suspend fun fetchRepositoryInt(
|
||||
url: String,
|
||||
username: String? = null,
|
||||
password: String? = null,
|
||||
proxy: Proxy? = null,
|
||||
) {
|
||||
if (hasDisallowInstallUnknownSources(context)) {
|
||||
addRepoState.value = AddRepoError(UNKNOWN_SOURCES_DISALLOWED)
|
||||
return
|
||||
}
|
||||
// get repo url and fingerprint
|
||||
val nUri = repoUriGetter.getUri(url)
|
||||
log.info("Parsed URI: $nUri")
|
||||
|
||||
// some plumping to receive the repo preview
|
||||
var receivedRepo: Repository? = null
|
||||
val apps = ArrayList<AppOverviewItem>()
|
||||
var fetchResult: FetchResult? = null
|
||||
val receiver = object : RepoPreviewReceiver {
|
||||
override fun onRepoReceived(repo: Repository) {
|
||||
receivedRepo = repo
|
||||
fetchResult = getFetchResult(nUri.uri.toString(), repo)
|
||||
addRepoState.value = Fetching(receivedRepo, apps.toList(), fetchResult)
|
||||
}
|
||||
|
||||
override fun onAppReceived(app: AppOverviewItem) {
|
||||
apps.add(app)
|
||||
addRepoState.value = Fetching(receivedRepo, apps.toList(), fetchResult)
|
||||
}
|
||||
}
|
||||
// set a state early, so the ui can show progress animation
|
||||
addRepoState.value = Fetching(receivedRepo, apps, fetchResult)
|
||||
|
||||
// try fetching repo with v2 format first and fallback to v1
|
||||
try {
|
||||
try {
|
||||
val repo = getTempRepo(nUri.uri, IndexFormatVersion.TWO, username, password)
|
||||
val repoFetcher =
|
||||
RepoV2Fetcher(tempFileProvider, downloaderFactory, httpManager, proxy)
|
||||
repoFetcher.fetchRepo(nUri.uri, repo, receiver, nUri.fingerprint)
|
||||
} catch (e: NotFoundException) {
|
||||
log.warn(e) { "Did not find v2 repo, trying v1 now." }
|
||||
// try to fetch v1 repo
|
||||
val repo = getTempRepo(nUri.uri, IndexFormatVersion.ONE, username, password)
|
||||
val repoFetcher = RepoV1Fetcher(tempFileProvider, downloaderFactory)
|
||||
repoFetcher.fetchRepo(nUri.uri, repo, receiver, nUri.fingerprint)
|
||||
}
|
||||
} catch (e: SigningException) {
|
||||
log.error(e) { "Error verifying repo with given fingerprint." }
|
||||
addRepoState.value = AddRepoError(INVALID_FINGERPRINT, e)
|
||||
return
|
||||
} catch (e: IOException) {
|
||||
log.error(e) { "Error fetching repo." }
|
||||
addRepoState.value = AddRepoError(IO_ERROR, e)
|
||||
return
|
||||
}
|
||||
// set final result
|
||||
val finalRepo = receivedRepo
|
||||
if (finalRepo == null) {
|
||||
addRepoState.value = AddRepoError(INVALID_INDEX)
|
||||
} else {
|
||||
addRepoState.value = Fetching(finalRepo, apps, fetchResult, done = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFetchResult(url: String, repo: Repository): FetchResult {
|
||||
val cert = repo.certificate ?: error("Certificate was null")
|
||||
val existingRepo = repositoryDao.getRepository(cert)
|
||||
return if (existingRepo == null) {
|
||||
FetchResult.IsNewRepository
|
||||
} else {
|
||||
val existingMirror = if (existingRepo.address.trimEnd('/') == url) {
|
||||
url
|
||||
} else {
|
||||
existingRepo.mirrors.find { it.url.trimEnd('/') == url }
|
||||
?: existingRepo.userMirrors.find { it.trimEnd('/') == url }
|
||||
}
|
||||
if (existingMirror == null) {
|
||||
FetchResult.IsNewMirror(existingRepo.repoId, url)
|
||||
} else {
|
||||
FetchResult.IsExistingRepository
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
internal fun addFetchedRepository() {
|
||||
// prevent double calls (e.g. caused by double tapping a UI button)
|
||||
if (addRepoState.compareAndSet(Adding, Adding)) return
|
||||
|
||||
// cancel fetch preview job, so it stops emitting new states
|
||||
fetchJob?.cancel()
|
||||
|
||||
// get current state before changing it
|
||||
val state = (addRepoState.value as? Fetching)
|
||||
?: throw IllegalStateException("Unexpected state: ${addRepoState.value}")
|
||||
addRepoState.value = Adding
|
||||
|
||||
val repo = state.repo ?: throw IllegalStateException("No repo: ${addRepoState.value}")
|
||||
val fetchResult = state.fetchResult
|
||||
?: throw IllegalStateException("No fetchResult: ${addRepoState.value}")
|
||||
|
||||
val modifiedRepo: Repository = when (fetchResult) {
|
||||
is FetchResult.IsExistingRepository -> error("Unexpected result: $fetchResult")
|
||||
is FetchResult.IsNewRepository -> {
|
||||
// reset the timestamp of the actual repo,
|
||||
// so a following repo update will pick this up
|
||||
val newRepo = NewRepository(
|
||||
name = repo.repository.name,
|
||||
icon = repo.repository.icon ?: emptyMap(),
|
||||
address = repo.address,
|
||||
formatVersion = repo.formatVersion,
|
||||
certificate = repo.certificate ?: error("Repo had no certificate"),
|
||||
username = repo.username,
|
||||
password = repo.password,
|
||||
)
|
||||
val repoId = repositoryDao.insert(newRepo)
|
||||
repositoryDao.getRepository(repoId) ?: error("New repository not found in DB")
|
||||
}
|
||||
|
||||
is FetchResult.IsNewMirror -> {
|
||||
val repoId = fetchResult.existingRepoId
|
||||
db.runInTransaction<Repository> {
|
||||
val existingRepo = repositoryDao.getRepository(repoId)
|
||||
?: error("No repo with $repoId")
|
||||
val userMirrors = existingRepo.userMirrors.toMutableList().apply {
|
||||
add(fetchResult.newMirrorUrl)
|
||||
}
|
||||
repositoryDao.updateUserMirrors(repoId, userMirrors)
|
||||
existingRepo
|
||||
}
|
||||
}
|
||||
}
|
||||
addRepoState.value = Added(modifiedRepo)
|
||||
}
|
||||
|
||||
internal fun abortAddingRepo() {
|
||||
addRepoState.value = None
|
||||
fetchJob?.cancel()
|
||||
}
|
||||
|
||||
private fun hasDisallowInstallUnknownSources(context: Context): Boolean {
|
||||
val userManager = getSystemService(context, UserManager::class.java)
|
||||
?: error("No UserManager available.")
|
||||
return if (SDK_INT < 29) userManager.hasUserRestriction(DISALLOW_INSTALL_UNKNOWN_SOURCES)
|
||||
else userManager.hasUserRestriction(DISALLOW_INSTALL_UNKNOWN_SOURCES) ||
|
||||
userManager.hasUserRestriction(DISALLOW_INSTALL_UNKNOWN_SOURCES_GLOBALLY)
|
||||
}
|
||||
|
||||
private fun getTempRepo(
|
||||
uri: Uri,
|
||||
indexFormatVersion: IndexFormatVersion,
|
||||
username: String?,
|
||||
password: String?,
|
||||
) = Repository(
|
||||
repoId = REPO_ID,
|
||||
address = uri.toString(),
|
||||
timestamp = -1L,
|
||||
formatVersion = indexFormatVersion,
|
||||
certificate = null,
|
||||
version = 0L,
|
||||
weight = 0,
|
||||
lastUpdated = -1L,
|
||||
username = username,
|
||||
password = password,
|
||||
)
|
||||
|
||||
}
|
||||
23
libs/database/src/main/java/org/fdroid/repo/RepoFetcher.kt
Normal file
23
libs/database/src/main/java/org/fdroid/repo/RepoFetcher.kt
Normal file
@@ -0,0 +1,23 @@
|
||||
package org.fdroid.repo
|
||||
|
||||
import android.net.Uri
|
||||
import org.fdroid.database.AppOverviewItem
|
||||
import org.fdroid.database.Repository
|
||||
import org.fdroid.download.NotFoundException
|
||||
import org.fdroid.index.SigningException
|
||||
import java.io.IOException
|
||||
|
||||
internal fun interface RepoFetcher {
|
||||
@Throws(IOException::class, SigningException::class, NotFoundException::class)
|
||||
suspend fun fetchRepo(
|
||||
uri: Uri,
|
||||
repo: Repository,
|
||||
receiver: RepoPreviewReceiver,
|
||||
fingerprint: String?,
|
||||
)
|
||||
}
|
||||
|
||||
internal interface RepoPreviewReceiver {
|
||||
fun onRepoReceived(repo: Repository)
|
||||
fun onAppReceived(app: AppOverviewItem)
|
||||
}
|
||||
48
libs/database/src/main/java/org/fdroid/repo/RepoUriGetter.kt
Normal file
48
libs/database/src/main/java/org/fdroid/repo/RepoUriGetter.kt
Normal file
@@ -0,0 +1,48 @@
|
||||
package org.fdroid.repo
|
||||
|
||||
import android.net.Uri
|
||||
import org.fdroid.database.Repository
|
||||
|
||||
internal object RepoUriGetter {
|
||||
|
||||
fun getUri(url: String): NormalizedUri {
|
||||
val uri = Uri.parse(url)
|
||||
val fingerprint = uri.getQueryParameter("fingerprint")?.lowercase()
|
||||
|
||||
val pathSegments = uri.pathSegments
|
||||
val normalizedUri = uri.buildUpon().apply {
|
||||
clearQuery() // removes fingerprint and other query params
|
||||
fragment("") // remove # hash fragment
|
||||
if (pathSegments.size >= 2 &&
|
||||
pathSegments[pathSegments.lastIndex - 1] == "fdroid" &&
|
||||
pathSegments.last() == "repo"
|
||||
) {
|
||||
// path already is /fdroid/repo, use as is
|
||||
} else if (pathSegments.lastOrNull() == "repo") {
|
||||
// path already ends in /repo, use as is
|
||||
} else if (pathSegments.size >= 1 && pathSegments.last() == "fdroid") {
|
||||
// path is /fdroid with missing /repo, so add that
|
||||
appendPath("repo")
|
||||
} else {
|
||||
// path is missing /fdroid/repo, so add it
|
||||
appendPath("fdroid")
|
||||
appendPath("repo")
|
||||
}
|
||||
}.build().let { newUri ->
|
||||
// hacky way to remove trailing slash
|
||||
val path = newUri.path
|
||||
if (path != null && path.endsWith('/')) {
|
||||
newUri.buildUpon().path(path.trimEnd('/')).build()
|
||||
} else {
|
||||
newUri
|
||||
}
|
||||
}
|
||||
return NormalizedUri(normalizedUri, fingerprint)
|
||||
}
|
||||
|
||||
/**
|
||||
* A class for normalizing the [Repository] URI and holding an optional fingerprint.
|
||||
*/
|
||||
data class NormalizedUri(val uri: Uri, val fingerprint: String?)
|
||||
|
||||
}
|
||||
66
libs/database/src/main/java/org/fdroid/repo/RepoV1Fetcher.kt
Normal file
66
libs/database/src/main/java/org/fdroid/repo/RepoV1Fetcher.kt
Normal file
@@ -0,0 +1,66 @@
|
||||
package org.fdroid.repo
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.net.Uri
|
||||
import androidx.core.os.ConfigurationCompat.getLocales
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import org.fdroid.database.Repository
|
||||
import org.fdroid.download.DownloaderFactory
|
||||
import org.fdroid.index.IndexConverter
|
||||
import org.fdroid.index.IndexFormatVersion
|
||||
import org.fdroid.index.IndexParser
|
||||
import org.fdroid.index.SigningException
|
||||
import org.fdroid.index.TempFileProvider
|
||||
import org.fdroid.index.parseV1
|
||||
import org.fdroid.index.v1.IndexV1Verifier
|
||||
import org.fdroid.index.v1.SIGNED_FILE_NAME
|
||||
import org.fdroid.index.v2.FileV2
|
||||
|
||||
internal class RepoV1Fetcher(
|
||||
private val tempFileProvider: TempFileProvider,
|
||||
private val downloaderFactory: DownloaderFactory,
|
||||
) : RepoFetcher {
|
||||
|
||||
private val locales: LocaleListCompat = getLocales(Resources.getSystem().configuration)
|
||||
|
||||
@Throws(SigningException::class)
|
||||
override suspend fun fetchRepo(
|
||||
uri: Uri,
|
||||
repo: Repository,
|
||||
receiver: RepoPreviewReceiver,
|
||||
fingerprint: String?,
|
||||
) {
|
||||
// download and verify index-v1.jar
|
||||
val indexFile = tempFileProvider.createTempFile()
|
||||
val entryDownloader = downloaderFactory.create(
|
||||
repo = repo,
|
||||
uri = uri.buildUpon().appendPath(SIGNED_FILE_NAME).build(),
|
||||
indexFile = FileV2.fromPath("/$SIGNED_FILE_NAME"),
|
||||
destFile = indexFile,
|
||||
)
|
||||
val (cert, indexV1) = try {
|
||||
entryDownloader.download()
|
||||
val verifier = IndexV1Verifier(indexFile, null, fingerprint)
|
||||
verifier.getStreamAndVerify { inputStream ->
|
||||
IndexParser.parseV1(inputStream)
|
||||
}
|
||||
} finally {
|
||||
indexFile.delete()
|
||||
}
|
||||
val version = indexV1.repo.version
|
||||
val indexV2 = IndexConverter().toIndexV2(indexV1)
|
||||
val receivedRepo = RepoV2StreamReceiver.getRepository(
|
||||
repo = indexV2.repo,
|
||||
version = version.toLong(),
|
||||
formatVersion = IndexFormatVersion.ONE,
|
||||
certificate = cert,
|
||||
username = repo.username,
|
||||
password = repo.password,
|
||||
)
|
||||
receiver.onRepoReceived(receivedRepo)
|
||||
indexV2.packages.forEach { (packageName, packageV2) ->
|
||||
val app = RepoV2StreamReceiver.getAppOverViewItem(packageName, packageV2, locales)
|
||||
receiver.onAppReceived(app)
|
||||
}
|
||||
}
|
||||
}
|
||||
74
libs/database/src/main/java/org/fdroid/repo/RepoV2Fetcher.kt
Normal file
74
libs/database/src/main/java/org/fdroid/repo/RepoV2Fetcher.kt
Normal file
@@ -0,0 +1,74 @@
|
||||
package org.fdroid.repo
|
||||
|
||||
import android.net.Uri
|
||||
import mu.KotlinLogging
|
||||
import org.fdroid.database.Repository
|
||||
import org.fdroid.download.DownloadRequest
|
||||
import org.fdroid.download.DownloaderFactory
|
||||
import org.fdroid.download.HttpManager
|
||||
import org.fdroid.download.getDigestInputStream
|
||||
import org.fdroid.index.IndexParser
|
||||
import org.fdroid.index.SigningException
|
||||
import org.fdroid.index.TempFileProvider
|
||||
import org.fdroid.index.parseEntry
|
||||
import org.fdroid.index.v2.EntryVerifier
|
||||
import org.fdroid.index.v2.FileV2
|
||||
import org.fdroid.index.v2.IndexV2FullStreamProcessor
|
||||
import org.fdroid.index.v2.SIGNED_FILE_NAME
|
||||
import java.net.Proxy
|
||||
|
||||
internal class RepoV2Fetcher(
|
||||
private val tempFileProvider: TempFileProvider,
|
||||
private val downloaderFactory: DownloaderFactory,
|
||||
private val httpManager: HttpManager,
|
||||
private val proxy: Proxy? = null,
|
||||
) : RepoFetcher {
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
@Throws(SigningException::class)
|
||||
override suspend fun fetchRepo(
|
||||
uri: Uri,
|
||||
repo: Repository,
|
||||
receiver: RepoPreviewReceiver,
|
||||
fingerprint: String?,
|
||||
) {
|
||||
// download and verify entry
|
||||
val entryFile = tempFileProvider.createTempFile()
|
||||
val entryDownloader = downloaderFactory.create(
|
||||
repo = repo,
|
||||
uri = uri.buildUpon().appendPath(SIGNED_FILE_NAME).build(),
|
||||
indexFile = FileV2.fromPath("/$SIGNED_FILE_NAME"),
|
||||
destFile = entryFile,
|
||||
)
|
||||
val (cert, entry) = try {
|
||||
entryDownloader.download()
|
||||
val verifier = EntryVerifier(entryFile, null, fingerprint)
|
||||
verifier.getStreamAndVerify { inputStream ->
|
||||
IndexParser.parseEntry(inputStream)
|
||||
}
|
||||
} finally {
|
||||
entryFile.delete()
|
||||
}
|
||||
|
||||
log.info { "Downloaded entry, now streaming index..." }
|
||||
|
||||
// stream index
|
||||
val indexRequest = DownloadRequest(
|
||||
indexFile = FileV2.fromPath(entry.index.name.trimStart('/')),
|
||||
mirrors = repo.getMirrors(),
|
||||
proxy = proxy,
|
||||
username = repo.username,
|
||||
password = repo.password,
|
||||
)
|
||||
val streamReceiver = RepoV2StreamReceiver(receiver, repo.username, repo.password)
|
||||
val streamProcessor = IndexV2FullStreamProcessor(streamReceiver, cert)
|
||||
val digestInputStream = httpManager.getDigestInputStream(indexRequest)
|
||||
digestInputStream.use { inputStream ->
|
||||
streamProcessor.process(entry.version, inputStream) { }
|
||||
}
|
||||
val hexDigest = digestInputStream.getDigestHex()
|
||||
if (!hexDigest.equals(entry.index.sha256, ignoreCase = true)) {
|
||||
throw SigningException("Invalid ${entry.index.name} hash: $hexDigest")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package org.fdroid.repo
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.core.os.ConfigurationCompat.getLocales
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import org.fdroid.LocaleChooser.getBestLocale
|
||||
import org.fdroid.database.AppOverviewItem
|
||||
import org.fdroid.database.LocalizedIcon
|
||||
import org.fdroid.database.Repository
|
||||
import org.fdroid.database.RepositoryPreferences
|
||||
import org.fdroid.database.toCoreRepository
|
||||
import org.fdroid.database.toRepoAntiFeatures
|
||||
import org.fdroid.database.toRepoCategories
|
||||
import org.fdroid.database.toRepoReleaseChannel
|
||||
import org.fdroid.index.IndexFormatVersion
|
||||
import org.fdroid.index.v2.IndexV2StreamReceiver
|
||||
import org.fdroid.index.v2.PackageV2
|
||||
import org.fdroid.index.v2.RepoV2
|
||||
|
||||
internal open class RepoV2StreamReceiver(
|
||||
private val receiver: RepoPreviewReceiver,
|
||||
private val username: String?,
|
||||
private val password: String?,
|
||||
) : IndexV2StreamReceiver {
|
||||
|
||||
companion object {
|
||||
fun getRepository(
|
||||
repo: RepoV2,
|
||||
version: Long,
|
||||
formatVersion: IndexFormatVersion,
|
||||
certificate: String?,
|
||||
username: String?,
|
||||
password: String?,
|
||||
) = Repository(
|
||||
repository = repo.toCoreRepository(
|
||||
version = version,
|
||||
formatVersion = formatVersion,
|
||||
certificate = certificate
|
||||
),
|
||||
mirrors = emptyList(),
|
||||
antiFeatures = repo.antiFeatures.toRepoAntiFeatures(REPO_ID),
|
||||
categories = repo.categories.toRepoCategories(REPO_ID),
|
||||
releaseChannels = repo.releaseChannels.toRepoReleaseChannel(REPO_ID),
|
||||
preferences = RepositoryPreferences(
|
||||
repoId = REPO_ID,
|
||||
weight = 0,
|
||||
enabled = true,
|
||||
username = username,
|
||||
password = password,
|
||||
),
|
||||
)
|
||||
|
||||
fun getAppOverViewItem(
|
||||
packageName: String,
|
||||
p: PackageV2,
|
||||
locales: LocaleListCompat,
|
||||
) = AppOverviewItem(
|
||||
repoId = REPO_ID,
|
||||
packageName = packageName,
|
||||
added = p.metadata.added,
|
||||
lastUpdated = p.metadata.lastUpdated,
|
||||
name = p.metadata.name.getBestLocale(locales),
|
||||
summary = p.metadata.summary.getBestLocale(locales),
|
||||
antiFeatures = p.versions.values.lastOrNull()?.antiFeatures,
|
||||
localizedIcon = p.metadata.icon?.map { (locale, file) ->
|
||||
LocalizedIcon(
|
||||
repoId = 0L,
|
||||
packageName = packageName,
|
||||
type = "icon",
|
||||
locale = locale,
|
||||
name = file.name,
|
||||
sha256 = file.sha256,
|
||||
size = file.size,
|
||||
ipfsCidV1 = file.ipfsCidV1,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private val locales: LocaleListCompat = getLocales(Resources.getSystem().configuration)
|
||||
|
||||
override fun receive(repo: RepoV2, version: Long, certificate: String) {
|
||||
receiver.onRepoReceived(
|
||||
getRepository(
|
||||
repo = repo,
|
||||
version = version,
|
||||
formatVersion = IndexFormatVersion.TWO,
|
||||
certificate = certificate,
|
||||
username = username,
|
||||
password = password,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun receive(packageName: String, p: PackageV2) {
|
||||
receiver.onAppReceived(getAppOverViewItem(packageName, p, locales))
|
||||
}
|
||||
|
||||
override fun onStreamEnded() {
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user