From a830f1ef869c2dfeb90154aed9b7c58df009ba6e Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 18 May 2022 11:46:20 -0300 Subject: [PATCH 01/42] [download] Add new download method for v2 index that receives total file size and ensures that the downloaded file has the provided sha256 hash --- .../fdroid/net/BluetoothDownloader.java | 8 +++ .../fdroid/net/LocalFileDownloader.java | 8 +++ .../fdroid/fdroid/net/TreeUriDownloader.java | 10 +++- .../kotlin/org/fdroid/download/Downloader.kt | 53 +++++++++++++++++-- .../org/fdroid/download/HttpDownloader.kt | 38 +++++++------ .../kotlin/org/fdroid/fdroid/HashUtils.kt | 13 +++++ .../org/fdroid/download/HttpDownloaderTest.kt | 36 ++++++++++++- .../kotlin/org/fdroid/download/HttpManager.kt | 8 +-- .../kotlin/org/fdroid/download/Mirror.kt | 4 +- .../org/fdroid/download/MirrorChooser.kt | 3 ++ .../org/fdroid/fdroid/ProgressListener.kt | 2 +- .../org/fdroid/download/HttpManagerTest.kt | 27 +++++++++- 12 files changed, 181 insertions(+), 29 deletions(-) create mode 100644 download/src/androidMain/kotlin/org/fdroid/fdroid/HashUtils.kt diff --git a/app/src/main/java/org/fdroid/fdroid/net/BluetoothDownloader.java b/app/src/main/java/org/fdroid/fdroid/net/BluetoothDownloader.java index 968a3f976..511dadc75 100644 --- a/app/src/main/java/org/fdroid/fdroid/net/BluetoothDownloader.java +++ b/app/src/main/java/org/fdroid/fdroid/net/BluetoothDownloader.java @@ -91,10 +91,18 @@ public class BluetoothDownloader extends Downloader { @Override public long totalDownloadSize() { + if (getFileSize() != null) return getFileSize(); FileDetails details = getFileDetails(); return details != null ? details.getFileSize() : -1; } + @Override + public void download(long totalSize, @Nullable String sha256) throws IOException, InterruptedException { + setFileSize(totalSize); + setSha256(sha256); + download(); + } + @Override public void download() throws IOException, InterruptedException { downloadFromStream(false); diff --git a/app/src/main/java/org/fdroid/fdroid/net/LocalFileDownloader.java b/app/src/main/java/org/fdroid/fdroid/net/LocalFileDownloader.java index 44c3bc7da..351dcf275 100644 --- a/app/src/main/java/org/fdroid/fdroid/net/LocalFileDownloader.java +++ b/app/src/main/java/org/fdroid/fdroid/net/LocalFileDownloader.java @@ -3,6 +3,7 @@ package org.fdroid.fdroid.net; import android.net.Uri; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.apache.commons.io.FileUtils; import org.apache.commons.io.IOUtils; @@ -69,6 +70,13 @@ public class LocalFileDownloader extends Downloader { return sourceFile.length(); } + @Override + public void download(long totalSize, @Nullable String sha256) throws IOException, InterruptedException { + setFileSize(totalSize); + setSha256(sha256); + download(); + } + @Override public void download() throws IOException, InterruptedException { if (!sourceFile.exists()) { diff --git a/app/src/main/java/org/fdroid/fdroid/net/TreeUriDownloader.java b/app/src/main/java/org/fdroid/fdroid/net/TreeUriDownloader.java index f1f0bc70f..a8b417a47 100644 --- a/app/src/main/java/org/fdroid/fdroid/net/TreeUriDownloader.java +++ b/app/src/main/java/org/fdroid/fdroid/net/TreeUriDownloader.java @@ -15,6 +15,7 @@ import java.io.InputStream; import java.net.ProtocolException; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.documentfile.provider.DocumentFile; /** @@ -94,7 +95,14 @@ public class TreeUriDownloader extends Downloader { @Override protected long totalDownloadSize() { - return documentFile.length(); // TODO how should this actually be implemented? + return getFileSize() != null ? getFileSize() : documentFile.length(); + } + + @Override + public void download(long totalSize, @Nullable String sha256) throws IOException, InterruptedException { + setFileSize(totalSize); + setSha256(sha256); + downloadFromStream(false); } @Override diff --git a/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt b/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt index 555335307..332ff86f1 100644 --- a/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt +++ b/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt @@ -2,11 +2,13 @@ package org.fdroid.download import mu.KotlinLogging import org.fdroid.fdroid.ProgressListener +import org.fdroid.fdroid.isMatching import java.io.File import java.io.FileOutputStream import java.io.IOException import java.io.InputStream import java.io.OutputStream +import java.security.MessageDigest public abstract class Downloader constructor( @JvmField @@ -17,6 +19,13 @@ public abstract class Downloader constructor( private val log = KotlinLogging.logger {} } + protected var fileSize: Long? = null + + /** + * If not null, this is the expected sha256 hash of the [outputFile] after download. + */ + protected var sha256: String? = null + /** * If you ask for the cacheTag before calling download(), you will get the * same one you passed in (if any). If you call it after download(), you @@ -25,6 +34,7 @@ public abstract class Downloader constructor( * If this cacheTag matches that returned by the server, then no download will * take place, and a status code of 304 will be returned by download(). */ + @Deprecated("Used only for v1 repos") public var cacheTag: String? = null @Volatile @@ -36,13 +46,24 @@ public abstract class Downloader constructor( /** * Call this to start the download. * Never call this more than once. Create a new [Downloader], if you need to download again! + * + * @totalSize must be set to what the index tells us the size will be + * @sha256 must be set to the sha256 hash from the index and only be null for `entry.jar`. */ @Throws(IOException::class, InterruptedException::class) + public abstract fun download(totalSize: Long, sha256: String? = null) + + /** + * Call this to start the download. + * Never call this more than once. Create a new [Downloader], if you need to download again! + */ + @Deprecated("Use only for v1 repos") + @Throws(IOException::class, InterruptedException::class) public abstract fun download() @Throws(IOException::class) protected abstract fun getInputStream(resumable: Boolean): InputStream - protected open suspend fun getBytes(resumable: Boolean, receiver: (ByteArray) -> Unit) { + protected open suspend fun getBytes(resumable: Boolean, receiver: BytesReceiver) { throw NotImplementedError() } @@ -57,6 +78,7 @@ public abstract class Downloader constructor( * After calling [download], this returns true if a new file was downloaded and * false if the file on the server has not changed and thus was not downloaded. */ + @Deprecated("Only for v1 repos") public abstract fun hasChanged(): Boolean public abstract fun close() @@ -88,17 +110,28 @@ public abstract class Downloader constructor( @Throws(InterruptedException::class, IOException::class, NoResumeException::class) protected suspend fun downloadFromBytesReceiver(isResume: Boolean) { try { + val messageDigest: MessageDigest? = if (sha256 == null) null else { + MessageDigest.getInstance("SHA-256") + } FileOutputStream(outputFile, isResume).use { outputStream -> var bytesCopied = outputFile.length() var lastTimeReported = 0L val bytesTotal = totalDownloadSize() - getBytes(isResume) { bytes -> + getBytes(isResume) { bytes, numTotalBytes -> // Getting the input stream is slow(ish) for HTTP downloads, so we'll check if // we were interrupted before proceeding to the download. throwExceptionIfInterrupted() outputStream.write(bytes) + messageDigest?.update(bytes) bytesCopied += bytes.size - lastTimeReported = reportProgress(lastTimeReported, bytesCopied, bytesTotal) + val total = if (bytesTotal == -1L) numTotalBytes ?: -1L else bytesTotal + lastTimeReported = reportProgress(lastTimeReported, bytesCopied, total) + } + // check if expected sha256 hash matches + sha256?.let { expectedHash -> + if (!messageDigest.isMatching(expectedHash)) { + throw IOException("Hash not matching") + } } // force progress reporting at the end reportProgress(0L, bytesCopied, bytesTotal) @@ -119,6 +152,9 @@ public abstract class Downloader constructor( */ @Throws(IOException::class, InterruptedException::class) private fun copyInputToOutputStream(input: InputStream, output: OutputStream) { + val messageDigest: MessageDigest? = if (sha256 == null) null else { + MessageDigest.getInstance("SHA-256") + } try { var bytesCopied = outputFile.length() var lastTimeReported = 0L @@ -128,10 +164,17 @@ public abstract class Downloader constructor( while (numBytes >= 0) { throwExceptionIfInterrupted() output.write(buffer, 0, numBytes) + messageDigest?.update(buffer, 0, numBytes) bytesCopied += numBytes lastTimeReported = reportProgress(lastTimeReported, bytesCopied, bytesTotal) numBytes = input.read(buffer) } + // check if expected sha256 hash matches + sha256?.let { expectedHash -> + if (!messageDigest.isMatching(expectedHash)) { + throw IOException("Hash not matching") + } + } // force progress reporting at the end reportProgress(0L, bytesCopied, bytesTotal) } finally { @@ -176,3 +219,7 @@ public abstract class Downloader constructor( } } + +public fun interface BytesReceiver { + public suspend fun receive(bytes: ByteArray, numTotalBytes: Long?) +} diff --git a/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloader.kt b/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloader.kt index 68621f2cc..bfcaef7e0 100644 --- a/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloader.kt +++ b/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloader.kt @@ -21,10 +21,8 @@ */ package org.fdroid.download -import android.annotation.TargetApi -import android.os.Build.VERSION.SDK_INT import io.ktor.client.plugins.ResponseException -import kotlinx.coroutines.DelicateCoroutinesApi +import io.ktor.http.HttpStatusCode.Companion.NotFound import kotlinx.coroutines.runBlocking import mu.KotlinLogging import java.io.File @@ -45,23 +43,30 @@ public class HttpDownloader constructor( val log = KotlinLogging.logger {} } + @Deprecated("Only for v1 repos") private var hasChanged = false - private var fileSize = -1L override fun getInputStream(resumable: Boolean): InputStream { throw NotImplementedError("Use getInputStreamSuspend instead.") } @Throws(IOException::class, NoResumeException::class) - override suspend fun getBytes(resumable: Boolean, receiver: (ByteArray) -> Unit) { + protected override suspend fun getBytes(resumable: Boolean, receiver: BytesReceiver) { val skipBytes = if (resumable) outputFile.length() else null return try { httpManager.get(request, skipBytes, receiver) } catch (e: ResponseException) { - throw IOException(e) + if (e.response.status == NotFound) throw NotFoundException(e) + else throw IOException(e) } } + public override fun download(totalSize: Long, sha256: String?) { + this.fileSize = totalSize + this.sha256 = sha256 + downloadToFile() + } + /** * Get a remote file, checking the HTTP response code, if it has changed since * the last time a download was tried. @@ -98,9 +103,9 @@ public class HttpDownloader constructor( * * @see [Cookieless cookies](http://lucb1e.com/rp/cookielesscookies) */ - @OptIn(DelicateCoroutinesApi::class) + @Suppress("DEPRECATION") @Throws(IOException::class, InterruptedException::class) - override fun download() { + public override fun download() { val headInfo = runBlocking { httpManager.head(request, cacheTag) ?: throw IOException() } @@ -137,9 +142,13 @@ public class HttpDownloader constructor( } hasChanged = true + downloadToFile() + } + + private fun downloadToFile() { var resumable = false val fileLength = outputFile.length() - if (fileLength > fileSize) { + if (fileLength > fileSize ?: -1) { if (!outputFile.delete()) log.warn { "Warning: " + outputFile.absolutePath + " not deleted" } @@ -163,15 +172,10 @@ public class HttpDownloader constructor( } } - @TargetApi(24) - public override fun totalDownloadSize(): Long { - return if (SDK_INT < 24) { - fileSize.toInt().toLong() // TODO why? - } else { - fileSize - } - } + protected override fun totalDownloadSize(): Long = fileSize ?: -1L + @Suppress("DEPRECATION") + @Deprecated("Only for v1 repos") override fun hasChanged(): Boolean { return hasChanged } diff --git a/download/src/androidMain/kotlin/org/fdroid/fdroid/HashUtils.kt b/download/src/androidMain/kotlin/org/fdroid/fdroid/HashUtils.kt new file mode 100644 index 000000000..64e421b35 --- /dev/null +++ b/download/src/androidMain/kotlin/org/fdroid/fdroid/HashUtils.kt @@ -0,0 +1,13 @@ +package org.fdroid.fdroid + +import java.security.MessageDigest + +internal fun MessageDigest?.isMatching(sha256: String): Boolean { + if (this == null) return false + val hexDigest = digest().toHex() + return hexDigest.equals(sha256, ignoreCase = true) +} + +internal fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> + "%02x".format(eachByte) +} diff --git a/download/src/androidTest/kotlin/org/fdroid/download/HttpDownloaderTest.kt b/download/src/androidTest/kotlin/org/fdroid/download/HttpDownloaderTest.kt index bca53c4c8..ab4b65ceb 100644 --- a/download/src/androidTest/kotlin/org/fdroid/download/HttpDownloaderTest.kt +++ b/download/src/androidTest/kotlin/org/fdroid/download/HttpDownloaderTest.kt @@ -19,6 +19,7 @@ import org.fdroid.runSuspend import org.junit.Assume.assumeTrue import org.junit.Rule import org.junit.rules.TemporaryFolder +import java.io.IOException import java.net.BindException import java.net.ServerSocket import kotlin.random.Random @@ -31,7 +32,7 @@ import kotlin.test.fail private const val TOR_SOCKS_PORT = 9050 -@Suppress("BlockingMethodInNonBlockingContext") +@Suppress("BlockingMethodInNonBlockingContext", "DEPRECATION") internal class HttpDownloaderTest { @get:Rule @@ -55,6 +56,39 @@ internal class HttpDownloaderTest { assertContentEquals(bytes, file.readBytes()) } + @Test + fun testDownloadWithCorrectHash() = runSuspend { + val file = folder.newFile() + val bytes = "We know the hash for this string".encodeToByteArray() + var progressReported = false + + val mockEngine = MockEngine { respond(bytes) } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + val httpDownloader = HttpDownloader(httpManager, downloadRequest, file) + httpDownloader.setListener { _, totalBytes -> + assertEquals(bytes.size.toLong(), totalBytes) + progressReported = true + } + httpDownloader.download(bytes.size.toLong(), + "e3802e5f8ae3dc7bbf5f1f4f7fb825d9bce9d1ddce50ac564fcbcfdeb31f1b90") + + assertContentEquals(bytes, file.readBytes()) + assertTrue(progressReported) + } + + @Test(expected = IOException::class) + fun testDownloadWithWrongHash() = runSuspend { + val file = folder.newFile() + val bytes = "We know the hash for this string".encodeToByteArray() + + val mockEngine = MockEngine { respond(bytes) } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + val httpDownloader = HttpDownloader(httpManager, downloadRequest, file) + httpDownloader.download(bytes.size.toLong(), "This is not the right hash") + + assertContentEquals(bytes, file.readBytes()) + } + @Test fun testResumeSuccess() = runSuspend { val file = folder.newFile() diff --git a/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt b/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt index 5d488c3e3..88f3ef6a3 100644 --- a/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt +++ b/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt @@ -119,9 +119,10 @@ public open class HttpManager @JvmOverloads constructor( public suspend fun get( request: DownloadRequest, skipFirstBytes: Long? = null, - receiver: suspend (ByteArray) -> Unit, + receiver: BytesReceiver, ): Unit = mirrorChooser.mirrorRequest(request) { mirror, url -> getHttpStatement(request, mirror, url, skipFirstBytes).execute { response -> + val contentLength = response.contentLength() if (skipFirstBytes != null && response.status != PartialContent) { throw NoResumeException() } @@ -130,7 +131,7 @@ public open class HttpManager @JvmOverloads constructor( while (!channel.isClosedForRead) { val packet = channel.readRemaining(limit) while (!packet.isEmpty) { - receiver(packet.readBytes()) + receiver.receive(packet.readBytes(), contentLength) } } } @@ -179,7 +180,7 @@ public open class HttpManager @JvmOverloads constructor( skipFirstBytes: Long? = null, ): ByteArray { val channel = ByteChannel() - get(request, skipFirstBytes) { bytes -> + get(request, skipFirstBytes) { bytes, _ -> channel.writeFully(bytes) } channel.close() @@ -225,3 +226,4 @@ public open class HttpManager @JvmOverloads constructor( } public class NoResumeException : Exception() +public class NotFoundException(e: Throwable? = null) : Exception(e) diff --git a/download/src/commonMain/kotlin/org/fdroid/download/Mirror.kt b/download/src/commonMain/kotlin/org/fdroid/download/Mirror.kt index 79d03d3ee..4235cf13a 100644 --- a/download/src/commonMain/kotlin/org/fdroid/download/Mirror.kt +++ b/download/src/commonMain/kotlin/org/fdroid/download/Mirror.kt @@ -7,7 +7,7 @@ import io.ktor.http.appendPathSegments import mu.KotlinLogging public data class Mirror @JvmOverloads constructor( - private val baseUrl: String, + val baseUrl: String, val location: String? = null, ) { public val url: Url by lazy { @@ -34,6 +34,8 @@ public data class Mirror @JvmOverloads constructor( public fun isLocal(): Boolean = url.isLocal() + public fun isHttp(): Boolean = url.protocol.name.startsWith("http") + public companion object { @JvmStatic public fun fromStrings(list: List): List = list.map { Mirror(it) } diff --git a/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt b/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt index fad2a438b..18b67b25a 100644 --- a/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt +++ b/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt @@ -2,6 +2,7 @@ package org.fdroid.download import io.ktor.client.plugins.ResponseException import io.ktor.http.HttpStatusCode.Companion.Forbidden +import io.ktor.http.HttpStatusCode.Companion.NotFound import io.ktor.http.Url import io.ktor.utils.io.errors.IOException import mu.KotlinLogging @@ -43,6 +44,8 @@ internal abstract class MirrorChooserImpl : MirrorChooser { } catch (e: ResponseException) { // don't try other mirrors if we got Forbidden response, but supplied credentials if (downloadRequest.hasCredentials && e.response.status == Forbidden) throw e + // don't try other mirrors if we got NotFount response and downloaded a repo + if (downloadRequest.tryFirstMirror != null && e.response.status == NotFound) throw e // also throw if this is the last mirror to try, otherwise try next throwOnLastMirror(e, index == downloadRequest.mirrors.size - 1) } catch (e: IOException) { diff --git a/download/src/commonMain/kotlin/org/fdroid/fdroid/ProgressListener.kt b/download/src/commonMain/kotlin/org/fdroid/fdroid/ProgressListener.kt index 8d988c6c3..b4a47b3b8 100644 --- a/download/src/commonMain/kotlin/org/fdroid/fdroid/ProgressListener.kt +++ b/download/src/commonMain/kotlin/org/fdroid/fdroid/ProgressListener.kt @@ -16,6 +16,6 @@ package org.fdroid.fdroid * * `int`s, i.e. [String.hashCode] * */ -public interface ProgressListener { +public fun interface ProgressListener { public fun onProgress(bytesRead: Long, totalBytes: Long) } diff --git a/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerTest.kt b/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerTest.kt index 38d8adf29..3d64b5435 100644 --- a/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerTest.kt +++ b/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerTest.kt @@ -18,6 +18,7 @@ import io.ktor.http.HttpHeaders.Range import io.ktor.http.HttpHeaders.UserAgent import io.ktor.http.HttpStatusCode.Companion.Forbidden import io.ktor.http.HttpStatusCode.Companion.InternalServerError +import io.ktor.http.HttpStatusCode.Companion.NotFound import io.ktor.http.HttpStatusCode.Companion.OK import io.ktor.http.HttpStatusCode.Companion.PartialContent import io.ktor.http.HttpStatusCode.Companion.TemporaryRedirect @@ -37,7 +38,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.test.fail -class HttpManagerTest { +internal class HttpManagerTest { private val userAgent = getRandomString() private val mirrors = listOf(Mirror("http://example.org"), Mirror("http://example.net/")) @@ -195,7 +196,29 @@ class HttpManagerTest { // assert there is only one request per API call using one of the mirrors assertEquals(2, mockEngine.requestHistory.size) mockEngine.requestHistory.forEach { request -> - println(mockEngine.requestHistory) + val url = request.url.toString() + assertTrue(url == "http://example.org/foo" || url == "http://example.net/foo") + } + } + + @Test + fun testNoMoreMirrorsWhenRepoDownloadNotFound() = runSuspend { + val downloadRequest = downloadRequest.copy(tryFirstMirror = mirrors[0]) + val mockEngine = MockEngine { respond("", NotFound) } + val httpManager = HttpManager(userAgent, null, httpClientEngineFactory = get(mockEngine)) + + assertTrue(downloadRequest.tryFirstMirror != null) + + assertNull(httpManager.head(downloadRequest)) + val e = assertFailsWith { + httpManager.getBytes(downloadRequest) + } + + // assert that the exception reflects the NotFound error + assertEquals(NotFound, e.response.status) + // assert there is only one request per API call using one of the mirrors + assertEquals(2, mockEngine.requestHistory.size) + mockEngine.requestHistory.forEach { request -> val url = request.url.toString() assertTrue(url == "http://example.org/foo" || url == "http://example.net/foo") } From ade37a4d9c6f5096365f107450149f453bda9d03 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 16 Jun 2022 17:41:24 -0300 Subject: [PATCH 02/42] [download] Add docs for special exceptions These are needed, so callers of the downloader can react to special errors. Also signal that getInputStream() can throw NotFoundException for repo version fallback. --- .../androidMain/kotlin/org/fdroid/download/Downloader.kt | 2 +- .../commonMain/kotlin/org/fdroid/download/HttpManager.kt | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt b/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt index 332ff86f1..0a8d165fd 100644 --- a/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt +++ b/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt @@ -61,7 +61,7 @@ public abstract class Downloader constructor( @Throws(IOException::class, InterruptedException::class) public abstract fun download() - @Throws(IOException::class) + @Throws(IOException::class, NotFoundException::class) protected abstract fun getInputStream(resumable: Boolean): InputStream protected open suspend fun getBytes(resumable: Boolean, receiver: BytesReceiver) { throw NotImplementedError() diff --git a/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt b/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt index 88f3ef6a3..60ed5e9df 100644 --- a/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt +++ b/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt @@ -225,5 +225,14 @@ public open class HttpManager @JvmOverloads constructor( } } +/** + * Thrown if we tried to resume a download, but the current mirror server does not offer resuming. + */ public class NoResumeException : Exception() + +/** + * Thrown when a file was not found. + * Catching this is useful when checking if a new index version exists + * and then falling back to an older version. + */ public class NotFoundException(e: Throwable? = null) : Exception(e) From ca6da651ec34424b16a443d9b6a11307e1adc1c6 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 23 Feb 2022 15:38:00 -0300 Subject: [PATCH 03/42] [db] First prototype --- .gitlab-ci.yml | 4 +- database/.gitignore | 1 + database/build.gradle | 57 + database/consumer-rules.pro | 0 database/proguard-rules.pro | 21 + .../1.json | 1005 +++++++++++++++++ .../java/org/fdroid/database/DbTest.kt | 52 + .../org/fdroid/database/RepositoryDiffTest.kt | 263 +++++ .../org/fdroid/database/RepositoryTest.kt | 46 + .../java/org/fdroid/database/TestUtils.kt | 99 ++ database/src/main/AndroidManifest.xml | 5 + .../java/org/fdroid/database/Converters.kt | 22 + .../org/fdroid/database/FDroidDatabase.kt | 41 + .../java/org/fdroid/database/Repository.kt | 151 +++ .../java/org/fdroid/database/RepositoryDao.kt | 202 ++++ .../org/fdroid/database/ReflectionTest.kt | 32 + .../java/org/fdroid/database/TestUtils.kt | 79 ++ gradle/verification-metadata.xml | 123 +- settings.gradle | 3 +- 19 files changed, 2200 insertions(+), 6 deletions(-) create mode 100644 database/.gitignore create mode 100644 database/build.gradle create mode 100644 database/consumer-rules.pro create mode 100644 database/proguard-rules.pro create mode 100644 database/schemas/org.fdroid.database.FDroidDatabaseInt/1.json create mode 100644 database/src/androidTest/java/org/fdroid/database/DbTest.kt create mode 100644 database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt create mode 100644 database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt create mode 100644 database/src/androidTest/java/org/fdroid/database/TestUtils.kt create mode 100644 database/src/main/AndroidManifest.xml create mode 100644 database/src/main/java/org/fdroid/database/Converters.kt create mode 100644 database/src/main/java/org/fdroid/database/FDroidDatabase.kt create mode 100644 database/src/main/java/org/fdroid/database/Repository.kt create mode 100644 database/src/main/java/org/fdroid/database/RepositoryDao.kt create mode 100644 database/src/test/java/org/fdroid/database/ReflectionTest.kt create mode 100644 database/src/test/java/org/fdroid/database/TestUtils.kt diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a72447741..2401db471 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -173,8 +173,8 @@ deploy_nightly: - echo "${CI_PROJECT_PATH}-nightly" >> app/src/main/res/values/default_repos.xml - echo "${CI_PROJECT_URL}-nightly/raw/master/fdroid/repo" >> app/src/main/res/values/default_repos.xml - cat config/nightly-repo/repo.xml >> app/src/main/res/values/default_repos.xml - - export DB=`sed -n 's,.*DB_VERSION *= *\([0-9][0-9]*\).*,\1,p' app/src/main/java/org/fdroid/fdroid/data/DBHelper.java` - - export versionCode=`printf '%d%05d' $DB $(date '+%s'| cut -b4-8)` + - export DB=`sed -n 's,.*version *= *\([0-9][0-9]*\).*,\1,p' database/src/main/java/org/fdroid/database/FDroidDatabase.kt` + - export versionCode=`printf '%d%05d' $DB $(date '+%s'| cut -b1-8)` - sed -i "s,^\(\s*versionCode\) *[0-9].*,\1 $versionCode," app/build.gradle # build the APKs! - ./gradlew assembleDebug diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/database/build.gradle b/database/build.gradle new file mode 100644 index 000000000..c0cec96d8 --- /dev/null +++ b/database/build.gradle @@ -0,0 +1,57 @@ +plugins { + id 'kotlin-android' + id 'com.android.library' + id 'kotlin-kapt' +// id "org.jlleitschuh.gradle.ktlint" version "10.2.1" +} + +android { + compileSdkVersion 30 + + defaultConfig { + minSdkVersion 22 + + consumerProguardFiles "consumer-rules.pro" + javaCompileOptions { + annotationProcessorOptions { + arguments += ["room.schemaLocation": "$projectDir/schemas".toString()] + } + } + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments disableAnalytics: 'true' + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + implementation project(":index") + + def room_version = "2.4.2" + implementation "androidx.room:room-runtime:$room_version" + implementation "androidx.room:room-ktx:$room_version" + kapt "androidx.room:room-compiler:$room_version" + + implementation 'io.github.microutils:kotlin-logging:2.1.21' + implementation "org.slf4j:slf4j-android:1.7.36" + + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2" + + testImplementation 'junit:junit:4.13.1' + testImplementation 'org.jetbrains.kotlin:kotlin-test' + androidTestImplementation 'org.jetbrains.kotlin:kotlin-test' + androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' +} diff --git a/database/consumer-rules.pro b/database/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/database/proguard-rules.pro b/database/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/database/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/database/schemas/org.fdroid.database.FDroidDatabaseInt/1.json b/database/schemas/org.fdroid.database.FDroidDatabaseInt/1.json new file mode 100644 index 000000000..901550bae --- /dev/null +++ b/database/schemas/org.fdroid.database.FDroidDatabaseInt/1.json @@ -0,0 +1,1005 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "bf86c814bcbc98d81f1530ea479c6340", + "entities": [ + { + "tableName": "CoreRepository", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `version` INTEGER, `description` TEXT NOT NULL, `certificate` TEXT, `icon_name` TEXT, `icon_sha256` TEXT, `icon_size` INTEGER)", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "certificate", + "columnName": "certificate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon.name", + "columnName": "icon_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon.sha256", + "columnName": "icon_sha256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon.size", + "columnName": "icon_size", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "repoId" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Mirror", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `url` TEXT NOT NULL, `location` TEXT, PRIMARY KEY(`repoId`, `url`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "repoId", + "url" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "tableName": "AntiFeature", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_name` TEXT, `icon_sha256` TEXT, `icon_size` INTEGER, PRIMARY KEY(`repoId`, `id`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon.name", + "columnName": "icon_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon.sha256", + "columnName": "icon_sha256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon.size", + "columnName": "icon_size", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "repoId", + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "tableName": "Category", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_name` TEXT, `icon_sha256` TEXT, `icon_size` INTEGER, PRIMARY KEY(`repoId`, `id`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon.name", + "columnName": "icon_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon.sha256", + "columnName": "icon_sha256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon.size", + "columnName": "icon_size", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "repoId", + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "tableName": "ReleaseChannel", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_name` TEXT, `icon_sha256` TEXT, `icon_size` INTEGER, PRIMARY KEY(`repoId`, `id`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon.name", + "columnName": "icon_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon.sha256", + "columnName": "icon_sha256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon.size", + "columnName": "icon_size", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "repoId", + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "tableName": "RepositoryPreferences", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `lastUpdated` INTEGER, `lastETag` TEXT, `userMirrors` TEXT, `disabledMirrors` TEXT, `username` TEXT, `password` TEXT, `isSwap` INTEGER NOT NULL, PRIMARY KEY(`repoId`))", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "weight", + "columnName": "weight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enabled", + "columnName": "enabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastETag", + "columnName": "lastETag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "userMirrors", + "columnName": "userMirrors", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "disabledMirrors", + "columnName": "disabledMirrors", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSwap", + "columnName": "isSwap", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "repoId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AppMetadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageId` TEXT NOT NULL, `added` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `name` TEXT, `summary` TEXT, `description` TEXT, `localizedName` TEXT, `localizedSummary` TEXT, `webSite` TEXT, `changelog` TEXT, `license` TEXT, `sourceCode` TEXT, `issueTracker` TEXT, `translation` TEXT, `preferredSigner` TEXT, `video` TEXT, `categories` TEXT, `isCompatible` INTEGER NOT NULL, `author_name` TEXT, `author_email` TEXT, `author_website` TEXT, `author_phone` TEXT, `donation_url` TEXT, `donation_liberapayID` TEXT, `donation_liberapay` TEXT, `donation_openCollective` TEXT, `donation_bitcoin` TEXT, `donation_litecoin` TEXT, `donation_flattrID` TEXT, PRIMARY KEY(`repoId`, `packageId`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageId", + "columnName": "packageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "added", + "columnName": "added", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "summary", + "columnName": "summary", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localizedName", + "columnName": "localizedName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "localizedSummary", + "columnName": "localizedSummary", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "webSite", + "columnName": "webSite", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "changelog", + "columnName": "changelog", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "license", + "columnName": "license", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sourceCode", + "columnName": "sourceCode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "issueTracker", + "columnName": "issueTracker", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "translation", + "columnName": "translation", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "preferredSigner", + "columnName": "preferredSigner", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "video", + "columnName": "video", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "categories", + "columnName": "categories", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isCompatible", + "columnName": "isCompatible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "author.name", + "columnName": "author_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "author.email", + "columnName": "author_email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "author.website", + "columnName": "author_website", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "author.phone", + "columnName": "author_phone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "donation.url", + "columnName": "donation_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "donation.liberapayID", + "columnName": "donation_liberapayID", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "donation.liberapay", + "columnName": "donation_liberapay", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "donation.openCollective", + "columnName": "donation_openCollective", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "donation.bitcoin", + "columnName": "donation_bitcoin", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "donation.litecoin", + "columnName": "donation_litecoin", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "donation.flattrID", + "columnName": "donation_flattrID", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "repoId", + "packageId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "CoreRepository", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId" + ], + "referencedColumns": [ + "repoId" + ] + } + ] + }, + { + "tableName": "LocalizedFile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageId` TEXT NOT NULL, `type` TEXT NOT NULL, `locale` TEXT NOT NULL, `name` TEXT NOT NULL, `sha256` TEXT, `size` INTEGER, PRIMARY KEY(`repoId`, `packageId`, `type`, `locale`), FOREIGN KEY(`repoId`, `packageId`) REFERENCES `AppMetadata`(`repoId`, `packageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageId", + "columnName": "packageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sha256", + "columnName": "sha256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "repoId", + "packageId", + "type", + "locale" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "AppMetadata", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId", + "packageId" + ], + "referencedColumns": [ + "repoId", + "packageId" + ] + } + ] + }, + { + "tableName": "LocalizedFileList", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageId` TEXT NOT NULL, `type` TEXT NOT NULL, `locale` TEXT NOT NULL, `name` TEXT NOT NULL, `sha256` TEXT, `size` INTEGER, PRIMARY KEY(`repoId`, `packageId`, `type`, `locale`, `name`), FOREIGN KEY(`repoId`, `packageId`) REFERENCES `AppMetadata`(`repoId`, `packageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageId", + "columnName": "packageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "locale", + "columnName": "locale", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sha256", + "columnName": "sha256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "size", + "columnName": "size", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "repoId", + "packageId", + "type", + "locale", + "name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "AppMetadata", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId", + "packageId" + ], + "referencedColumns": [ + "repoId", + "packageId" + ] + } + ] + }, + { + "tableName": "Version", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageId` TEXT NOT NULL, `versionId` TEXT NOT NULL, `added` INTEGER NOT NULL, `releaseChannels` TEXT, `antiFeatures` TEXT, `whatsNew` TEXT, `isCompatible` INTEGER NOT NULL, `file_name` TEXT NOT NULL, `file_sha256` TEXT NOT NULL, `file_size` INTEGER, `src_name` TEXT, `src_sha256` TEXT, `src_size` INTEGER, `manifest_versionName` TEXT NOT NULL, `manifest_versionCode` INTEGER NOT NULL, `manifest_maxSdkVersion` INTEGER, `manifest_nativecode` TEXT, `manifest_features` TEXT, `manifest_usesSdk_minSdkVersion` INTEGER, `manifest_usesSdk_targetSdkVersion` INTEGER, `manifest_signer_sha256` TEXT, PRIMARY KEY(`repoId`, `packageId`, `versionId`), FOREIGN KEY(`repoId`, `packageId`) REFERENCES `AppMetadata`(`repoId`, `packageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageId", + "columnName": "packageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionId", + "columnName": "versionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "added", + "columnName": "added", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseChannels", + "columnName": "releaseChannels", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "antiFeatures", + "columnName": "antiFeatures", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "whatsNew", + "columnName": "whatsNew", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isCompatible", + "columnName": "isCompatible", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "file.name", + "columnName": "file_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "file.sha256", + "columnName": "file_sha256", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "file.size", + "columnName": "file_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "src.name", + "columnName": "src_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "src.sha256", + "columnName": "src_sha256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "src.size", + "columnName": "src_size", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "manifest.versionName", + "columnName": "manifest_versionName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "manifest.versionCode", + "columnName": "manifest_versionCode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "manifest.maxSdkVersion", + "columnName": "manifest_maxSdkVersion", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "manifest.nativecode", + "columnName": "manifest_nativecode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "manifest.features", + "columnName": "manifest_features", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "manifest.usesSdk.minSdkVersion", + "columnName": "manifest_usesSdk_minSdkVersion", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "manifest.usesSdk.targetSdkVersion", + "columnName": "manifest_usesSdk_targetSdkVersion", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "manifest.signer.sha256", + "columnName": "manifest_signer_sha256", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "repoId", + "packageId", + "versionId" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "AppMetadata", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId", + "packageId" + ], + "referencedColumns": [ + "repoId", + "packageId" + ] + } + ] + }, + { + "tableName": "VersionedString", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageId` TEXT NOT NULL, `versionId` TEXT NOT NULL, `type` TEXT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER, PRIMARY KEY(`repoId`, `packageId`, `versionId`, `type`, `name`), FOREIGN KEY(`repoId`, `packageId`, `versionId`) REFERENCES `Version`(`repoId`, `packageId`, `versionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageId", + "columnName": "packageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "versionId", + "columnName": "versionId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "repoId", + "packageId", + "versionId", + "type", + "name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "Version", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "repoId", + "packageId", + "versionId" + ], + "referencedColumns": [ + "repoId", + "packageId", + "versionId" + ] + } + ] + } + ], + "views": [ + { + "viewName": "LocalizedIcon", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM LocalizedFile\n JOIN RepositoryPreferences AS prefs USING (repoId)\n WHERE type='icon' GROUP BY repoId, packageId, locale HAVING MAX(prefs.weight)" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bf86c814bcbc98d81f1530ea479c6340')" + ] + } +} \ No newline at end of file diff --git a/database/src/androidTest/java/org/fdroid/database/DbTest.kt b/database/src/androidTest/java/org/fdroid/database/DbTest.kt new file mode 100644 index 000000000..fe206fa7c --- /dev/null +++ b/database/src/androidTest/java/org/fdroid/database/DbTest.kt @@ -0,0 +1,52 @@ +package org.fdroid.database + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fdroid.index.v2.RepoV2 +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.runner.RunWith +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +abstract class DbTest { + + internal lateinit var repoDao: RepositoryDaoInt + private lateinit var db: FDroidDatabase + + @Before + fun createDb() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, FDroidDatabase::class.java).build() + repoDao = db.getRepositoryDaoInt() + } + + @After + @Throws(IOException::class) + fun closeDb() { + db.close() + } + + protected fun assertRepoEquals(repoV2: RepoV2, repo: Repository) { + val repoId = repo.repository.repoId + // mirrors + val expectedMirrors = repoV2.mirrors.map { it.toMirror(repoId) }.toSet() + Assert.assertEquals(expectedMirrors, repo.mirrors.toSet()) + // anti-features + val expectedAntiFeatures = repoV2.antiFeatures.toRepoAntiFeatures(repoId).toSet() + Assert.assertEquals(expectedAntiFeatures, repo.antiFeatures.toSet()) + // categories + val expectedCategories = repoV2.categories.toRepoCategories(repoId).toSet() + Assert.assertEquals(expectedCategories, repo.categories.toSet()) + // release channels + val expectedReleaseChannels = repoV2.releaseChannels.toRepoReleaseChannel(repoId).toSet() + Assert.assertEquals(expectedReleaseChannels, repo.releaseChannels.toSet()) + // core repo + val coreRepo = repoV2.toCoreRepository().copy(repoId = repoId) + Assert.assertEquals(coreRepo, repo.repository) + } + +} diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt new file mode 100644 index 000000000..4aa474541 --- /dev/null +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt @@ -0,0 +1,263 @@ +package org.fdroid.database + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.fdroid.database.TestUtils.applyDiff +import org.fdroid.database.TestUtils.getRandomFileV2 +import org.fdroid.database.TestUtils.getRandomLocalizedTextV2 +import org.fdroid.database.TestUtils.getRandomMap +import org.fdroid.database.TestUtils.getRandomMirror +import org.fdroid.database.TestUtils.getRandomRepo +import org.fdroid.database.TestUtils.getRandomString +import org.fdroid.database.TestUtils.randomDiff +import org.fdroid.index.v2.AntiFeatureV2 +import org.fdroid.index.v2.CategoryV2 +import org.fdroid.index.v2.ReleaseChannelV2 +import org.fdroid.index.v2.RepoV2 +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.random.Random +import kotlin.test.assertEquals + +/** + * Tests that repository diffs get applied to the database correctly. + */ +@RunWith(AndroidJUnit4::class) +class RepositoryDiffTest : DbTest() { + + private val j = Json + + @Test + fun timestampDiff() { + val repo = getRandomRepo() + val updateTimestamp = repo.timestamp + 1 + val json = """ + { + "timestamp": $updateTimestamp + } + """.trimIndent() + testDiff(repo, json) { repos -> + assertEquals(updateTimestamp, repos[0].repository.timestamp) + assertRepoEquals(repo.copy(timestamp = updateTimestamp), repos[0]) + } + } + + @Test + fun timestampDiffTwoReposInDb() { + // insert repo + val repo = getRandomRepo() + repoDao.insert(repo) + + // insert another repo before updating + repoDao.insert(getRandomRepo()) + + // check that the repo got added and retrieved as expected + var repos = repoDao.getRepositories().sortedBy { it.repository.repoId } + assertEquals(2, repos.size) + val repoId = repos[0].repository.repoId + + val updateTimestamp = Random.nextLong() + val json = """ + { + "timestamp": $updateTimestamp + } + """.trimIndent() + + // decode diff from JSON and update DB with it + val diff = j.parseToJsonElement(json).jsonObject // Json.decodeFromString(json) + repoDao.updateRepository(repoId, diff) + + // fetch repos again and check that the result is as expected + repos = repoDao.getRepositories().sortedBy { it.repository.repoId } + assertEquals(2, repos.size) + assertEquals(repoId, repos[0].repository.repoId) + assertEquals(updateTimestamp, repos[0].repository.timestamp) + assertRepoEquals(repo.copy(timestamp = updateTimestamp), repos[0]) + } + + @Test + fun iconDiff() { + val repo = getRandomRepo() + val updateIcon = getRandomFileV2() + val json = """ + { + "icon": ${Json.encodeToString(updateIcon)} + } + """.trimIndent() + testDiff(repo, json) { repos -> + assertEquals(updateIcon, repos[0].repository.icon) + assertRepoEquals(repo.copy(icon = updateIcon), repos[0]) + } + } + + @Test + fun iconPartialDiff() { + val repo = getRandomRepo() + val updateIcon = repo.icon!!.copy(name = getRandomString()) + val json = """ + { + "icon": { "name": "${updateIcon.name}" } + } + """.trimIndent() + testDiff(repo, json) { repos -> + assertEquals(updateIcon, repos[0].repository.icon) + assertRepoEquals(repo.copy(icon = updateIcon), repos[0]) + } + } + + @Test + fun iconRemoval() { + val repo = getRandomRepo() + val json = """ + { + "icon": null + } + """.trimIndent() + testDiff(repo, json) { repos -> + assertEquals(null, repos[0].repository.icon) + assertRepoEquals(repo.copy(icon = null), repos[0]) + } + } + + @Test + fun mirrorDiff() { + val repo = getRandomRepo() + val updateMirrors = repo.mirrors.toMutableList().apply { + removeLastOrNull() + add(getRandomMirror()) + add(getRandomMirror()) + } + val json = """ + { + "mirrors": ${Json.encodeToString(updateMirrors)} + } + """.trimIndent() + testDiff(repo, json) { repos -> + val expectedMirrors = updateMirrors.map { mirror -> + mirror.toMirror(repos[0].repository.repoId) + }.toSet() + assertEquals(expectedMirrors, repos[0].mirrors.toSet()) + assertRepoEquals(repo.copy(mirrors = updateMirrors), repos[0]) + } + } + + @Test + fun descriptionDiff() { + val repo = getRandomRepo().copy(description = mapOf("de" to "foo", "en" to "bar")) + val updateText = if (Random.nextBoolean()) mapOf("de" to null, "en" to "foo") else null + val json = """ + { + "description": ${Json.encodeToString(updateText)} + } + """.trimIndent() + val expectedText = if (updateText == null) emptyMap() else mapOf("en" to "foo") + testDiff(repo, json) { repos -> + assertEquals(expectedText, repos[0].repository.description) + assertRepoEquals(repo.copy(description = expectedText), repos[0]) + } + } + + @Test + fun antiFeaturesDiff() { + val repo = getRandomRepo().copy(antiFeatures = getRandomMap { + getRandomString() to AntiFeatureV2(getRandomFileV2(), getRandomLocalizedTextV2()) + }) + val antiFeatures = repo.antiFeatures.randomDiff { + AntiFeatureV2(getRandomFileV2(), getRandomLocalizedTextV2()) + } + val json = """ + { + "antiFeatures": ${Json.encodeToString(antiFeatures)} + } + """.trimIndent() + testDiff(repo, json) { repos -> + val expectedFeatures = repo.antiFeatures.applyDiff(antiFeatures) + val expectedRepoAntiFeatures = + expectedFeatures.toRepoAntiFeatures(repos[0].repository.repoId) + assertEquals(expectedRepoAntiFeatures.toSet(), repos[0].antiFeatures.toSet()) + assertRepoEquals(repo.copy(antiFeatures = expectedFeatures), repos[0]) + } + } + + @Test + fun antiFeatureKeyChangeDiff() { + // TODO test with changing keys + } + + @Test + fun categoriesDiff() { + val repo = getRandomRepo().copy(categories = getRandomMap { + getRandomString() to CategoryV2(getRandomFileV2(), getRandomLocalizedTextV2()) + }) + val categories = repo.categories.randomDiff { + CategoryV2(getRandomFileV2(), getRandomLocalizedTextV2()) + } + val json = """ + { + "categories": ${Json.encodeToString(categories)} + } + """.trimIndent() + testDiff(repo, json) { repos -> + val expectedFeatures = repo.categories.applyDiff(categories) + val expectedRepoCategories = + expectedFeatures.toRepoCategories(repos[0].repository.repoId) + assertEquals(expectedRepoCategories.toSet(), repos[0].categories.toSet()) + assertRepoEquals(repo.copy(categories = expectedFeatures), repos[0]) + } + } + + @Test + fun categoriesKeyChangeDiff() { + // TODO test with changing keys + } + + @Test + fun releaseChannelsDiff() { + val repo = getRandomRepo().copy(releaseChannels = getRandomMap { + getRandomString() to ReleaseChannelV2(getRandomLocalizedTextV2()) + }) + val releaseChannels = repo.releaseChannels.randomDiff { + ReleaseChannelV2(getRandomLocalizedTextV2()) + } + val json = """ + { + "releaseChannels": ${Json.encodeToString(releaseChannels)} + } + """.trimIndent() + testDiff(repo, json) { repos -> + val expectedFeatures = repo.releaseChannels.applyDiff(releaseChannels) + val expectedRepoReleaseChannels = + expectedFeatures.toRepoReleaseChannel(repos[0].repository.repoId) + assertEquals(expectedRepoReleaseChannels.toSet(), repos[0].releaseChannels.toSet()) + assertRepoEquals(repo.copy(releaseChannels = expectedFeatures), repos[0]) + } + } + + @Test + fun releaseChannelKeyChangeDiff() { + // TODO test with changing keys + } + + private fun testDiff(repo: RepoV2, json: String, repoChecker: (List) -> Unit) { + // insert repo + repoDao.insert(repo) + + // check that the repo got added and retrieved as expected + var repos = repoDao.getRepositories() + assertEquals(1, repos.size) + val repoId = repos[0].repository.repoId + + // decode diff from JSON and update DB with it + val diff = j.parseToJsonElement(json).jsonObject // Json.decodeFromString(json) + repoDao.updateRepository(repoId, diff) + + // fetch repos again and check that the result is as expected + repos = repoDao.getRepositories().sortedBy { it.repository.repoId } + assertEquals(1, repos.size) + assertEquals(repoId, repos[0].repository.repoId) + repoChecker(repos) + } + +} diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt new file mode 100644 index 000000000..f0c47443a --- /dev/null +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt @@ -0,0 +1,46 @@ +package org.fdroid.database + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fdroid.database.TestUtils.getRandomRepo +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RepositoryTest : DbTest() { + + @Test + fun insertAndDeleteTwoRepos() { + // insert first repo + val repo1 = getRandomRepo() + repoDao.insert(repo1) + + // check that first repo got added and retrieved as expected + var repos = repoDao.getRepositories() + assertEquals(1, repos.size) + assertRepoEquals(repo1, repos[0]) + + // insert second repo + val repo2 = getRandomRepo() + repoDao.insert(repo2) + + // check that both repos got added and retrieved as expected + repos = repoDao.getRepositories().sortedBy { it.repository.repoId } + assertEquals(2, repos.size) + assertRepoEquals(repo1, repos[0]) + assertRepoEquals(repo2, repos[1]) + + // remove first repo and check that the database only returns one + repoDao.removeRepository(repos[0].repository) + assertEquals(1, repoDao.getRepositories().size) + + // remove second repo as well and check that all associated data got removed as well + repoDao.removeRepository(repos[1].repository) + assertEquals(0, repoDao.getRepositories().size) + assertEquals(0, repoDao.getMirrors().size) + assertEquals(0, repoDao.getAntiFeatures().size) + assertEquals(0, repoDao.getCategories().size) + assertEquals(0, repoDao.getReleaseChannels().size) + } + +} diff --git a/database/src/androidTest/java/org/fdroid/database/TestUtils.kt b/database/src/androidTest/java/org/fdroid/database/TestUtils.kt new file mode 100644 index 000000000..bf426ae30 --- /dev/null +++ b/database/src/androidTest/java/org/fdroid/database/TestUtils.kt @@ -0,0 +1,99 @@ +package org.fdroid.database + +import org.fdroid.index.v2.AntiFeatureV2 +import org.fdroid.index.v2.CategoryV2 +import org.fdroid.index.v2.FileV2 +import org.fdroid.index.v2.LocalizedTextV2 +import org.fdroid.index.v2.MirrorV2 +import org.fdroid.index.v2.ReleaseChannelV2 +import org.fdroid.index.v2.RepoV2 +import kotlin.random.Random + +object TestUtils { + + private val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') + + fun getRandomString(length: Int = Random.nextInt(1, 128)) = (1..length) + .map { Random.nextInt(0, charPool.size) } + .map(charPool::get) + .joinToString("") + + fun getRandomList( + size: Int = Random.nextInt(0, 23), + factory: () -> T, + ): List = if (size == 0) emptyList() else buildList { + repeat(Random.nextInt(0, size)) { + add(factory()) + } + } + + fun getRandomMap( + size: Int = Random.nextInt(0, 23), + factory: () -> Pair, + ): Map = if (size == 0) emptyMap() else buildMap { + repeat(size) { + val pair = factory() + put(pair.first, pair.second) + } + } + + private fun T.orNull(): T? { + return if (Random.nextBoolean()) null else this + } + + fun getRandomMirror() = MirrorV2( + url = getRandomString(), + location = getRandomString().orNull() + ) + + fun getRandomLocalizedTextV2(size: Int = Random.nextInt(0, 23)): LocalizedTextV2 = buildMap { + repeat(size) { + put(getRandomString(4), getRandomString()) + } + } + + fun getRandomFileV2() = FileV2( + name = getRandomString(), + sha256 = getRandomString(64), + size = Random.nextLong(-1, Long.MAX_VALUE) + ) + + fun getRandomRepo() = RepoV2( + name = getRandomString(), + icon = getRandomFileV2(), + address = getRandomString(), + description = getRandomLocalizedTextV2(), + mirrors = getRandomList { getRandomMirror() }, + timestamp = System.currentTimeMillis(), + antiFeatures = getRandomMap { + getRandomString() to AntiFeatureV2(getRandomFileV2(), getRandomLocalizedTextV2()) + }, + categories = getRandomMap { + getRandomString() to CategoryV2(getRandomFileV2(), getRandomLocalizedTextV2()) + }, + releaseChannels = getRandomMap { + getRandomString() to ReleaseChannelV2(getRandomLocalizedTextV2()) + }, + ) + + /** + * Create a map diff by adding or removing keys. Note that this does not change keys. + */ + fun Map.randomDiff(factory: () -> T): Map = buildMap { + if (this@randomDiff.isNotEmpty()) { + // remove random keys + while (Random.nextBoolean()) put(this@randomDiff.keys.random(), null) + // Note: we don't replace random keys, because we can't easily diff inside T + } + // add random keys + while (Random.nextBoolean()) put(getRandomString(), factory()) + } + + fun Map.applyDiff(diff: Map): Map = toMutableMap().apply { + diff.entries.forEach { (key, value) -> + if (value == null) remove(key) + else set(key, value) + } + } + +} diff --git a/database/src/main/AndroidManifest.xml b/database/src/main/AndroidManifest.xml new file mode 100644 index 000000000..07beb0bec --- /dev/null +++ b/database/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/database/src/main/java/org/fdroid/database/Converters.kt b/database/src/main/java/org/fdroid/database/Converters.kt new file mode 100644 index 000000000..46025de69 --- /dev/null +++ b/database/src/main/java/org/fdroid/database/Converters.kt @@ -0,0 +1,22 @@ +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.LocalizedTextV2 + +internal class Converters { + + private val localizedTextV2Serializer = MapSerializer(String.serializer(), String.serializer()) + + @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) } + } +} diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt new file mode 100644 index 000000000..5f2eed9a5 --- /dev/null +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -0,0 +1,41 @@ +package org.fdroid.database + +import android.content.Context +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.TypeConverters + +@Database(entities = [ + CoreRepository::class, + Mirror::class, + AntiFeature::class, + Category::class, + ReleaseChannel::class, +], version = 1) +@TypeConverters(Converters::class) +internal abstract class FDroidDatabase internal constructor() : RoomDatabase() { + abstract fun getRepositoryDaoInt(): RepositoryDaoInt + + companion object { + // Singleton prevents multiple instances of database opening at the same time. + @Volatile + private var INSTANCE: FDroidDatabase? = null + + fun getDb(context: Context, name: String = "fdroid_db"): FDroidDatabase { + // if the INSTANCE is not null, then return it, + // if it is, then create the database + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + FDroidDatabase::class.java, + name, + ).build() + INSTANCE = instance + // return instance + instance + } + } + } + +} diff --git a/database/src/main/java/org/fdroid/database/Repository.kt b/database/src/main/java/org/fdroid/database/Repository.kt new file mode 100644 index 000000000..dda0b1bad --- /dev/null +++ b/database/src/main/java/org/fdroid/database/Repository.kt @@ -0,0 +1,151 @@ +package org.fdroid.database + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import androidx.room.Relation +import org.fdroid.index.v2.AntiFeatureV2 +import org.fdroid.index.v2.CategoryV2 +import org.fdroid.index.v2.FileV2 +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 +data class CoreRepository( + @PrimaryKey(autoGenerate = true) val repoId: Long = 0, + val name: String, + @Embedded(prefix = "icon") val icon: FileV2?, + val address: String, + val timestamp: Long, + val description: LocalizedTextV2 = emptyMap(), +) + +fun RepoV2.toCoreRepository() = CoreRepository( + name = name, + icon = icon, + address = address, + timestamp = timestamp, + description = description, +) + +data class Repository( + @Embedded val repository: CoreRepository, + @Relation( + parentColumn = "repoId", + entityColumn = "repoId", + ) + val mirrors: List, + @Relation( + parentColumn = "repoId", + entityColumn = "repoId", + ) + val antiFeatures: List, + @Relation( + parentColumn = "repoId", + entityColumn = "repoId", + ) + val categories: List, + @Relation( + parentColumn = "repoId", + entityColumn = "repoId", + ) + val releaseChannels: List, +) + +@Entity( + primaryKeys = ["repoId", "url"], + foreignKeys = [ForeignKey( + entity = CoreRepository::class, + parentColumns = ["repoId"], + childColumns = ["repoId"], + onDelete = ForeignKey.CASCADE, + )], +) +data class Mirror( + val repoId: Long, + val url: String, + val location: String? = null, +) + +fun MirrorV2.toMirror(repoId: Long) = Mirror( + repoId = repoId, + url = url, + location = location, +) + +@Entity( + primaryKeys = ["repoId", "name"], + foreignKeys = [ForeignKey( + entity = CoreRepository::class, + parentColumns = ["repoId"], + childColumns = ["repoId"], + onDelete = ForeignKey.CASCADE, + )], +) +data class AntiFeature( + val repoId: Long, + val name: String, + @Embedded(prefix = "icon") val icon: FileV2? = null, + val description: LocalizedTextV2, +) + +fun Map.toRepoAntiFeatures(repoId: Long) = map { + AntiFeature( + repoId = repoId, + name = it.key, + icon = it.value.icon, + description = it.value.description, + ) +} + +@Entity( + primaryKeys = ["repoId", "name"], + foreignKeys = [ForeignKey( + entity = CoreRepository::class, + parentColumns = ["repoId"], + childColumns = ["repoId"], + onDelete = ForeignKey.CASCADE, + )], +) +data class Category( + val repoId: Long, + val name: String, + @Embedded(prefix = "icon") val icon: FileV2? = null, + val description: LocalizedTextV2, +) + +fun Map.toRepoCategories(repoId: Long) = map { + Category( + repoId = repoId, + name = it.key, + icon = it.value.icon, + description = it.value.description, + ) +} + +@Entity( + primaryKeys = ["repoId", "name"], + foreignKeys = [ForeignKey( + entity = CoreRepository::class, + parentColumns = ["repoId"], + childColumns = ["repoId"], + onDelete = ForeignKey.CASCADE, + )], +) +data class ReleaseChannel( + val repoId: Long, + val name: String, + @Embedded(prefix = "icon") val icon: FileV2? = null, + val description: LocalizedTextV2, +) + +fun Map.toRepoReleaseChannel(repoId: Long) = map { + ReleaseChannel( + repoId = repoId, + name = it.key, + description = it.value.description, + ) +} diff --git a/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/database/src/main/java/org/fdroid/database/RepositoryDao.kt new file mode 100644 index 000000000..9090374e7 --- /dev/null +++ b/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -0,0 +1,202 @@ +package org.fdroid.database + +import androidx.annotation.VisibleForTesting +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy.ABORT +import androidx.room.OnConflictStrategy.REPLACE +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonObject +import org.fdroid.index.ReflectionDiffer.applyDiff +import org.fdroid.index.IndexParser.json +import org.fdroid.index.v2.MirrorV2 +import org.fdroid.index.v2.RepoV2 + +public interface RepositoryDao { + fun insert(repository: RepoV2) +} + +@Dao +internal interface RepositoryDaoInt : RepositoryDao { + + @Insert(onConflict = ABORT) + fun insert(repository: CoreRepository): Long + + @Insert(onConflict = REPLACE) + fun insertMirrors(mirrors: List) + + @Insert(onConflict = REPLACE) + fun insertAntiFeatures(repoFeature: List) + + @Insert(onConflict = REPLACE) + fun insertCategories(repoFeature: List) + + @Insert(onConflict = REPLACE) + fun insertReleaseChannels(repoFeature: List) + + @Transaction + override fun insert(repository: RepoV2) { + val repoId = insert(repository.toCoreRepository()) + insertMirrors(repository.mirrors.map { it.toMirror(repoId) }) + insertAntiFeatures(repository.antiFeatures.toRepoAntiFeatures(repoId)) + insertCategories(repository.categories.toRepoCategories(repoId)) + insertReleaseChannels(repository.releaseChannels.toRepoReleaseChannel(repoId)) + } + + @Transaction + @Query("SELECT * FROM CoreRepository WHERE repoId = :repoId") + fun getRepository(repoId: Long): Repository + + @Transaction + fun updateRepository(repoId: Long, jsonObject: JsonObject) { + // get existing repo + val repo = getRepository(repoId) + // update repo with JSON diff + updateRepository(applyDiff(repo.repository, jsonObject)) + // replace mirror list, if it is in the diff + if (jsonObject.containsKey("mirrors")) { + val mirrorArray = jsonObject["mirrors"] as JsonArray + val mirrors = json.decodeFromJsonElement>(mirrorArray).map { + it.toMirror(repoId) + } + // delete and re-insert mirrors, because it is easier than diffing + deleteMirrors(repoId) + insertMirrors(mirrors) + } + // diff and update the antiFeatures + diffAndUpdateTable( + jsonObject, + "antiFeatures", + repo.antiFeatures, + { name -> AntiFeature(repoId, name, null, emptyMap()) }, + { item -> item.name }, + { deleteAntiFeatures(repoId) }, + { name -> deleteAntiFeature(repoId, name) }, + { list -> insertAntiFeatures(list) }, + ) + // diff and update the categories + diffAndUpdateTable( + jsonObject, + "categories", + repo.categories, + { name -> Category(repoId, name, null, emptyMap()) }, + { item -> item.name }, + { deleteCategories(repoId) }, + { name -> deleteCategory(repoId, name) }, + { list -> insertCategories(list) }, + ) + // diff and update the releaseChannels + diffAndUpdateTable( + jsonObject, + "releaseChannels", + repo.releaseChannels, + { name -> ReleaseChannel(repoId, name, null, emptyMap()) }, + { item -> item.name }, + { deleteReleaseChannels(repoId) }, + { name -> deleteReleaseChannel(repoId, name) }, + { list -> insertReleaseChannels(list) }, + ) + } + + /** + * Applies the diff from [JsonObject] identified by the given [key] of the given [jsonObject] + * to the given [itemList] and updates the DB as needed. + * + * @param newItem A function to produce a new [T] which typically contains the primary key(s). + */ + private fun diffAndUpdateTable( + jsonObject: JsonObject, + key: String, + itemList: List, + newItem: (String) -> T, + keyGetter: (T) -> String, + deleteAll: () -> Unit, + deleteOne: (String) -> Unit, + insertReplace: (List) -> Unit, + ) { + if (!jsonObject.containsKey(key)) return + if (jsonObject[key] == JsonNull) { + deleteAll() + } else { + val features = jsonObject[key]?.jsonObject ?: error("no $key object") + val list = itemList.toMutableList() + features.entries.forEach { (key, value) -> + if (value is JsonNull) { + list.removeAll { keyGetter(it) == key } + deleteOne(key) + } else { + val index = list.indexOfFirst { keyGetter(it) == key } + val item = if (index == -1) null else list[index] + if (item == null) { + list.add(applyDiff(newItem(key), value.jsonObject)) + } else { + list[index] = applyDiff(item, value.jsonObject) + } + } + } + insertReplace(list) + } + } + + @Update + fun updateRepository(repo: CoreRepository): Int + + @Transaction + @Query("SELECT * FROM CoreRepository") + fun getRepositories(): List + + @VisibleForTesting + @Query("SELECT * FROM Mirror") + fun getMirrors(): List + + @VisibleForTesting + @Query("DELETE FROM Mirror WHERE repoId = :repoId") + fun deleteMirrors(repoId: Long) + + @VisibleForTesting + @Query("SELECT * FROM AntiFeature") + fun getAntiFeatures(): List + + @VisibleForTesting + @Query("DELETE FROM AntiFeature WHERE repoId = :repoId") + fun deleteAntiFeatures(repoId: Long) + + @VisibleForTesting + @Query("DELETE FROM AntiFeature WHERE repoId = :repoId AND name = :name") + fun deleteAntiFeature(repoId: Long, name: String) + + @VisibleForTesting + @Query("SELECT * FROM Category") + fun getCategories(): List + + @VisibleForTesting + @Query("DELETE FROM Category WHERE repoId = :repoId") + fun deleteCategories(repoId: Long) + + @VisibleForTesting + @Query("DELETE FROM Category WHERE repoId = :repoId AND name = :name") + fun deleteCategory(repoId: Long, name: String) + + @VisibleForTesting + @Query("SELECT * FROM ReleaseChannel") + fun getReleaseChannels(): List + + @VisibleForTesting + @Query("DELETE FROM ReleaseChannel WHERE repoId = :repoId") + fun deleteReleaseChannels(repoId: Long) + + @VisibleForTesting + @Query("DELETE FROM ReleaseChannel WHERE repoId = :repoId AND name = :name") + fun deleteReleaseChannel(repoId: Long, name: String) + + @Delete + fun removeRepository(repository: CoreRepository) + +} diff --git a/database/src/test/java/org/fdroid/database/ReflectionTest.kt b/database/src/test/java/org/fdroid/database/ReflectionTest.kt new file mode 100644 index 000000000..ebd544414 --- /dev/null +++ b/database/src/test/java/org/fdroid/database/ReflectionTest.kt @@ -0,0 +1,32 @@ +package org.fdroid.database + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.fdroid.index.ReflectionDiffer.applyDiff +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals + +class ReflectionTest { + + @Test + fun testRepository() { + val repo = TestUtils2.getRandomRepo().toCoreRepository() + val icon = TestUtils2.getRandomFileV2() + val description = if (Random.nextBoolean()) mapOf("de" to null, "en" to "foo") else null + val json = """ + { + "name": "test", + "timestamp": ${Long.MAX_VALUE}, + "icon": ${Json.encodeToString(icon)}, + "description": ${Json.encodeToString(description)} + } + """.trimIndent() + val diff = Json.parseToJsonElement(json).jsonObject + val diffed = applyDiff(repo, diff) + println(diffed) + assertEquals(Long.MAX_VALUE, diffed.timestamp) + } + +} diff --git a/database/src/test/java/org/fdroid/database/TestUtils.kt b/database/src/test/java/org/fdroid/database/TestUtils.kt new file mode 100644 index 000000000..5df26cc98 --- /dev/null +++ b/database/src/test/java/org/fdroid/database/TestUtils.kt @@ -0,0 +1,79 @@ +package org.fdroid.database + +import org.fdroid.index.v2.AntiFeatureV2 +import org.fdroid.index.v2.CategoryV2 +import org.fdroid.index.v2.FileV2 +import org.fdroid.index.v2.LocalizedTextV2 +import org.fdroid.index.v2.MirrorV2 +import org.fdroid.index.v2.ReleaseChannelV2 +import org.fdroid.index.v2.RepoV2 +import kotlin.random.Random + +object TestUtils2 { + + private val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') + + fun getRandomString(length: Int = Random.nextInt(1, 128)) = (1..length) + .map { Random.nextInt(0, charPool.size) } + .map(charPool::get) + .joinToString("") + + fun getRandomList( + size: Int = Random.nextInt(0, 23), + factory: () -> T, + ): List = if (size == 0) emptyList() else buildList { + repeat(Random.nextInt(0, size)) { + add(factory()) + } + } + + fun getRandomMap( + size: Int = Random.nextInt(0, 23), + factory: () -> Pair, + ): Map = if (size == 0) emptyMap() else buildMap { + repeat(Random.nextInt(0, size)) { + val pair = factory() + put(pair.first, pair.second) + } + } + + private fun T.orNull(): T? { + return if (Random.nextBoolean()) null else this + } + + fun getRandomMirror() = MirrorV2( + url = getRandomString(), + location = getRandomString().orNull() + ) + + fun getRandomLocalizedTextV2(size: Int = Random.nextInt(0, 23)): LocalizedTextV2 = buildMap { + repeat(size) { + put(getRandomString(4), getRandomString()) + } + } + + fun getRandomFileV2() = FileV2( + name = getRandomString(), + sha256 = getRandomString(64), + size = Random.nextLong(-1, Long.MAX_VALUE) + ) + + fun getRandomRepo() = RepoV2( + name = getRandomString(), + icon = getRandomFileV2(), + address = getRandomString(), + description = getRandomLocalizedTextV2(), + mirrors = getRandomList { getRandomMirror() }, + timestamp = System.currentTimeMillis(), + antiFeatures = getRandomMap { + getRandomString() to AntiFeatureV2(getRandomFileV2(), getRandomLocalizedTextV2()) + }, + categories = getRandomMap { + getRandomString() to CategoryV2(getRandomFileV2(), getRandomLocalizedTextV2()) + }, + releaseChannels = getRandomMap { + getRandomString() to ReleaseChannelV2(getRandomLocalizedTextV2()) + }, + ) + +} diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 814b291d8..bb5c015e3 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -10,6 +10,9 @@ + + + @@ -51,7 +54,10 @@ - + + + + @@ -243,7 +249,7 @@ - + @@ -287,6 +293,11 @@ + + + + + @@ -295,6 +306,11 @@ + + + + + @@ -705,11 +721,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -736,11 +787,21 @@ + + + + + + + + + + @@ -785,6 +846,11 @@ + + + + + @@ -880,6 +946,21 @@ + + + + + + + + + + + + + + + @@ -2199,6 +2280,11 @@ + + + + + @@ -2234,6 +2320,12 @@ + + + + + + @@ -2604,6 +2696,11 @@ + + + + + @@ -2764,6 +2861,11 @@ + + + + + @@ -2797,6 +2899,11 @@ + + + + + @@ -5989,6 +6096,11 @@ + + + + + @@ -6695,7 +6807,12 @@ - + + + + + + diff --git a/settings.gradle b/settings.gradle index f7d6d2151..4110cba70 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,3 +1,4 @@ include ':app' include ':download' -include ':index' \ No newline at end of file +include ':index' +include ':database' From a445bee197d6816b9b19e34ae49434677b887ebb Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 15 Mar 2022 09:45:29 -0300 Subject: [PATCH 04/42] [db] Add support for apps and streaming --- database/build.gradle | 6 + .../java/org/fdroid/database/AppTest.kt | 40 +++++ .../java/org/fdroid/database/DbTest.kt | 27 +-- .../org/fdroid/database/IndexV1InsertTest.kt | 143 +++++++++++++++ .../org/fdroid/database/IndexV2InsertTest.kt | 82 +++++++++ .../org/fdroid/database/RepositoryDiffTest.kt | 17 +- .../org/fdroid/database/RepositoryTest.kt | 31 +++- .../java/org/fdroid/database/VersionTest.kt | 88 +++++++++ .../org/fdroid/database/test/TestAppUtils.kt | 99 ++++++++++ .../org/fdroid/database/test/TestRepoUtils.kt | 80 +++++++++ .../fdroid/database/{ => test}/TestUtils.kt | 48 +---- .../fdroid/database/test/TestVersionUtils.kt | 55 ++++++ .../src/main/java/org/fdroid/database/App.kt | 169 +++++++++++++++++ .../main/java/org/fdroid/database/AppDao.kt | 117 ++++++++++++ .../java/org/fdroid/database/Converters.kt | 23 +++ .../org/fdroid/database/DbStreamReceiver.kt | 20 +++ .../org/fdroid/database/DbV1StreamReceiver.kt | 43 +++++ .../org/fdroid/database/FDroidDatabase.kt | 10 ++ .../java/org/fdroid/database/Repository.kt | 11 +- .../java/org/fdroid/database/RepositoryDao.kt | 30 +++- .../main/java/org/fdroid/database/Version.kt | 170 ++++++++++++++++++ .../java/org/fdroid/database/VersionDao.kt | 79 ++++++++ .../org/fdroid/database/ReflectionTest.kt | 2 +- .../java/org/fdroid/database/TestUtils.kt | 2 +- 24 files changed, 1303 insertions(+), 89 deletions(-) create mode 100644 database/src/androidTest/java/org/fdroid/database/AppTest.kt create mode 100644 database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt create mode 100644 database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt create mode 100644 database/src/androidTest/java/org/fdroid/database/VersionTest.kt create mode 100644 database/src/androidTest/java/org/fdroid/database/test/TestAppUtils.kt create mode 100644 database/src/androidTest/java/org/fdroid/database/test/TestRepoUtils.kt rename database/src/androidTest/java/org/fdroid/database/{ => test}/TestUtils.kt (52%) create mode 100644 database/src/androidTest/java/org/fdroid/database/test/TestVersionUtils.kt create mode 100644 database/src/main/java/org/fdroid/database/App.kt create mode 100644 database/src/main/java/org/fdroid/database/AppDao.kt create mode 100644 database/src/main/java/org/fdroid/database/DbStreamReceiver.kt create mode 100644 database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt create mode 100644 database/src/main/java/org/fdroid/database/Version.kt create mode 100644 database/src/main/java/org/fdroid/database/VersionDao.kt diff --git a/database/build.gradle b/database/build.gradle index c0cec96d8..354e68e58 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -34,6 +34,10 @@ android { kotlinOptions { jvmTarget = '1.8' } + aaptOptions { + // needed only for instrumentation tests: assets.openFd() + noCompress "json" + } } dependencies { @@ -54,4 +58,6 @@ dependencies { androidTestImplementation 'org.jetbrains.kotlin:kotlin-test' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' + + androidTestImplementation 'commons-io:commons-io:2.6' } diff --git a/database/src/androidTest/java/org/fdroid/database/AppTest.kt b/database/src/androidTest/java/org/fdroid/database/AppTest.kt new file mode 100644 index 000000000..16a0ccbc8 --- /dev/null +++ b/database/src/androidTest/java/org/fdroid/database/AppTest.kt @@ -0,0 +1,40 @@ +package org.fdroid.database + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fdroid.database.test.TestAppUtils.assertScreenshotsEqual +import org.fdroid.database.test.TestAppUtils.getRandomMetadataV2 +import org.fdroid.database.test.TestRepoUtils.getRandomRepo +import org.fdroid.database.test.TestUtils.getRandomString +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +class AppTest : DbTest() { + + private val packageId = getRandomString() + + @Test + fun insertGetDeleteSingleApp() { + val repoId = repoDao.insert(getRandomRepo()) + val metadataV2 = getRandomMetadataV2() + appDao.insert(repoId, packageId, metadataV2) + + val app = appDao.getApp(repoId, packageId) + val metadata = metadataV2.toAppMetadata(repoId, packageId) + assertEquals(metadata.author, app.metadata.author) + assertEquals(metadata.donation, app.metadata.donation) + assertEquals(metadata, app.metadata) + assertEquals(metadataV2.icon, app.icon) + assertEquals(metadataV2.featureGraphic, app.featureGraphic) + assertEquals(metadataV2.promoGraphic, app.promoGraphic) + assertEquals(metadataV2.tvBanner, app.tvBanner) + assertScreenshotsEqual(metadataV2.screenshots, app.screenshots) + + appDao.deleteAppMetadata(repoId, packageId) + assertEquals(0, appDao.getAppMetadata().size) + assertEquals(0, appDao.getLocalizedFiles().size) + assertEquals(0, appDao.getLocalizedFileLists().size) + } + +} diff --git a/database/src/androidTest/java/org/fdroid/database/DbTest.kt b/database/src/androidTest/java/org/fdroid/database/DbTest.kt index fe206fa7c..0bc1bb774 100644 --- a/database/src/androidTest/java/org/fdroid/database/DbTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/DbTest.kt @@ -4,9 +4,7 @@ import android.content.Context import androidx.room.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.fdroid.index.v2.RepoV2 import org.junit.After -import org.junit.Assert import org.junit.Before import org.junit.runner.RunWith import java.io.IOException @@ -15,13 +13,17 @@ import java.io.IOException abstract class DbTest { internal lateinit var repoDao: RepositoryDaoInt - private lateinit var db: FDroidDatabase + internal lateinit var appDao: AppDaoInt + internal lateinit var versionDao: VersionDaoInt + internal lateinit var db: FDroidDatabase @Before fun createDb() { val context = ApplicationProvider.getApplicationContext() db = Room.inMemoryDatabaseBuilder(context, FDroidDatabase::class.java).build() repoDao = db.getRepositoryDaoInt() + appDao = db.getAppDaoInt() + versionDao = db.getVersionDaoInt() } @After @@ -30,23 +32,4 @@ abstract class DbTest { db.close() } - protected fun assertRepoEquals(repoV2: RepoV2, repo: Repository) { - val repoId = repo.repository.repoId - // mirrors - val expectedMirrors = repoV2.mirrors.map { it.toMirror(repoId) }.toSet() - Assert.assertEquals(expectedMirrors, repo.mirrors.toSet()) - // anti-features - val expectedAntiFeatures = repoV2.antiFeatures.toRepoAntiFeatures(repoId).toSet() - Assert.assertEquals(expectedAntiFeatures, repo.antiFeatures.toSet()) - // categories - val expectedCategories = repoV2.categories.toRepoCategories(repoId).toSet() - Assert.assertEquals(expectedCategories, repo.categories.toSet()) - // release channels - val expectedReleaseChannels = repoV2.releaseChannels.toRepoReleaseChannel(repoId).toSet() - Assert.assertEquals(expectedReleaseChannels, repo.releaseChannels.toSet()) - // core repo - val coreRepo = repoV2.toCoreRepository().copy(repoId = repoId) - Assert.assertEquals(coreRepo, repo.repository) - } - } diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt new file mode 100644 index 000000000..4907ec479 --- /dev/null +++ b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt @@ -0,0 +1,143 @@ +package org.fdroid.database + +import android.content.Context +import android.util.Log +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.serialization.SerializationException +import org.apache.commons.io.input.CountingInputStream +import org.fdroid.index.v2.IndexStreamProcessor +import org.fdroid.index.IndexV1StreamProcessor +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.math.roundToInt +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class IndexV1InsertTest : DbTest() { + + @Test + fun testStreamIndexV1IntoDb() { + val c = getApplicationContext() + val fileSize = c.resources.assets.openFd("index-v1.json").use { it.length } + val inputStream = CountingInputStream(c.resources.assets.open("index-v1.json")) + var currentByteCount: Long = 0 + val indexProcessor = IndexV1StreamProcessor(DbV1StreamReceiver(db)) { + val bytesRead = inputStream.byteCount + val bytesSinceLastCall = bytesRead - currentByteCount + if (bytesSinceLastCall > 0) { + val percent = ((bytesRead.toDouble() / fileSize) * 100).roundToInt() + Log.e("IndexV1InsertTest", + "Stream bytes read: $bytesRead ($percent%) +$bytesSinceLastCall") + } + // the stream gets read in big chunks, but ensure they are not too big, e.g. entire file + assertTrue(bytesSinceLastCall < 600_000, "$bytesSinceLastCall") + currentByteCount = bytesRead + bytesRead + } + + db.runInTransaction { + inputStream.use { indexStream -> + indexProcessor.process(1, indexStream) + } + } + assertTrue(repoDao.getRepositories().size == 1) + assertTrue(appDao.countApps() > 0) + assertTrue(appDao.countLocalizedFiles() > 0) + assertTrue(appDao.countLocalizedFileLists() > 0) + assertTrue(versionDao.countAppVersions() > 0) + assertTrue(versionDao.countVersionedStrings() > 0) + + println("Apps: " + appDao.countApps()) + println("LocalizedFiles: " + appDao.countLocalizedFiles()) + println("LocalizedFileLists: " + appDao.countLocalizedFileLists()) + println("Versions: " + versionDao.countAppVersions()) + println("Perms/Features: " + versionDao.countVersionedStrings()) + + insertV2ForComparison(2) + + val repo1 = repoDao.getRepository(1) + val repo2 = repoDao.getRepository(2) + assertEquals(repo1.repository, repo2.repository.copy(repoId = 1)) + assertEquals(repo1.mirrors, repo2.mirrors.map { it.copy(repoId = 1) }) + assertEquals(repo1.antiFeatures, repo2.antiFeatures) + assertEquals(repo1.categories, repo2.categories) + assertEquals(repo1.releaseChannels, repo2.releaseChannels) + + val appMetadata = appDao.getAppMetadata() + val appMetadata1 = appMetadata.count { it.repoId == 1L } + val appMetadata2 = appMetadata.count { it.repoId == 2L } + assertEquals(appMetadata1, appMetadata2) + + val localizedFiles = appDao.getLocalizedFiles() + val localizedFiles1 = localizedFiles.count { it.repoId == 1L } + val localizedFiles2 = localizedFiles.count { it.repoId == 2L } + assertEquals(localizedFiles1, localizedFiles2) + + val localizedFileLists = appDao.getLocalizedFileLists() + val localizedFileLists1 = localizedFileLists.count { it.repoId == 1L } + val localizedFileLists2 = localizedFileLists.count { it.repoId == 2L } + assertEquals(localizedFileLists1, localizedFileLists2) + + appMetadata.filter { it.repoId ==2L }.forEach { m -> + val metadata1 = appDao.getAppMetadata(1, m.packageId) + val metadata2 = appDao.getAppMetadata(2, m.packageId) + assertEquals(metadata1, metadata2.copy(repoId = 1)) + + val lFiles1 = appDao.getLocalizedFiles(1, m.packageId).toSet() + val lFiles2 = appDao.getLocalizedFiles(2, m.packageId) + assertEquals(lFiles1, lFiles2.map { it.copy(repoId = 1) }.toSet()) + + val lFileLists1 = appDao.getLocalizedFileLists(1, m.packageId).toSet() + val lFileLists2 = appDao.getLocalizedFileLists(2, m.packageId) + assertEquals(lFileLists1, lFileLists2.map { it.copy(repoId = 1) }.toSet()) + + val version1 = versionDao.getVersions(1, m.packageId).toSet() + val version2 = versionDao.getVersions(2, m.packageId) + assertEquals(version1, version2.map { it.copy(repoId = 1) }.toSet()) + + val vStrings1 = versionDao.getVersionedStrings(1, m.packageId).toSet() + val vStrings2 = versionDao.getVersionedStrings(2, m.packageId) + assertEquals(vStrings1, vStrings2.map { it.copy(repoId = 1) }.toSet()) + } + } + + @Suppress("SameParameterValue") + private fun insertV2ForComparison(repoId: Long) { + val c = getApplicationContext() + val inputStream = CountingInputStream(c.resources.assets.open("index-v2.json")) + val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db)) + db.runInTransaction { + inputStream.use { indexStream -> + indexProcessor.process(repoId, indexStream) + } + } + } + + @Test + fun testExceptionWhileStreamingDoesNotSaveIntoDb() { + val c = getApplicationContext() + val cIn = CountingInputStream(c.resources.assets.open("index-v1.json")) + val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db)) { + if (cIn.byteCount > 824096) throw SerializationException() + cIn.byteCount + } + + assertFailsWith { + db.runInTransaction { + cIn.use { indexStream -> + indexProcessor.process(1, indexStream) + } + } + } + assertTrue(repoDao.getRepositories().isEmpty()) + assertTrue(appDao.countApps() == 0) + assertTrue(appDao.countLocalizedFiles() == 0) + assertTrue(appDao.countLocalizedFileLists() == 0) + assertTrue(versionDao.countAppVersions() == 0) + assertTrue(versionDao.countVersionedStrings() == 0) + } + +} diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt new file mode 100644 index 000000000..09a2f589f --- /dev/null +++ b/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt @@ -0,0 +1,82 @@ +package org.fdroid.database + +import android.content.Context +import android.util.Log +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.serialization.SerializationException +import org.apache.commons.io.input.CountingInputStream +import org.fdroid.index.v2.IndexStreamProcessor +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.math.roundToInt +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class IndexV2InsertTest : DbTest() { + + @Test + fun testStreamIndexV2IntoDb() { + val c = getApplicationContext() + val fileSize = c.resources.assets.openFd("index-v2.json").use { it.length } + val inputStream = CountingInputStream(c.resources.assets.open("index-v2.json")) + var currentByteCount: Long = 0 + val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db)) { + val bytesRead = inputStream.byteCount + val bytesSinceLastCall = bytesRead - currentByteCount + if (bytesSinceLastCall > 0) { + val percent = ((bytesRead.toDouble() / fileSize) * 100).roundToInt() + Log.e("IndexV2InsertTest", + "Stream bytes read: $bytesRead ($percent%) +$bytesSinceLastCall") + } + // the stream gets read in big chunks, but ensure they are not too big, e.g. entire file + assertTrue(bytesSinceLastCall < 400_000, "$bytesSinceLastCall") + currentByteCount = bytesRead + bytesRead + } + + db.runInTransaction { + inputStream.use { indexStream -> + indexProcessor.process(1, indexStream) + } + } + assertTrue(repoDao.getRepositories().size == 1) + assertTrue(appDao.countApps() > 0) + assertTrue(appDao.countLocalizedFiles() > 0) + assertTrue(appDao.countLocalizedFileLists() > 0) + assertTrue(versionDao.countAppVersions() > 0) + assertTrue(versionDao.countVersionedStrings() > 0) + + println("Apps: " + appDao.countApps()) + println("LocalizedFiles: " + appDao.countLocalizedFiles()) + println("LocalizedFileLists: " + appDao.countLocalizedFileLists()) + println("Versions: " + versionDao.countAppVersions()) + println("Perms/Features: " + versionDao.countVersionedStrings()) + } + + @Test + fun testExceptionWhileStreamingDoesNotSaveIntoDb() { + val c = getApplicationContext() + val cIn = CountingInputStream(c.resources.assets.open("index-v2.json")) + val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db)) { + if (cIn.byteCount > 824096) throw SerializationException() + cIn.byteCount + } + + assertFailsWith { + db.runInTransaction { + cIn.use { indexStream -> + indexProcessor.process(1, indexStream) + } + } + } + assertTrue(repoDao.getRepositories().isEmpty()) + assertTrue(appDao.countApps() == 0) + assertTrue(appDao.countLocalizedFiles() == 0) + assertTrue(appDao.countLocalizedFileLists() == 0) + assertTrue(versionDao.countAppVersions() == 0) + assertTrue(versionDao.countVersionedStrings() == 0) + } + +} diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt index 4aa474541..105e4a62b 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt @@ -4,14 +4,15 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject -import org.fdroid.database.TestUtils.applyDiff -import org.fdroid.database.TestUtils.getRandomFileV2 -import org.fdroid.database.TestUtils.getRandomLocalizedTextV2 -import org.fdroid.database.TestUtils.getRandomMap -import org.fdroid.database.TestUtils.getRandomMirror -import org.fdroid.database.TestUtils.getRandomRepo -import org.fdroid.database.TestUtils.getRandomString -import org.fdroid.database.TestUtils.randomDiff +import org.fdroid.database.test.TestRepoUtils.assertRepoEquals +import org.fdroid.database.test.TestRepoUtils.getRandomFileV2 +import org.fdroid.database.test.TestRepoUtils.getRandomLocalizedTextV2 +import org.fdroid.database.test.TestRepoUtils.getRandomMirror +import org.fdroid.database.test.TestRepoUtils.getRandomRepo +import org.fdroid.database.test.TestUtils.applyDiff +import org.fdroid.database.test.TestUtils.getRandomMap +import org.fdroid.database.test.TestUtils.getRandomString +import org.fdroid.database.test.TestUtils.randomDiff import org.fdroid.index.v2.AntiFeatureV2 import org.fdroid.index.v2.CategoryV2 import org.fdroid.index.v2.ReleaseChannelV2 diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt index f0c47443a..c79d037c5 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt @@ -1,10 +1,15 @@ package org.fdroid.database import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.fdroid.database.TestUtils.getRandomRepo -import org.junit.Assert.assertEquals +import org.fdroid.database.test.TestAppUtils.getRandomMetadataV2 +import org.fdroid.database.test.TestRepoUtils.assertRepoEquals +import org.fdroid.database.test.TestRepoUtils.getRandomRepo +import org.fdroid.database.test.TestUtils.getRandomString +import org.fdroid.database.test.TestVersionUtils.getRandomPackageVersionV2 import org.junit.Test import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) class RepositoryTest : DbTest() { @@ -43,4 +48,26 @@ class RepositoryTest : DbTest() { assertEquals(0, repoDao.getReleaseChannels().size) } + @Test + fun replacingRepoRemovesAllAssociatedData() { + val repoId = repoDao.insert(getRandomRepo()) + val packageId = getRandomString() + val versionId = getRandomString() + appDao.insert(repoId, packageId, getRandomMetadataV2()) + val packageVersion = getRandomPackageVersionV2() + versionDao.insert(repoId, packageId, versionId, packageVersion) + + assertEquals(1, repoDao.getRepositories().size) + assertEquals(1, appDao.getAppMetadata().size) + assertEquals(1, versionDao.getAppVersions(repoId, packageId).size) + assertTrue(versionDao.getVersionedStrings(repoId, packageId).isNotEmpty()) + + repoDao.replace(repoId, getRandomRepo()) + assertEquals(1, repoDao.getRepositories().size) + assertEquals(0, appDao.getAppMetadata().size) + assertEquals(0, appDao.getLocalizedFiles().size) + assertEquals(0, appDao.getLocalizedFileLists().size) + assertEquals(0, versionDao.getAppVersions(repoId, packageId).size) + assertEquals(0, versionDao.getVersionedStrings(repoId, packageId).size) + } } diff --git a/database/src/androidTest/java/org/fdroid/database/VersionTest.kt b/database/src/androidTest/java/org/fdroid/database/VersionTest.kt new file mode 100644 index 000000000..a17eadc4a --- /dev/null +++ b/database/src/androidTest/java/org/fdroid/database/VersionTest.kt @@ -0,0 +1,88 @@ +package org.fdroid.database + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fdroid.database.test.TestAppUtils.getRandomMetadataV2 +import org.fdroid.database.test.TestRepoUtils.getRandomRepo +import org.fdroid.database.test.TestUtils.getRandomString +import org.fdroid.database.test.TestVersionUtils.getRandomPackageVersionV2 +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +class VersionTest : DbTest() { + + private val packageId = getRandomString() + private val versionId = getRandomString() + + @Test + fun insertGetDeleteSingleVersion() { + val repoId = repoDao.insert(getRandomRepo()) + appDao.insert(repoId, packageId, getRandomMetadataV2()) + val packageVersion = getRandomPackageVersionV2() + versionDao.insert(repoId, packageId, versionId, packageVersion) + + val appVersions = versionDao.getAppVersions(repoId, packageId) + assertEquals(1, appVersions.size) + val appVersion = appVersions[0] + assertEquals(versionId, appVersion.version.versionId) + assertEquals(packageVersion.toVersion(repoId, packageId, versionId), appVersion.version) + val manifest = packageVersion.manifest + assertEquals(manifest.usesPermission.toSet(), appVersion.usesPermission?.toSet()) + assertEquals(manifest.usesPermissionSdk23.toSet(), appVersion.usesPermissionSdk23?.toSet()) + assertEquals(manifest.features.toSet(), appVersion.features?.toSet()) + + val versionedStrings = versionDao.getVersionedStrings(repoId, packageId) + val expectedSize = + manifest.usesPermission.size + manifest.usesPermissionSdk23.size + manifest.features.size + assertEquals(expectedSize, versionedStrings.size) + + versionDao.deleteAppVersion(repoId, packageId, versionId) + assertEquals(0, versionDao.getAppVersions(repoId, packageId).size) + assertEquals(0, versionDao.getVersionedStrings(repoId, packageId).size) + } + + @Test + fun insertGetDeleteTwoVersions() { + // insert two versions along with required objects + val repoId = repoDao.insert(getRandomRepo()) + appDao.insert(repoId, packageId, getRandomMetadataV2()) + val packageVersion1 = getRandomPackageVersionV2() + val version1 = getRandomString() + versionDao.insert(repoId, packageId, version1, packageVersion1) + val packageVersion2 = getRandomPackageVersionV2() + val version2 = getRandomString() + versionDao.insert(repoId, packageId, version2, packageVersion2) + + // get app versions from DB and assign them correctly + val appVersions = versionDao.getAppVersions(repoId, packageId) + assertEquals(2, appVersions.size) + val appVersion = if (version1 == appVersions[0].version.versionId) { + appVersions[0] + } else appVersions[1] + val appVersion2 = if (version2 == appVersions[0].version.versionId) { + appVersions[0] + } else appVersions[1] + + // check first version matches + assertEquals(packageVersion1.toVersion(repoId, packageId, version1), appVersion.version) + val manifest = packageVersion1.manifest + assertEquals(manifest.usesPermission.toSet(), appVersion.usesPermission?.toSet()) + assertEquals(manifest.usesPermissionSdk23.toSet(), appVersion.usesPermissionSdk23?.toSet()) + assertEquals(manifest.features.toSet(), appVersion.features?.toSet()) + + // check second version matches + assertEquals(packageVersion2.toVersion(repoId, packageId, version2), appVersion2.version) + val manifest2 = packageVersion2.manifest + assertEquals(manifest2.usesPermission.toSet(), appVersion2.usesPermission?.toSet()) + assertEquals(manifest2.usesPermissionSdk23.toSet(), + appVersion2.usesPermissionSdk23?.toSet()) + assertEquals(manifest2.features.toSet(), appVersion2.features?.toSet()) + + // delete app and check that all associated data also gets deleted + appDao.deleteAppMetadata(repoId, packageId) + assertEquals(0, versionDao.getAppVersions(repoId, packageId).size) + assertEquals(0, versionDao.getVersionedStrings(repoId, packageId).size) + } + +} diff --git a/database/src/androidTest/java/org/fdroid/database/test/TestAppUtils.kt b/database/src/androidTest/java/org/fdroid/database/test/TestAppUtils.kt new file mode 100644 index 000000000..a2a631289 --- /dev/null +++ b/database/src/androidTest/java/org/fdroid/database/test/TestAppUtils.kt @@ -0,0 +1,99 @@ +package org.fdroid.database.test + +import org.fdroid.database.test.TestRepoUtils.getRandomLocalizedFileV2 +import org.fdroid.database.test.TestRepoUtils.getRandomLocalizedTextV2 +import org.fdroid.database.test.TestUtils.getRandomList +import org.fdroid.database.test.TestUtils.getRandomString +import org.fdroid.database.test.TestUtils.orNull +import org.fdroid.index.v2.Author +import org.fdroid.index.v2.Donation +import org.fdroid.index.v2.LocalizedFileListV2 +import org.fdroid.index.v2.MetadataV2 +import org.fdroid.index.v2.Screenshots +import kotlin.random.Random +import kotlin.test.assertEquals + +internal object TestAppUtils { + + fun getRandomMetadataV2() = MetadataV2( + added = Random.nextLong(), + lastUpdated = Random.nextLong(), + name = getRandomLocalizedTextV2().orNull(), + summary = getRandomLocalizedTextV2().orNull(), + description = getRandomLocalizedTextV2().orNull(), + webSite = getRandomString().orNull(), + changelog = getRandomString().orNull(), + license = getRandomString().orNull(), + sourceCode = getRandomString().orNull(), + issueTracker = getRandomString().orNull(), + translation = getRandomString().orNull(), + preferredSigner = getRandomString().orNull(), + video = getRandomLocalizedTextV2().orNull(), + author = getRandomAuthor().orNull(), + donation = getRandomDonation().orNull(), + icon = getRandomLocalizedFileV2().orNull(), + featureGraphic = getRandomLocalizedFileV2().orNull(), + promoGraphic = getRandomLocalizedFileV2().orNull(), + tvBanner = getRandomLocalizedFileV2().orNull(), + categories = getRandomList { getRandomString() }.orNull() + ?: emptyList(), + screenshots = getRandomScreenshots().orNull(), + ) + + fun getRandomAuthor() = Author( + name = getRandomString().orNull(), + email = getRandomString().orNull(), + website = getRandomString().orNull(), + phone = getRandomString().orNull(), + ) + + fun getRandomDonation() = Donation( + url = getRandomString().orNull(), + liberapay = getRandomString().orNull(), + liberapayID = getRandomString().orNull(), + openCollective = getRandomString().orNull(), + bitcoin = getRandomString().orNull(), + litecoin = getRandomString().orNull(), + flattrID = getRandomString().orNull(), + ) + + fun getRandomScreenshots() = Screenshots( + phone = getRandomLocalizedFileListV2().orNull(), + sevenInch = getRandomLocalizedFileListV2().orNull(), + tenInch = getRandomLocalizedFileListV2().orNull(), + wear = getRandomLocalizedFileListV2().orNull(), + tv = getRandomLocalizedFileListV2().orNull(), + ).takeIf { !it.isNull } + + fun getRandomLocalizedFileListV2() = TestUtils.getRandomMap(Random.nextInt(1, 3)) { + getRandomString() to getRandomList(Random.nextInt(1, + 7)) { TestRepoUtils.getRandomFileV2() } + } + + /** + * [Screenshots] include lists which can be ordered differently, + * so we need to ignore order when comparing them. + */ + fun assertScreenshotsEqual(s1: Screenshots?, s2: Screenshots?) { + if (s1 != null && s2 != null) { + assertLocalizedFileListV2Equal(s1.phone, s2.phone) + assertLocalizedFileListV2Equal(s1.sevenInch, s2.sevenInch) + assertLocalizedFileListV2Equal(s1.tenInch, s2.tenInch) + assertLocalizedFileListV2Equal(s1.wear, s2.wear) + assertLocalizedFileListV2Equal(s1.tv, s2.tv) + } else { + assertEquals(s1, s2) + } + } + + private fun assertLocalizedFileListV2Equal(l1: LocalizedFileListV2?, l2: LocalizedFileListV2?) { + if (l1 != null && l2 != null) { + l1.keys.forEach { key -> + assertEquals(l1[key]?.toSet(), l2[key]?.toSet()) + } + } else { + assertEquals(l1, l2) + } + } + +} diff --git a/database/src/androidTest/java/org/fdroid/database/test/TestRepoUtils.kt b/database/src/androidTest/java/org/fdroid/database/test/TestRepoUtils.kt new file mode 100644 index 000000000..e6268e8f6 --- /dev/null +++ b/database/src/androidTest/java/org/fdroid/database/test/TestRepoUtils.kt @@ -0,0 +1,80 @@ +package org.fdroid.database.test + +import org.fdroid.database.Repository +import org.fdroid.database.test.TestUtils.orNull +import org.fdroid.database.toCoreRepository +import org.fdroid.database.toMirror +import org.fdroid.database.toRepoAntiFeatures +import org.fdroid.database.toRepoCategories +import org.fdroid.database.toRepoReleaseChannel +import org.fdroid.index.v2.AntiFeatureV2 +import org.fdroid.index.v2.CategoryV2 +import org.fdroid.index.v2.FileV2 +import org.fdroid.index.v2.LocalizedTextV2 +import org.fdroid.index.v2.MirrorV2 +import org.fdroid.index.v2.ReleaseChannelV2 +import org.fdroid.index.v2.RepoV2 +import org.junit.Assert +import kotlin.random.Random + +object TestRepoUtils { + + fun getRandomMirror() = MirrorV2( + url = TestUtils.getRandomString(), + location = TestUtils.getRandomString().orNull() + ) + + fun getRandomLocalizedTextV2(size: Int = Random.nextInt(0, 23)): LocalizedTextV2 = buildMap { + repeat(size) { + put(TestUtils.getRandomString(4), TestUtils.getRandomString()) + } + } + + fun getRandomFileV2(sha256Nullable: Boolean = true) = FileV2( + name = TestUtils.getRandomString(), + sha256 = TestUtils.getRandomString(64).also { if (sha256Nullable) orNull() }, + size = Random.nextLong(-1, Long.MAX_VALUE) + ) + + fun getRandomLocalizedFileV2() = TestUtils.getRandomMap(Random.nextInt(1, 8)) { + TestUtils.getRandomString(4) to getRandomFileV2() + } + + fun getRandomRepo() = RepoV2( + name = TestUtils.getRandomString(), + icon = getRandomFileV2(), + address = TestUtils.getRandomString(), + description = getRandomLocalizedTextV2(), + mirrors = TestUtils.getRandomList { getRandomMirror() }, + timestamp = System.currentTimeMillis(), + antiFeatures = TestUtils.getRandomMap { + TestUtils.getRandomString() to AntiFeatureV2(getRandomFileV2(), getRandomLocalizedTextV2()) + }, + categories = TestUtils.getRandomMap { + TestUtils.getRandomString() to CategoryV2(getRandomFileV2(), getRandomLocalizedTextV2()) + }, + releaseChannels = TestUtils.getRandomMap { + TestUtils.getRandomString() to ReleaseChannelV2(getRandomLocalizedTextV2()) + }, + ) + + internal fun assertRepoEquals(repoV2: RepoV2, repo: Repository) { + val repoId = repo.repository.repoId + // mirrors + val expectedMirrors = repoV2.mirrors.map { it.toMirror(repoId) }.toSet() + Assert.assertEquals(expectedMirrors, repo.mirrors.toSet()) + // anti-features + val expectedAntiFeatures = repoV2.antiFeatures.toRepoAntiFeatures(repoId).toSet() + Assert.assertEquals(expectedAntiFeatures, repo.antiFeatures.toSet()) + // categories + val expectedCategories = repoV2.categories.toRepoCategories(repoId).toSet() + Assert.assertEquals(expectedCategories, repo.categories.toSet()) + // release channels + val expectedReleaseChannels = repoV2.releaseChannels.toRepoReleaseChannel(repoId).toSet() + Assert.assertEquals(expectedReleaseChannels, repo.releaseChannels.toSet()) + // core repo + val coreRepo = repoV2.toCoreRepository().copy(repoId = repoId) + Assert.assertEquals(coreRepo, repo.repository) + } + +} diff --git a/database/src/androidTest/java/org/fdroid/database/TestUtils.kt b/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt similarity index 52% rename from database/src/androidTest/java/org/fdroid/database/TestUtils.kt rename to database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt index bf426ae30..572cdf263 100644 --- a/database/src/androidTest/java/org/fdroid/database/TestUtils.kt +++ b/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt @@ -1,12 +1,5 @@ -package org.fdroid.database +package org.fdroid.database.test -import org.fdroid.index.v2.AntiFeatureV2 -import org.fdroid.index.v2.CategoryV2 -import org.fdroid.index.v2.FileV2 -import org.fdroid.index.v2.LocalizedTextV2 -import org.fdroid.index.v2.MirrorV2 -import org.fdroid.index.v2.ReleaseChannelV2 -import org.fdroid.index.v2.RepoV2 import kotlin.random.Random object TestUtils { @@ -22,7 +15,7 @@ object TestUtils { size: Int = Random.nextInt(0, 23), factory: () -> T, ): List = if (size == 0) emptyList() else buildList { - repeat(Random.nextInt(0, size)) { + repeat(size) { add(factory()) } } @@ -37,45 +30,10 @@ object TestUtils { } } - private fun T.orNull(): T? { + fun T.orNull(): T? { return if (Random.nextBoolean()) null else this } - fun getRandomMirror() = MirrorV2( - url = getRandomString(), - location = getRandomString().orNull() - ) - - fun getRandomLocalizedTextV2(size: Int = Random.nextInt(0, 23)): LocalizedTextV2 = buildMap { - repeat(size) { - put(getRandomString(4), getRandomString()) - } - } - - fun getRandomFileV2() = FileV2( - name = getRandomString(), - sha256 = getRandomString(64), - size = Random.nextLong(-1, Long.MAX_VALUE) - ) - - fun getRandomRepo() = RepoV2( - name = getRandomString(), - icon = getRandomFileV2(), - address = getRandomString(), - description = getRandomLocalizedTextV2(), - mirrors = getRandomList { getRandomMirror() }, - timestamp = System.currentTimeMillis(), - antiFeatures = getRandomMap { - getRandomString() to AntiFeatureV2(getRandomFileV2(), getRandomLocalizedTextV2()) - }, - categories = getRandomMap { - getRandomString() to CategoryV2(getRandomFileV2(), getRandomLocalizedTextV2()) - }, - releaseChannels = getRandomMap { - getRandomString() to ReleaseChannelV2(getRandomLocalizedTextV2()) - }, - ) - /** * Create a map diff by adding or removing keys. Note that this does not change keys. */ diff --git a/database/src/androidTest/java/org/fdroid/database/test/TestVersionUtils.kt b/database/src/androidTest/java/org/fdroid/database/test/TestVersionUtils.kt new file mode 100644 index 000000000..584226852 --- /dev/null +++ b/database/src/androidTest/java/org/fdroid/database/test/TestVersionUtils.kt @@ -0,0 +1,55 @@ +package org.fdroid.database.test + +import org.fdroid.database.test.TestRepoUtils.getRandomFileV2 +import org.fdroid.database.test.TestRepoUtils.getRandomLocalizedTextV2 +import org.fdroid.database.test.TestUtils.getRandomList +import org.fdroid.database.test.TestUtils.getRandomMap +import org.fdroid.database.test.TestUtils.getRandomString +import org.fdroid.database.test.TestUtils.orNull +import org.fdroid.index.v2.FeatureV2 +import org.fdroid.index.v2.FileV1 +import org.fdroid.index.v2.ManifestV2 +import org.fdroid.index.v2.PackageVersionV2 +import org.fdroid.index.v2.PermissionV2 +import org.fdroid.index.v2.SignatureV2 +import org.fdroid.index.v2.UsesSdkV2 +import kotlin.random.Random + +internal object TestVersionUtils { + + fun getRandomPackageVersionV2() = PackageVersionV2( + added = Random.nextLong(), + file = getRandomFileV2(false).let { + FileV1(it.name, it.sha256!!, it.size) + }, + src = getRandomFileV2().orNull(), + manifest = getRandomManifestV2(), + releaseChannels = getRandomList { getRandomString() }, + antiFeatures = getRandomMap { getRandomString() to getRandomLocalizedTextV2() }, + whatsNew = getRandomLocalizedTextV2(), + ) + + fun getRandomManifestV2() = ManifestV2( + versionName = getRandomString(), + versionCode = Random.nextLong(), + usesSdk = UsesSdkV2( + minSdkVersion = Random.nextInt(), + targetSdkVersion = Random.nextInt(), + ), + maxSdkVersion = Random.nextInt().orNull(), + signer = SignatureV2(getRandomList(Random.nextInt(1, 3)) { + getRandomString(64) + }).orNull(), + usesPermission = getRandomList { + PermissionV2(getRandomString(), Random.nextInt().orNull()) + }, + usesPermissionSdk23 = getRandomList { + PermissionV2(getRandomString(), Random.nextInt().orNull()) + }, + nativeCode = getRandomList(Random.nextInt(0, 4)) { getRandomString() }, + features = getRandomList { + FeatureV2(getRandomString(), Random.nextInt().orNull()) + }, + ) + +} diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt new file mode 100644 index 000000000..5021de5f3 --- /dev/null +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -0,0 +1,169 @@ +package org.fdroid.database + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import org.fdroid.index.v2.Author +import org.fdroid.index.v2.Donation +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 + +@Entity( + primaryKeys = ["repoId", "packageId"], + foreignKeys = [ForeignKey( + entity = CoreRepository::class, + parentColumns = ["repoId"], + childColumns = ["repoId"], + onDelete = ForeignKey.CASCADE, + )], +) +data class AppMetadata( + val repoId: Long, + val packageId: String, + val added: Long, + val lastUpdated: Long, + val name: LocalizedTextV2? = null, + val summary: LocalizedTextV2? = null, + val description: LocalizedTextV2? = null, + val webSite: String? = null, + val changelog: String? = null, + val license: String? = null, + val sourceCode: String? = null, + val issueTracker: String? = null, + val translation: String? = null, + val preferredSigner: String? = null, + val video: LocalizedTextV2? = null, + @Embedded(prefix = "author_") val author: Author? = Author(), + @Embedded(prefix = "donation_") val donation: Donation? = Donation(), + val categories: List? = null, +) + +fun MetadataV2.toAppMetadata(repoId: Long, packageId: String) = AppMetadata( + repoId = repoId, + packageId = packageId, + added = added, + lastUpdated = lastUpdated, + name = name, + summary = summary, + description = description, + webSite = webSite, + changelog = changelog, + license = license, + sourceCode = sourceCode, + issueTracker = issueTracker, + translation = translation, + preferredSigner = preferredSigner, + video = video, + author = if (author?.isNull == true) null else author, + donation = if (donation?.isNull == true) null else donation, + categories = categories, +) + +data class App( + val metadata: AppMetadata, + val icon: LocalizedFileV2? = null, + val featureGraphic: LocalizedFileV2? = null, + val promoGraphic: LocalizedFileV2? = null, + val tvBanner: LocalizedFileV2? = null, + val screenshots: Screenshots? = null, +) + +@Entity( + primaryKeys = ["repoId", "packageId", "type", "locale"], + foreignKeys = [ForeignKey( + entity = AppMetadata::class, + parentColumns = ["repoId", "packageId"], + childColumns = ["repoId", "packageId"], + onDelete = ForeignKey.CASCADE, + )], +) +data class LocalizedFile( + val repoId: Long, + val packageId: String, + val type: String, + val locale: String, + val name: String, + val sha256: String? = null, + val size: Long? = null, +) + +fun LocalizedFileV2.toLocalizedFile( + repoId: Long, + packageId: String, + type: String, +): List = map { (locale, file) -> + LocalizedFile( + repoId = repoId, + packageId = packageId, + type = type, + locale = locale, + name = file.name, + sha256 = file.sha256, + size = file.size, + ) +} + +fun List.toLocalizedFileV2(type: String): LocalizedFileV2? = filter { file -> + file.type == type +}.associate { file -> + file.locale to FileV2( + name = file.name, + sha256 = file.sha256, + size = file.size, + ) +}.ifEmpty { null } + +@Entity( + primaryKeys = ["repoId", "packageId", "type", "locale", "name"], + foreignKeys = [ForeignKey( + entity = AppMetadata::class, + parentColumns = ["repoId", "packageId"], + childColumns = ["repoId", "packageId"], + onDelete = ForeignKey.CASCADE, + )], +) +data class LocalizedFileList( + val repoId: Long, + val packageId: String, + val type: String, + val locale: String, + val name: String, + val sha256: String? = null, + val size: Long? = null, +) + +fun LocalizedFileListV2.toLocalizedFileList( + repoId: Long, + packageId: String, + type: String, +): List = flatMap { (locale, files) -> + files.map { file -> + LocalizedFileList( + repoId = repoId, + packageId = packageId, + type = type, + locale = locale, + name = file.name, + sha256 = file.sha256, + size = file.size, + ) + } +} + +fun List.toLocalizedFileListV2(type: String): LocalizedFileListV2? { + val map = HashMap>() + iterator().forEach { file -> + if (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 } +} diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt new file mode 100644 index 000000000..e742535aa --- /dev/null +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -0,0 +1,117 @@ +package org.fdroid.database + +import androidx.annotation.VisibleForTesting +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import org.fdroid.index.v2.LocalizedFileListV2 +import org.fdroid.index.v2.LocalizedFileV2 +import org.fdroid.index.v2.MetadataV2 +import org.fdroid.index.v2.Screenshots + +public interface AppDao { + fun insert(repoId: Long, packageId: String, app: MetadataV2) + fun getApp(repoId: Long, packageId: String): App +} + +@Dao +internal interface AppDaoInt : AppDao { + + @Transaction + override fun insert(repoId: Long, packageId: String, app: MetadataV2) { + insert(app.toAppMetadata(repoId, packageId)) + app.icon.insert(repoId, packageId, "icon") + app.featureGraphic.insert(repoId, packageId, "featureGraphic") + app.promoGraphic.insert(repoId, packageId, "promoGraphic") + app.tvBanner.insert(repoId, packageId, "tvBanner") + app.screenshots?.let { + it.phone.insert(repoId, packageId, "phone") + it.sevenInch.insert(repoId, packageId, "sevenInch") + it.tenInch.insert(repoId, packageId, "tenInch") + it.wear.insert(repoId, packageId, "wear") + it.tv.insert(repoId, packageId, "tv") + } + } + + private fun LocalizedFileV2?.insert(repoId: Long, packageId: String, type: String) { + this?.toLocalizedFile(repoId, packageId, type)?.let { files -> + insert(files) + } + } + + @JvmName("insertLocalizedFileListV2") + private fun LocalizedFileListV2?.insert(repoId: Long, packageId: String, type: String) { + this?.toLocalizedFileList(repoId, packageId, type)?.let { files -> + insertLocalizedFileLists(files) + } + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(appMetadata: AppMetadata) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(localizedFiles: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertLocalizedFileLists(localizedFiles: List) + + /** + * This is needed to support v1 streaming and shouldn't be used for something else. + */ + @Query("UPDATE AppMetadata SET preferredSigner = :preferredSigner WHERE repoId = :repoId AND packageId = :packageId") + fun updatePreferredSigner(repoId: Long, packageId: String, preferredSigner: String?) + + @Transaction + override fun getApp(repoId: Long, packageId: String): App { + val localizedFiles = getLocalizedFiles(repoId, packageId) + val localizedFileList = getLocalizedFileLists(repoId, packageId) + return App( + metadata = getAppMetadata(repoId, packageId), + icon = localizedFiles.toLocalizedFileV2("icon"), + featureGraphic = localizedFiles.toLocalizedFileV2("featureGraphic"), + promoGraphic = localizedFiles.toLocalizedFileV2("promoGraphic"), + tvBanner = localizedFiles.toLocalizedFileV2("tvBanner"), + screenshots = if (localizedFileList.isEmpty()) null else Screenshots( + phone = localizedFileList.toLocalizedFileListV2("phone"), + sevenInch = localizedFileList.toLocalizedFileListV2("sevenInch"), + tenInch = localizedFileList.toLocalizedFileListV2("tenInch"), + wear = localizedFileList.toLocalizedFileListV2("wear"), + tv = localizedFileList.toLocalizedFileListV2("tv"), + ) + ) + } + + @Query("SELECT * FROM AppMetadata WHERE repoId = :repoId AND packageId = :packageId") + fun getAppMetadata(repoId: Long, packageId: String): AppMetadata + + @Query("SELECT * FROM AppMetadata") + fun getAppMetadata(): List + + @Query("SELECT * FROM LocalizedFile WHERE repoId = :repoId AND packageId = :packageId") + fun getLocalizedFiles(repoId: Long, packageId: String): List + + @Query("SELECT * FROM LocalizedFileList WHERE repoId = :repoId AND packageId = :packageId") + fun getLocalizedFileLists(repoId: Long, packageId: String): List + + @Query("SELECT * FROM LocalizedFile") + fun getLocalizedFiles(): List + + @Query("SELECT * FROM LocalizedFileList") + fun getLocalizedFileLists(): List + + @VisibleForTesting + @Query("DELETE FROM AppMetadata WHERE repoId = :repoId AND packageId = :packageId") + fun deleteAppMetadata(repoId: Long, packageId: String) + + @Query("SELECT COUNT(*) FROM AppMetadata") + fun countApps(): Int + + @Query("SELECT COUNT(*) FROM LocalizedFile") + fun countLocalizedFiles(): Int + + @Query("SELECT COUNT(*) FROM LocalizedFileList") + fun countLocalizedFileLists(): Int + +} diff --git a/database/src/main/java/org/fdroid/database/Converters.kt b/database/src/main/java/org/fdroid/database/Converters.kt index 46025de69..f5c263fc2 100644 --- a/database/src/main/java/org/fdroid/database/Converters.kt +++ b/database/src/main/java/org/fdroid/database/Converters.kt @@ -9,6 +9,8 @@ import org.fdroid.index.v2.LocalizedTextV2 internal class Converters { private val localizedTextV2Serializer = MapSerializer(String.serializer(), String.serializer()) + private val mapOfLocalizedTextV2Serializer = + MapSerializer(String.serializer(), localizedTextV2Serializer) @TypeConverter fun fromStringToLocalizedTextV2(value: String?): LocalizedTextV2? { @@ -19,4 +21,25 @@ internal class Converters { fun localizedTextV2toString(text: LocalizedTextV2?): String? { return text?.let { json.encodeToString(localizedTextV2Serializer, it) } } + + @TypeConverter + fun fromStringToMapOfLocalizedTextV2(value: String?): Map? { + return value?.let { json.decodeFromString(mapOfLocalizedTextV2Serializer, it) } + } + + @TypeConverter + fun mapOfLocalizedTextV2toString(text: Map?): String? { + return text?.let { json.encodeToString(mapOfLocalizedTextV2Serializer, it) } + } + + @TypeConverter + fun fromStringToListString(value: String?): List { + return value?.split(',') ?: emptyList() + } + + @TypeConverter + fun listStringToString(text: List?): String? { + if (text.isNullOrEmpty()) return null + return text.joinToString(",") { it.replace(',', '_') } + } } diff --git a/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt new file mode 100644 index 000000000..56692bbe0 --- /dev/null +++ b/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt @@ -0,0 +1,20 @@ +package org.fdroid.database + +import org.fdroid.index.v2.IndexStreamReceiver +import org.fdroid.index.v2.PackageV2 +import org.fdroid.index.v2.RepoV2 + +internal class DbStreamReceiver( + private val db: FDroidDatabase, +) : IndexStreamReceiver { + + override fun receive(repoId: Long, repo: RepoV2) { + db.getRepositoryDaoInt().replace(repoId, repo) + } + + override fun receive(repoId: Long, packageId: String, p: PackageV2) { + db.getAppDaoInt().insert(repoId, packageId, p.metadata) + db.getVersionDaoInt().insert(repoId, packageId, p.versions) + } + +} diff --git a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt new file mode 100644 index 000000000..1a85d854c --- /dev/null +++ b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt @@ -0,0 +1,43 @@ +package org.fdroid.database + +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 + +internal class DbV1StreamReceiver( + private val db: FDroidDatabase, +) : IndexV1StreamReceiver { + + override fun receive(repoId: Long, repo: RepoV2) { + db.getRepositoryDaoInt().replace(repoId, repo) + } + + override fun receive(repoId: Long, packageId: String, m: MetadataV2) { + db.getAppDaoInt().insert(repoId, packageId, m) + } + + override fun receive(repoId: Long, packageId: String, v: Map) { + db.getVersionDaoInt().insert(repoId, packageId, v) + } + + override fun updateRepo( + repoId: Long, + antiFeatures: Map, + categories: Map, + releaseChannels: Map, + ) { + val repoDao = db.getRepositoryDaoInt() + repoDao.insertAntiFeatures(antiFeatures.toRepoAntiFeatures(repoId)) + repoDao.insertCategories(categories.toRepoCategories(repoId)) + repoDao.insertReleaseChannels(releaseChannels.toRepoReleaseChannel(repoId)) + } + + override fun updateAppMetadata(repoId: Long, packageId: String, preferredSigner: String?) { + db.getAppDaoInt().updatePreferredSigner(repoId, packageId, preferredSigner) + } + +} diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index 5f2eed9a5..2c50290ae 100644 --- a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -7,15 +7,25 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters @Database(entities = [ + // repo CoreRepository::class, Mirror::class, AntiFeature::class, Category::class, ReleaseChannel::class, + // packages + AppMetadata::class, + LocalizedFile::class, + LocalizedFileList::class, + // versions + Version::class, + VersionedString::class, ], version = 1) @TypeConverters(Converters::class) internal abstract class FDroidDatabase internal constructor() : RoomDatabase() { abstract fun getRepositoryDaoInt(): RepositoryDaoInt + abstract fun getAppDaoInt(): AppDaoInt + abstract fun getVersionDaoInt(): VersionDaoInt companion object { // Singleton prevents multiple instances of database opening at the same time. diff --git a/database/src/main/java/org/fdroid/database/Repository.kt b/database/src/main/java/org/fdroid/database/Repository.kt index dda0b1bad..424ba04dc 100644 --- a/database/src/main/java/org/fdroid/database/Repository.kt +++ b/database/src/main/java/org/fdroid/database/Repository.kt @@ -17,13 +17,14 @@ import org.fdroid.index.v2.RepoV2 data class CoreRepository( @PrimaryKey(autoGenerate = true) val repoId: Long = 0, val name: String, - @Embedded(prefix = "icon") val icon: FileV2?, + @Embedded(prefix = "icon_") val icon: FileV2?, val address: String, val timestamp: Long, val description: LocalizedTextV2 = emptyMap(), ) -fun RepoV2.toCoreRepository() = CoreRepository( +fun RepoV2.toCoreRepository(repoId: Long = 0) = CoreRepository( + repoId = repoId, name = name, icon = icon, address = address, @@ -88,7 +89,7 @@ fun MirrorV2.toMirror(repoId: Long) = Mirror( data class AntiFeature( val repoId: Long, val name: String, - @Embedded(prefix = "icon") val icon: FileV2? = null, + @Embedded(prefix = "icon_") val icon: FileV2? = null, val description: LocalizedTextV2, ) @@ -113,7 +114,7 @@ fun Map.toRepoAntiFeatures(repoId: Long) = map { data class Category( val repoId: Long, val name: String, - @Embedded(prefix = "icon") val icon: FileV2? = null, + @Embedded(prefix = "icon_") val icon: FileV2? = null, val description: LocalizedTextV2, ) @@ -138,7 +139,7 @@ fun Map.toRepoCategories(repoId: Long) = map { data class ReleaseChannel( val repoId: Long, val name: String, - @Embedded(prefix = "icon") val icon: FileV2? = null, + @Embedded(prefix = "icon_") val icon: FileV2? = null, val description: LocalizedTextV2, ) diff --git a/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/database/src/main/java/org/fdroid/database/RepositoryDao.kt index 9090374e7..373636880 100644 --- a/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -4,7 +4,6 @@ import androidx.annotation.VisibleForTesting import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert -import androidx.room.OnConflictStrategy.ABORT import androidx.room.OnConflictStrategy.REPLACE import androidx.room.Query import androidx.room.Transaction @@ -14,19 +13,28 @@ import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.jsonObject -import org.fdroid.index.ReflectionDiffer.applyDiff +import org.fdroid.index.v2.ReflectionDiffer.applyDiff import org.fdroid.index.IndexParser.json import org.fdroid.index.v2.MirrorV2 import org.fdroid.index.v2.RepoV2 public interface RepositoryDao { - fun insert(repository: RepoV2) + /** + * Use when inserting a new repo for the first time. + */ + fun insert(repository: RepoV2): Long + + /** + * Use when replacing an existing repo with a full index. + * This removes all existing index data associated with this repo from the database. + */ + fun replace(repoId: Long, repository: RepoV2) } @Dao internal interface RepositoryDaoInt : RepositoryDao { - @Insert(onConflict = ABORT) + @Insert(onConflict = REPLACE) fun insert(repository: CoreRepository): Long @Insert(onConflict = REPLACE) @@ -42,8 +50,20 @@ internal interface RepositoryDaoInt : RepositoryDao { fun insertReleaseChannels(repoFeature: List) @Transaction - override fun insert(repository: RepoV2) { + override fun insert(repository: RepoV2): Long { val repoId = insert(repository.toCoreRepository()) + insertRepoTables(repoId, repository) + return repoId + } + + @Transaction + override fun replace(repoId: Long, repository: RepoV2) { + val newRepoId = insert(repository.toCoreRepository(repoId)) + require(newRepoId == repoId) { "New repoId $newRepoId did not match old $repoId" } + 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)) diff --git a/database/src/main/java/org/fdroid/database/Version.kt b/database/src/main/java/org/fdroid/database/Version.kt new file mode 100644 index 000000000..0e32dc175 --- /dev/null +++ b/database/src/main/java/org/fdroid/database/Version.kt @@ -0,0 +1,170 @@ +package org.fdroid.database + +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import org.fdroid.database.VersionedStringType.FEATURE +import org.fdroid.database.VersionedStringType.PERMISSION +import org.fdroid.database.VersionedStringType.PERMISSION_SDK_23 +import org.fdroid.index.v2.FeatureV2 +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.PackageVersionV2 +import org.fdroid.index.v2.PermissionV2 +import org.fdroid.index.v2.SignatureV2 +import org.fdroid.index.v2.UsesSdkV2 + +@Entity( + primaryKeys = ["repoId", "packageId", "versionId"], + foreignKeys = [ForeignKey( + entity = AppMetadata::class, + parentColumns = ["repoId", "packageId"], + childColumns = ["repoId", "packageId"], + onDelete = ForeignKey.CASCADE, + )], +) +data class Version( + val repoId: Long, + val packageId: 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, + val releaseChannels: List? = emptyList(), + val antiFeatures: Map? = null, + val whatsNew: LocalizedTextV2? = null, +) + +fun PackageVersionV2.toVersion(repoId: Long, packageId: String, versionId: String) = Version( + repoId = repoId, + packageId = packageId, + versionId = versionId, + added = added, + file = file, + src = src, + manifest = manifest.toManifest(), + releaseChannels = releaseChannels, + antiFeatures = antiFeatures, + whatsNew = whatsNew, +) + +data class AppVersion( + val version: Version, + val usesPermission: List? = null, + val usesPermissionSdk23: List? = null, + val features: List? = null, +) + +data class AppManifest( + val versionName: String, + val versionCode: Long, + @Embedded(prefix = "usesSdk_") val usesSdk: UsesSdkV2? = null, + val maxSdkVersion: Int? = null, + @Embedded(prefix = "signer_") val signer: SignatureV2? = null, + val nativecode: List? = emptyList(), +) + +fun ManifestV2.toManifest() = AppManifest( + versionName = versionName, + versionCode = versionCode, + usesSdk = usesSdk, + maxSdkVersion = maxSdkVersion, + signer = signer, + nativecode = nativeCode, +) + +enum class VersionedStringType { + PERMISSION, + PERMISSION_SDK_23, + FEATURE, +} + +@Entity( + primaryKeys = ["repoId", "packageId", "versionId", "type", "name"], + foreignKeys = [ForeignKey( + entity = Version::class, + parentColumns = ["repoId", "packageId", "versionId"], + childColumns = ["repoId", "packageId", "versionId"], + onDelete = ForeignKey.CASCADE, + )], +) +data class VersionedString( + val repoId: Long, + val packageId: String, + val versionId: String, + val type: VersionedStringType, + val name: String, + val version: Int? = null, +) + +fun List.toVersionedString( + version: Version, + type: VersionedStringType, +) = map { permission -> + VersionedString( + repoId = version.repoId, + packageId = version.packageId, + versionId = version.versionId, + type = type, + name = permission.name, + version = permission.maxSdkVersion, + ) +} + +fun List.toVersionedString(version: Version) = map { feature -> + VersionedString( + repoId = version.repoId, + packageId = version.packageId, + versionId = version.versionId, + type = FEATURE, + name = feature.name, + version = feature.version, + ) +} + +fun ManifestV2.getVersionedStrings(version: Version): List { + return usesPermission.toVersionedString(version, PERMISSION) + + usesPermissionSdk23.toVersionedString(version, PERMISSION_SDK_23) + + features.toVersionedString(version) +} + +fun List.getPermissions(version: Version) = mapNotNull { v -> + v.map(version, PERMISSION) { + PermissionV2( + name = v.name, + maxSdkVersion = v.version, + ) + } +} + +fun List.getPermissionsSdk23(version: Version) = mapNotNull { v -> + v.map(version, PERMISSION_SDK_23) { + PermissionV2( + name = v.name, + maxSdkVersion = v.version, + ) + } +} + +fun List.getFeatures(version: Version) = mapNotNull { v -> + v.map(version, FEATURE) { + FeatureV2( + name = v.name, + version = v.version, + ) + } +} + +private fun VersionedString.map( + v: Version, + wantedType: VersionedStringType, + factory: () -> T, +): T? { + return if (repoId != v.repoId || packageId != v.packageId || versionId != v.versionId || + type != wantedType + ) null + else factory() +} diff --git a/database/src/main/java/org/fdroid/database/VersionDao.kt b/database/src/main/java/org/fdroid/database/VersionDao.kt new file mode 100644 index 000000000..06edc6f5a --- /dev/null +++ b/database/src/main/java/org/fdroid/database/VersionDao.kt @@ -0,0 +1,79 @@ +package org.fdroid.database + +import androidx.annotation.VisibleForTesting +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import org.fdroid.index.v2.PackageVersionV2 + +public interface VersionDao { + fun insert(repoId: Long, packageId: String, packageVersions: Map) + fun insert(repoId: Long, packageId: String, versionId: String, packageVersion: PackageVersionV2) + fun getAppVersions(repoId: Long, packageId: String): List +} + +@Dao +internal interface VersionDaoInt : VersionDao { + + @Transaction + override fun insert( + repoId: Long, + packageId: String, + packageVersions: Map, + ) { + // TODO maybe the number of queries here can be reduced + packageVersions.entries.forEach { (versionId, packageVersion) -> + insert(repoId, packageId, versionId, packageVersion) + } + } + + @Transaction + override fun insert( + repoId: Long, + packageId: String, + versionId: String, + packageVersion: PackageVersionV2, + ) { + val version = packageVersion.toVersion(repoId, packageId, versionId) + insert(version) + insert(packageVersion.manifest.getVersionedStrings(version)) + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(version: Version) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(versionedString: List) + + @Transaction + override fun getAppVersions(repoId: Long, packageId: String): List { + val versionedStrings = getVersionedStrings(repoId, packageId) + return getVersions(repoId, packageId).map { version -> + AppVersion( + version = version, + usesPermission = versionedStrings.getPermissions(version), + usesPermissionSdk23 = versionedStrings.getPermissionsSdk23(version), + features = versionedStrings.getFeatures(version), + ) + } + } + + @Query("SELECT * FROM Version WHERE repoId = :repoId AND packageId = :packageId") + fun getVersions(repoId: Long, packageId: String): List + + @Query("SELECT * FROM VersionedString WHERE repoId = :repoId AND packageId = :packageId") + fun getVersionedStrings(repoId: Long, packageId: String): List + + @VisibleForTesting + @Query("DELETE FROM Version WHERE repoId = :repoId AND packageId = :packageId AND versionId = :versionId") + fun deleteAppVersion(repoId: Long, packageId: String, versionId: String) + + @Query("SELECT COUNT(*) FROM Version") + fun countAppVersions(): Int + + @Query("SELECT COUNT(*) FROM VersionedString") + fun countVersionedStrings(): Int + +} diff --git a/database/src/test/java/org/fdroid/database/ReflectionTest.kt b/database/src/test/java/org/fdroid/database/ReflectionTest.kt index ebd544414..4f70e11df 100644 --- a/database/src/test/java/org/fdroid/database/ReflectionTest.kt +++ b/database/src/test/java/org/fdroid/database/ReflectionTest.kt @@ -3,7 +3,7 @@ package org.fdroid.database import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject -import org.fdroid.index.ReflectionDiffer.applyDiff +import org.fdroid.index.v2.ReflectionDiffer.applyDiff import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals diff --git a/database/src/test/java/org/fdroid/database/TestUtils.kt b/database/src/test/java/org/fdroid/database/TestUtils.kt index 5df26cc98..5f43ae973 100644 --- a/database/src/test/java/org/fdroid/database/TestUtils.kt +++ b/database/src/test/java/org/fdroid/database/TestUtils.kt @@ -37,7 +37,7 @@ object TestUtils2 { } } - private fun T.orNull(): T? { + fun T.orNull(): T? { return if (Random.nextBoolean()) null else this } From 95266df96a5cb28433a5538d8c523dbb6fa00206 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 16 Mar 2022 15:51:58 -0300 Subject: [PATCH 05/42] [db] Add simple IndexV1Updater (not in final form) just to be able to get a real DB into the app for further testing --- database/build.gradle | 1 + .../org/fdroid/database/IndexV1InsertTest.kt | 7 +-- .../org/fdroid/database/RepositoryTest.kt | 4 +- .../src/main/java/org/fdroid/database/App.kt | 2 +- .../java/org/fdroid/database/RepositoryDao.kt | 22 +++++++-- .../org/fdroid/index/v1/IndexV1Updater.kt | 49 +++++++++++++++++++ 6 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt diff --git a/database/build.gradle b/database/build.gradle index 354e68e58..e4f7cb887 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -41,6 +41,7 @@ android { } dependencies { + implementation project(":download") implementation project(":index") def room_version = "2.4.2" diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt index 4907ec479..c9a154f82 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt @@ -6,14 +6,15 @@ import androidx.test.core.app.ApplicationProvider.getApplicationContext import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.serialization.SerializationException import org.apache.commons.io.input.CountingInputStream -import org.fdroid.index.v2.IndexStreamProcessor import org.fdroid.index.IndexV1StreamProcessor +import org.fdroid.index.v2.IndexStreamProcessor import org.junit.Test import org.junit.runner.RunWith import kotlin.math.roundToInt import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertTrue +import kotlin.test.fail @RunWith(AndroidJUnit4::class) class IndexV1InsertTest : DbTest() { @@ -58,8 +59,8 @@ class IndexV1InsertTest : DbTest() { insertV2ForComparison(2) - val repo1 = repoDao.getRepository(1) - val repo2 = repoDao.getRepository(2) + val repo1 = repoDao.getRepository(1) ?: fail() + val repo2 = repoDao.getRepository(2) ?: fail() assertEquals(repo1.repository, repo2.repository.copy(repoId = 1)) assertEquals(repo1.mirrors, repo2.mirrors.map { it.copy(repoId = 1) }) assertEquals(repo1.antiFeatures, repo2.antiFeatures) diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt index c79d037c5..72c038611 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt @@ -36,11 +36,11 @@ class RepositoryTest : DbTest() { assertRepoEquals(repo2, repos[1]) // remove first repo and check that the database only returns one - repoDao.removeRepository(repos[0].repository) + repoDao.deleteRepository(repos[0].repository) assertEquals(1, repoDao.getRepositories().size) // remove second repo as well and check that all associated data got removed as well - repoDao.removeRepository(repos[1].repository) + repoDao.deleteRepository(repos[1].repository) assertEquals(0, repoDao.getRepositories().size) assertEquals(0, repoDao.getMirrors().size) assertEquals(0, repoDao.getAntiFeatures().size) diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt index 5021de5f3..0897ec93f 100644 --- a/database/src/main/java/org/fdroid/database/App.kt +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -35,7 +35,7 @@ data class AppMetadata( val sourceCode: String? = null, val issueTracker: String? = null, val translation: String? = null, - val preferredSigner: String? = null, + val preferredSigner: String? = null, // TODO use platformSig if an APK matches it val video: LocalizedTextV2? = null, @Embedded(prefix = "author_") val author: Author? = Author(), @Embedded(prefix = "donation_") val donation: Donation? = Donation(), diff --git a/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/database/src/main/java/org/fdroid/database/RepositoryDao.kt index 373636880..e22cf9ff6 100644 --- a/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -13,9 +13,9 @@ import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.jsonObject -import org.fdroid.index.v2.ReflectionDiffer.applyDiff 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 { @@ -49,6 +49,17 @@ internal interface RepositoryDaoInt : RepositoryDao { @Insert(onConflict = REPLACE) fun insertReleaseChannels(repoFeature: List) + @Transaction + fun insertEmptyRepo(address: String): Long { + val repo = CoreRepository( + name = "", + icon = null, + address = address, + timestamp = System.currentTimeMillis(), + ) + return insert(repo) + } + @Transaction override fun insert(repository: RepoV2): Long { val repoId = insert(repository.toCoreRepository()) @@ -72,12 +83,12 @@ internal interface RepositoryDaoInt : RepositoryDao { @Transaction @Query("SELECT * FROM CoreRepository WHERE repoId = :repoId") - fun getRepository(repoId: Long): Repository + fun getRepository(repoId: Long): Repository? @Transaction fun updateRepository(repoId: Long, jsonObject: JsonObject) { // get existing repo - val repo = getRepository(repoId) + val repo = getRepository(repoId) ?: error("Repo $repoId does not exist") // update repo with JSON diff updateRepository(applyDiff(repo.repository, jsonObject)) // replace mirror list, if it is in the diff @@ -217,6 +228,9 @@ internal interface RepositoryDaoInt : RepositoryDao { fun deleteReleaseChannel(repoId: Long, name: String) @Delete - fun removeRepository(repository: CoreRepository) + fun deleteRepository(repository: CoreRepository) + + @Query("DELETE FROM CoreRepository WHERE repoId = :repoId") + fun deleteRepository(repoId: Long) } diff --git a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt new file mode 100644 index 000000000..036ed7209 --- /dev/null +++ b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt @@ -0,0 +1,49 @@ +package org.fdroid.index.v1 + +import android.content.Context +import org.fdroid.database.DbV1StreamReceiver +import org.fdroid.database.FDroidDatabase +import org.fdroid.download.Downloader +import org.fdroid.index.IndexV1StreamProcessor +import java.io.File +import java.io.IOException + +// TODO should this live here and cause a dependency on download lib or in dedicated module? +public class IndexV1Updater( + context: Context, + private val file: File, + private val downloader: Downloader, + ) { + + private val db: FDroidDatabase = FDroidDatabase.getDb(context, "test") // TODO final name + + @Throws(IOException::class, InterruptedException::class) + fun update(address: String, expectedSigningFingerprint: String?) { + val repoId = db.getRepositoryDaoInt().insertEmptyRepo(address) + try { + update(repoId, null, expectedSigningFingerprint) + } catch (e: Throwable) { + db.getRepositoryDaoInt().deleteRepository(repoId) + throw e + } + db.getRepositoryDaoInt().getRepositories().forEach { println(it) } + } + + @Throws(IOException::class, InterruptedException::class) + fun update(repoId: Long, certificate: String) { + update(repoId, certificate, null) + } + + @Throws(IOException::class, InterruptedException::class) + private fun update(repoId: Long, certificate: String?, fingerprint: String?) { + downloader.download() + val verifier = IndexV1Verifier(file, certificate, fingerprint) + db.runInTransaction { + verifier.getStreamAndVerify { inputStream -> + val streamProcessor = IndexV1StreamProcessor(DbV1StreamReceiver(db)) + streamProcessor.process(repoId, inputStream) + } + } + } + +} From 50bb9ce60c05bb1a9bf12ba06a74f36008d22828 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 17 Mar 2022 15:24:07 -0300 Subject: [PATCH 06/42] [db] Prepare DB for use by UI Add AppOverviewItem and certificates for repos --- database/build.gradle | 4 + .../java/org/fdroid/database/AppTest.kt | 52 ++++++++++ .../java/org/fdroid/database/DbTest.kt | 8 +- .../org/fdroid/database/IndexV1InsertTest.kt | 8 +- .../org/fdroid/database/IndexV2InsertTest.kt | 4 +- .../org/fdroid/database/RepositoryDiffTest.kt | 59 ++++++----- .../org/fdroid/database/RepositoryTest.kt | 20 +++- .../org/fdroid/database/test/TestRepoUtils.kt | 51 ++++++---- .../org/fdroid/database/test/TestUtils.kt | 20 ++++ .../src/main/java/org/fdroid/database/App.kt | 84 +++++++++++++--- .../main/java/org/fdroid/database/AppDao.kt | 46 +++++++++ .../org/fdroid/database/DbStreamReceiver.kt | 8 +- .../org/fdroid/database/DbV1StreamReceiver.kt | 12 +-- .../org/fdroid/database/FDroidDatabase.kt | 55 +++++++---- .../java/org/fdroid/database/Repository.kt | 51 +++++++--- .../java/org/fdroid/database/RepositoryDao.kt | 97 ++++++++++++------- .../org/fdroid/index/v1/IndexV1Updater.kt | 20 ++-- 17 files changed, 439 insertions(+), 160 deletions(-) diff --git a/database/build.gradle b/database/build.gradle index e4f7cb887..a4c21f86f 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -44,6 +44,9 @@ dependencies { implementation project(":download") implementation project(":index") + implementation 'androidx.core:core-ktx:1.5.0' + implementation 'androidx.lifecycle:lifecycle-livedata-core-ktx:2.4.1' + def room_version = "2.4.2" implementation "androidx.room:room-runtime:$room_version" implementation "androidx.room:room-ktx:$room_version" @@ -58,6 +61,7 @@ dependencies { testImplementation 'org.jetbrains.kotlin:kotlin-test' androidTestImplementation 'org.jetbrains.kotlin:kotlin-test' androidTestImplementation 'androidx.test.ext:junit:1.1.3' + androidTestImplementation 'androidx.arch.core:core-testing:2.1.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' androidTestImplementation 'commons-io:commons-io:2.6' diff --git a/database/src/androidTest/java/org/fdroid/database/AppTest.kt b/database/src/androidTest/java/org/fdroid/database/AppTest.kt index 16a0ccbc8..4a755d079 100644 --- a/database/src/androidTest/java/org/fdroid/database/AppTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/AppTest.kt @@ -1,17 +1,25 @@ package org.fdroid.database +import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fdroid.database.test.TestAppUtils.assertScreenshotsEqual import org.fdroid.database.test.TestAppUtils.getRandomMetadataV2 +import org.fdroid.database.test.TestRepoUtils.getRandomFileV2 import org.fdroid.database.test.TestRepoUtils.getRandomRepo +import org.fdroid.database.test.TestUtils.getOrAwaitValue import org.fdroid.database.test.TestUtils.getRandomString +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertEquals +import kotlin.test.fail @RunWith(AndroidJUnit4::class) class AppTest : DbTest() { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + private val packageId = getRandomString() @Test @@ -37,4 +45,48 @@ class AppTest : DbTest() { assertEquals(0, appDao.getLocalizedFileLists().size) } + @Test + fun testAppOverViewItem() { + val repoId = repoDao.insert(getRandomRepo()) + val packageId1 = getRandomString() + val packageId2 = getRandomString() + val packageId3 = getRandomString() + val name1 = mapOf("en-US" to "1") + val name2 = mapOf("en-US" to "2") + val name3 = mapOf("en-US" to "3") + val icons1 = mapOf("foo" to getRandomFileV2(), "bar" to getRandomFileV2()) + val icons2 = mapOf("23" to getRandomFileV2(), "42" to getRandomFileV2()) + val app1 = getRandomMetadataV2().copy(name = name1, icon = icons1) + val app2 = getRandomMetadataV2().copy(name = name2, icon = icons2) + val app3 = getRandomMetadataV2().copy(name = name3, icon = null) + appDao.insert(repoId, packageId1, app1) + appDao.insert(repoId, packageId2, app2) + + // icons of both apps are returned correctly + val apps = appDao.getAppOverviewItems().getOrAwaitValue() ?: fail() + assertEquals(2, apps.size) + assertEquals(icons1, + apps.find { it.packageId == packageId1 }?.localizedIcon?.toLocalizedFileV2()) + assertEquals(icons2, + apps.find { it.packageId == packageId2 }?.localizedIcon?.toLocalizedFileV2()) + + // app without icon is returned as well + appDao.insert(repoId, packageId3, app3) + val apps3 = appDao.getAppOverviewItems().getOrAwaitValue() ?: fail() + assertEquals(3, apps3.size) + assertEquals(icons1, + apps3.find { it.packageId == packageId1 }?.localizedIcon?.toLocalizedFileV2()) + assertEquals(icons2, + apps3.find { it.packageId == packageId2 }?.localizedIcon?.toLocalizedFileV2()) + assertEquals(emptyList(), apps3.find { it.packageId == packageId3 }!!.localizedIcon) + + // app4 is the same as app1 + val repoId2 = repoDao.insert(getRandomRepo()) + val app4 = getRandomMetadataV2().copy(name = name2, icon = icons2) + appDao.insert(repoId2, packageId1, app4) + + val apps4 = appDao.getAppOverviewItems().getOrAwaitValue() ?: fail() + assertEquals(4, apps4.size) + } + } diff --git a/database/src/androidTest/java/org/fdroid/database/DbTest.kt b/database/src/androidTest/java/org/fdroid/database/DbTest.kt index 0bc1bb774..cabbb1721 100644 --- a/database/src/androidTest/java/org/fdroid/database/DbTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/DbTest.kt @@ -15,14 +15,14 @@ abstract class DbTest { internal lateinit var repoDao: RepositoryDaoInt internal lateinit var appDao: AppDaoInt internal lateinit var versionDao: VersionDaoInt - internal lateinit var db: FDroidDatabase + internal lateinit var db: FDroidDatabaseInt @Before fun createDb() { val context = ApplicationProvider.getApplicationContext() - db = Room.inMemoryDatabaseBuilder(context, FDroidDatabase::class.java).build() - repoDao = db.getRepositoryDaoInt() - appDao = db.getAppDaoInt() + db = Room.inMemoryDatabaseBuilder(context, FDroidDatabaseInt::class.java).build() + repoDao = db.getRepositoryDao() + appDao = db.getAppDao() versionDao = db.getVersionDaoInt() } diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt index c9a154f82..ba4812f77 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt @@ -6,7 +6,7 @@ import androidx.test.core.app.ApplicationProvider.getApplicationContext import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.serialization.SerializationException import org.apache.commons.io.input.CountingInputStream -import org.fdroid.index.IndexV1StreamProcessor +import org.fdroid.index.v1.IndexV1StreamProcessor import org.fdroid.index.v2.IndexStreamProcessor import org.junit.Test import org.junit.runner.RunWith @@ -25,7 +25,7 @@ class IndexV1InsertTest : DbTest() { val fileSize = c.resources.assets.openFd("index-v1.json").use { it.length } val inputStream = CountingInputStream(c.resources.assets.open("index-v1.json")) var currentByteCount: Long = 0 - val indexProcessor = IndexV1StreamProcessor(DbV1StreamReceiver(db)) { + val indexProcessor = IndexV1StreamProcessor(DbV1StreamReceiver(db), null) { val bytesRead = inputStream.byteCount val bytesSinceLastCall = bytesRead - currentByteCount if (bytesSinceLastCall > 0) { @@ -109,7 +109,7 @@ class IndexV1InsertTest : DbTest() { private fun insertV2ForComparison(repoId: Long) { val c = getApplicationContext() val inputStream = CountingInputStream(c.resources.assets.open("index-v2.json")) - val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db)) + val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db), null) db.runInTransaction { inputStream.use { indexStream -> indexProcessor.process(repoId, indexStream) @@ -121,7 +121,7 @@ class IndexV1InsertTest : DbTest() { fun testExceptionWhileStreamingDoesNotSaveIntoDb() { val c = getApplicationContext() val cIn = CountingInputStream(c.resources.assets.open("index-v1.json")) - val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db)) { + val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db), null) { if (cIn.byteCount > 824096) throw SerializationException() cIn.byteCount } diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt index 09a2f589f..a652d794f 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt @@ -22,7 +22,7 @@ class IndexV2InsertTest : DbTest() { val fileSize = c.resources.assets.openFd("index-v2.json").use { it.length } val inputStream = CountingInputStream(c.resources.assets.open("index-v2.json")) var currentByteCount: Long = 0 - val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db)) { + val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db), null) { val bytesRead = inputStream.byteCount val bytesSinceLastCall = bytesRead - currentByteCount if (bytesSinceLastCall > 0) { @@ -59,7 +59,7 @@ class IndexV2InsertTest : DbTest() { fun testExceptionWhileStreamingDoesNotSaveIntoDb() { val c = getApplicationContext() val cIn = CountingInputStream(c.resources.assets.open("index-v2.json")) - val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db)) { + val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db), null) { if (cIn.byteCount > 824096) throw SerializationException() cIn.byteCount } diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt index 105e4a62b..e6588eb93 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt @@ -40,7 +40,7 @@ class RepositoryDiffTest : DbTest() { } """.trimIndent() testDiff(repo, json) { repos -> - assertEquals(updateTimestamp, repos[0].repository.timestamp) + assertEquals(updateTimestamp, repos[0].timestamp) assertRepoEquals(repo.copy(timestamp = updateTimestamp), repos[0]) } } @@ -55,9 +55,9 @@ class RepositoryDiffTest : DbTest() { repoDao.insert(getRandomRepo()) // check that the repo got added and retrieved as expected - var repos = repoDao.getRepositories().sortedBy { it.repository.repoId } + var repos = repoDao.getRepositories().sortedBy { it.repoId } assertEquals(2, repos.size) - val repoId = repos[0].repository.repoId + val repoId = repos[0].repoId val updateTimestamp = Random.nextLong() val json = """ @@ -71,10 +71,10 @@ class RepositoryDiffTest : DbTest() { repoDao.updateRepository(repoId, diff) // fetch repos again and check that the result is as expected - repos = repoDao.getRepositories().sortedBy { it.repository.repoId } + repos = repoDao.getRepositories().sortedBy { it.repoId } assertEquals(2, repos.size) - assertEquals(repoId, repos[0].repository.repoId) - assertEquals(updateTimestamp, repos[0].repository.timestamp) + assertEquals(repoId, repos[0].repoId) + assertEquals(updateTimestamp, repos[0].timestamp) assertRepoEquals(repo.copy(timestamp = updateTimestamp), repos[0]) } @@ -88,7 +88,7 @@ class RepositoryDiffTest : DbTest() { } """.trimIndent() testDiff(repo, json) { repos -> - assertEquals(updateIcon, repos[0].repository.icon) + assertEquals(updateIcon, repos[0].icon) assertRepoEquals(repo.copy(icon = updateIcon), repos[0]) } } @@ -103,7 +103,7 @@ class RepositoryDiffTest : DbTest() { } """.trimIndent() testDiff(repo, json) { repos -> - assertEquals(updateIcon, repos[0].repository.icon) + assertEquals(updateIcon, repos[0].icon) assertRepoEquals(repo.copy(icon = updateIcon), repos[0]) } } @@ -117,7 +117,7 @@ class RepositoryDiffTest : DbTest() { } """.trimIndent() testDiff(repo, json) { repos -> - assertEquals(null, repos[0].repository.icon) + assertEquals(null, repos[0].icon) assertRepoEquals(repo.copy(icon = null), repos[0]) } } @@ -137,7 +137,7 @@ class RepositoryDiffTest : DbTest() { """.trimIndent() testDiff(repo, json) { repos -> val expectedMirrors = updateMirrors.map { mirror -> - mirror.toMirror(repos[0].repository.repoId) + mirror.toMirror(repos[0].repoId) }.toSet() assertEquals(expectedMirrors, repos[0].mirrors.toSet()) assertRepoEquals(repo.copy(mirrors = updateMirrors), repos[0]) @@ -155,7 +155,7 @@ class RepositoryDiffTest : DbTest() { """.trimIndent() val expectedText = if (updateText == null) emptyMap() else mapOf("en" to "foo") testDiff(repo, json) { repos -> - assertEquals(expectedText, repos[0].repository.description) + assertEquals(expectedText, repos[0].description) assertRepoEquals(repo.copy(description = expectedText), repos[0]) } } @@ -163,10 +163,14 @@ class RepositoryDiffTest : DbTest() { @Test fun antiFeaturesDiff() { val repo = getRandomRepo().copy(antiFeatures = getRandomMap { - getRandomString() to AntiFeatureV2(getRandomFileV2(), getRandomLocalizedTextV2()) + getRandomString() to AntiFeatureV2( + icon = getRandomFileV2(), + name = getRandomLocalizedTextV2(), + description = getRandomLocalizedTextV2(), + ) }) val antiFeatures = repo.antiFeatures.randomDiff { - AntiFeatureV2(getRandomFileV2(), getRandomLocalizedTextV2()) + AntiFeatureV2(getRandomFileV2(), getRandomLocalizedTextV2(), getRandomLocalizedTextV2()) } val json = """ { @@ -176,7 +180,7 @@ class RepositoryDiffTest : DbTest() { testDiff(repo, json) { repos -> val expectedFeatures = repo.antiFeatures.applyDiff(antiFeatures) val expectedRepoAntiFeatures = - expectedFeatures.toRepoAntiFeatures(repos[0].repository.repoId) + expectedFeatures.toRepoAntiFeatures(repos[0].repoId) assertEquals(expectedRepoAntiFeatures.toSet(), repos[0].antiFeatures.toSet()) assertRepoEquals(repo.copy(antiFeatures = expectedFeatures), repos[0]) } @@ -190,10 +194,14 @@ class RepositoryDiffTest : DbTest() { @Test fun categoriesDiff() { val repo = getRandomRepo().copy(categories = getRandomMap { - getRandomString() to CategoryV2(getRandomFileV2(), getRandomLocalizedTextV2()) + getRandomString() to CategoryV2( + icon = getRandomFileV2(), + name = getRandomLocalizedTextV2(), + description = getRandomLocalizedTextV2(), + ) }) val categories = repo.categories.randomDiff { - CategoryV2(getRandomFileV2(), getRandomLocalizedTextV2()) + CategoryV2(getRandomFileV2(), getRandomLocalizedTextV2(), getRandomLocalizedTextV2()) } val json = """ { @@ -203,7 +211,7 @@ class RepositoryDiffTest : DbTest() { testDiff(repo, json) { repos -> val expectedFeatures = repo.categories.applyDiff(categories) val expectedRepoCategories = - expectedFeatures.toRepoCategories(repos[0].repository.repoId) + expectedFeatures.toRepoCategories(repos[0].repoId) assertEquals(expectedRepoCategories.toSet(), repos[0].categories.toSet()) assertRepoEquals(repo.copy(categories = expectedFeatures), repos[0]) } @@ -217,10 +225,13 @@ class RepositoryDiffTest : DbTest() { @Test fun releaseChannelsDiff() { val repo = getRandomRepo().copy(releaseChannels = getRandomMap { - getRandomString() to ReleaseChannelV2(getRandomLocalizedTextV2()) + getRandomString() to ReleaseChannelV2( + name = getRandomLocalizedTextV2(), + description = getRandomLocalizedTextV2(), + ) }) val releaseChannels = repo.releaseChannels.randomDiff { - ReleaseChannelV2(getRandomLocalizedTextV2()) + ReleaseChannelV2(getRandomLocalizedTextV2(), getRandomLocalizedTextV2()) } val json = """ { @@ -230,7 +241,7 @@ class RepositoryDiffTest : DbTest() { testDiff(repo, json) { repos -> val expectedFeatures = repo.releaseChannels.applyDiff(releaseChannels) val expectedRepoReleaseChannels = - expectedFeatures.toRepoReleaseChannel(repos[0].repository.repoId) + expectedFeatures.toRepoReleaseChannel(repos[0].repoId) assertEquals(expectedRepoReleaseChannels.toSet(), repos[0].releaseChannels.toSet()) assertRepoEquals(repo.copy(releaseChannels = expectedFeatures), repos[0]) } @@ -248,16 +259,16 @@ class RepositoryDiffTest : DbTest() { // check that the repo got added and retrieved as expected var repos = repoDao.getRepositories() assertEquals(1, repos.size) - val repoId = repos[0].repository.repoId + val repoId = repos[0].repoId // decode diff from JSON and update DB with it - val diff = j.parseToJsonElement(json).jsonObject // Json.decodeFromString(json) + val diff = j.parseToJsonElement(json).jsonObject repoDao.updateRepository(repoId, diff) // fetch repos again and check that the result is as expected - repos = repoDao.getRepositories().sortedBy { it.repository.repoId } + repos = repoDao.getRepositories().sortedBy { it.repoId } assertEquals(1, repos.size) - assertEquals(repoId, repos[0].repository.repoId) + assertEquals(repoId, repos[0].repoId) repoChecker(repos) } diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt index 72c038611..91be73bb1 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt @@ -30,7 +30,7 @@ class RepositoryTest : DbTest() { repoDao.insert(repo2) // check that both repos got added and retrieved as expected - repos = repoDao.getRepositories().sortedBy { it.repository.repoId } + repos = repoDao.getRepositories().sortedBy { it.repoId } assertEquals(2, repos.size) assertRepoEquals(repo1, repos[0]) assertRepoEquals(repo2, repos[1]) @@ -62,12 +62,28 @@ class RepositoryTest : DbTest() { assertEquals(1, versionDao.getAppVersions(repoId, packageId).size) assertTrue(versionDao.getVersionedStrings(repoId, packageId).isNotEmpty()) - repoDao.replace(repoId, getRandomRepo()) + val cert = getRandomString() + repoDao.replace(repoId, getRandomRepo(), cert) assertEquals(1, repoDao.getRepositories().size) assertEquals(0, appDao.getAppMetadata().size) assertEquals(0, appDao.getLocalizedFiles().size) assertEquals(0, appDao.getLocalizedFileLists().size) assertEquals(0, versionDao.getAppVersions(repoId, packageId).size) assertEquals(0, versionDao.getVersionedStrings(repoId, packageId).size) + + assertEquals(cert, repoDao.getRepository(repoId)?.certificate) + } + + @Test + fun certGetsUpdates() { + val repoId = repoDao.insert(getRandomRepo()) + assertEquals(1, repoDao.getRepositories().size) + assertEquals(null, repoDao.getRepositories()[0].certificate) + + val cert = getRandomString() + repoDao.updateRepository(repoId, cert) + + assertEquals(1, repoDao.getRepositories().size) + assertEquals(cert, repoDao.getRepositories()[0].certificate) } } diff --git a/database/src/androidTest/java/org/fdroid/database/test/TestRepoUtils.kt b/database/src/androidTest/java/org/fdroid/database/test/TestRepoUtils.kt index e6268e8f6..b28515053 100644 --- a/database/src/androidTest/java/org/fdroid/database/test/TestRepoUtils.kt +++ b/database/src/androidTest/java/org/fdroid/database/test/TestRepoUtils.kt @@ -1,6 +1,8 @@ package org.fdroid.database.test import org.fdroid.database.Repository +import org.fdroid.database.test.TestUtils.getRandomList +import org.fdroid.database.test.TestUtils.getRandomString import org.fdroid.database.test.TestUtils.orNull import org.fdroid.database.toCoreRepository import org.fdroid.database.toMirror @@ -14,67 +16,78 @@ import org.fdroid.index.v2.LocalizedTextV2 import org.fdroid.index.v2.MirrorV2 import org.fdroid.index.v2.ReleaseChannelV2 import org.fdroid.index.v2.RepoV2 -import org.junit.Assert +import org.junit.Assert.assertEquals import kotlin.random.Random object TestRepoUtils { fun getRandomMirror() = MirrorV2( - url = TestUtils.getRandomString(), - location = TestUtils.getRandomString().orNull() + url = getRandomString(), + location = getRandomString().orNull() ) fun getRandomLocalizedTextV2(size: Int = Random.nextInt(0, 23)): LocalizedTextV2 = buildMap { repeat(size) { - put(TestUtils.getRandomString(4), TestUtils.getRandomString()) + put(getRandomString(4), getRandomString()) } } fun getRandomFileV2(sha256Nullable: Boolean = true) = FileV2( - name = TestUtils.getRandomString(), - sha256 = TestUtils.getRandomString(64).also { if (sha256Nullable) orNull() }, + name = getRandomString(), + sha256 = getRandomString(64).also { if (sha256Nullable) orNull() }, size = Random.nextLong(-1, Long.MAX_VALUE) ) fun getRandomLocalizedFileV2() = TestUtils.getRandomMap(Random.nextInt(1, 8)) { - TestUtils.getRandomString(4) to getRandomFileV2() + getRandomString(4) to getRandomFileV2() } fun getRandomRepo() = RepoV2( - name = TestUtils.getRandomString(), + name = getRandomString(), icon = getRandomFileV2(), - address = TestUtils.getRandomString(), + address = getRandomString(), description = getRandomLocalizedTextV2(), - mirrors = TestUtils.getRandomList { getRandomMirror() }, + mirrors = getRandomList { getRandomMirror() }, timestamp = System.currentTimeMillis(), antiFeatures = TestUtils.getRandomMap { - TestUtils.getRandomString() to AntiFeatureV2(getRandomFileV2(), getRandomLocalizedTextV2()) + getRandomString() to AntiFeatureV2( + icon = getRandomFileV2(), + name = getRandomLocalizedTextV2(), + description = getRandomLocalizedTextV2(), + ) }, categories = TestUtils.getRandomMap { - TestUtils.getRandomString() to CategoryV2(getRandomFileV2(), getRandomLocalizedTextV2()) + getRandomString() to CategoryV2( + icon = getRandomFileV2(), + name = getRandomLocalizedTextV2(), + description = getRandomLocalizedTextV2(), + ) }, releaseChannels = TestUtils.getRandomMap { - TestUtils.getRandomString() to ReleaseChannelV2(getRandomLocalizedTextV2()) + getRandomString() to ReleaseChannelV2( + name = getRandomLocalizedTextV2(), + description = getRandomLocalizedTextV2(), + ) }, ) internal fun assertRepoEquals(repoV2: RepoV2, repo: Repository) { - val repoId = repo.repository.repoId + val repoId = repo.repoId // mirrors val expectedMirrors = repoV2.mirrors.map { it.toMirror(repoId) }.toSet() - Assert.assertEquals(expectedMirrors, repo.mirrors.toSet()) + assertEquals(expectedMirrors, repo.mirrors.toSet()) // anti-features val expectedAntiFeatures = repoV2.antiFeatures.toRepoAntiFeatures(repoId).toSet() - Assert.assertEquals(expectedAntiFeatures, repo.antiFeatures.toSet()) + assertEquals(expectedAntiFeatures, repo.antiFeatures.toSet()) // categories val expectedCategories = repoV2.categories.toRepoCategories(repoId).toSet() - Assert.assertEquals(expectedCategories, repo.categories.toSet()) + assertEquals(expectedCategories, repo.categories.toSet()) // release channels val expectedReleaseChannels = repoV2.releaseChannels.toRepoReleaseChannel(repoId).toSet() - Assert.assertEquals(expectedReleaseChannels, repo.releaseChannels.toSet()) + assertEquals(expectedReleaseChannels, repo.releaseChannels.toSet()) // core repo val coreRepo = repoV2.toCoreRepository().copy(repoId = repoId) - Assert.assertEquals(coreRepo, repo.repository) + assertEquals(coreRepo, repo.repository) } } diff --git a/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt b/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt index 572cdf263..2ff540aa5 100644 --- a/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt +++ b/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt @@ -1,5 +1,9 @@ package org.fdroid.database.test +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit import kotlin.random.Random object TestUtils { @@ -54,4 +58,20 @@ object TestUtils { } } + fun LiveData.getOrAwaitValue(): T? { + val data = arrayOfNulls(1) + val latch = CountDownLatch(1) + val observer: Observer = object : Observer { + override fun onChanged(o: T?) { + data[0] = o + latch.countDown() + removeObserver(this) + } + } + observeForever(observer) + latch.await(2, TimeUnit.SECONDS) + @Suppress("UNCHECKED_CAST") + return data[0] as T? + } + } diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt index 0897ec93f..c9d3cd975 100644 --- a/database/src/main/java/org/fdroid/database/App.kt +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -1,8 +1,11 @@ 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.index.v2.Author import org.fdroid.index.v2.Donation import org.fdroid.index.v2.FileV2 @@ -72,6 +75,46 @@ data class App( val screenshots: Screenshots? = null, ) +public data class AppOverviewItem( + public val repoId: Long, + public val packageId: String, + public val added: Long, + public val lastUpdated: Long, + private val name: LocalizedTextV2? = null, + private val summary: LocalizedTextV2? = null, + @Relation( + parentColumn = "packageId", + entityColumn = "packageId", + ) + internal val localizedIcon: List? = null, +) { + public fun getName(localeList: LocaleListCompat) = name.getBestLocale(localeList) + public fun getSummary(localeList: LocaleListCompat) = summary.getBestLocale(localeList) + public fun getIcon(localeList: LocaleListCompat) = + localizedIcon?.toLocalizedFileV2().getBestLocale(localeList)?.name +} + +internal fun Map?.getBestLocale(localeList: LocaleListCompat): T? { + if (isNullOrEmpty()) return null + val firstMatch = localeList.getFirstMatch(keys.toTypedArray()) ?: error("not empty: $keys") + val tag = firstMatch.toLanguageTag() + // try first matched tag first (usually has region tag, e.g. de-DE) + return get(tag) ?: run { + // split away region tag and try language only + val langTag = tag.split('-')[0] + // try language, then English and then just take the first of the list + get(langTag) ?: get("en-US") ?: get("en") ?: values.first() + } +} + +interface IFile { + val type: String + val locale: String + val name: String + val sha256: String? + val size: Long? +} + @Entity( primaryKeys = ["repoId", "packageId", "type", "locale"], foreignKeys = [ForeignKey( @@ -84,12 +127,12 @@ data class App( data class LocalizedFile( val repoId: Long, val packageId: String, - val type: String, - val locale: String, - val name: String, - val sha256: String? = null, - val size: Long? = null, -) + override val type: String, + override val locale: String, + override val name: String, + override val sha256: String? = null, + override val size: Long? = null, +) : IFile fun LocalizedFileV2.toLocalizedFile( repoId: Long, @@ -107,15 +150,26 @@ fun LocalizedFileV2.toLocalizedFile( ) } -fun List.toLocalizedFileV2(type: String): LocalizedFileV2? = filter { file -> - file.type == type -}.associate { file -> - file.locale to FileV2( - name = file.name, - sha256 = file.sha256, - size = file.size, - ) -}.ifEmpty { null } +fun List.toLocalizedFileV2(type: String? = null): LocalizedFileV2? { + return (if (type != null) filter { file -> file.type == type } else this).associate { file -> + file.locale to FileV2( + name = file.name, + sha256 = file.sha256, + size = file.size, + ) + }.ifEmpty { null } +} + +@DatabaseView("SELECT * FROM LocalizedFile WHERE type='icon'") +data class LocalizedIcon( + val repoId: Long, + val packageId: 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", "packageId", "type", "locale", "name"], diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index e742535aa..7e7667038 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -1,6 +1,7 @@ package org.fdroid.database import androidx.annotation.VisibleForTesting +import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy @@ -14,6 +15,9 @@ import org.fdroid.index.v2.Screenshots public interface AppDao { fun insert(repoId: Long, packageId: String, app: MetadataV2) fun getApp(repoId: Long, packageId: String): App + fun getAppOverviewItems(limit: Int = 200): LiveData> + fun getAppOverviewItems(category: String, limit: Int = 50): LiveData> + fun getNumberOfAppsInCategory(category: String): Int } @Dao @@ -101,6 +105,48 @@ internal interface AppDaoInt : AppDao { @Query("SELECT * FROM LocalizedFileList") fun getLocalizedFileLists(): List + // sort order from F-Droid + //table + "." + Cols.IS_LOCALIZED + " DESC" + //+ ", " + table + "." + Cols.NAME + " IS NULL ASC" + //+ ", CASE WHEN " + table + "." + Cols.ICON + " IS NULL" + //+ " AND " + table + "." + Cols.ICON_URL + " IS NULL" + //+ " THEN 1 ELSE 0 END" + //+ ", " + table + "." + Cols.SUMMARY + " IS NULL ASC" + //+ ", " + table + "." + Cols.DESCRIPTION + " IS NULL ASC" + //+ ", CASE WHEN " + table + "." + Cols.PHONE_SCREENSHOTS + " IS NULL" + //+ " AND " + table + "." + Cols.SEVEN_INCH_SCREENSHOTS + " IS NULL" + //+ " AND " + table + "." + Cols.TEN_INCH_SCREENSHOTS + " IS NULL" + //+ " AND " + table + "." + Cols.TV_SCREENSHOTS + " IS NULL" + //+ " AND " + table + "." + Cols.WEAR_SCREENSHOTS + " IS NULL" + //+ " AND " + table + "." + Cols.FEATURE_GRAPHIC + " IS NULL" + //+ " AND " + table + "." + Cols.PROMO_GRAPHIC + " IS NULL" + //+ " AND " + table + "." + Cols.TV_BANNER + " IS NULL" + //+ " THEN 1 ELSE 0 END" + //+ ", CASE WHEN date(" + added + ") >= date(" + lastUpdated + ")" + //+ " AND date((SELECT " + RepoTable.Cols.LAST_UPDATED + " FROM " + RepoTable.NAME + //+ " WHERE _id=" + table + "." + Cols.REPO_ID + //+ " ),'-" + AppCardController.DAYS_TO_CONSIDER_NEW + " days') " + //+ " < date(" + lastUpdated + ")" + //+ " THEN 0 ELSE 1 END" + //+ ", " + table + "." + Cols.WHATSNEW + " IS NULL ASC" + //+ ", " + lastUpdated + " DESC" + //+ ", " + added + " ASC"); + @Transaction + @Query("""SELECT repoId, packageId, added, lastUpdated, name, summary FROM AppMetadata + ORDER BY name IS NULL ASC, summary IS NULL ASC, lastUpdated DESC, added ASC LIMIT :limit""") + override fun getAppOverviewItems(limit: Int): LiveData> + + @Transaction + // TODO maybe it makes sense to split categories into their own table for this? + @Query("""SELECT repoId, packageId, added, lastUpdated, name, summary FROM AppMetadata + WHERE categories LIKE '%' || :category || '%' + ORDER BY name IS NULL ASC, summary IS NULL ASC, lastUpdated DESC, added ASC LIMIT :limit""") + override fun getAppOverviewItems(category: String, limit: Int): LiveData> + + // FIXME don't over report the same app twice (e.g. in several repos) + @Query("SELECT COUNT(*) FROM AppMetadata WHERE categories LIKE '%' || :category || '%'") + override fun getNumberOfAppsInCategory(category: String): Int + @VisibleForTesting @Query("DELETE FROM AppMetadata WHERE repoId = :repoId AND packageId = :packageId") fun deleteAppMetadata(repoId: Long, packageId: String) diff --git a/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt index 56692bbe0..59bd231cd 100644 --- a/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt @@ -5,15 +5,15 @@ import org.fdroid.index.v2.PackageV2 import org.fdroid.index.v2.RepoV2 internal class DbStreamReceiver( - private val db: FDroidDatabase, + private val db: FDroidDatabaseInt, ) : IndexStreamReceiver { - override fun receive(repoId: Long, repo: RepoV2) { - db.getRepositoryDaoInt().replace(repoId, repo) + override fun receive(repoId: Long, repo: RepoV2, certificate: String?) { + db.getRepositoryDao().replace(repoId, repo, certificate) } override fun receive(repoId: Long, packageId: String, p: PackageV2) { - db.getAppDaoInt().insert(repoId, packageId, p.metadata) + db.getAppDao().insert(repoId, packageId, p.metadata) db.getVersionDaoInt().insert(repoId, packageId, p.versions) } diff --git a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt index 1a85d854c..7769f38b7 100644 --- a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt @@ -9,15 +9,15 @@ import org.fdroid.index.v2.ReleaseChannelV2 import org.fdroid.index.v2.RepoV2 internal class DbV1StreamReceiver( - private val db: FDroidDatabase, + private val db: FDroidDatabaseInt, ) : IndexV1StreamReceiver { - override fun receive(repoId: Long, repo: RepoV2) { - db.getRepositoryDaoInt().replace(repoId, repo) + override fun receive(repoId: Long, repo: RepoV2, certificate: String?) { + db.getRepositoryDao().replace(repoId, repo, certificate) } override fun receive(repoId: Long, packageId: String, m: MetadataV2) { - db.getAppDaoInt().insert(repoId, packageId, m) + db.getAppDao().insert(repoId, packageId, m) } override fun receive(repoId: Long, packageId: String, v: Map) { @@ -30,14 +30,14 @@ internal class DbV1StreamReceiver( categories: Map, releaseChannels: Map, ) { - val repoDao = db.getRepositoryDaoInt() + val repoDao = db.getRepositoryDao() repoDao.insertAntiFeatures(antiFeatures.toRepoAntiFeatures(repoId)) repoDao.insertCategories(categories.toRepoCategories(repoId)) repoDao.insertReleaseChannels(releaseChannels.toRepoReleaseChannel(repoId)) } override fun updateAppMetadata(repoId: Long, packageId: String, preferredSigner: String?) { - db.getAppDaoInt().updatePreferredSigner(repoId, packageId, preferredSigner) + db.getAppDao().updatePreferredSigner(repoId, packageId, preferredSigner) } } diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index 2c50290ae..3091c6132 100644 --- a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -20,32 +20,45 @@ import androidx.room.TypeConverters // versions Version::class, VersionedString::class, +], views = [ + LocalizedIcon::class, ], version = 1) @TypeConverters(Converters::class) -internal abstract class FDroidDatabase internal constructor() : RoomDatabase() { - abstract fun getRepositoryDaoInt(): RepositoryDaoInt - abstract fun getAppDaoInt(): AppDaoInt +internal abstract class FDroidDatabaseInt internal constructor() : RoomDatabase(), FDroidDatabase { + abstract override fun getRepositoryDao(): RepositoryDaoInt + abstract override fun getAppDao(): AppDaoInt abstract fun getVersionDaoInt(): VersionDaoInt +} - companion object { - // Singleton prevents multiple instances of database opening at the same time. - @Volatile - private var INSTANCE: FDroidDatabase? = null +public interface FDroidDatabase { + fun getRepositoryDao(): RepositoryDao + fun getAppDao(): AppDao + fun runInTransaction(body: Runnable) +} - fun getDb(context: Context, name: String = "fdroid_db"): FDroidDatabase { - // if the INSTANCE is not null, then return it, - // if it is, then create the database - return INSTANCE ?: synchronized(this) { - val instance = Room.databaseBuilder( - context.applicationContext, - FDroidDatabase::class.java, - name, - ).build() - INSTANCE = instance - // return instance - instance - } - } +public object FDroidDatabaseHolder { + // Singleton prevents multiple instances of database opening at the same time. + @Volatile + private var INSTANCE: FDroidDatabaseInt? = null + + @JvmStatic + public fun getDb(context: Context): FDroidDatabase { + return getDb(context, "test") } + internal fun getDb(context: Context, name: String = "fdroid_db"): FDroidDatabase { + // if the INSTANCE is not null, then return it, + // if it is, then create the database + return INSTANCE ?: synchronized(this) { + val instance = Room.databaseBuilder( + context.applicationContext, + FDroidDatabaseInt::class.java, + name, + )//.allowMainThreadQueries() // TODO remove before release + .build() + INSTANCE = instance + // return instance + instance + } + } } diff --git a/database/src/main/java/org/fdroid/database/Repository.kt b/database/src/main/java/org/fdroid/database/Repository.kt index 424ba04dc..b5e22b4b8 100644 --- a/database/src/main/java/org/fdroid/database/Repository.kt +++ b/database/src/main/java/org/fdroid/database/Repository.kt @@ -21,24 +21,26 @@ data class CoreRepository( val address: String, val timestamp: Long, val description: LocalizedTextV2 = emptyMap(), + val certificate: String?, ) -fun RepoV2.toCoreRepository(repoId: Long = 0) = CoreRepository( +fun RepoV2.toCoreRepository(repoId: Long = 0, certificate: String? = null) = CoreRepository( repoId = repoId, name = name, icon = icon, address = address, timestamp = timestamp, description = description, + certificate = certificate, ) data class Repository( - @Embedded val repository: CoreRepository, + @Embedded internal val repository: CoreRepository, @Relation( parentColumn = "repoId", entityColumn = "repoId", ) - val mirrors: List, + internal val mirrors: List, @Relation( parentColumn = "repoId", entityColumn = "repoId", @@ -54,7 +56,17 @@ data class Repository( entityColumn = "repoId", ) val releaseChannels: List, -) +) { + val repoId: Long get() = repository.repoId + val name: String get() = repository.name + val icon: FileV2? get() = repository.icon + val address: String get() = repository.address + val timestamp: Long get() = repository.timestamp + val description: LocalizedTextV2 get() = repository.description + val certificate: String? get() = repository.certificate + + fun getMirrors() = mirrors.map { it.toDownloadMirror() } +} @Entity( primaryKeys = ["repoId", "url"], @@ -69,7 +81,12 @@ data class Mirror( val repoId: Long, val url: String, val location: String? = null, -) +) { + fun toDownloadMirror() = org.fdroid.download.Mirror( + baseUrl = url, + location = location, + ) +} fun MirrorV2.toMirror(repoId: Long) = Mirror( repoId = repoId, @@ -78,7 +95,7 @@ fun MirrorV2.toMirror(repoId: Long) = Mirror( ) @Entity( - primaryKeys = ["repoId", "name"], + primaryKeys = ["repoId", "id"], foreignKeys = [ForeignKey( entity = CoreRepository::class, parentColumns = ["repoId"], @@ -88,22 +105,24 @@ fun MirrorV2.toMirror(repoId: Long) = Mirror( ) data class AntiFeature( val repoId: Long, - val name: String, + val id: String, @Embedded(prefix = "icon_") val icon: FileV2? = null, + val name: LocalizedTextV2, val description: LocalizedTextV2, ) fun Map.toRepoAntiFeatures(repoId: Long) = map { AntiFeature( repoId = repoId, - name = it.key, + id = it.key, icon = it.value.icon, + name = it.value.name, description = it.value.description, ) } @Entity( - primaryKeys = ["repoId", "name"], + primaryKeys = ["repoId", "id"], foreignKeys = [ForeignKey( entity = CoreRepository::class, parentColumns = ["repoId"], @@ -113,22 +132,24 @@ fun Map.toRepoAntiFeatures(repoId: Long) = map { ) data class Category( val repoId: Long, - val name: String, + val id: String, @Embedded(prefix = "icon_") val icon: FileV2? = null, + val name: LocalizedTextV2, val description: LocalizedTextV2, ) fun Map.toRepoCategories(repoId: Long) = map { Category( repoId = repoId, - name = it.key, + id = it.key, icon = it.value.icon, + name = it.value.name, description = it.value.description, ) } @Entity( - primaryKeys = ["repoId", "name"], + primaryKeys = ["repoId", "id"], foreignKeys = [ForeignKey( entity = CoreRepository::class, parentColumns = ["repoId"], @@ -138,15 +159,17 @@ fun Map.toRepoCategories(repoId: Long) = map { ) data class ReleaseChannel( val repoId: Long, - val name: String, + val id: String, @Embedded(prefix = "icon_") val icon: FileV2? = null, + val name: LocalizedTextV2, val description: LocalizedTextV2, ) fun Map.toRepoReleaseChannel(repoId: Long) = map { ReleaseChannel( repoId = repoId, - name = it.key, + id = it.key, + name = it.value.name, description = it.value.description, ) } diff --git a/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/database/src/main/java/org/fdroid/database/RepositoryDao.kt index e22cf9ff6..f72c9ec11 100644 --- a/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -1,6 +1,7 @@ package org.fdroid.database import androidx.annotation.VisibleForTesting +import androidx.lifecycle.LiveData import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert @@ -28,7 +29,15 @@ public interface RepositoryDao { * Use when replacing an existing repo with a full index. * This removes all existing index data associated with this repo from the database. */ - fun replace(repoId: Long, repository: RepoV2) + fun replace(repoId: Long, repository: RepoV2, certificate: String?) + + fun getRepository(repoId: Long): Repository? + fun insertEmptyRepo(address:String): Long + fun deleteRepository(repoId: Long) + fun getRepositories(): List + fun getLiveRepositories(): LiveData> + // FIXME: We probably want unique categories here flattened by repo weight + fun getLiveCategories(): LiveData> } @Dao @@ -50,12 +59,13 @@ internal interface RepositoryDaoInt : RepositoryDao { fun insertReleaseChannels(repoFeature: List) @Transaction - fun insertEmptyRepo(address: String): Long { + override fun insertEmptyRepo(address: String): Long { val repo = CoreRepository( name = "", icon = null, address = address, timestamp = System.currentTimeMillis(), + certificate = null, ) return insert(repo) } @@ -68,8 +78,8 @@ internal interface RepositoryDaoInt : RepositoryDao { } @Transaction - override fun replace(repoId: Long, repository: RepoV2) { - val newRepoId = insert(repository.toCoreRepository(repoId)) + override fun replace(repoId: Long, repository: RepoV2, certificate: String?) { + val newRepoId = insert(repository.toCoreRepository(repoId, certificate)) require(newRepoId == repoId) { "New repoId $newRepoId did not match old $repoId" } insertRepoTables(repoId, repository) } @@ -83,7 +93,7 @@ internal interface RepositoryDaoInt : RepositoryDao { @Transaction @Query("SELECT * FROM CoreRepository WHERE repoId = :repoId") - fun getRepository(repoId: Long): Repository? + override fun getRepository(repoId: Long): Repository? @Transaction fun updateRepository(repoId: Long, jsonObject: JsonObject) { @@ -103,36 +113,36 @@ internal interface RepositoryDaoInt : RepositoryDao { } // diff and update the antiFeatures diffAndUpdateTable( - jsonObject, - "antiFeatures", - repo.antiFeatures, - { name -> AntiFeature(repoId, name, null, emptyMap()) }, - { item -> item.name }, - { deleteAntiFeatures(repoId) }, - { name -> deleteAntiFeature(repoId, name) }, - { list -> insertAntiFeatures(list) }, + jsonObject = jsonObject, + key = "antiFeatures", + itemList = repo.antiFeatures, + newItem = { key -> AntiFeature(repoId, key, null, emptyMap(), emptyMap()) }, + keyGetter = { item -> item.id }, + deleteAll = { deleteAntiFeatures(repoId) }, + deleteOne = { key -> deleteAntiFeature(repoId, key) }, + insertReplace = { list -> insertAntiFeatures(list) }, ) // diff and update the categories diffAndUpdateTable( - jsonObject, - "categories", - repo.categories, - { name -> Category(repoId, name, null, emptyMap()) }, - { item -> item.name }, - { deleteCategories(repoId) }, - { name -> deleteCategory(repoId, name) }, - { list -> insertCategories(list) }, + jsonObject = jsonObject, + key = "categories", + itemList = repo.categories, + newItem = { key -> Category(repoId, key, null, emptyMap(), emptyMap()) }, + keyGetter = { item -> item.id }, + deleteAll = { deleteCategories(repoId) }, + deleteOne = { key -> deleteCategory(repoId, key) }, + insertReplace = { list -> insertCategories(list) }, ) // diff and update the releaseChannels diffAndUpdateTable( - jsonObject, - "releaseChannels", - repo.releaseChannels, - { name -> ReleaseChannel(repoId, name, null, emptyMap()) }, - { item -> item.name }, - { deleteReleaseChannels(repoId) }, - { name -> deleteReleaseChannel(repoId, name) }, - { list -> insertReleaseChannels(list) }, + jsonObject = jsonObject, + key = "releaseChannels", + itemList = repo.releaseChannels, + newItem = { key -> ReleaseChannel(repoId, key, null, emptyMap(), emptyMap()) }, + keyGetter = { item -> item.id }, + deleteAll = { deleteReleaseChannels(repoId) }, + deleteOne = { key -> deleteReleaseChannel(repoId, key) }, + insertReplace = { list -> insertReleaseChannels(list) }, ) } @@ -179,9 +189,16 @@ internal interface RepositoryDaoInt : RepositoryDao { @Update fun updateRepository(repo: CoreRepository): Int + @Query("UPDATE CoreRepository SET certificate = :certificate WHERE repoId = :repoId") + fun updateRepository(repoId: Long, certificate: String) + @Transaction @Query("SELECT * FROM CoreRepository") - fun getRepositories(): List + override fun getRepositories(): List + + @Transaction + @Query("SELECT * FROM CoreRepository") + override fun getLiveRepositories(): LiveData> @VisibleForTesting @Query("SELECT * FROM Mirror") @@ -200,20 +217,26 @@ internal interface RepositoryDaoInt : RepositoryDao { fun deleteAntiFeatures(repoId: Long) @VisibleForTesting - @Query("DELETE FROM AntiFeature WHERE repoId = :repoId AND name = :name") - fun deleteAntiFeature(repoId: Long, name: String) + @Query("DELETE FROM AntiFeature WHERE repoId = :repoId AND id = :id") + fun deleteAntiFeature(repoId: Long, id: String) @VisibleForTesting @Query("SELECT * FROM Category") fun getCategories(): List + @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> + @VisibleForTesting @Query("DELETE FROM Category WHERE repoId = :repoId") fun deleteCategories(repoId: Long) @VisibleForTesting - @Query("DELETE FROM Category WHERE repoId = :repoId AND name = :name") - fun deleteCategory(repoId: Long, name: String) + @Query("DELETE FROM Category WHERE repoId = :repoId AND id = :id") + fun deleteCategory(repoId: Long, id: String) @VisibleForTesting @Query("SELECT * FROM ReleaseChannel") @@ -224,13 +247,13 @@ internal interface RepositoryDaoInt : RepositoryDao { fun deleteReleaseChannels(repoId: Long) @VisibleForTesting - @Query("DELETE FROM ReleaseChannel WHERE repoId = :repoId AND name = :name") - fun deleteReleaseChannel(repoId: Long, name: String) + @Query("DELETE FROM ReleaseChannel WHERE repoId = :repoId AND id = :id") + fun deleteReleaseChannel(repoId: Long, id: String) @Delete fun deleteRepository(repository: CoreRepository) @Query("DELETE FROM CoreRepository WHERE repoId = :repoId") - fun deleteRepository(repoId: Long) + override fun deleteRepository(repoId: Long) } diff --git a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt index 036ed7209..a8042d6b0 100644 --- a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt +++ b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt @@ -2,9 +2,9 @@ package org.fdroid.index.v1 import android.content.Context import org.fdroid.database.DbV1StreamReceiver -import org.fdroid.database.FDroidDatabase +import org.fdroid.database.FDroidDatabaseHolder +import org.fdroid.database.FDroidDatabaseInt import org.fdroid.download.Downloader -import org.fdroid.index.IndexV1StreamProcessor import java.io.File import java.io.IOException @@ -15,18 +15,19 @@ public class IndexV1Updater( private val downloader: Downloader, ) { - private val db: FDroidDatabase = FDroidDatabase.getDb(context, "test") // TODO final name + private val db: FDroidDatabaseInt = + FDroidDatabaseHolder.getDb(context) as FDroidDatabaseInt // TODO final name @Throws(IOException::class, InterruptedException::class) fun update(address: String, expectedSigningFingerprint: String?) { - val repoId = db.getRepositoryDaoInt().insertEmptyRepo(address) + val repoId = db.getRepositoryDao().insertEmptyRepo(address) try { update(repoId, null, expectedSigningFingerprint) } catch (e: Throwable) { - db.getRepositoryDaoInt().deleteRepository(repoId) + db.getRepositoryDao().deleteRepository(repoId) throw e } - db.getRepositoryDaoInt().getRepositories().forEach { println(it) } + db.getRepositoryDao().getRepositories().forEach { println(it) } } @Throws(IOException::class, InterruptedException::class) @@ -39,10 +40,13 @@ public class IndexV1Updater( downloader.download() val verifier = IndexV1Verifier(file, certificate, fingerprint) db.runInTransaction { - verifier.getStreamAndVerify { inputStream -> - val streamProcessor = IndexV1StreamProcessor(DbV1StreamReceiver(db)) + val cert = verifier.getStreamAndVerify { inputStream -> + val streamProcessor = IndexV1StreamProcessor(DbV1StreamReceiver(db), certificate) streamProcessor.process(repoId, inputStream) } + if (certificate == null) { + db.getRepositoryDao().updateRepository(repoId, cert) + } } } From 5908789c29b984e260911c3813e95138a974fa27 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 21 Mar 2022 15:00:36 -0300 Subject: [PATCH 07/42] [db] Add repo preferences as separate table and use it in IndexV1Updater --- .../org/fdroid/database/IndexV1InsertTest.kt | 15 +-- .../org/fdroid/database/IndexV2InsertTest.kt | 3 +- .../org/fdroid/database/RepositoryTest.kt | 18 +++- .../org/fdroid/database/FDroidDatabase.kt | 1 + .../java/org/fdroid/database/Repository.kt | 40 +++++++- .../java/org/fdroid/database/RepositoryDao.kt | 80 ++++++++++++++-- .../org/fdroid/download/DownloaderFactory.kt | 40 ++++++++ .../java/org/fdroid/index/v1/IndexUpdater.kt | 25 +++++ .../org/fdroid/index/v1/IndexV1Updater.kt | 94 +++++++++++++------ 9 files changed, 267 insertions(+), 49 deletions(-) create mode 100644 database/src/main/java/org/fdroid/download/DownloaderFactory.kt create mode 100644 database/src/main/java/org/fdroid/index/v1/IndexUpdater.kt diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt index ba4812f77..38835e78d 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt @@ -40,8 +40,9 @@ class IndexV1InsertTest : DbTest() { } db.runInTransaction { + val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") inputStream.use { indexStream -> - indexProcessor.process(1, indexStream) + indexProcessor.process(repoId, indexStream) } } assertTrue(repoDao.getRepositories().size == 1) @@ -57,15 +58,16 @@ class IndexV1InsertTest : DbTest() { println("Versions: " + versionDao.countAppVersions()) println("Perms/Features: " + versionDao.countVersionedStrings()) - insertV2ForComparison(2) + insertV2ForComparison() val repo1 = repoDao.getRepository(1) ?: fail() val repo2 = repoDao.getRepository(2) ?: fail() assertEquals(repo1.repository, repo2.repository.copy(repoId = 1)) assertEquals(repo1.mirrors, repo2.mirrors.map { it.copy(repoId = 1) }) - assertEquals(repo1.antiFeatures, repo2.antiFeatures) - assertEquals(repo1.categories, repo2.categories) - assertEquals(repo1.releaseChannels, repo2.releaseChannels) + // TODO enable when better test data +// assertEquals(repo1.antiFeatures, repo2.antiFeatures) +// assertEquals(repo1.categories, repo2.categories) +// assertEquals(repo1.releaseChannels, repo2.releaseChannels) val appMetadata = appDao.getAppMetadata() val appMetadata1 = appMetadata.count { it.repoId == 1L } @@ -106,11 +108,12 @@ class IndexV1InsertTest : DbTest() { } @Suppress("SameParameterValue") - private fun insertV2ForComparison(repoId: Long) { + private fun insertV2ForComparison() { val c = getApplicationContext() val inputStream = CountingInputStream(c.resources.assets.open("index-v2.json")) val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db), null) db.runInTransaction { + val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") inputStream.use { indexStream -> indexProcessor.process(repoId, indexStream) } diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt index a652d794f..e6240c057 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt @@ -37,8 +37,9 @@ class IndexV2InsertTest : DbTest() { } db.runInTransaction { + val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") inputStream.use { indexStream -> - indexProcessor.process(1, indexStream) + indexProcessor.process(repoId, indexStream) } } assertTrue(repoDao.getRepositories().size == 1) diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt index 91be73bb1..86c3a38b2 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt @@ -9,6 +9,7 @@ import org.fdroid.database.test.TestVersionUtils.getRandomPackageVersionV2 import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @@ -18,39 +19,47 @@ class RepositoryTest : DbTest() { fun insertAndDeleteTwoRepos() { // insert first repo val repo1 = getRandomRepo() - repoDao.insert(repo1) + val repoId1 = repoDao.insert(repo1) // check that first repo got added and retrieved as expected var repos = repoDao.getRepositories() assertEquals(1, repos.size) assertRepoEquals(repo1, repos[0]) + val repositoryPreferences1 = repoDao.getRepositoryPreferences(repoId1) + assertEquals(repoId1, repositoryPreferences1?.repoId) // insert second repo val repo2 = getRandomRepo() - repoDao.insert(repo2) + val repoId2 = repoDao.insert(repo2) // check that both repos got added and retrieved as expected repos = repoDao.getRepositories().sortedBy { it.repoId } assertEquals(2, repos.size) assertRepoEquals(repo1, repos[0]) assertRepoEquals(repo2, repos[1]) + val repositoryPreferences2 = repoDao.getRepositoryPreferences(repoId2) + assertEquals(repoId2, repositoryPreferences2?.repoId) + assertEquals(repositoryPreferences1?.weight?.plus(1), repositoryPreferences2?.weight) // remove first repo and check that the database only returns one - repoDao.deleteRepository(repos[0].repository) + repoDao.deleteRepository(repos[0].repository.repoId) assertEquals(1, repoDao.getRepositories().size) // remove second repo as well and check that all associated data got removed as well - repoDao.deleteRepository(repos[1].repository) + repoDao.deleteRepository(repos[1].repository.repoId) assertEquals(0, repoDao.getRepositories().size) assertEquals(0, repoDao.getMirrors().size) assertEquals(0, repoDao.getAntiFeatures().size) assertEquals(0, repoDao.getCategories().size) assertEquals(0, repoDao.getReleaseChannels().size) + assertNull(repoDao.getRepositoryPreferences(repoId1)) + assertNull(repoDao.getRepositoryPreferences(repoId2)) } @Test fun replacingRepoRemovesAllAssociatedData() { val repoId = repoDao.insert(getRandomRepo()) + val repositoryPreferences = repoDao.getRepositoryPreferences(repoId) val packageId = getRandomString() val versionId = getRandomString() appDao.insert(repoId, packageId, getRandomMetadataV2()) @@ -72,6 +81,7 @@ class RepositoryTest : DbTest() { assertEquals(0, versionDao.getVersionedStrings(repoId, packageId).size) assertEquals(cert, repoDao.getRepository(repoId)?.certificate) + assertEquals(repositoryPreferences, repoDao.getRepositoryPreferences(repoId)) } @Test diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index 3091c6132..a91c94cbc 100644 --- a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -13,6 +13,7 @@ import androidx.room.TypeConverters AntiFeature::class, Category::class, ReleaseChannel::class, + RepositoryPreferences::class, // packages AppMetadata::class, LocalizedFile::class, diff --git a/database/src/main/java/org/fdroid/database/Repository.kt b/database/src/main/java/org/fdroid/database/Repository.kt index b5e22b4b8..ca23ddd78 100644 --- a/database/src/main/java/org/fdroid/database/Repository.kt +++ b/database/src/main/java/org/fdroid/database/Repository.kt @@ -1,5 +1,6 @@ package org.fdroid.database +import androidx.core.os.LocaleListCompat import androidx.room.Embedded import androidx.room.Entity import androidx.room.ForeignKey @@ -56,6 +57,11 @@ data class Repository( entityColumn = "repoId", ) val releaseChannels: List, + @Relation( + parentColumn = "repoId", + entityColumn = "repoId", + ) + internal val preferences: RepositoryPreferences, ) { val repoId: Long get() = repository.repoId val name: String get() = repository.name @@ -65,7 +71,25 @@ data class Repository( val description: LocalizedTextV2 get() = repository.description val certificate: String? get() = repository.certificate - fun getMirrors() = mirrors.map { it.toDownloadMirror() } + val weight: Int get() = preferences.weight + val enabled: Boolean get() = preferences.enabled + val lastUpdated: Long? get() = preferences.lastUpdated + val lastETag: String? get() = preferences.lastETag + val userMirrors: List get() = preferences.userMirrors ?: emptyList() + val disabledMirrors: List get() = preferences.disabledMirrors ?: emptyList() + val username: String? get() = preferences.username + val password: String? get() = preferences.password + val isSwap: Boolean get() = preferences.isSwap + + @JvmOverloads + fun getMirrors(includeUserMirrors: Boolean = true) = mirrors.map { + it.toDownloadMirror() + } + listOf(org.fdroid.download.Mirror(address)) + // FIXME decide whether we need to add this + if (includeUserMirrors) userMirrors.map { + org.fdroid.download.Mirror(it) + } else emptyList() + + fun getDescription(localeList: LocaleListCompat) = description.getBestLocale(localeList) } @Entity( @@ -173,3 +197,17 @@ fun Map.toRepoReleaseChannel(repoId: Long) = map { description = it.value.description, ) } + +@Entity +data class RepositoryPreferences( + @PrimaryKey internal val repoId: Long, + val weight: Int, + val enabled: Boolean = true, + val lastUpdated: Long? = System.currentTimeMillis(), // TODO set this after repo updates + val lastETag: String? = null, + val userMirrors: List? = null, + val disabledMirrors: List? = null, + val username: String? = null, + val password: String? = null, + val isSwap: Boolean = false, // TODO remove +) diff --git a/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/database/src/main/java/org/fdroid/database/RepositoryDao.kt index f72c9ec11..dfddd98e6 100644 --- a/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -3,7 +3,6 @@ package org.fdroid.database import androidx.annotation.VisibleForTesting import androidx.lifecycle.LiveData import androidx.room.Dao -import androidx.room.Delete import androidx.room.Insert import androidx.room.OnConflictStrategy.REPLACE import androidx.room.Query @@ -32,10 +31,21 @@ public interface RepositoryDao { fun replace(repoId: Long, repository: RepoV2, certificate: String?) fun getRepository(repoId: Long): Repository? - fun insertEmptyRepo(address:String): Long + fun insertEmptyRepo( + address: String, + username: String? = null, + password: String? = null, + ): Long + fun deleteRepository(repoId: Long) fun getRepositories(): List fun getLiveRepositories(): LiveData> + fun countAppsPerRepository(repoId: Long): Int + fun setRepositoryEnabled(repoId: Long, enabled: Boolean) + fun updateUserMirrors(repoId: Long, mirrors: List) + fun updateUsernameAndPassword(repoId: Long, username: String?, password: String?) + fun updateDisabledMirrors(repoId: Long, disabledMirrors: List) + // FIXME: We probably want unique categories here flattened by repo weight fun getLiveCategories(): LiveData> } @@ -58,8 +68,15 @@ internal interface RepositoryDaoInt : RepositoryDao { @Insert(onConflict = REPLACE) fun insertReleaseChannels(repoFeature: List) + @Insert(onConflict = REPLACE) + fun insert(repositoryPreferences: RepositoryPreferences) + @Transaction - override fun insertEmptyRepo(address: String): Long { + override fun insertEmptyRepo( + address: String, + username: String?, + password: String?, + ): Long { val repo = CoreRepository( name = "", icon = null, @@ -67,16 +84,33 @@ internal interface RepositoryDaoInt : RepositoryDao { timestamp = System.currentTimeMillis(), certificate = null, ) - return insert(repo) + val repoId = insert(repo) + val currentMaxWeight = getMaxRepositoryWeight() + val repositoryPreferences = RepositoryPreferences( + repoId = repoId, + weight = currentMaxWeight + 1, + lastUpdated = null, + username = username, + password = password, + ) + insert(repositoryPreferences) + return repoId } @Transaction override fun insert(repository: RepoV2): Long { val repoId = insert(repository.toCoreRepository()) + insertRepositoryPreferences(repoId) insertRepoTables(repoId, repository) return repoId } + private fun insertRepositoryPreferences(repoId: Long) { + val currentMaxWeight = getMaxRepositoryWeight() + val repositoryPreferences = RepositoryPreferences(repoId, currentMaxWeight + 1) + insert(repositoryPreferences) + } + @Transaction override fun replace(repoId: Long, repository: RepoV2, certificate: String?) { val newRepoId = insert(repository.toCoreRepository(repoId, certificate)) @@ -192,6 +226,21 @@ internal interface RepositoryDaoInt : RepositoryDao { @Query("UPDATE CoreRepository SET certificate = :certificate WHERE repoId = :repoId") fun updateRepository(repoId: Long, certificate: String) + @Update + fun updateRepositoryPreferences(preferences: RepositoryPreferences) + + @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) + + @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) + @Transaction @Query("SELECT * FROM CoreRepository") override fun getRepositories(): List @@ -212,6 +261,12 @@ internal interface RepositoryDaoInt : RepositoryDao { @Query("SELECT * FROM AntiFeature") fun getAntiFeatures(): List + @Query("SELECT * FROM RepositoryPreferences WHERE repoId = :repoId") + fun getRepositoryPreferences(repoId: Long): RepositoryPreferences? + + @Query("SELECT MAX(weight) FROM RepositoryPreferences") + fun getMaxRepositoryWeight(): Int + @VisibleForTesting @Query("DELETE FROM AntiFeature WHERE repoId = :repoId") fun deleteAntiFeatures(repoId: Long) @@ -230,6 +285,9 @@ internal interface RepositoryDaoInt : RepositoryDao { WHERE pref.enabled = 1 GROUP BY id HAVING MAX(pref.weight)""") override fun getLiveCategories(): LiveData> + @Query("SELECT COUNT(*) FROM AppMetadata WHERE repoId = :repoId") + override fun countAppsPerRepository(repoId: Long): Int + @VisibleForTesting @Query("DELETE FROM Category WHERE repoId = :repoId") fun deleteCategories(repoId: Long) @@ -250,10 +308,18 @@ internal interface RepositoryDaoInt : RepositoryDao { @Query("DELETE FROM ReleaseChannel WHERE repoId = :repoId AND id = :id") fun deleteReleaseChannel(repoId: Long, id: String) - @Delete - fun deleteRepository(repository: CoreRepository) + @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") - override fun deleteRepository(repoId: Long) + fun deleteCoreRepository(repoId: Long) + + @Query("DELETE FROM RepositoryPreferences WHERE repoId = :repoId") + fun deleteRepositoryPreferences(repoId: Long) } diff --git a/database/src/main/java/org/fdroid/download/DownloaderFactory.kt b/database/src/main/java/org/fdroid/download/DownloaderFactory.kt new file mode 100644 index 000000000..b687b1f2a --- /dev/null +++ b/database/src/main/java/org/fdroid/download/DownloaderFactory.kt @@ -0,0 +1,40 @@ +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 + +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) + 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 = repo.getMirrors() + return create(repo, mirrors, uri, destFile, tryFirst) + } + + @Throws(IOException::class) + abstract fun create(repo: Repository, uri: Uri, destFile: File): Downloader + + @Throws(IOException::class) + protected abstract fun create( + repo: Repository, + mirrors: List, + uri: Uri, + destFile: File, + tryFirst: Mirror?, + ): Downloader + +} diff --git a/database/src/main/java/org/fdroid/index/v1/IndexUpdater.kt b/database/src/main/java/org/fdroid/index/v1/IndexUpdater.kt new file mode 100644 index 000000000..823f0b305 --- /dev/null +++ b/database/src/main/java/org/fdroid/index/v1/IndexUpdater.kt @@ -0,0 +1,25 @@ +package org.fdroid.index.v1 + +import android.net.Uri +import org.fdroid.database.Repository + +public enum class IndexUpdateResult { + UNCHANGED, + PROCESSED, + NOT_FOUND, +} + +public interface IndexUpdateListener { + fun onDownloadProgress(bytesRead: Long, totalBytes: Long) + fun onStartProcessing() + +} + +public class IndexUpdater { +} + + +public fun Repository.getCanonicalUri(): Uri = Uri.parse(address) + .buildUpon() + .appendPath(SIGNED_FILE_NAME) + .build() diff --git a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt index a8042d6b0..9c61de6b0 100644 --- a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt +++ b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt @@ -4,50 +4,84 @@ import android.content.Context import org.fdroid.database.DbV1StreamReceiver import org.fdroid.database.FDroidDatabaseHolder import org.fdroid.database.FDroidDatabaseInt -import org.fdroid.download.Downloader +import org.fdroid.download.DownloaderFactory import java.io.File import java.io.IOException +internal const val SIGNED_FILE_NAME = "index-v1.jar" + // TODO should this live here and cause a dependency on download lib or in dedicated module? public class IndexV1Updater( - context: Context, - private val file: File, - private val downloader: Downloader, - ) { + private val context: Context, + private val downloaderFactory: DownloaderFactory, +) { private val db: FDroidDatabaseInt = FDroidDatabaseHolder.getDb(context) as FDroidDatabaseInt // TODO final name @Throws(IOException::class, InterruptedException::class) - fun update(address: String, expectedSigningFingerprint: String?) { - val repoId = db.getRepositoryDao().insertEmptyRepo(address) + fun updateNewRepo( + repoId: Long, + expectedSigningFingerprint: String?, + updateListener: IndexUpdateListener? = null, + ): IndexUpdateResult { + return update(repoId, null, expectedSigningFingerprint, updateListener) + } + + @Throws(IOException::class, InterruptedException::class) + fun update( + repoId: Long, + certificate: String, + updateListener: IndexUpdateListener? = null, + ): IndexUpdateResult { + return update(repoId, certificate, null, updateListener) + } + + @Throws(IOException::class, InterruptedException::class) + private fun update( + repoId: Long, + certificate: String?, + fingerprint: String?, + updateListener: IndexUpdateListener?, + ): IndexUpdateResult { + val repo = + db.getRepositoryDao().getRepository(repoId) ?: error("Unexpected repoId: $repoId") + val uri = repo.getCanonicalUri() + val file = File.createTempFile("dl-", "", context.cacheDir) + val downloader = downloaderFactory.createWithTryFirstMirror(repo, uri, file).apply { + cacheTag = repo.lastETag + updateListener?.let { setListener(updateListener::onDownloadProgress) } + } try { - update(repoId, null, expectedSigningFingerprint) - } catch (e: Throwable) { - db.getRepositoryDao().deleteRepository(repoId) - throw e - } - db.getRepositoryDao().getRepositories().forEach { println(it) } - } + downloader.download() + // TODO in MirrorChooser don't try again on 404 + // when tryFirstMirror is set == isRepoDownload + if (!downloader.hasChanged()) return IndexUpdateResult.UNCHANGED + val eTag = downloader.cacheTag - @Throws(IOException::class, InterruptedException::class) - fun update(repoId: Long, certificate: String) { - update(repoId, certificate, null) - } - - @Throws(IOException::class, InterruptedException::class) - private fun update(repoId: Long, certificate: String?, fingerprint: String?) { - downloader.download() - val verifier = IndexV1Verifier(file, certificate, fingerprint) - db.runInTransaction { - val cert = verifier.getStreamAndVerify { inputStream -> - val streamProcessor = IndexV1StreamProcessor(DbV1StreamReceiver(db), certificate) - streamProcessor.process(repoId, inputStream) - } - if (certificate == null) { - db.getRepositoryDao().updateRepository(repoId, cert) + val verifier = IndexV1Verifier(file, certificate, fingerprint) + db.runInTransaction { + val cert = verifier.getStreamAndVerify { inputStream -> + updateListener?.onStartProcessing() // TODO maybe do more fine-grained reporting + val streamProcessor = + IndexV1StreamProcessor(DbV1StreamReceiver(db), certificate) + streamProcessor.process(repoId, inputStream) + } + // update certificate, if we didn't have any before + if (certificate == null) { + db.getRepositoryDao().updateRepository(repoId, cert) + } + // update RepositoryPreferences with timestamp and ETag (for v1) + val updatedPrefs = repo.preferences.copy( + lastUpdated = System.currentTimeMillis(), + lastETag = eTag, + ) + db.getRepositoryDao().updateRepositoryPreferences(updatedPrefs) } + } finally { + file.delete() } + return IndexUpdateResult.PROCESSED } } From 44ceaa6842d2f7ce058897cc47f0ef81f5c5ae29 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 21 Mar 2022 15:33:13 -0300 Subject: [PATCH 08/42] [db] Add a method for getting an app without specifying the repoId --- database/build.gradle | 9 +- .../java/org/fdroid/database/AppTest.kt | 37 ++- .../java/org/fdroid/database/DbTest.kt | 12 +- .../java/org/fdroid/database/VersionTest.kt | 9 +- .../src/main/java/org/fdroid/database/App.kt | 34 ++- .../main/java/org/fdroid/database/AppDao.kt | 79 +++-- .../org/fdroid/database/DbStreamReceiver.kt | 2 +- .../org/fdroid/database/DbV1StreamReceiver.kt | 2 +- .../org/fdroid/database/FDroidDatabase.kt | 6 +- .../main/java/org/fdroid/database/Version.kt | 13 +- .../java/org/fdroid/database/VersionDao.kt | 31 ++ gradle/verification-metadata.xml | 286 ++++-------------- 12 files changed, 265 insertions(+), 255 deletions(-) diff --git a/database/build.gradle b/database/build.gradle index a4c21f86f..1b39ee2c0 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -38,6 +38,10 @@ android { // needed only for instrumentation tests: assets.openFd() noCompress "json" } + packagingOptions { + exclude 'META-INF/AL2.0' + exclude 'META-INF/LGPL2.1' + } } dependencies { @@ -45,7 +49,7 @@ dependencies { implementation project(":index") implementation 'androidx.core:core-ktx:1.5.0' - implementation 'androidx.lifecycle:lifecycle-livedata-core-ktx:2.4.1' + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1' def room_version = "2.4.2" implementation "androidx.room:room-runtime:$room_version" @@ -59,10 +63,13 @@ dependencies { testImplementation 'junit:junit:4.13.1' testImplementation 'org.jetbrains.kotlin:kotlin-test' + + androidTestImplementation 'io.mockk:mockk-android:1.12.3' androidTestImplementation 'org.jetbrains.kotlin:kotlin-test' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.arch.core:core-testing:2.1.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' + androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2' androidTestImplementation 'commons-io:commons-io:2.6' } diff --git a/database/src/androidTest/java/org/fdroid/database/AppTest.kt b/database/src/androidTest/java/org/fdroid/database/AppTest.kt index 4a755d079..dce0fe375 100644 --- a/database/src/androidTest/java/org/fdroid/database/AppTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/AppTest.kt @@ -12,6 +12,8 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue import kotlin.test.fail @RunWith(AndroidJUnit4::class) @@ -28,7 +30,7 @@ class AppTest : DbTest() { val metadataV2 = getRandomMetadataV2() appDao.insert(repoId, packageId, metadataV2) - val app = appDao.getApp(repoId, packageId) + val app = appDao.getApp(repoId, packageId) ?: fail() val metadata = metadataV2.toAppMetadata(repoId, packageId) assertEquals(metadata.author, app.metadata.author) assertEquals(metadata.donation, app.metadata.donation) @@ -39,6 +41,8 @@ class AppTest : DbTest() { assertEquals(metadataV2.tvBanner, app.tvBanner) assertScreenshotsEqual(metadataV2.screenshots, app.screenshots) + assertEquals(metadata, appDao.getApp(packageId).getOrAwaitValue()?.metadata) + appDao.deleteAppMetadata(repoId, packageId) assertEquals(0, appDao.getAppMetadata().size) assertEquals(0, appDao.getLocalizedFiles().size) @@ -89,4 +93,35 @@ class AppTest : DbTest() { assertEquals(4, apps4.size) } + @Test + fun testAppByRepoWeight() { + val repoId1 = repoDao.insert(getRandomRepo()) + val repoId2 = repoDao.insert(getRandomRepo()) + val metadata1 = getRandomMetadataV2() + val metadata2 = metadata1.copy(lastUpdated = metadata1.lastUpdated + 1) + + // app is only in one repo, so returns it's repoId + appDao.insert(repoId1, packageId, metadata1) + assertEquals(repoId1, appDao.getRepoIdForPackage(packageId).getOrAwaitValue()) + + // ensure second repo has a higher weight + val repoPrefs1 = repoDao.getRepositoryPreferences(repoId1) ?: fail() + val repoPrefs2 = repoDao.getRepositoryPreferences(repoId2) ?: fail() + assertTrue(repoPrefs1.weight < repoPrefs2.weight) + + // app is now in repo with higher weight, so it's repoId gets returned + appDao.insert(repoId2, packageId, metadata2) + assertEquals(repoId2, appDao.getRepoIdForPackage(packageId).getOrAwaitValue()) + assertEquals(appDao.getApp(repoId2, packageId)?.metadata, + appDao.getApp(packageId).getOrAwaitValue()?.metadata) + assertScreenshotsEqual(appDao.getApp(repoId2, packageId)?.screenshots, + appDao.getApp(packageId).getOrAwaitValue()?.screenshots) + assertEquals(appDao.getApp(repoId2, packageId)?.icon, + appDao.getApp(packageId).getOrAwaitValue()?.icon) + assertEquals(appDao.getApp(repoId2, packageId)?.featureGraphic, + appDao.getApp(packageId).getOrAwaitValue()?.featureGraphic) + assertNotEquals(appDao.getApp(repoId1, packageId), + appDao.getApp(packageId).getOrAwaitValue()) + } + } diff --git a/database/src/androidTest/java/org/fdroid/database/DbTest.kt b/database/src/androidTest/java/org/fdroid/database/DbTest.kt index cabbb1721..bd5e84761 100644 --- a/database/src/androidTest/java/org/fdroid/database/DbTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/DbTest.kt @@ -4,6 +4,10 @@ import android.content.Context import androidx.room.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.every +import io.mockk.mockkObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before import org.junit.runner.RunWith @@ -16,6 +20,7 @@ abstract class DbTest { internal lateinit var appDao: AppDaoInt internal lateinit var versionDao: VersionDaoInt internal lateinit var db: FDroidDatabaseInt + private val testCoroutineDispatcher = Dispatchers.Unconfined @Before fun createDb() { @@ -23,7 +28,12 @@ abstract class DbTest { db = Room.inMemoryDatabaseBuilder(context, FDroidDatabaseInt::class.java).build() repoDao = db.getRepositoryDao() appDao = db.getAppDao() - versionDao = db.getVersionDaoInt() + versionDao = db.getVersionDao() + + Dispatchers.setMain(testCoroutineDispatcher) + + mockkObject(FDroidDatabaseHolder) + every { FDroidDatabaseHolder.dispatcher } returns testCoroutineDispatcher } @After diff --git a/database/src/androidTest/java/org/fdroid/database/VersionTest.kt b/database/src/androidTest/java/org/fdroid/database/VersionTest.kt index a17eadc4a..51416fb29 100644 --- a/database/src/androidTest/java/org/fdroid/database/VersionTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/VersionTest.kt @@ -1,17 +1,24 @@ package org.fdroid.database +import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.AndroidJUnit4 import org.fdroid.database.test.TestAppUtils.getRandomMetadataV2 import org.fdroid.database.test.TestRepoUtils.getRandomRepo +import org.fdroid.database.test.TestUtils.getOrAwaitValue import org.fdroid.database.test.TestUtils.getRandomString import org.fdroid.database.test.TestVersionUtils.getRandomPackageVersionV2 +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertEquals +import kotlin.test.fail @RunWith(AndroidJUnit4::class) class VersionTest : DbTest() { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + private val packageId = getRandomString() private val versionId = getRandomString() @@ -55,7 +62,7 @@ class VersionTest : DbTest() { versionDao.insert(repoId, packageId, version2, packageVersion2) // get app versions from DB and assign them correctly - val appVersions = versionDao.getAppVersions(repoId, packageId) + val appVersions = versionDao.getAppVersions(packageId).getOrAwaitValue() ?: fail() assertEquals(2, appVersions.size) val appVersion = if (version1 == appVersions[0].version.versionId) { appVersions[0] diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt index c9d3cd975..a01f9ac7f 100644 --- a/database/src/main/java/org/fdroid/database/App.kt +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -73,7 +73,39 @@ data class App( val promoGraphic: LocalizedFileV2? = null, val tvBanner: LocalizedFileV2? = null, val screenshots: Screenshots? = null, -) +) { + public fun getName(localeList: LocaleListCompat) = metadata.name.getBestLocale(localeList) + public fun getSummary(localeList: LocaleListCompat) = metadata.summary.getBestLocale(localeList) + public fun getDescription(localeList: LocaleListCompat) = + metadata.description.getBestLocale(localeList) + + public fun getVideo(localeList: LocaleListCompat) = metadata.video.getBestLocale(localeList) + + public fun getIcon(localeList: LocaleListCompat) = icon.getBestLocale(localeList) + public fun getFeatureGraphic(localeList: LocaleListCompat) = + featureGraphic.getBestLocale(localeList) + + public fun getPromoGraphic(localeList: LocaleListCompat) = + promoGraphic.getBestLocale(localeList) + + public fun getTvBanner(localeList: LocaleListCompat) = tvBanner.getBestLocale(localeList) + + // TODO remove ?.map { it.name } when client can handle FileV2 + public fun getPhoneScreenshots(localeList: LocaleListCompat) = + screenshots?.phone.getBestLocale(localeList)?.map { it.name }?.toTypedArray() + + public fun getSevenInchScreenshots(localeList: LocaleListCompat) = + screenshots?.sevenInch.getBestLocale(localeList)?.map { it.name }?.toTypedArray() + + public fun getTenInchScreenshots(localeList: LocaleListCompat) = + screenshots?.tenInch.getBestLocale(localeList)?.map { it.name }?.toTypedArray() + + public fun getTvScreenshots(localeList: LocaleListCompat) = + screenshots?.tv.getBestLocale(localeList)?.map { it.name }?.toTypedArray() + + public fun getWearScreenshots(localeList: LocaleListCompat) = + screenshots?.wear.getBestLocale(localeList)?.map { it.name }?.toTypedArray() +} public data class AppOverviewItem( public val repoId: Long, diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index 7e7667038..d9333ed32 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -2,11 +2,17 @@ package org.fdroid.database import androidx.annotation.VisibleForTesting import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.liveData +import androidx.lifecycle.map +import androidx.lifecycle.switchMap import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction +import org.fdroid.database.FDroidDatabaseHolder.dispatcher import org.fdroid.index.v2.LocalizedFileListV2 import org.fdroid.index.v2.LocalizedFileV2 import org.fdroid.index.v2.MetadataV2 @@ -14,7 +20,13 @@ import org.fdroid.index.v2.Screenshots public interface AppDao { fun insert(repoId: Long, packageId: String, app: MetadataV2) - fun getApp(repoId: Long, packageId: String): App + + /** + * Gets the app from the DB. If more than one app with this [packageId] exists, + * the one from the repository with the highest weight is returned. + */ + fun getApp(packageId: String): LiveData + fun getApp(repoId: Long, packageId: String): App? fun getAppOverviewItems(limit: Int = 200): LiveData> fun getAppOverviewItems(category: String, limit: Int = 50): LiveData> fun getNumberOfAppsInCategory(category: String): Int @@ -67,26 +79,59 @@ internal interface AppDaoInt : AppDao { @Query("UPDATE AppMetadata SET preferredSigner = :preferredSigner WHERE repoId = :repoId AND packageId = :packageId") fun updatePreferredSigner(repoId: Long, packageId: String, preferredSigner: String?) - @Transaction - override fun getApp(repoId: Long, packageId: String): App { + override fun getApp(packageId: String): LiveData { + return getRepoIdForPackage(packageId).distinctUntilChanged().switchMap { repoId -> + if (repoId == null) MutableLiveData(null) + else getLiveApp(repoId, packageId) + } + } + + @Query("""SELECT repoId FROM RepositoryPreferences + JOIN AppMetadata AS app USING(repoId) + WHERE app.packageId = :packageId AND enabled = 1 ORDER BY weight DESC LIMIT 1""") + fun getRepoIdForPackage(packageId: String): LiveData + + fun getLiveApp(repoId: Long, packageId: String): LiveData = liveData(dispatcher) { + // TODO maybe observe those as well? val localizedFiles = getLocalizedFiles(repoId, packageId) val localizedFileList = getLocalizedFileLists(repoId, packageId) - return App( - metadata = getAppMetadata(repoId, packageId), - icon = localizedFiles.toLocalizedFileV2("icon"), - featureGraphic = localizedFiles.toLocalizedFileV2("featureGraphic"), - promoGraphic = localizedFiles.toLocalizedFileV2("promoGraphic"), - tvBanner = localizedFiles.toLocalizedFileV2("tvBanner"), - screenshots = if (localizedFileList.isEmpty()) null else Screenshots( - phone = localizedFileList.toLocalizedFileListV2("phone"), - sevenInch = localizedFileList.toLocalizedFileListV2("sevenInch"), - tenInch = localizedFileList.toLocalizedFileListV2("tenInch"), - wear = localizedFileList.toLocalizedFileListV2("wear"), - tv = localizedFileList.toLocalizedFileListV2("tv"), - ) - ) + val liveData: LiveData = + getLiveAppMetadata(repoId, packageId).distinctUntilChanged().map { + getApp(it, localizedFiles, localizedFileList) + } + emitSource(liveData) } + @Transaction + override fun getApp(repoId: Long, packageId: String): App? { + val metadata = getAppMetadata(repoId, packageId) + val localizedFiles = getLocalizedFiles(repoId, packageId) + val localizedFileList = getLocalizedFileLists(repoId, packageId) + return getApp(metadata, localizedFiles, localizedFileList) + } + + private fun getApp( + metadata: AppMetadata, + localizedFiles: List?, + localizedFileList: List?, + ) = App( + metadata = metadata, + icon = localizedFiles?.toLocalizedFileV2("icon"), + featureGraphic = localizedFiles?.toLocalizedFileV2("featureGraphic"), + promoGraphic = localizedFiles?.toLocalizedFileV2("promoGraphic"), + tvBanner = localizedFiles?.toLocalizedFileV2("tvBanner"), + screenshots = if (localizedFileList.isNullOrEmpty()) null else Screenshots( + phone = localizedFileList.toLocalizedFileListV2("phone"), + sevenInch = localizedFileList.toLocalizedFileListV2("sevenInch"), + tenInch = localizedFileList.toLocalizedFileListV2("tenInch"), + wear = localizedFileList.toLocalizedFileListV2("wear"), + tv = localizedFileList.toLocalizedFileListV2("tv"), + ) + ) + + @Query("SELECT * FROM AppMetadata WHERE repoId = :repoId AND packageId = :packageId") + fun getLiveAppMetadata(repoId: Long, packageId: String): LiveData + @Query("SELECT * FROM AppMetadata WHERE repoId = :repoId AND packageId = :packageId") fun getAppMetadata(repoId: Long, packageId: String): AppMetadata diff --git a/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt index 59bd231cd..807f0cd00 100644 --- a/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt @@ -14,7 +14,7 @@ internal class DbStreamReceiver( override fun receive(repoId: Long, packageId: String, p: PackageV2) { db.getAppDao().insert(repoId, packageId, p.metadata) - db.getVersionDaoInt().insert(repoId, packageId, p.versions) + db.getVersionDao().insert(repoId, packageId, p.versions) } } diff --git a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt index 7769f38b7..68ea29112 100644 --- a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt @@ -21,7 +21,7 @@ internal class DbV1StreamReceiver( } override fun receive(repoId: Long, packageId: String, v: Map) { - db.getVersionDaoInt().insert(repoId, packageId, v) + db.getVersionDao().insert(repoId, packageId, v) } override fun updateRepo( diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index a91c94cbc..eb6ce6356 100644 --- a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -5,6 +5,7 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters +import kotlinx.coroutines.Dispatchers @Database(entities = [ // repo @@ -28,12 +29,13 @@ import androidx.room.TypeConverters internal abstract class FDroidDatabaseInt internal constructor() : RoomDatabase(), FDroidDatabase { abstract override fun getRepositoryDao(): RepositoryDaoInt abstract override fun getAppDao(): AppDaoInt - abstract fun getVersionDaoInt(): VersionDaoInt + abstract override fun getVersionDao(): VersionDaoInt } public interface FDroidDatabase { fun getRepositoryDao(): RepositoryDao fun getAppDao(): AppDao + fun getVersionDao(): VersionDao fun runInTransaction(body: Runnable) } @@ -42,6 +44,8 @@ public object FDroidDatabaseHolder { @Volatile private var INSTANCE: FDroidDatabaseInt? = null + internal val dispatcher get() = Dispatchers.IO + @JvmStatic public fun getDb(context: Context): FDroidDatabase { return getDb(context, "test") diff --git a/database/src/main/java/org/fdroid/database/Version.kt b/database/src/main/java/org/fdroid/database/Version.kt index 0e32dc175..3bde3bb12 100644 --- a/database/src/main/java/org/fdroid/database/Version.kt +++ b/database/src/main/java/org/fdroid/database/Version.kt @@ -1,5 +1,6 @@ package org.fdroid.database +import androidx.core.os.LocaleListCompat import androidx.room.Embedded import androidx.room.Entity import androidx.room.ForeignKey @@ -56,7 +57,17 @@ data class AppVersion( val usesPermission: List? = null, val usesPermissionSdk23: List? = null, val features: List? = null, -) +) { + val packageId get() = version.packageId + val featureNames get() = features?.map { it.name }?.toTypedArray() ?: emptyArray() + val nativeCode get() = version.manifest.nativecode?.toTypedArray() ?: emptyArray() + val antiFeatureNames: Array + get() { + return version.antiFeatures?.map { it.key }?.toTypedArray() ?: emptyArray() + } + + fun getWhatsNew(localeList: LocaleListCompat) = version.whatsNew.getBestLocale(localeList) +} data class AppManifest( val versionName: String, diff --git a/database/src/main/java/org/fdroid/database/VersionDao.kt b/database/src/main/java/org/fdroid/database/VersionDao.kt index 06edc6f5a..f1c8ff5db 100644 --- a/database/src/main/java/org/fdroid/database/VersionDao.kt +++ b/database/src/main/java/org/fdroid/database/VersionDao.kt @@ -1,16 +1,22 @@ package org.fdroid.database import androidx.annotation.VisibleForTesting +import androidx.lifecycle.LiveData +import androidx.lifecycle.distinctUntilChanged +import androidx.lifecycle.liveData +import androidx.lifecycle.map import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction +import org.fdroid.database.FDroidDatabaseHolder.dispatcher import org.fdroid.index.v2.PackageVersionV2 public interface VersionDao { fun insert(repoId: Long, packageId: String, packageVersions: Map) fun insert(repoId: Long, packageId: String, versionId: String, packageVersion: PackageVersionV2) + fun getAppVersions(packageId: String): LiveData> fun getAppVersions(repoId: Long, packageId: String): List } @@ -47,6 +53,24 @@ internal interface VersionDaoInt : VersionDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(versionedString: List) + override fun getAppVersions( + packageId: String, + ): LiveData> = liveData(dispatcher) { + // TODO we should probably react to changes of versioned strings as well + val versionedStrings = getVersionedStrings(packageId) + val liveData = getVersions(packageId).distinctUntilChanged().map { versions -> + versions.map { version -> + AppVersion( + version = version, + usesPermission = versionedStrings.getPermissions(version), + usesPermissionSdk23 = versionedStrings.getPermissionsSdk23(version), + features = versionedStrings.getFeatures(version), + ) + } + } + emitSource(liveData) + } + @Transaction override fun getAppVersions(repoId: Long, packageId: String): List { val versionedStrings = getVersionedStrings(repoId, packageId) @@ -60,9 +84,16 @@ internal interface VersionDaoInt : VersionDao { } } + @Query("""SELECT * FROM Version WHERE packageId = :packageId + ORDER BY manifest_versionCode DESC""") + fun getVersions(packageId: String): LiveData> + @Query("SELECT * FROM Version WHERE repoId = :repoId AND packageId = :packageId") fun getVersions(repoId: Long, packageId: String): List + @Query("SELECT * FROM VersionedString WHERE packageId = :packageId") + fun getVersionedStrings(packageId: String): List + @Query("SELECT * FROM VersionedString WHERE repoId = :repoId AND packageId = :packageId") fun getVersionedStrings(repoId: Long, packageId: String): List diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index bb5c015e3..1592bb576 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -13,12 +13,14 @@ + + @@ -289,6 +291,9 @@ + + + @@ -342,7 +347,7 @@ - + @@ -413,16 +418,6 @@ - - - - - - - - - - @@ -580,6 +575,11 @@ + + + + + @@ -593,6 +593,11 @@ + + + + + @@ -606,7 +611,25 @@ + + + + + + + + + + + + + + + + + + @@ -846,11 +869,6 @@ - - - - - @@ -931,31 +949,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - @@ -2280,11 +2273,6 @@ - - - - - @@ -2320,7 +2308,6 @@ - @@ -2696,11 +2683,6 @@ - - - - - @@ -2884,6 +2866,12 @@ + + + + + + @@ -2899,17 +2887,6 @@ - - - - - - - - - - - @@ -3027,6 +3004,11 @@ + + + + + @@ -4044,66 +4026,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @@ -4314,11 +4236,6 @@ - - - - - @@ -5277,11 +5194,6 @@ - - - - - @@ -5292,11 +5204,6 @@ - - - - - @@ -5322,11 +5229,6 @@ - - - - - @@ -5347,11 +5249,6 @@ - - - - - @@ -5377,11 +5274,6 @@ - - - - - @@ -5442,11 +5334,6 @@ - - - - - @@ -5477,11 +5364,6 @@ - - - - - @@ -5542,11 +5424,6 @@ - - - - - @@ -5692,11 +5569,6 @@ - - - - - @@ -5712,6 +5584,11 @@ + + + + + @@ -5787,11 +5664,6 @@ - - - - - @@ -5812,16 +5684,6 @@ - - - - - - - - - - @@ -5832,11 +5694,6 @@ - - - - - @@ -5907,11 +5764,6 @@ - - - - - @@ -5932,11 +5784,6 @@ - - - - - @@ -5987,11 +5834,6 @@ - - - - - @@ -6076,16 +5918,16 @@ - - - - - + + + + + @@ -6168,16 +6010,6 @@ - - - - - - - - - - @@ -6344,6 +6176,7 @@ + @@ -6807,12 +6640,7 @@ - - - - - - + From 55a446fe6405555304cbf21f9111df470b6679a2 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 25 Mar 2022 16:59:55 -0300 Subject: [PATCH 09/42] [db] Allow pre-populating the database via onCreate callback Also add more methods for managing repos and improve selecting apps from repos --- .../java/org/fdroid/database/AppTest.kt | 12 +-- .../main/java/org/fdroid/database/AppDao.kt | 26 ++++-- .../org/fdroid/database/FDroidDatabase.kt | 91 ++++++++++++++----- .../java/org/fdroid/database/Repository.kt | 42 ++++++++- .../java/org/fdroid/database/RepositoryDao.kt | 26 +++++- 5 files changed, 156 insertions(+), 41 deletions(-) diff --git a/database/src/androidTest/java/org/fdroid/database/AppTest.kt b/database/src/androidTest/java/org/fdroid/database/AppTest.kt index dce0fe375..0103017d2 100644 --- a/database/src/androidTest/java/org/fdroid/database/AppTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/AppTest.kt @@ -13,6 +13,7 @@ import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertEquals import kotlin.test.assertNotEquals +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.test.fail @@ -74,23 +75,22 @@ class AppTest : DbTest() { assertEquals(icons2, apps.find { it.packageId == packageId2 }?.localizedIcon?.toLocalizedFileV2()) - // app without icon is returned as well + // app without icon is not returned appDao.insert(repoId, packageId3, app3) val apps3 = appDao.getAppOverviewItems().getOrAwaitValue() ?: fail() - assertEquals(3, apps3.size) + assertEquals(2, apps3.size) assertEquals(icons1, apps3.find { it.packageId == packageId1 }?.localizedIcon?.toLocalizedFileV2()) assertEquals(icons2, apps3.find { it.packageId == packageId2 }?.localizedIcon?.toLocalizedFileV2()) - assertEquals(emptyList(), apps3.find { it.packageId == packageId3 }!!.localizedIcon) + assertNull(apps3.find { it.packageId == packageId3 }) - // app4 is the same as app1 + // app4 is the same as app1 and thus will not be shown again val repoId2 = repoDao.insert(getRandomRepo()) val app4 = getRandomMetadataV2().copy(name = name2, icon = icons2) appDao.insert(repoId2, packageId1, app4) - val apps4 = appDao.getAppOverviewItems().getOrAwaitValue() ?: fail() - assertEquals(4, apps4.size) + assertEquals(2, apps4.size) } @Test diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index d9333ed32..bdce0671c 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -177,31 +177,45 @@ internal interface AppDaoInt : AppDao { //+ ", " + lastUpdated + " DESC" //+ ", " + added + " ASC"); @Transaction - @Query("""SELECT repoId, packageId, added, lastUpdated, name, summary FROM AppMetadata - ORDER BY name IS NULL ASC, summary IS NULL ASC, lastUpdated DESC, added ASC LIMIT :limit""") + @Query("""SELECT repoId, packageId, added, app.lastUpdated, app.name, summary + FROM AppMetadata AS app + JOIN RepositoryPreferences AS pref USING (repoId) + JOIN LocalizedIcon AS icon USING (repoId, packageId) + WHERE pref.enabled = 1 GROUP BY packageId + ORDER BY app.name IS NULL ASC, summary IS NULL ASC, app.lastUpdated DESC, added ASC + LIMIT :limit""") override fun getAppOverviewItems(limit: Int): LiveData> @Transaction // TODO maybe it makes sense to split categories into their own table for this? - @Query("""SELECT repoId, packageId, added, lastUpdated, name, summary FROM AppMetadata - WHERE categories LIKE '%' || :category || '%' - ORDER BY name IS NULL ASC, summary IS NULL ASC, lastUpdated DESC, added ASC LIMIT :limit""") + @Query("""SELECT repoId, packageId, added, app.lastUpdated, app.name, summary + FROM AppMetadata AS app + JOIN RepositoryPreferences AS pref USING (repoId) + JOIN LocalizedIcon AS icon USING (repoId, packageId) + WHERE pref.enabled = 1 AND categories LIKE '%' || :category || '%' GROUP BY packageId + ORDER BY app.name IS NULL ASC, summary IS NULL ASC, app.lastUpdated DESC, added ASC + LIMIT :limit""") override fun getAppOverviewItems(category: String, limit: Int): LiveData> // FIXME don't over report the same app twice (e.g. in several repos) - @Query("SELECT COUNT(*) FROM AppMetadata WHERE categories LIKE '%' || :category || '%'") + @Query("""SELECT COUNT(*) FROM AppMetadata + JOIN RepositoryPreferences AS pref USING (repoId) + WHERE pref.enabled = 1 AND categories LIKE '%' || :category || '%'""") override fun getNumberOfAppsInCategory(category: String): Int @VisibleForTesting @Query("DELETE FROM AppMetadata WHERE repoId = :repoId AND packageId = :packageId") fun deleteAppMetadata(repoId: Long, packageId: 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 diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index eb6ce6356..937b4ee68 100644 --- a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -1,30 +1,42 @@ package org.fdroid.database import android.content.Context +import android.util.Log import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters +import androidx.sqlite.db.SupportSQLiteDatabase +import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch -@Database(entities = [ - // repo - CoreRepository::class, - Mirror::class, - AntiFeature::class, - Category::class, - ReleaseChannel::class, - RepositoryPreferences::class, - // packages - AppMetadata::class, - LocalizedFile::class, - LocalizedFileList::class, - // versions - Version::class, - VersionedString::class, -], views = [ - LocalizedIcon::class, -], version = 1) +@Database( + version = 1, // TODO set version to 1 before release and wipe old schemas + entities = [ + // repo + CoreRepository::class, + Mirror::class, + AntiFeature::class, + Category::class, + ReleaseChannel::class, + RepositoryPreferences::class, + // packages + AppMetadata::class, + LocalizedFile::class, + LocalizedFileList::class, + // versions + Version::class, + VersionedString::class, + ], + views = [ + LocalizedIcon::class, + ], + autoMigrations = [ + // AutoMigration (from = 1, to = 2) // seems to require Java 11 + ], +) @TypeConverters(Converters::class) internal abstract class FDroidDatabaseInt internal constructor() : RoomDatabase(), FDroidDatabase { abstract override fun getRepositoryDao(): RepositoryDaoInt @@ -39,31 +51,62 @@ public interface FDroidDatabase { fun runInTransaction(body: Runnable) } +public fun interface FDroidFixture { + fun prePopulateDb(db: FDroidDatabase) +} + public object FDroidDatabaseHolder { // Singleton prevents multiple instances of database opening at the same time. @Volatile private var INSTANCE: FDroidDatabaseInt? = null + internal val TAG = FDroidDatabase::class.simpleName internal val dispatcher get() = Dispatchers.IO @JvmStatic - public fun getDb(context: Context): FDroidDatabase { - return getDb(context, "test") + public fun getDb(context: Context, fixture: FDroidFixture?): FDroidDatabase { + return getDb(context, "test", fixture) } - internal fun getDb(context: Context, name: String = "fdroid_db"): FDroidDatabase { + internal 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(this) { - val instance = Room.databaseBuilder( + val builder = Room.databaseBuilder( context.applicationContext, FDroidDatabaseInt::class.java, name, - )//.allowMainThreadQueries() // TODO remove before release - .build() + ).fallbackToDestructiveMigration() + //.allowMainThreadQueries() // TODO remove before release + if (fixture != null) builder.addCallback(FixtureCallback(fixture)) + val instance = builder.build() INSTANCE = instance // return instance instance } } + + @OptIn(DelicateCoroutinesApi::class) + private class FixtureCallback(private val fixture: FDroidFixture) : RoomDatabase.Callback() { + override fun onCreate(db: SupportSQLiteDatabase) { + super.onCreate(db) + GlobalScope.launch(dispatcher) { + synchronized(this) { + val database = INSTANCE ?: error("DB not yet initialized") + fixture.prePopulateDb(database) + Log.d(TAG, "Loaded fixtures") + } + } + } + + // TODO remove before release + override fun onDestructiveMigration(db: SupportSQLiteDatabase) { + onCreate(db) + } + } + } diff --git a/database/src/main/java/org/fdroid/database/Repository.kt b/database/src/main/java/org/fdroid/database/Repository.kt index ca23ddd78..85a0c37dc 100644 --- a/database/src/main/java/org/fdroid/database/Repository.kt +++ b/database/src/main/java/org/fdroid/database/Repository.kt @@ -4,8 +4,10 @@ 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.index.IndexUtils.getFingerprint import org.fdroid.index.v2.AntiFeatureV2 import org.fdroid.index.v2.CategoryV2 import org.fdroid.index.v2.FileV2 @@ -81,13 +83,32 @@ data class Repository( val password: String? get() = preferences.password val isSwap: Boolean get() = preferences.isSwap + @delegate:Ignore + val fingerprint: String? by lazy { + certificate?.let { getFingerprint(it) } + } + + /** + * Returns official and user-added mirrors without the [disabledMirrors]. + */ + fun getMirrors(): List { + return getAllMirrors(true).filter { + !disabledMirrors.contains(it.baseUrl) + } + } + + /** + * Returns all mirrors, including [disabledMirrors]. + */ @JvmOverloads - fun getMirrors(includeUserMirrors: Boolean = true) = mirrors.map { - it.toDownloadMirror() - } + listOf(org.fdroid.download.Mirror(address)) + // FIXME decide whether we need to add this - if (includeUserMirrors) userMirrors.map { + fun getAllMirrors(includeUserMirrors: Boolean = true): List { + // FIXME decide whether we need to add our own address here + return listOf(org.fdroid.download.Mirror(address)) + mirrors.map { + it.toDownloadMirror() + } + if (includeUserMirrors) userMirrors.map { org.fdroid.download.Mirror(it) } else emptyList() + } fun getDescription(localeList: LocaleListCompat) = description.getBestLocale(localeList) } @@ -211,3 +232,16 @@ data class RepositoryPreferences( val password: String? = null, val isSwap: Boolean = false, // TODO remove ) + +/** + * A [Repository] which the [FDroidDatabase] gets pre-populated with. + */ +data class InitialRepository( + val name: String, + val address: String, + val description: String, + val certificate: String, + val version: Int, + val enabled: Boolean, + val weight: Int, +) diff --git a/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/database/src/main/java/org/fdroid/database/RepositoryDao.kt index dfddd98e6..91b925288 100644 --- a/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -6,6 +6,7 @@ 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.JsonArray @@ -19,6 +20,8 @@ import org.fdroid.index.v2.ReflectionDiffer.applyDiff import org.fdroid.index.v2.RepoV2 public interface RepositoryDao { + fun insert(initialRepo: InitialRepository) + /** * Use when inserting a new repo for the first time. */ @@ -71,6 +74,27 @@ internal interface RepositoryDaoInt : RepositoryDao { @Insert(onConflict = REPLACE) fun insert(repositoryPreferences: RepositoryPreferences) + @Transaction + override fun insert(initialRepo: InitialRepository) { + val repo = CoreRepository( + name = initialRepo.name, + address = initialRepo.address, + icon = null, + timestamp = -1, + description = mapOf("en-US" to initialRepo.description), + certificate = initialRepo.certificate, + //version = initialRepo.version, // TODO add version + ) + val repoId = insert(repo) + val repositoryPreferences = RepositoryPreferences( + repoId = repoId, + weight = initialRepo.weight, + lastUpdated = null, + enabled = initialRepo.enabled, + ) + insert(repositoryPreferences) + } + @Transaction override fun insertEmptyRepo( address: String, @@ -78,7 +102,7 @@ internal interface RepositoryDaoInt : RepositoryDao { password: String?, ): Long { val repo = CoreRepository( - name = "", + name = address, icon = null, address = address, timestamp = System.currentTimeMillis(), From 97567a20577d68014ca27ba2c3fb35773e3b4381 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 28 Mar 2022 17:26:09 -0300 Subject: [PATCH 10/42] [db] Add UpdateChecker Doing the actual update check is more work than it would be with keeping info about all installed apps in the DB. However, this way, we don't need to keep that info in sync with reality. Also, we need to check for updates only after updating repos, so there we are on a worker thread already anyway and an spare an extra second. --- .../java/org/fdroid/database/DbTest.kt | 2 +- .../org/fdroid/database/UpdateCheckerTest.kt | 47 ++++++++ .../src/main/java/org/fdroid/database/App.kt | 27 ++++- .../main/java/org/fdroid/database/AppDao.kt | 8 ++ .../java/org/fdroid/database/UpdateChecker.kt | 113 ++++++++++++++++++ .../main/java/org/fdroid/database/Version.kt | 9 +- .../java/org/fdroid/database/VersionDao.kt | 39 +++--- .../org/fdroid/index/v1/IndexV1Updater.kt | 6 +- 8 files changed, 226 insertions(+), 25 deletions(-) create mode 100644 database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt create mode 100644 database/src/main/java/org/fdroid/database/UpdateChecker.kt diff --git a/database/src/androidTest/java/org/fdroid/database/DbTest.kt b/database/src/androidTest/java/org/fdroid/database/DbTest.kt index bd5e84761..9f4db479b 100644 --- a/database/src/androidTest/java/org/fdroid/database/DbTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/DbTest.kt @@ -23,7 +23,7 @@ abstract class DbTest { private val testCoroutineDispatcher = Dispatchers.Unconfined @Before - fun createDb() { + open fun createDb() { val context = ApplicationProvider.getApplicationContext() db = Room.inMemoryDatabaseBuilder(context, FDroidDatabaseInt::class.java).build() repoDao = db.getRepositoryDao() diff --git a/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt b/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt new file mode 100644 index 000000000..47a23fa6d --- /dev/null +++ b/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt @@ -0,0 +1,47 @@ +package org.fdroid.database + +import android.content.Context +import android.util.Log +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.apache.commons.io.input.CountingInputStream +import org.fdroid.index.v1.IndexV1StreamProcessor +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.time.ExperimentalTime +import kotlin.time.measureTime + +@RunWith(AndroidJUnit4::class) +class UpdateCheckerTest : DbTest() { + + private lateinit var context: Context + private lateinit var updateChecker: UpdateChecker + + @Before + override fun createDb() { + super.createDb() + context = ApplicationProvider.getApplicationContext() + updateChecker = UpdateChecker(db, context.packageManager) + } + + @OptIn(ExperimentalTime::class) + @Test + fun testGetUpdates() { + val inputStream = CountingInputStream(context.resources.assets.open("index-v1.json")) + val indexProcessor = IndexV1StreamProcessor(DbV1StreamReceiver(db), null) + + db.runInTransaction { + val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") + inputStream.use { indexStream -> + indexProcessor.process(repoId, indexStream) + } + } + + val duration = measureTime { + updateChecker.getUpdatableApps() + } + Log.e("TEST", "$duration") + } + +} diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt index a01f9ac7f..0dc01f3b0 100644 --- a/database/src/main/java/org/fdroid/database/App.kt +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -112,8 +112,8 @@ public data class AppOverviewItem( public val packageId: String, public val added: Long, public val lastUpdated: Long, - private val name: LocalizedTextV2? = null, - private val summary: LocalizedTextV2? = null, + internal val name: LocalizedTextV2? = null, + internal val summary: LocalizedTextV2? = null, @Relation( parentColumn = "packageId", entityColumn = "packageId", @@ -126,6 +126,23 @@ public data class AppOverviewItem( localizedIcon?.toLocalizedFileV2().getBestLocale(localeList)?.name } +public data class UpdatableApp( + public val packageId: String, + public val installedVersionCode: Long, + public val upgrade: AppVersion, + internal val name: LocalizedTextV2? = null, + public val summary: String? = null, + @Relation( + parentColumn = "packageId", + entityColumn = "packageId", + ) + internal val localizedIcon: List? = null, +) { + public fun getName(localeList: LocaleListCompat) = name.getBestLocale(localeList) + public fun getIcon(localeList: LocaleListCompat) = + localizedIcon?.toLocalizedFileV2().getBestLocale(localeList) +} + internal fun Map?.getBestLocale(localeList: LocaleListCompat): T? { if (isNullOrEmpty()) return null val firstMatch = localeList.getFirstMatch(keys.toTypedArray()) ?: error("not empty: $keys") @@ -192,7 +209,11 @@ fun List.toLocalizedFileV2(type: String? = null): LocalizedFileV2? { }.ifEmpty { null } } -@DatabaseView("SELECT * FROM LocalizedFile WHERE type='icon'") +// TODO write test that ensures that in case of the same locale, +// only the one from the repo with higher weight is returned +@DatabaseView("""SELECT * FROM LocalizedFile + JOIN RepositoryPreferences AS prefs USING (repoId) + WHERE type='icon' GROUP BY repoId, packageId, locale HAVING MAX(prefs.weight)""") data class LocalizedIcon( val repoId: Long, val packageId: String, diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index bdce0671c..73056f7ad 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -203,6 +203,14 @@ internal interface AppDaoInt : AppDao { WHERE pref.enabled = 1 AND categories LIKE '%' || :category || '%'""") override fun getNumberOfAppsInCategory(category: String): Int + /** + * Used by [UpdateChecker] to get specific apps with available updates. + */ + @Transaction + @Query("""SELECT repoId, packageId, added, app.lastUpdated, app.name, summary + FROM AppMetadata AS app WHERE repoId = :repoId AND packageId = :packageId""") + fun getAppOverviewItem(repoId: Long, packageId: String): AppOverviewItem? + @VisibleForTesting @Query("DELETE FROM AppMetadata WHERE repoId = :repoId AND packageId = :packageId") fun deleteAppMetadata(repoId: Long, packageId: String) diff --git a/database/src/main/java/org/fdroid/database/UpdateChecker.kt b/database/src/main/java/org/fdroid/database/UpdateChecker.kt new file mode 100644 index 000000000..e826787ef --- /dev/null +++ b/database/src/main/java/org/fdroid/database/UpdateChecker.kt @@ -0,0 +1,113 @@ +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.index.IndexUtils + +public class UpdateChecker( + db: FDroidDatabase, + private val packageManager: PackageManager, +) { + + private val appDao = db.getAppDao() as AppDaoInt + private val versionDao = db.getVersionDao() as VersionDaoInt + + fun getUpdatableApps(): List { + val updatableApps = ArrayList() + @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>(packageNames.size) + versionDao.getVersions(packageNames).forEach { version -> + val list = versionsByPackage.getOrPut(version.packageId) { ArrayList() } + list.add(version) + } + installedPackages.iterator().forEach { packageInfo -> + val versions = versionsByPackage[packageInfo.packageName] ?: return@forEach // continue + val version = getVersion(versions, packageInfo) + if (version != null) { + val versionCode = packageInfo.getVersionCode() + val app = getUpdatableApp(version, versionCode) + if (app != null) updatableApps.add(app) + } + } + return updatableApps + } + + @SuppressLint("PackageManagerGetSignatures") + fun getUpdate(packageName: String): 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, packageInfo) ?: return null + val versionedStrings = versionDao.getVersionedStrings( + repoId = version.repoId, + packageId = version.packageId, + versionId = version.versionId, + ) + return version.toAppVersion(versionedStrings) + } + + private fun getVersion(versions: List, packageInfo: PackageInfo?): Version? { + val versionCode = packageInfo?.getVersionCode() ?: 0 + // the below is rather expensive, so we only do that when there's update candidates + // TODO handle signingInfo.signingCertificateHistory as well + @Suppress("DEPRECATION") + val signatures by lazy { + packageInfo?.signatures?.map { + IndexUtils.getPackageSignature(it.toByteArray()) + }?.toSet() + } + versions.iterator().forEach versions@{ version -> + // if version code is not higher than installed skip package as list is sorted + if (version.manifest.versionCode <= versionCode) return null + // not considering beta versions for now + if (!version.releaseChannels.isNullOrEmpty()) return@versions + val canInstall = if (packageInfo == null) { + true // take first one with highest version code and repo weight + } else { + // TODO also support AppPrefs with ignoring updates + val versionSignatures = version.manifest.signer?.sha256?.toSet() + signatures == versionSignatures + } + // no need to see other versions, we got the highest version code per sorting + if (canInstall) return version + } + return null + } + + private fun getUpdatableApp(version: Version, installedVersionCode: Long): UpdatableApp? { + val versionedStrings = versionDao.getVersionedStrings( + repoId = version.repoId, + packageId = version.packageId, + versionId = version.versionId, + ) + val appOverviewItem = + appDao.getAppOverviewItem(version.repoId, version.packageId) ?: return null + return UpdatableApp( + packageId = version.packageId, + installedVersionCode = installedVersionCode, + upgrade = version.toAppVersion(versionedStrings), + 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() + } +} diff --git a/database/src/main/java/org/fdroid/database/Version.kt b/database/src/main/java/org/fdroid/database/Version.kt index 3bde3bb12..4ad335bb6 100644 --- a/database/src/main/java/org/fdroid/database/Version.kt +++ b/database/src/main/java/org/fdroid/database/Version.kt @@ -37,7 +37,14 @@ data class Version( val releaseChannels: List? = emptyList(), val antiFeatures: Map? = null, val whatsNew: LocalizedTextV2? = null, -) +) { + fun toAppVersion(versionedStrings: List) = AppVersion( + version = this, + usesPermission = versionedStrings.getPermissions(this), + usesPermissionSdk23 = versionedStrings.getPermissionsSdk23(this), + features = versionedStrings.getFeatures(this), + ) +} fun PackageVersionV2.toVersion(repoId: Long, packageId: String, versionId: String) = Version( repoId = repoId, diff --git a/database/src/main/java/org/fdroid/database/VersionDao.kt b/database/src/main/java/org/fdroid/database/VersionDao.kt index f1c8ff5db..203968a91 100644 --- a/database/src/main/java/org/fdroid/database/VersionDao.kt +++ b/database/src/main/java/org/fdroid/database/VersionDao.kt @@ -9,6 +9,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.RewriteQueriesToDropUnusedColumns import androidx.room.Transaction import org.fdroid.database.FDroidDatabaseHolder.dispatcher import org.fdroid.index.v2.PackageVersionV2 @@ -59,14 +60,7 @@ internal interface VersionDaoInt : VersionDao { // TODO we should probably react to changes of versioned strings as well val versionedStrings = getVersionedStrings(packageId) val liveData = getVersions(packageId).distinctUntilChanged().map { versions -> - versions.map { version -> - AppVersion( - version = version, - usesPermission = versionedStrings.getPermissions(version), - usesPermissionSdk23 = versionedStrings.getPermissionsSdk23(version), - features = versionedStrings.getFeatures(version), - ) - } + versions.map { version -> version.toAppVersion(versionedStrings) } } emitSource(liveData) } @@ -74,29 +68,40 @@ internal interface VersionDaoInt : VersionDao { @Transaction override fun getAppVersions(repoId: Long, packageId: String): List { val versionedStrings = getVersionedStrings(repoId, packageId) - return getVersions(repoId, packageId).map { version -> - AppVersion( - version = version, - usesPermission = versionedStrings.getPermissions(version), - usesPermissionSdk23 = versionedStrings.getPermissionsSdk23(version), - features = versionedStrings.getFeatures(version), - ) + return getVersions(repoId, packageId).map { + version -> version.toAppVersion(versionedStrings) } } - @Query("""SELECT * FROM Version WHERE packageId = :packageId + @RewriteQueriesToDropUnusedColumns + @Query("""SELECT * FROM Version + JOIN RepositoryPreferences AS pref USING (repoId) + WHERE pref.enabled = 1 AND packageId = :packageId ORDER BY manifest_versionCode DESC""") fun getVersions(packageId: String): LiveData> @Query("SELECT * FROM Version WHERE repoId = :repoId AND packageId = :packageId") fun getVersions(repoId: Long, packageId: String): List - @Query("SELECT * FROM VersionedString WHERE packageId = :packageId") + @RewriteQueriesToDropUnusedColumns + @Query("""SELECT * FROM Version + JOIN RepositoryPreferences AS pref USING (repoId) + WHERE pref.enabled = 1 AND packageId IN (:packageNames) + ORDER BY manifest_versionCode DESC, pref.weight DESC""") + fun getVersions(packageNames: List): List + + @RewriteQueriesToDropUnusedColumns + @Query("""SELECT * FROM VersionedString + JOIN RepositoryPreferences AS pref USING (repoId) + WHERE pref.enabled = 1 AND packageId = :packageId""") fun getVersionedStrings(packageId: String): List @Query("SELECT * FROM VersionedString WHERE repoId = :repoId AND packageId = :packageId") fun getVersionedStrings(repoId: Long, packageId: String): List + @Query("SELECT * FROM VersionedString WHERE repoId = :repoId AND packageId = :packageId AND versionId = :versionId") + fun getVersionedStrings(repoId: Long, packageId: String, versionId: String): List + @VisibleForTesting @Query("DELETE FROM Version WHERE repoId = :repoId AND packageId = :packageId AND versionId = :versionId") fun deleteAppVersion(repoId: Long, packageId: String, versionId: String) diff --git a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt index 9c61de6b0..89c333ef9 100644 --- a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt +++ b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt @@ -2,7 +2,7 @@ package org.fdroid.index.v1 import android.content.Context import org.fdroid.database.DbV1StreamReceiver -import org.fdroid.database.FDroidDatabaseHolder +import org.fdroid.database.FDroidDatabase import org.fdroid.database.FDroidDatabaseInt import org.fdroid.download.DownloaderFactory import java.io.File @@ -13,11 +13,11 @@ internal const val SIGNED_FILE_NAME = "index-v1.jar" // TODO should this live here and cause a dependency on download lib or in dedicated module? public class IndexV1Updater( private val context: Context, + database: FDroidDatabase, private val downloaderFactory: DownloaderFactory, ) { - private val db: FDroidDatabaseInt = - FDroidDatabaseHolder.getDb(context) as FDroidDatabaseInt // TODO final name + private val db: FDroidDatabaseInt = database as FDroidDatabaseInt @Throws(IOException::class, InterruptedException::class) fun updateNewRepo( From 3cb7538fc856a31be829ebbf92f21096940bab18 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 1 Apr 2022 17:09:24 -0300 Subject: [PATCH 11/42] [db] Add special queries for AppListItems --- .../src/main/java/org/fdroid/database/App.kt | 45 +++++++++++ .../main/java/org/fdroid/database/AppDao.kt | 76 ++++++++++++++++++- .../java/org/fdroid/database/Converters.kt | 2 +- .../java/org/fdroid/database/RepositoryDao.kt | 2 - 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt index 0dc01f3b0..983b76c7a 100644 --- a/database/src/main/java/org/fdroid/database/App.kt +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -5,7 +5,10 @@ import androidx.room.DatabaseView import androidx.room.Embedded import androidx.room.Entity import androidx.room.ForeignKey +import androidx.room.Ignore import androidx.room.Relation +import org.fdroid.database.Converters.fromStringToLocalizedTextV2 +import org.fdroid.database.Converters.fromStringToMapOfLocalizedTextV2 import org.fdroid.index.v2.Author import org.fdroid.index.v2.Donation import org.fdroid.index.v2.FileV2 @@ -126,6 +129,48 @@ public data class AppOverviewItem( localizedIcon?.toLocalizedFileV2().getBestLocale(localeList)?.name } +public data class AppListItem @JvmOverloads constructor( + public val repoId: Long, + public val packageId: String, + internal val name: String?, + internal val summary: String?, + internal val antiFeatures: String?, + @Relation( + parentColumn = "packageId", + entityColumn = "packageId", + ) + internal val localizedIcon: List?, + /** + * If true, this this app has at least one version that is compatible with this device. + */ + @Ignore // TODO actually get this from the DB (probably needs post-processing). + public val isCompatible: Boolean = true, + /** + * The name of the installed version, null if this app is not installed. + */ + @Ignore + public val installedVersionName: String? = null, + @Ignore + public val installedVersionCode: Long? = null, +) { + public fun getName(localeList: LocaleListCompat): String? { + // queries for this class return a larger number, so we convert on demand + return fromStringToLocalizedTextV2(name).getBestLocale(localeList) + } + + public fun getSummary(localeList: LocaleListCompat): String? { + // queries for this class return a larger number, so we convert on demand + return fromStringToLocalizedTextV2(summary).getBestLocale(localeList) + } + + public fun getAntiFeatureNames(): List { + return fromStringToMapOfLocalizedTextV2(antiFeatures)?.map { it.key } ?: emptyList() + } + + public fun getIcon(localeList: LocaleListCompat) = + localizedIcon?.toLocalizedFileV2().getBestLocale(localeList)?.name +} + public data class UpdatableApp( public val packageId: String, public val installedVersionCode: Long, diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index 73056f7ad..2337f3dbc 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -1,5 +1,7 @@ package org.fdroid.database +import android.content.pm.PackageInfo +import android.content.pm.PackageManager import androidx.annotation.VisibleForTesting import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -11,6 +13,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.RoomWarnings.CURSOR_MISMATCH import androidx.room.Transaction import org.fdroid.database.FDroidDatabaseHolder.dispatcher import org.fdroid.index.v2.LocalizedFileListV2 @@ -29,6 +32,14 @@ public interface AppDao { fun getApp(repoId: Long, packageId: String): App? fun getAppOverviewItems(limit: Int = 200): LiveData> fun getAppOverviewItems(category: String, limit: Int = 50): LiveData> + fun getAppListItems(packageManager: PackageManager): LiveData> + fun getAppListItems( + packageManager: PackageManager, + category: String, + ): LiveData> + + fun getInstalledAppListItems(packageManager: PackageManager): LiveData> + fun getNumberOfAppsInCategory(category: String): Int } @@ -197,8 +208,69 @@ internal interface AppDaoInt : AppDao { LIMIT :limit""") override fun getAppOverviewItems(category: String, limit: Int): LiveData> - // FIXME don't over report the same app twice (e.g. in several repos) - @Query("""SELECT COUNT(*) FROM AppMetadata + override fun getAppListItems(packageManager: PackageManager): LiveData> { + return getAppListItems().map(packageManager) + } + + private fun LiveData>.map( + packageManager: PackageManager, + installedPackages: Map = packageManager.getInstalledPackages(0) + .associateBy { packageInfo -> packageInfo.packageName }, + ) = map { items -> + items.map { item -> + val packageInfo = installedPackages[item.packageId] + if (packageInfo == null) item else item.copy( + installedVersionName = packageInfo.versionName, + installedVersionCode = packageInfo.getVersionCode(), + ) + } + } + + @Transaction + @Query("""SELECT repoId, packageId, app.name, summary, version.antiFeatures + FROM AppMetadata AS app + JOIN Version AS version USING (repoId, packageId) + JOIN RepositoryPreferences AS pref USING (repoId) + WHERE pref.enabled = 1 + GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) + ORDER BY app.lastUpdated DESC""") + fun getAppListItems(): LiveData> + + override fun getAppListItems( + packageManager: PackageManager, + category: String, + ): LiveData> { + return getAppListItems(category).map(packageManager) + } + + // TODO maybe it makes sense to split categories into their own table for this? + @Transaction + @Query("""SELECT repoId, packageId, app.name, summary, version.antiFeatures + FROM AppMetadata AS app + JOIN Version AS version USING (repoId, packageId) + JOIN RepositoryPreferences AS pref USING (repoId) + WHERE pref.enabled = 1 AND categories LIKE '%' || :category || '%' + GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) + ORDER BY app.lastUpdated DESC""") + fun getAppListItems(category: String): LiveData> + + @Transaction + @SuppressWarnings(CURSOR_MISMATCH) // no anti-features needed here + @Query("""SELECT repoId, packageId, app.name, summary + FROM AppMetadata AS app + JOIN RepositoryPreferences AS pref USING (repoId) + WHERE pref.enabled = 1 AND packageId IN (:packageNames) + GROUP BY packageId HAVING MAX(pref.weight)""") + fun getAppListItems(packageNames: List): LiveData> + + override fun getInstalledAppListItems(packageManager: PackageManager): LiveData> { + val installedPackages = packageManager.getInstalledPackages(0) + .associateBy { packageInfo -> packageInfo.packageName } + val packageNames = installedPackages.keys.toList() + return getAppListItems(packageNames).map(packageManager, installedPackages) + } + + @Query("""SELECT COUNT(DISTINCT packageId) FROM AppMetadata JOIN RepositoryPreferences AS pref USING (repoId) WHERE pref.enabled = 1 AND categories LIKE '%' || :category || '%'""") override fun getNumberOfAppsInCategory(category: String): Int diff --git a/database/src/main/java/org/fdroid/database/Converters.kt b/database/src/main/java/org/fdroid/database/Converters.kt index f5c263fc2..80a06b62d 100644 --- a/database/src/main/java/org/fdroid/database/Converters.kt +++ b/database/src/main/java/org/fdroid/database/Converters.kt @@ -6,7 +6,7 @@ import kotlinx.serialization.builtins.serializer import org.fdroid.index.IndexParser.json import org.fdroid.index.v2.LocalizedTextV2 -internal class Converters { +internal object Converters { private val localizedTextV2Serializer = MapSerializer(String.serializer(), String.serializer()) private val mapOfLocalizedTextV2Serializer = diff --git a/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/database/src/main/java/org/fdroid/database/RepositoryDao.kt index 91b925288..1a724d5bc 100644 --- a/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -48,8 +48,6 @@ public interface RepositoryDao { fun updateUserMirrors(repoId: Long, mirrors: List) fun updateUsernameAndPassword(repoId: Long, username: String?, password: String?) fun updateDisabledMirrors(repoId: Long, disabledMirrors: List) - - // FIXME: We probably want unique categories here flattened by repo weight fun getLiveCategories(): LiveData> } From 4df60a42c8f19e53885b9c36da4ff1971d2e76d5 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 6 Apr 2022 14:19:07 -0300 Subject: [PATCH 12/42] [db] Calculate app compatibility in DB and remove feature version as it app can't even use that. Compatibility checking has been added to the DB layer as a post-processing step only because the UI wants to query for that on the app level (which would need all APKs). --- .../org/fdroid/database/IndexV1InsertTest.kt | 10 ++-- .../org/fdroid/database/IndexV2InsertTest.kt | 4 +- .../org/fdroid/database/RepositoryTest.kt | 3 +- .../org/fdroid/database/UpdateCheckerTest.kt | 2 +- .../java/org/fdroid/database/VersionTest.kt | 39 +++++++++----- .../fdroid/database/test/TestVersionUtils.kt | 4 +- .../src/main/java/org/fdroid/database/App.kt | 19 +++++-- .../main/java/org/fdroid/database/AppDao.kt | 24 +++++++-- .../org/fdroid/database/DbStreamReceiver.kt | 10 +++- .../org/fdroid/database/DbV1StreamReceiver.kt | 8 ++- .../java/org/fdroid/database/Repository.kt | 2 +- .../java/org/fdroid/database/UpdateChecker.kt | 39 +++++++++++--- .../main/java/org/fdroid/database/Version.kt | 52 ++++++++----------- .../java/org/fdroid/database/VersionDao.kt | 30 +++++++---- .../org/fdroid/index/v1/IndexV1Updater.kt | 6 ++- 15 files changed, 171 insertions(+), 81 deletions(-) diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt index 38835e78d..4398febce 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt @@ -25,7 +25,7 @@ class IndexV1InsertTest : DbTest() { val fileSize = c.resources.assets.openFd("index-v1.json").use { it.length } val inputStream = CountingInputStream(c.resources.assets.open("index-v1.json")) var currentByteCount: Long = 0 - val indexProcessor = IndexV1StreamProcessor(DbV1StreamReceiver(db), null) { + val indexProcessor = IndexV1StreamProcessor(DbV1StreamReceiver(db) { true }, null) { val bytesRead = inputStream.byteCount val bytesSinceLastCall = bytesRead - currentByteCount if (bytesSinceLastCall > 0) { @@ -84,10 +84,10 @@ class IndexV1InsertTest : DbTest() { val localizedFileLists2 = localizedFileLists.count { it.repoId == 2L } assertEquals(localizedFileLists1, localizedFileLists2) - appMetadata.filter { it.repoId ==2L }.forEach { m -> + appMetadata.filter { it.repoId == 2L }.forEach { m -> val metadata1 = appDao.getAppMetadata(1, m.packageId) val metadata2 = appDao.getAppMetadata(2, m.packageId) - assertEquals(metadata1, metadata2.copy(repoId = 1)) + assertEquals(metadata1, metadata2.copy(repoId = 1, isCompatible = true)) val lFiles1 = appDao.getLocalizedFiles(1, m.packageId).toSet() val lFiles2 = appDao.getLocalizedFiles(2, m.packageId) @@ -111,7 +111,7 @@ class IndexV1InsertTest : DbTest() { private fun insertV2ForComparison() { val c = getApplicationContext() val inputStream = CountingInputStream(c.resources.assets.open("index-v2.json")) - val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db), null) + val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db) { true }, null) db.runInTransaction { val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") inputStream.use { indexStream -> @@ -124,7 +124,7 @@ class IndexV1InsertTest : DbTest() { fun testExceptionWhileStreamingDoesNotSaveIntoDb() { val c = getApplicationContext() val cIn = CountingInputStream(c.resources.assets.open("index-v1.json")) - val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db), null) { + val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db) { true }, null) { if (cIn.byteCount > 824096) throw SerializationException() cIn.byteCount } diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt index e6240c057..a058fc2bb 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt @@ -22,7 +22,7 @@ class IndexV2InsertTest : DbTest() { val fileSize = c.resources.assets.openFd("index-v2.json").use { it.length } val inputStream = CountingInputStream(c.resources.assets.open("index-v2.json")) var currentByteCount: Long = 0 - val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db), null) { + val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db) { true }, null) { val bytesRead = inputStream.byteCount val bytesSinceLastCall = bytesRead - currentByteCount if (bytesSinceLastCall > 0) { @@ -60,7 +60,7 @@ class IndexV2InsertTest : DbTest() { fun testExceptionWhileStreamingDoesNotSaveIntoDb() { val c = getApplicationContext() val cIn = CountingInputStream(c.resources.assets.open("index-v2.json")) - val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db), null) { + val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db) { true }, null) { if (cIn.byteCount > 824096) throw SerializationException() cIn.byteCount } diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt index 86c3a38b2..070656fe8 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt @@ -8,6 +8,7 @@ import org.fdroid.database.test.TestUtils.getRandomString import org.fdroid.database.test.TestVersionUtils.getRandomPackageVersionV2 import org.junit.Test import org.junit.runner.RunWith +import kotlin.random.Random import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue @@ -64,7 +65,7 @@ class RepositoryTest : DbTest() { val versionId = getRandomString() appDao.insert(repoId, packageId, getRandomMetadataV2()) val packageVersion = getRandomPackageVersionV2() - versionDao.insert(repoId, packageId, versionId, packageVersion) + versionDao.insert(repoId, packageId, versionId, packageVersion, Random.nextBoolean()) assertEquals(1, repoDao.getRepositories().size) assertEquals(1, appDao.getAppMetadata().size) diff --git a/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt b/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt index 47a23fa6d..d33b0edb7 100644 --- a/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt @@ -29,7 +29,7 @@ class UpdateCheckerTest : DbTest() { @Test fun testGetUpdates() { val inputStream = CountingInputStream(context.resources.assets.open("index-v1.json")) - val indexProcessor = IndexV1StreamProcessor(DbV1StreamReceiver(db), null) + val indexProcessor = IndexV1StreamProcessor(DbV1StreamReceiver(db) { true }, null) db.runInTransaction { val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") diff --git a/database/src/androidTest/java/org/fdroid/database/VersionTest.kt b/database/src/androidTest/java/org/fdroid/database/VersionTest.kt index 51416fb29..9560f9292 100644 --- a/database/src/androidTest/java/org/fdroid/database/VersionTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/VersionTest.kt @@ -10,6 +10,7 @@ import org.fdroid.database.test.TestVersionUtils.getRandomPackageVersionV2 import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import kotlin.random.Random import kotlin.test.assertEquals import kotlin.test.fail @@ -27,21 +28,25 @@ class VersionTest : DbTest() { val repoId = repoDao.insert(getRandomRepo()) appDao.insert(repoId, packageId, getRandomMetadataV2()) val packageVersion = getRandomPackageVersionV2() - versionDao.insert(repoId, packageId, versionId, packageVersion) + val isCompatible = Random.nextBoolean() + versionDao.insert(repoId, packageId, versionId, packageVersion, isCompatible) val appVersions = versionDao.getAppVersions(repoId, packageId) assertEquals(1, appVersions.size) val appVersion = appVersions[0] assertEquals(versionId, appVersion.version.versionId) - assertEquals(packageVersion.toVersion(repoId, packageId, versionId), appVersion.version) + val version = packageVersion.toVersion(repoId, packageId, versionId, isCompatible) + assertEquals(version, appVersion.version) val manifest = packageVersion.manifest assertEquals(manifest.usesPermission.toSet(), appVersion.usesPermission?.toSet()) assertEquals(manifest.usesPermissionSdk23.toSet(), appVersion.usesPermissionSdk23?.toSet()) - assertEquals(manifest.features.toSet(), appVersion.features?.toSet()) + assertEquals( + manifest.features.map { it.name }.toSet(), + appVersion.version.manifest.features?.toSet() + ) val versionedStrings = versionDao.getVersionedStrings(repoId, packageId) - val expectedSize = - manifest.usesPermission.size + manifest.usesPermissionSdk23.size + manifest.features.size + val expectedSize = manifest.usesPermission.size + manifest.usesPermissionSdk23.size assertEquals(expectedSize, versionedStrings.size) versionDao.deleteAppVersion(repoId, packageId, versionId) @@ -55,11 +60,13 @@ class VersionTest : DbTest() { val repoId = repoDao.insert(getRandomRepo()) appDao.insert(repoId, packageId, getRandomMetadataV2()) val packageVersion1 = getRandomPackageVersionV2() - val version1 = getRandomString() - versionDao.insert(repoId, packageId, version1, packageVersion1) val packageVersion2 = getRandomPackageVersionV2() + val version1 = getRandomString() val version2 = getRandomString() - versionDao.insert(repoId, packageId, version2, packageVersion2) + val isCompatible1 = Random.nextBoolean() + val isCompatible2 = Random.nextBoolean() + versionDao.insert(repoId, packageId, version1, packageVersion1, isCompatible1) + versionDao.insert(repoId, packageId, version2, packageVersion2, isCompatible2) // get app versions from DB and assign them correctly val appVersions = versionDao.getAppVersions(packageId).getOrAwaitValue() ?: fail() @@ -72,19 +79,27 @@ class VersionTest : DbTest() { } else appVersions[1] // check first version matches - assertEquals(packageVersion1.toVersion(repoId, packageId, version1), appVersion.version) + val exVersion1 = packageVersion1.toVersion(repoId, packageId, version1, isCompatible1) + assertEquals(exVersion1, appVersion.version) val manifest = packageVersion1.manifest assertEquals(manifest.usesPermission.toSet(), appVersion.usesPermission?.toSet()) assertEquals(manifest.usesPermissionSdk23.toSet(), appVersion.usesPermissionSdk23?.toSet()) - assertEquals(manifest.features.toSet(), appVersion.features?.toSet()) + assertEquals( + manifest.features.map { it.name }.toSet(), + appVersion.version.manifest.features?.toSet() + ) // check second version matches - assertEquals(packageVersion2.toVersion(repoId, packageId, version2), appVersion2.version) + val exVersion2 = packageVersion2.toVersion(repoId, packageId, version2, isCompatible2) + assertEquals(exVersion2, appVersion2.version) val manifest2 = packageVersion2.manifest assertEquals(manifest2.usesPermission.toSet(), appVersion2.usesPermission?.toSet()) assertEquals(manifest2.usesPermissionSdk23.toSet(), appVersion2.usesPermissionSdk23?.toSet()) - assertEquals(manifest2.features.toSet(), appVersion2.features?.toSet()) + assertEquals( + manifest.features.map { it.name }.toSet(), + appVersion.version.manifest.features?.toSet() + ) // delete app and check that all associated data also gets deleted appDao.deleteAppMetadata(repoId, packageId) diff --git a/database/src/androidTest/java/org/fdroid/database/test/TestVersionUtils.kt b/database/src/androidTest/java/org/fdroid/database/test/TestVersionUtils.kt index 584226852..caa128b4a 100644 --- a/database/src/androidTest/java/org/fdroid/database/test/TestVersionUtils.kt +++ b/database/src/androidTest/java/org/fdroid/database/test/TestVersionUtils.kt @@ -47,9 +47,7 @@ internal object TestVersionUtils { PermissionV2(getRandomString(), Random.nextInt().orNull()) }, nativeCode = getRandomList(Random.nextInt(0, 4)) { getRandomString() }, - features = getRandomList { - FeatureV2(getRandomString(), Random.nextInt().orNull()) - }, + features = getRandomList { FeatureV2(getRandomString()) }, ) } diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt index 983b76c7a..4fd4e1867 100644 --- a/database/src/main/java/org/fdroid/database/App.kt +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -35,6 +35,8 @@ data class AppMetadata( val name: LocalizedTextV2? = null, val summary: LocalizedTextV2? = null, val description: LocalizedTextV2? = null, + val localizedName: String? = null, + val localizedSummary: String? = null, val webSite: String? = null, val changelog: String? = null, val license: String? = null, @@ -46,9 +48,20 @@ data class AppMetadata( @Embedded(prefix = "author_") val author: Author? = Author(), @Embedded(prefix = "donation_") val donation: Donation? = Donation(), val categories: List? = 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]. + */ + val isCompatible: Boolean, ) -fun MetadataV2.toAppMetadata(repoId: Long, packageId: String) = AppMetadata( +fun MetadataV2.toAppMetadata( + repoId: Long, + packageId: String, + isCompatible: Boolean = false, +) = AppMetadata( repoId = repoId, packageId = packageId, added = added, @@ -67,6 +80,7 @@ fun MetadataV2.toAppMetadata(repoId: Long, packageId: String) = AppMetadata( author = if (author?.isNull == true) null else author, donation = if (donation?.isNull == true) null else donation, categories = categories, + isCompatible = isCompatible, ) data class App( @@ -143,8 +157,7 @@ public data class AppListItem @JvmOverloads constructor( /** * If true, this this app has at least one version that is compatible with this device. */ - @Ignore // TODO actually get this from the DB (probably needs post-processing). - public val isCompatible: Boolean = true, + public val isCompatible: Boolean, /** * The name of the installed version, null if this app is not installed. */ diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index 2337f3dbc..115c75bca 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -48,7 +48,7 @@ internal interface AppDaoInt : AppDao { @Transaction override fun insert(repoId: Long, packageId: String, app: MetadataV2) { - insert(app.toAppMetadata(repoId, packageId)) + insert(app.toAppMetadata(repoId, packageId, false)) app.icon.insert(repoId, packageId, "icon") app.featureGraphic.insert(repoId, packageId, "featureGraphic") app.promoGraphic.insert(repoId, packageId, "promoGraphic") @@ -90,6 +90,20 @@ internal interface AppDaoInt : AppDao { @Query("UPDATE AppMetadata SET preferredSigner = :preferredSigner WHERE repoId = :repoId AND packageId = :packageId") fun updatePreferredSigner(repoId: Long, packageId: String, preferredSigner: String?) + /** + * 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. + * Otherwise the compatibility is wrong. + */ + @Query("""UPDATE AppMetadata + SET isCompatible = ( + SELECT TOTAL(isCompatible) > 0 FROM Version + WHERE repoId = :repoId AND AppMetadata.packageId = Version.packageId + ) + WHERE repoId = :repoId""") + fun updateCompatibility(repoId: Long) + override fun getApp(packageId: String): LiveData { return getRepoIdForPackage(packageId).distinctUntilChanged().switchMap { repoId -> if (repoId == null) MutableLiveData(null) @@ -227,7 +241,8 @@ internal interface AppDaoInt : AppDao { } @Transaction - @Query("""SELECT repoId, packageId, app.name, summary, version.antiFeatures + @Query(""" + SELECT repoId, packageId, app.name, summary, version.antiFeatures, app.isCompatible FROM AppMetadata AS app JOIN Version AS version USING (repoId, packageId) JOIN RepositoryPreferences AS pref USING (repoId) @@ -245,7 +260,8 @@ internal interface AppDaoInt : AppDao { // TODO maybe it makes sense to split categories into their own table for this? @Transaction - @Query("""SELECT repoId, packageId, app.name, summary, version.antiFeatures + @Query(""" + SELECT repoId, packageId, app.name, summary, version.antiFeatures, app.isCompatible FROM AppMetadata AS app JOIN Version AS version USING (repoId, packageId) JOIN RepositoryPreferences AS pref USING (repoId) @@ -256,7 +272,7 @@ internal interface AppDaoInt : AppDao { @Transaction @SuppressWarnings(CURSOR_MISMATCH) // no anti-features needed here - @Query("""SELECT repoId, packageId, app.name, summary + @Query("""SELECT repoId, packageId, app.name, summary, app.isCompatible FROM AppMetadata AS app JOIN RepositoryPreferences AS pref USING (repoId) WHERE pref.enabled = 1 AND packageId IN (:packageNames) diff --git a/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt index 807f0cd00..27c1cf979 100644 --- a/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt @@ -1,11 +1,13 @@ package org.fdroid.database +import org.fdroid.CompatibilityChecker import org.fdroid.index.v2.IndexStreamReceiver import org.fdroid.index.v2.PackageV2 import org.fdroid.index.v2.RepoV2 internal class DbStreamReceiver( private val db: FDroidDatabaseInt, + private val compatibilityChecker: CompatibilityChecker, ) : IndexStreamReceiver { override fun receive(repoId: Long, repo: RepoV2, certificate: String?) { @@ -14,7 +16,13 @@ internal class DbStreamReceiver( override fun receive(repoId: Long, packageId: String, p: PackageV2) { db.getAppDao().insert(repoId, packageId, p.metadata) - db.getVersionDao().insert(repoId, packageId, p.versions) + db.getVersionDao().insert(repoId, packageId, p.versions) { + compatibilityChecker.isCompatible(it.manifest) + } + } + + override fun onStreamEnded(repoId: Long) { + db.getAppDao().updateCompatibility(repoId) } } diff --git a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt index 68ea29112..672833d1f 100644 --- a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt @@ -1,5 +1,6 @@ package org.fdroid.database +import org.fdroid.CompatibilityChecker import org.fdroid.index.v1.IndexV1StreamReceiver import org.fdroid.index.v2.AntiFeatureV2 import org.fdroid.index.v2.CategoryV2 @@ -10,6 +11,7 @@ import org.fdroid.index.v2.RepoV2 internal class DbV1StreamReceiver( private val db: FDroidDatabaseInt, + private val compatibilityChecker: CompatibilityChecker, ) : IndexV1StreamReceiver { override fun receive(repoId: Long, repo: RepoV2, certificate: String?) { @@ -21,7 +23,9 @@ internal class DbV1StreamReceiver( } override fun receive(repoId: Long, packageId: String, v: Map) { - db.getVersionDao().insert(repoId, packageId, v) + db.getVersionDao().insert(repoId, packageId, v) { + compatibilityChecker.isCompatible(it.manifest) + } } override fun updateRepo( @@ -34,6 +38,8 @@ internal class DbV1StreamReceiver( repoDao.insertAntiFeatures(antiFeatures.toRepoAntiFeatures(repoId)) repoDao.insertCategories(categories.toRepoCategories(repoId)) repoDao.insertReleaseChannels(releaseChannels.toRepoReleaseChannel(repoId)) + + db.getAppDao().updateCompatibility(repoId) } override fun updateAppMetadata(repoId: Long, packageId: String, preferredSigner: String?) { diff --git a/database/src/main/java/org/fdroid/database/Repository.kt b/database/src/main/java/org/fdroid/database/Repository.kt index 85a0c37dc..dbe10067b 100644 --- a/database/src/main/java/org/fdroid/database/Repository.kt +++ b/database/src/main/java/org/fdroid/database/Repository.kt @@ -224,7 +224,7 @@ data class RepositoryPreferences( @PrimaryKey internal val repoId: Long, val weight: Int, val enabled: Boolean = true, - val lastUpdated: Long? = System.currentTimeMillis(), // TODO set this after repo updates + val lastUpdated: Long? = System.currentTimeMillis(), val lastETag: String? = null, val userMirrors: List? = null, val disabledMirrors: List? = null, diff --git a/database/src/main/java/org/fdroid/database/UpdateChecker.kt b/database/src/main/java/org/fdroid/database/UpdateChecker.kt index e826787ef..1d1bcc332 100644 --- a/database/src/main/java/org/fdroid/database/UpdateChecker.kt +++ b/database/src/main/java/org/fdroid/database/UpdateChecker.kt @@ -5,6 +5,7 @@ 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.index.IndexUtils public class UpdateChecker( @@ -14,9 +15,16 @@ public class UpdateChecker( private val appDao = db.getAppDao() as AppDaoInt private val versionDao = db.getVersionDao() as VersionDaoInt + private val compatibilityChecker = CompatibilityCheckerImpl(packageManager) - fun getUpdatableApps(): List { + /** + * 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. + */ + fun getUpdatableApps(releaseChannels: List? = null): List { val updatableApps = ArrayList() + @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 } @@ -27,7 +35,7 @@ public class UpdateChecker( } installedPackages.iterator().forEach { packageInfo -> val versions = versionsByPackage[packageInfo.packageName] ?: return@forEach // continue - val version = getVersion(versions, packageInfo) + val version = getVersion(versions, packageInfo, releaseChannels) if (version != null) { val versionCode = packageInfo.getVersionCode() val app = getUpdatableApp(version, versionCode) @@ -37,8 +45,14 @@ public class UpdateChecker( return updatableApps } + /** + * Returns an [AppVersion] for the given [packageName] that is an update + * 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") - fun getUpdate(packageName: String): AppVersion? { + fun getUpdate(packageName: String, releaseChannels: List? = null): AppVersion? { val versions = versionDao.getVersions(listOf(packageName)) if (versions.isEmpty()) return null val packageInfo = try { @@ -47,7 +61,7 @@ public class UpdateChecker( } catch (e: PackageManager.NameNotFoundException) { null } - val version = getVersion(versions, packageInfo) ?: return null + val version = getVersion(versions, packageInfo, releaseChannels) ?: return null val versionedStrings = versionDao.getVersionedStrings( repoId = version.repoId, packageId = version.packageId, @@ -56,7 +70,11 @@ public class UpdateChecker( return version.toAppVersion(versionedStrings) } - private fun getVersion(versions: List, packageInfo: PackageInfo?): Version? { + private fun getVersion( + versions: List, + packageInfo: PackageInfo?, + releaseChannels: List?, + ): Version? { val versionCode = packageInfo?.getVersionCode() ?: 0 // the below is rather expensive, so we only do that when there's update candidates // TODO handle signingInfo.signingCertificateHistory as well @@ -69,8 +87,15 @@ public class UpdateChecker( versions.iterator().forEach versions@{ version -> // if version code is not higher than installed skip package as list is sorted if (version.manifest.versionCode <= versionCode) return null - // not considering beta versions for now - if (!version.releaseChannels.isNullOrEmpty()) return@versions + // check release channels if they are not empty + if (!version.releaseChannels.isNullOrEmpty()) { + // if release channels are not empty (stable) don't consider this version + if (releaseChannels == null) return@versions + // don't consider version with non-matching release channel + if (releaseChannels.intersect(version.releaseChannels).isEmpty()) return@versions + } + // skip incompatible versions + if (!compatibilityChecker.isCompatible(version.manifest)) return@versions val canInstall = if (packageInfo == null) { true // take first one with highest version code and repo weight } else { diff --git a/database/src/main/java/org/fdroid/database/Version.kt b/database/src/main/java/org/fdroid/database/Version.kt index 4ad335bb6..6b53232cb 100644 --- a/database/src/main/java/org/fdroid/database/Version.kt +++ b/database/src/main/java/org/fdroid/database/Version.kt @@ -4,7 +4,6 @@ import androidx.core.os.LocaleListCompat import androidx.room.Embedded import androidx.room.Entity import androidx.room.ForeignKey -import org.fdroid.database.VersionedStringType.FEATURE import org.fdroid.database.VersionedStringType.PERMISSION import org.fdroid.database.VersionedStringType.PERMISSION_SDK_23 import org.fdroid.index.v2.FeatureV2 @@ -37,16 +36,21 @@ data class Version( val releaseChannels: List? = emptyList(), val antiFeatures: Map? = null, val whatsNew: LocalizedTextV2? = null, + val isCompatible: Boolean, ) { fun toAppVersion(versionedStrings: List) = AppVersion( version = this, usesPermission = versionedStrings.getPermissions(this), usesPermissionSdk23 = versionedStrings.getPermissionsSdk23(this), - features = versionedStrings.getFeatures(this), ) } -fun PackageVersionV2.toVersion(repoId: Long, packageId: String, versionId: String) = Version( +fun PackageVersionV2.toVersion( + repoId: Long, + packageId: String, + versionId: String, + isCompatible: Boolean, +) = Version( repoId = repoId, packageId = packageId, versionId = versionId, @@ -57,16 +61,16 @@ fun PackageVersionV2.toVersion(repoId: Long, packageId: String, versionId: Strin releaseChannels = releaseChannels, antiFeatures = antiFeatures, whatsNew = whatsNew, + isCompatible = isCompatible, ) data class AppVersion( val version: Version, val usesPermission: List? = null, val usesPermissionSdk23: List? = null, - val features: List? = null, ) { val packageId get() = version.packageId - val featureNames get() = features?.map { it.name }?.toTypedArray() ?: emptyArray() + val featureNames get() = version.manifest.features?.toTypedArray() ?: emptyArray() val nativeCode get() = version.manifest.nativecode?.toTypedArray() ?: emptyArray() val antiFeatureNames: Array get() { @@ -83,7 +87,18 @@ data class AppManifest( val maxSdkVersion: Int? = null, @Embedded(prefix = "signer_") val signer: SignatureV2? = null, val nativecode: List? = emptyList(), -) + val features: List? = emptyList(), +) { + internal fun toManifestV2(): ManifestV2 = ManifestV2( + versionName = versionName, + versionCode = versionCode, + usesSdk = usesSdk, + maxSdkVersion = maxSdkVersion, + signer = signer, + nativeCode = nativecode ?: emptyList(), + features = features?.map { FeatureV2(it) } ?: emptyList(), + ) +} fun ManifestV2.toManifest() = AppManifest( versionName = versionName, @@ -92,12 +107,12 @@ fun ManifestV2.toManifest() = AppManifest( maxSdkVersion = maxSdkVersion, signer = signer, nativecode = nativeCode, + features = features.map { it.name }, ) enum class VersionedStringType { PERMISSION, PERMISSION_SDK_23, - FEATURE, } @Entity( @@ -132,21 +147,9 @@ fun List.toVersionedString( ) } -fun List.toVersionedString(version: Version) = map { feature -> - VersionedString( - repoId = version.repoId, - packageId = version.packageId, - versionId = version.versionId, - type = FEATURE, - name = feature.name, - version = feature.version, - ) -} - fun ManifestV2.getVersionedStrings(version: Version): List { return usesPermission.toVersionedString(version, PERMISSION) + - usesPermissionSdk23.toVersionedString(version, PERMISSION_SDK_23) + - features.toVersionedString(version) + usesPermissionSdk23.toVersionedString(version, PERMISSION_SDK_23) } fun List.getPermissions(version: Version) = mapNotNull { v -> @@ -167,15 +170,6 @@ fun List.getPermissionsSdk23(version: Version) = mapNotNull { v } } -fun List.getFeatures(version: Version) = mapNotNull { v -> - v.map(version, FEATURE) { - FeatureV2( - name = v.name, - version = v.version, - ) - } -} - private fun VersionedString.map( v: Version, wantedType: VersionedStringType, diff --git a/database/src/main/java/org/fdroid/database/VersionDao.kt b/database/src/main/java/org/fdroid/database/VersionDao.kt index 203968a91..4709b7c10 100644 --- a/database/src/main/java/org/fdroid/database/VersionDao.kt +++ b/database/src/main/java/org/fdroid/database/VersionDao.kt @@ -15,8 +15,13 @@ import org.fdroid.database.FDroidDatabaseHolder.dispatcher import org.fdroid.index.v2.PackageVersionV2 public interface VersionDao { - fun insert(repoId: Long, packageId: String, packageVersions: Map) - fun insert(repoId: Long, packageId: String, versionId: String, packageVersion: PackageVersionV2) + fun insert( + repoId: Long, + packageId: String, + packageVersions: Map, + checkIfCompatible: (PackageVersionV2) -> Boolean, + ) + fun getAppVersions(packageId: String): LiveData> fun getAppVersions(repoId: Long, packageId: String): List } @@ -29,21 +34,24 @@ internal interface VersionDaoInt : VersionDao { repoId: Long, packageId: String, packageVersions: Map, + checkIfCompatible: (PackageVersionV2) -> Boolean, ) { // TODO maybe the number of queries here can be reduced - packageVersions.entries.forEach { (versionId, packageVersion) -> - insert(repoId, packageId, versionId, packageVersion) + packageVersions.entries.iterator().forEach { (versionId, packageVersion) -> + val isCompatible = checkIfCompatible(packageVersion) + insert(repoId, packageId, versionId, packageVersion, isCompatible) } } @Transaction - override fun insert( + fun insert( repoId: Long, packageId: String, versionId: String, packageVersion: PackageVersionV2, + isCompatible: Boolean, ) { - val version = packageVersion.toVersion(repoId, packageId, versionId) + val version = packageVersion.toVersion(repoId, packageId, versionId, isCompatible) insert(version) insert(packageVersion.manifest.getVersionedStrings(version)) } @@ -68,8 +76,8 @@ internal interface VersionDaoInt : VersionDao { @Transaction override fun getAppVersions(repoId: Long, packageId: String): List { val versionedStrings = getVersionedStrings(repoId, packageId) - return getVersions(repoId, packageId).map { - version -> version.toAppVersion(versionedStrings) + return getVersions(repoId, packageId).map { version -> + version.toAppVersion(versionedStrings) } } @@ -100,7 +108,11 @@ internal interface VersionDaoInt : VersionDao { fun getVersionedStrings(repoId: Long, packageId: String): List @Query("SELECT * FROM VersionedString WHERE repoId = :repoId AND packageId = :packageId AND versionId = :versionId") - fun getVersionedStrings(repoId: Long, packageId: String, versionId: String): List + fun getVersionedStrings( + repoId: Long, + packageId: String, + versionId: String, + ): List @VisibleForTesting @Query("DELETE FROM Version WHERE repoId = :repoId AND packageId = :packageId AND versionId = :versionId") diff --git a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt index 89c333ef9..7369d0fc7 100644 --- a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt +++ b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt @@ -1,6 +1,7 @@ package org.fdroid.index.v1 import android.content.Context +import org.fdroid.CompatibilityChecker import org.fdroid.database.DbV1StreamReceiver import org.fdroid.database.FDroidDatabase import org.fdroid.database.FDroidDatabaseInt @@ -15,6 +16,7 @@ public class IndexV1Updater( private val context: Context, database: FDroidDatabase, private val downloaderFactory: DownloaderFactory, + private val compatibilityChecker: CompatibilityChecker, ) { private val db: FDroidDatabaseInt = database as FDroidDatabaseInt @@ -63,8 +65,8 @@ public class IndexV1Updater( db.runInTransaction { val cert = verifier.getStreamAndVerify { inputStream -> updateListener?.onStartProcessing() // TODO maybe do more fine-grained reporting - val streamProcessor = - IndexV1StreamProcessor(DbV1StreamReceiver(db), certificate) + val streamReceiver = DbV1StreamReceiver(db, compatibilityChecker) + val streamProcessor = IndexV1StreamProcessor(streamReceiver, certificate) streamProcessor.process(repoId, inputStream) } // update certificate, if we didn't have any before From c4c49b7072afa676235bdad166c5d997be6c6e19 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 6 Apr 2022 17:25:28 -0300 Subject: [PATCH 13/42] Use sharedTest sourceSet from index library instead own TestUtils --- database/build.gradle | 8 ++ database/src/androidTest/assets/resources | 1 + .../java/org/fdroid/database/AppTest.kt | 10 +- .../org/fdroid/database/RepositoryDiffTest.kt | 18 ++-- .../org/fdroid/database/RepositoryTest.kt | 10 +- .../java/org/fdroid/database/VersionTest.kt | 8 +- .../org/fdroid/database/test/TestAppUtils.kt | 99 ------------------- .../org/fdroid/database/test/TestRepoUtils.kt | 93 ----------------- .../org/fdroid/database/test/TestUtils.kt | 75 +++++--------- .../fdroid/database/test/TestVersionUtils.kt | 53 ---------- database/src/sharedTest | 1 + .../org/fdroid/database/ReflectionTest.kt | 6 +- .../java/org/fdroid/database/TestUtils.kt | 79 --------------- 13 files changed, 63 insertions(+), 398 deletions(-) create mode 120000 database/src/androidTest/assets/resources delete mode 100644 database/src/androidTest/java/org/fdroid/database/test/TestAppUtils.kt delete mode 100644 database/src/androidTest/java/org/fdroid/database/test/TestRepoUtils.kt delete mode 100644 database/src/androidTest/java/org/fdroid/database/test/TestVersionUtils.kt create mode 120000 database/src/sharedTest delete mode 100644 database/src/test/java/org/fdroid/database/TestUtils.kt diff --git a/database/build.gradle b/database/build.gradle index 1b39ee2c0..3282ca8d3 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -27,6 +27,14 @@ android { proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } + sourceSets { + androidTest { + java.srcDirs += "src/sharedTest/kotlin" + } + test { + java.srcDirs += "src/sharedTest/kotlin" + } + } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 diff --git a/database/src/androidTest/assets/resources b/database/src/androidTest/assets/resources new file mode 120000 index 000000000..522d03381 --- /dev/null +++ b/database/src/androidTest/assets/resources @@ -0,0 +1 @@ +../../sharedTest/resources \ No newline at end of file diff --git a/database/src/androidTest/java/org/fdroid/database/AppTest.kt b/database/src/androidTest/java/org/fdroid/database/AppTest.kt index 0103017d2..6c81ace11 100644 --- a/database/src/androidTest/java/org/fdroid/database/AppTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/AppTest.kt @@ -2,12 +2,12 @@ package org.fdroid.database import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.fdroid.database.test.TestAppUtils.assertScreenshotsEqual -import org.fdroid.database.test.TestAppUtils.getRandomMetadataV2 -import org.fdroid.database.test.TestRepoUtils.getRandomFileV2 -import org.fdroid.database.test.TestRepoUtils.getRandomRepo import org.fdroid.database.test.TestUtils.getOrAwaitValue -import org.fdroid.database.test.TestUtils.getRandomString +import org.fdroid.test.TestAppUtils.assertScreenshotsEqual +import org.fdroid.test.TestAppUtils.getRandomMetadataV2 +import org.fdroid.test.TestRepoUtils.getRandomFileV2 +import org.fdroid.test.TestRepoUtils.getRandomRepo +import org.fdroid.test.TestUtils.getRandomString import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt index e6588eb93..f2cad81ba 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt @@ -4,19 +4,19 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject -import org.fdroid.database.test.TestRepoUtils.assertRepoEquals -import org.fdroid.database.test.TestRepoUtils.getRandomFileV2 -import org.fdroid.database.test.TestRepoUtils.getRandomLocalizedTextV2 -import org.fdroid.database.test.TestRepoUtils.getRandomMirror -import org.fdroid.database.test.TestRepoUtils.getRandomRepo -import org.fdroid.database.test.TestUtils.applyDiff -import org.fdroid.database.test.TestUtils.getRandomMap -import org.fdroid.database.test.TestUtils.getRandomString -import org.fdroid.database.test.TestUtils.randomDiff +import org.fdroid.database.test.TestUtils.assertRepoEquals import org.fdroid.index.v2.AntiFeatureV2 import org.fdroid.index.v2.CategoryV2 import org.fdroid.index.v2.ReleaseChannelV2 import org.fdroid.index.v2.RepoV2 +import org.fdroid.test.DiffUtils.applyDiff +import org.fdroid.test.DiffUtils.randomDiff +import org.fdroid.test.TestRepoUtils.getRandomFileV2 +import org.fdroid.test.TestRepoUtils.getRandomLocalizedTextV2 +import org.fdroid.test.TestRepoUtils.getRandomMirror +import org.fdroid.test.TestRepoUtils.getRandomRepo +import org.fdroid.test.TestUtils.getRandomMap +import org.fdroid.test.TestUtils.getRandomString import org.junit.Test import org.junit.runner.RunWith import kotlin.random.Random diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt index 070656fe8..c8c9cd79b 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt @@ -1,11 +1,11 @@ package org.fdroid.database import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.fdroid.database.test.TestAppUtils.getRandomMetadataV2 -import org.fdroid.database.test.TestRepoUtils.assertRepoEquals -import org.fdroid.database.test.TestRepoUtils.getRandomRepo -import org.fdroid.database.test.TestUtils.getRandomString -import org.fdroid.database.test.TestVersionUtils.getRandomPackageVersionV2 +import org.fdroid.database.test.TestUtils.assertRepoEquals +import org.fdroid.test.TestAppUtils.getRandomMetadataV2 +import org.fdroid.test.TestRepoUtils.getRandomRepo +import org.fdroid.test.TestUtils.getRandomString +import org.fdroid.test.TestVersionUtils.getRandomPackageVersionV2 import org.junit.Test import org.junit.runner.RunWith import kotlin.random.Random diff --git a/database/src/androidTest/java/org/fdroid/database/VersionTest.kt b/database/src/androidTest/java/org/fdroid/database/VersionTest.kt index 9560f9292..51586e780 100644 --- a/database/src/androidTest/java/org/fdroid/database/VersionTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/VersionTest.kt @@ -2,11 +2,11 @@ package org.fdroid.database import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.fdroid.database.test.TestAppUtils.getRandomMetadataV2 -import org.fdroid.database.test.TestRepoUtils.getRandomRepo import org.fdroid.database.test.TestUtils.getOrAwaitValue -import org.fdroid.database.test.TestUtils.getRandomString -import org.fdroid.database.test.TestVersionUtils.getRandomPackageVersionV2 +import org.fdroid.test.TestAppUtils.getRandomMetadataV2 +import org.fdroid.test.TestRepoUtils.getRandomRepo +import org.fdroid.test.TestUtils.getRandomString +import org.fdroid.test.TestVersionUtils.getRandomPackageVersionV2 import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith diff --git a/database/src/androidTest/java/org/fdroid/database/test/TestAppUtils.kt b/database/src/androidTest/java/org/fdroid/database/test/TestAppUtils.kt deleted file mode 100644 index a2a631289..000000000 --- a/database/src/androidTest/java/org/fdroid/database/test/TestAppUtils.kt +++ /dev/null @@ -1,99 +0,0 @@ -package org.fdroid.database.test - -import org.fdroid.database.test.TestRepoUtils.getRandomLocalizedFileV2 -import org.fdroid.database.test.TestRepoUtils.getRandomLocalizedTextV2 -import org.fdroid.database.test.TestUtils.getRandomList -import org.fdroid.database.test.TestUtils.getRandomString -import org.fdroid.database.test.TestUtils.orNull -import org.fdroid.index.v2.Author -import org.fdroid.index.v2.Donation -import org.fdroid.index.v2.LocalizedFileListV2 -import org.fdroid.index.v2.MetadataV2 -import org.fdroid.index.v2.Screenshots -import kotlin.random.Random -import kotlin.test.assertEquals - -internal object TestAppUtils { - - fun getRandomMetadataV2() = MetadataV2( - added = Random.nextLong(), - lastUpdated = Random.nextLong(), - name = getRandomLocalizedTextV2().orNull(), - summary = getRandomLocalizedTextV2().orNull(), - description = getRandomLocalizedTextV2().orNull(), - webSite = getRandomString().orNull(), - changelog = getRandomString().orNull(), - license = getRandomString().orNull(), - sourceCode = getRandomString().orNull(), - issueTracker = getRandomString().orNull(), - translation = getRandomString().orNull(), - preferredSigner = getRandomString().orNull(), - video = getRandomLocalizedTextV2().orNull(), - author = getRandomAuthor().orNull(), - donation = getRandomDonation().orNull(), - icon = getRandomLocalizedFileV2().orNull(), - featureGraphic = getRandomLocalizedFileV2().orNull(), - promoGraphic = getRandomLocalizedFileV2().orNull(), - tvBanner = getRandomLocalizedFileV2().orNull(), - categories = getRandomList { getRandomString() }.orNull() - ?: emptyList(), - screenshots = getRandomScreenshots().orNull(), - ) - - fun getRandomAuthor() = Author( - name = getRandomString().orNull(), - email = getRandomString().orNull(), - website = getRandomString().orNull(), - phone = getRandomString().orNull(), - ) - - fun getRandomDonation() = Donation( - url = getRandomString().orNull(), - liberapay = getRandomString().orNull(), - liberapayID = getRandomString().orNull(), - openCollective = getRandomString().orNull(), - bitcoin = getRandomString().orNull(), - litecoin = getRandomString().orNull(), - flattrID = getRandomString().orNull(), - ) - - fun getRandomScreenshots() = Screenshots( - phone = getRandomLocalizedFileListV2().orNull(), - sevenInch = getRandomLocalizedFileListV2().orNull(), - tenInch = getRandomLocalizedFileListV2().orNull(), - wear = getRandomLocalizedFileListV2().orNull(), - tv = getRandomLocalizedFileListV2().orNull(), - ).takeIf { !it.isNull } - - fun getRandomLocalizedFileListV2() = TestUtils.getRandomMap(Random.nextInt(1, 3)) { - getRandomString() to getRandomList(Random.nextInt(1, - 7)) { TestRepoUtils.getRandomFileV2() } - } - - /** - * [Screenshots] include lists which can be ordered differently, - * so we need to ignore order when comparing them. - */ - fun assertScreenshotsEqual(s1: Screenshots?, s2: Screenshots?) { - if (s1 != null && s2 != null) { - assertLocalizedFileListV2Equal(s1.phone, s2.phone) - assertLocalizedFileListV2Equal(s1.sevenInch, s2.sevenInch) - assertLocalizedFileListV2Equal(s1.tenInch, s2.tenInch) - assertLocalizedFileListV2Equal(s1.wear, s2.wear) - assertLocalizedFileListV2Equal(s1.tv, s2.tv) - } else { - assertEquals(s1, s2) - } - } - - private fun assertLocalizedFileListV2Equal(l1: LocalizedFileListV2?, l2: LocalizedFileListV2?) { - if (l1 != null && l2 != null) { - l1.keys.forEach { key -> - assertEquals(l1[key]?.toSet(), l2[key]?.toSet()) - } - } else { - assertEquals(l1, l2) - } - } - -} diff --git a/database/src/androidTest/java/org/fdroid/database/test/TestRepoUtils.kt b/database/src/androidTest/java/org/fdroid/database/test/TestRepoUtils.kt deleted file mode 100644 index b28515053..000000000 --- a/database/src/androidTest/java/org/fdroid/database/test/TestRepoUtils.kt +++ /dev/null @@ -1,93 +0,0 @@ -package org.fdroid.database.test - -import org.fdroid.database.Repository -import org.fdroid.database.test.TestUtils.getRandomList -import org.fdroid.database.test.TestUtils.getRandomString -import org.fdroid.database.test.TestUtils.orNull -import org.fdroid.database.toCoreRepository -import org.fdroid.database.toMirror -import org.fdroid.database.toRepoAntiFeatures -import org.fdroid.database.toRepoCategories -import org.fdroid.database.toRepoReleaseChannel -import org.fdroid.index.v2.AntiFeatureV2 -import org.fdroid.index.v2.CategoryV2 -import org.fdroid.index.v2.FileV2 -import org.fdroid.index.v2.LocalizedTextV2 -import org.fdroid.index.v2.MirrorV2 -import org.fdroid.index.v2.ReleaseChannelV2 -import org.fdroid.index.v2.RepoV2 -import org.junit.Assert.assertEquals -import kotlin.random.Random - -object TestRepoUtils { - - fun getRandomMirror() = MirrorV2( - url = getRandomString(), - location = getRandomString().orNull() - ) - - fun getRandomLocalizedTextV2(size: Int = Random.nextInt(0, 23)): LocalizedTextV2 = buildMap { - repeat(size) { - put(getRandomString(4), getRandomString()) - } - } - - fun getRandomFileV2(sha256Nullable: Boolean = true) = FileV2( - name = getRandomString(), - sha256 = getRandomString(64).also { if (sha256Nullable) orNull() }, - size = Random.nextLong(-1, Long.MAX_VALUE) - ) - - fun getRandomLocalizedFileV2() = TestUtils.getRandomMap(Random.nextInt(1, 8)) { - getRandomString(4) to getRandomFileV2() - } - - fun getRandomRepo() = RepoV2( - name = getRandomString(), - icon = getRandomFileV2(), - address = getRandomString(), - description = getRandomLocalizedTextV2(), - mirrors = getRandomList { getRandomMirror() }, - timestamp = System.currentTimeMillis(), - antiFeatures = TestUtils.getRandomMap { - getRandomString() to AntiFeatureV2( - icon = getRandomFileV2(), - name = getRandomLocalizedTextV2(), - description = getRandomLocalizedTextV2(), - ) - }, - categories = TestUtils.getRandomMap { - getRandomString() to CategoryV2( - icon = getRandomFileV2(), - name = getRandomLocalizedTextV2(), - description = getRandomLocalizedTextV2(), - ) - }, - releaseChannels = TestUtils.getRandomMap { - getRandomString() to ReleaseChannelV2( - name = getRandomLocalizedTextV2(), - description = getRandomLocalizedTextV2(), - ) - }, - ) - - internal fun assertRepoEquals(repoV2: RepoV2, repo: Repository) { - val repoId = repo.repoId - // mirrors - val expectedMirrors = repoV2.mirrors.map { it.toMirror(repoId) }.toSet() - assertEquals(expectedMirrors, repo.mirrors.toSet()) - // anti-features - val expectedAntiFeatures = repoV2.antiFeatures.toRepoAntiFeatures(repoId).toSet() - assertEquals(expectedAntiFeatures, repo.antiFeatures.toSet()) - // categories - val expectedCategories = repoV2.categories.toRepoCategories(repoId).toSet() - assertEquals(expectedCategories, repo.categories.toSet()) - // release channels - val expectedReleaseChannels = repoV2.releaseChannels.toRepoReleaseChannel(repoId).toSet() - assertEquals(expectedReleaseChannels, repo.releaseChannels.toSet()) - // core repo - val coreRepo = repoV2.toCoreRepository().copy(repoId = repoId) - assertEquals(coreRepo, repo.repository) - } - -} diff --git a/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt b/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt index 2ff540aa5..3f6082ea8 100644 --- a/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt +++ b/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt @@ -2,60 +2,37 @@ package org.fdroid.database.test import androidx.lifecycle.LiveData import androidx.lifecycle.Observer +import org.fdroid.database.Repository +import org.fdroid.database.toCoreRepository +import org.fdroid.database.toMirror +import org.fdroid.database.toRepoAntiFeatures +import org.fdroid.database.toRepoCategories +import org.fdroid.database.toRepoReleaseChannel +import org.fdroid.index.v2.RepoV2 +import org.junit.Assert import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit -import kotlin.random.Random +import kotlin.test.assertEquals object TestUtils { - private val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') - - fun getRandomString(length: Int = Random.nextInt(1, 128)) = (1..length) - .map { Random.nextInt(0, charPool.size) } - .map(charPool::get) - .joinToString("") - - fun getRandomList( - size: Int = Random.nextInt(0, 23), - factory: () -> T, - ): List = if (size == 0) emptyList() else buildList { - repeat(size) { - add(factory()) - } - } - - fun getRandomMap( - size: Int = Random.nextInt(0, 23), - factory: () -> Pair, - ): Map = if (size == 0) emptyMap() else buildMap { - repeat(size) { - val pair = factory() - put(pair.first, pair.second) - } - } - - fun T.orNull(): T? { - return if (Random.nextBoolean()) null else this - } - - /** - * Create a map diff by adding or removing keys. Note that this does not change keys. - */ - fun Map.randomDiff(factory: () -> T): Map = buildMap { - if (this@randomDiff.isNotEmpty()) { - // remove random keys - while (Random.nextBoolean()) put(this@randomDiff.keys.random(), null) - // Note: we don't replace random keys, because we can't easily diff inside T - } - // add random keys - while (Random.nextBoolean()) put(getRandomString(), factory()) - } - - fun Map.applyDiff(diff: Map): Map = toMutableMap().apply { - diff.entries.forEach { (key, value) -> - if (value == null) remove(key) - else set(key, value) - } + fun assertRepoEquals(repoV2: RepoV2, repo: Repository) { + val repoId = repo.repoId + // mirrors + val expectedMirrors = repoV2.mirrors.map { it.toMirror(repoId) }.toSet() + Assert.assertEquals(expectedMirrors, repo.mirrors.toSet()) + // anti-features + val expectedAntiFeatures = repoV2.antiFeatures.toRepoAntiFeatures(repoId).toSet() + assertEquals(expectedAntiFeatures, repo.antiFeatures.toSet()) + // categories + val expectedCategories = repoV2.categories.toRepoCategories(repoId).toSet() + assertEquals(expectedCategories, repo.categories.toSet()) + // release channels + val expectedReleaseChannels = repoV2.releaseChannels.toRepoReleaseChannel(repoId).toSet() + assertEquals(expectedReleaseChannels, repo.releaseChannels.toSet()) + // core repo + val coreRepo = repoV2.toCoreRepository().copy(repoId = repoId) + assertEquals(coreRepo, repo.repository) } fun LiveData.getOrAwaitValue(): T? { diff --git a/database/src/androidTest/java/org/fdroid/database/test/TestVersionUtils.kt b/database/src/androidTest/java/org/fdroid/database/test/TestVersionUtils.kt deleted file mode 100644 index caa128b4a..000000000 --- a/database/src/androidTest/java/org/fdroid/database/test/TestVersionUtils.kt +++ /dev/null @@ -1,53 +0,0 @@ -package org.fdroid.database.test - -import org.fdroid.database.test.TestRepoUtils.getRandomFileV2 -import org.fdroid.database.test.TestRepoUtils.getRandomLocalizedTextV2 -import org.fdroid.database.test.TestUtils.getRandomList -import org.fdroid.database.test.TestUtils.getRandomMap -import org.fdroid.database.test.TestUtils.getRandomString -import org.fdroid.database.test.TestUtils.orNull -import org.fdroid.index.v2.FeatureV2 -import org.fdroid.index.v2.FileV1 -import org.fdroid.index.v2.ManifestV2 -import org.fdroid.index.v2.PackageVersionV2 -import org.fdroid.index.v2.PermissionV2 -import org.fdroid.index.v2.SignatureV2 -import org.fdroid.index.v2.UsesSdkV2 -import kotlin.random.Random - -internal object TestVersionUtils { - - fun getRandomPackageVersionV2() = PackageVersionV2( - added = Random.nextLong(), - file = getRandomFileV2(false).let { - FileV1(it.name, it.sha256!!, it.size) - }, - src = getRandomFileV2().orNull(), - manifest = getRandomManifestV2(), - releaseChannels = getRandomList { getRandomString() }, - antiFeatures = getRandomMap { getRandomString() to getRandomLocalizedTextV2() }, - whatsNew = getRandomLocalizedTextV2(), - ) - - fun getRandomManifestV2() = ManifestV2( - versionName = getRandomString(), - versionCode = Random.nextLong(), - usesSdk = UsesSdkV2( - minSdkVersion = Random.nextInt(), - targetSdkVersion = Random.nextInt(), - ), - maxSdkVersion = Random.nextInt().orNull(), - signer = SignatureV2(getRandomList(Random.nextInt(1, 3)) { - getRandomString(64) - }).orNull(), - usesPermission = getRandomList { - PermissionV2(getRandomString(), Random.nextInt().orNull()) - }, - usesPermissionSdk23 = getRandomList { - PermissionV2(getRandomString(), Random.nextInt().orNull()) - }, - nativeCode = getRandomList(Random.nextInt(0, 4)) { getRandomString() }, - features = getRandomList { FeatureV2(getRandomString()) }, - ) - -} diff --git a/database/src/sharedTest b/database/src/sharedTest new file mode 120000 index 000000000..2b1905dab --- /dev/null +++ b/database/src/sharedTest @@ -0,0 +1 @@ +../../index/src/sharedTest \ No newline at end of file diff --git a/database/src/test/java/org/fdroid/database/ReflectionTest.kt b/database/src/test/java/org/fdroid/database/ReflectionTest.kt index 4f70e11df..bdc7ad6e0 100644 --- a/database/src/test/java/org/fdroid/database/ReflectionTest.kt +++ b/database/src/test/java/org/fdroid/database/ReflectionTest.kt @@ -4,6 +4,8 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject import org.fdroid.index.v2.ReflectionDiffer.applyDiff +import org.fdroid.test.TestRepoUtils.getRandomFileV2 +import org.fdroid.test.TestRepoUtils.getRandomRepo import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals @@ -12,8 +14,8 @@ class ReflectionTest { @Test fun testRepository() { - val repo = TestUtils2.getRandomRepo().toCoreRepository() - val icon = TestUtils2.getRandomFileV2() + val repo = getRandomRepo().toCoreRepository() + val icon = getRandomFileV2() val description = if (Random.nextBoolean()) mapOf("de" to null, "en" to "foo") else null val json = """ { diff --git a/database/src/test/java/org/fdroid/database/TestUtils.kt b/database/src/test/java/org/fdroid/database/TestUtils.kt deleted file mode 100644 index 5f43ae973..000000000 --- a/database/src/test/java/org/fdroid/database/TestUtils.kt +++ /dev/null @@ -1,79 +0,0 @@ -package org.fdroid.database - -import org.fdroid.index.v2.AntiFeatureV2 -import org.fdroid.index.v2.CategoryV2 -import org.fdroid.index.v2.FileV2 -import org.fdroid.index.v2.LocalizedTextV2 -import org.fdroid.index.v2.MirrorV2 -import org.fdroid.index.v2.ReleaseChannelV2 -import org.fdroid.index.v2.RepoV2 -import kotlin.random.Random - -object TestUtils2 { - - private val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') - - fun getRandomString(length: Int = Random.nextInt(1, 128)) = (1..length) - .map { Random.nextInt(0, charPool.size) } - .map(charPool::get) - .joinToString("") - - fun getRandomList( - size: Int = Random.nextInt(0, 23), - factory: () -> T, - ): List = if (size == 0) emptyList() else buildList { - repeat(Random.nextInt(0, size)) { - add(factory()) - } - } - - fun getRandomMap( - size: Int = Random.nextInt(0, 23), - factory: () -> Pair, - ): Map = if (size == 0) emptyMap() else buildMap { - repeat(Random.nextInt(0, size)) { - val pair = factory() - put(pair.first, pair.second) - } - } - - fun T.orNull(): T? { - return if (Random.nextBoolean()) null else this - } - - fun getRandomMirror() = MirrorV2( - url = getRandomString(), - location = getRandomString().orNull() - ) - - fun getRandomLocalizedTextV2(size: Int = Random.nextInt(0, 23)): LocalizedTextV2 = buildMap { - repeat(size) { - put(getRandomString(4), getRandomString()) - } - } - - fun getRandomFileV2() = FileV2( - name = getRandomString(), - sha256 = getRandomString(64), - size = Random.nextLong(-1, Long.MAX_VALUE) - ) - - fun getRandomRepo() = RepoV2( - name = getRandomString(), - icon = getRandomFileV2(), - address = getRandomString(), - description = getRandomLocalizedTextV2(), - mirrors = getRandomList { getRandomMirror() }, - timestamp = System.currentTimeMillis(), - antiFeatures = getRandomMap { - getRandomString() to AntiFeatureV2(getRandomFileV2(), getRandomLocalizedTextV2()) - }, - categories = getRandomMap { - getRandomString() to CategoryV2(getRandomFileV2(), getRandomLocalizedTextV2()) - }, - releaseChannels = getRandomMap { - getRandomString() to ReleaseChannelV2(getRandomLocalizedTextV2()) - }, - ) - -} From a4da725772127617eec17f5856cf100db3f458fe Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 7 Apr 2022 09:59:51 -0300 Subject: [PATCH 14/42] [db] Store repo version in DB --- .../org/fdroid/database/IndexV1InsertTest.kt | 9 +++++---- .../org/fdroid/database/IndexV2InsertTest.kt | 4 ++-- .../org/fdroid/database/RepositoryTest.kt | 2 +- .../org/fdroid/database/test/TestUtils.kt | 3 ++- .../org/fdroid/database/DbStreamReceiver.kt | 4 ++-- .../org/fdroid/database/DbV1StreamReceiver.kt | 4 ++-- .../java/org/fdroid/database/Repository.kt | 9 ++++++++- .../java/org/fdroid/database/RepositoryDao.kt | 19 ++++++++----------- .../org/fdroid/database/ReflectionTest.kt | 2 +- 9 files changed, 31 insertions(+), 25 deletions(-) diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt index 4398febce..12576e6d4 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt @@ -58,7 +58,8 @@ class IndexV1InsertTest : DbTest() { println("Versions: " + versionDao.countAppVersions()) println("Perms/Features: " + versionDao.countVersionedStrings()) - insertV2ForComparison() + val version = repoDao.getRepositories()[0].repository.version ?: fail() + insertV2ForComparison(version) val repo1 = repoDao.getRepository(1) ?: fail() val repo2 = repoDao.getRepository(2) ?: fail() @@ -108,14 +109,14 @@ class IndexV1InsertTest : DbTest() { } @Suppress("SameParameterValue") - private fun insertV2ForComparison() { + private fun insertV2ForComparison(version: Int) { val c = getApplicationContext() val inputStream = CountingInputStream(c.resources.assets.open("index-v2.json")) val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db) { true }, null) db.runInTransaction { val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") inputStream.use { indexStream -> - indexProcessor.process(repoId, indexStream) + indexProcessor.process(repoId, version, indexStream) } } } @@ -132,7 +133,7 @@ class IndexV1InsertTest : DbTest() { assertFailsWith { db.runInTransaction { cIn.use { indexStream -> - indexProcessor.process(1, indexStream) + indexProcessor.process(1, 42, indexStream) } } } diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt index a058fc2bb..49b09061d 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt @@ -39,7 +39,7 @@ class IndexV2InsertTest : DbTest() { db.runInTransaction { val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") inputStream.use { indexStream -> - indexProcessor.process(repoId, indexStream) + indexProcessor.process(repoId, 42, indexStream) } } assertTrue(repoDao.getRepositories().size == 1) @@ -68,7 +68,7 @@ class IndexV2InsertTest : DbTest() { assertFailsWith { db.runInTransaction { cIn.use { indexStream -> - indexProcessor.process(1, indexStream) + indexProcessor.process(1, 42, indexStream) } } } diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt index c8c9cd79b..454c0af2b 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt @@ -73,7 +73,7 @@ class RepositoryTest : DbTest() { assertTrue(versionDao.getVersionedStrings(repoId, packageId).isNotEmpty()) val cert = getRandomString() - repoDao.replace(repoId, getRandomRepo(), cert) + repoDao.replace(repoId, getRandomRepo(), 42, cert) assertEquals(1, repoDao.getRepositories().size) assertEquals(0, appDao.getAppMetadata().size) assertEquals(0, appDao.getLocalizedFiles().size) diff --git a/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt b/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt index 3f6082ea8..f096a1fa7 100644 --- a/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt +++ b/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt @@ -31,7 +31,8 @@ object TestUtils { val expectedReleaseChannels = repoV2.releaseChannels.toRepoReleaseChannel(repoId).toSet() assertEquals(expectedReleaseChannels, repo.releaseChannels.toSet()) // core repo - val coreRepo = repoV2.toCoreRepository().copy(repoId = repoId) + val coreRepo = repoV2.toCoreRepository(version = repo.repository.version!!) + .copy(repoId = repoId) assertEquals(coreRepo, repo.repository) } diff --git a/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt index 27c1cf979..4fdcddaf1 100644 --- a/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt @@ -10,8 +10,8 @@ internal class DbStreamReceiver( private val compatibilityChecker: CompatibilityChecker, ) : IndexStreamReceiver { - override fun receive(repoId: Long, repo: RepoV2, certificate: String?) { - db.getRepositoryDao().replace(repoId, repo, certificate) + override fun receive(repoId: Long, repo: RepoV2, version: Int, certificate: String?) { + db.getRepositoryDao().replace(repoId, repo, version, certificate) } override fun receive(repoId: Long, packageId: String, p: PackageV2) { diff --git a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt index 672833d1f..a77c8326c 100644 --- a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt @@ -14,8 +14,8 @@ internal class DbV1StreamReceiver( private val compatibilityChecker: CompatibilityChecker, ) : IndexV1StreamReceiver { - override fun receive(repoId: Long, repo: RepoV2, certificate: String?) { - db.getRepositoryDao().replace(repoId, repo, certificate) + override fun receive(repoId: Long, repo: RepoV2, version: Int, certificate: String?) { + db.getRepositoryDao().replace(repoId, repo, version, certificate) } override fun receive(repoId: Long, packageId: String, m: MetadataV2) { diff --git a/database/src/main/java/org/fdroid/database/Repository.kt b/database/src/main/java/org/fdroid/database/Repository.kt index dbe10067b..a81422555 100644 --- a/database/src/main/java/org/fdroid/database/Repository.kt +++ b/database/src/main/java/org/fdroid/database/Repository.kt @@ -23,16 +23,22 @@ data class CoreRepository( @Embedded(prefix = "icon_") val icon: FileV2?, val address: String, val timestamp: Long, + val version: Int?, val description: LocalizedTextV2 = emptyMap(), val certificate: String?, ) -fun RepoV2.toCoreRepository(repoId: Long = 0, certificate: String? = null) = CoreRepository( +fun RepoV2.toCoreRepository( + repoId: Long = 0, + version: Int, + certificate: String? = null, +) = CoreRepository( repoId = repoId, name = name, icon = icon, address = address, timestamp = timestamp, + version = version, description = description, certificate = certificate, ) @@ -70,6 +76,7 @@ data class Repository( val icon: FileV2? get() = repository.icon val address: String get() = repository.address val timestamp: Long get() = repository.timestamp + val version: Int get() = repository.version ?: 0 val description: LocalizedTextV2 get() = repository.description val certificate: String? get() = repository.certificate diff --git a/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/database/src/main/java/org/fdroid/database/RepositoryDao.kt index 1a724d5bc..04ce0ec80 100644 --- a/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -22,16 +22,11 @@ import org.fdroid.index.v2.RepoV2 public interface RepositoryDao { fun insert(initialRepo: InitialRepository) - /** - * Use when inserting a new repo for the first time. - */ - fun insert(repository: RepoV2): Long - /** * Use when replacing an existing repo with a full index. * This removes all existing index data associated with this repo from the database. */ - fun replace(repoId: Long, repository: RepoV2, certificate: String?) + fun replace(repoId: Long, repository: RepoV2, version: Int, certificate: String?) fun getRepository(repoId: Long): Repository? fun insertEmptyRepo( @@ -79,9 +74,9 @@ internal interface RepositoryDaoInt : RepositoryDao { address = initialRepo.address, icon = null, timestamp = -1, + version = initialRepo.version, description = mapOf("en-US" to initialRepo.description), certificate = initialRepo.certificate, - //version = initialRepo.version, // TODO add version ) val repoId = insert(repo) val repositoryPreferences = RepositoryPreferences( @@ -104,6 +99,7 @@ internal interface RepositoryDaoInt : RepositoryDao { icon = null, address = address, timestamp = System.currentTimeMillis(), + version = null, certificate = null, ) val repoId = insert(repo) @@ -120,8 +116,9 @@ internal interface RepositoryDaoInt : RepositoryDao { } @Transaction - override fun insert(repository: RepoV2): Long { - val repoId = insert(repository.toCoreRepository()) + @VisibleForTesting + fun insert(repository: RepoV2): Long { + val repoId = insert(repository.toCoreRepository(version = 0)) insertRepositoryPreferences(repoId) insertRepoTables(repoId, repository) return repoId @@ -134,8 +131,8 @@ internal interface RepositoryDaoInt : RepositoryDao { } @Transaction - override fun replace(repoId: Long, repository: RepoV2, certificate: String?) { - val newRepoId = insert(repository.toCoreRepository(repoId, certificate)) + override fun replace(repoId: Long, repository: RepoV2, version: Int, certificate: String?) { + val newRepoId = insert(repository.toCoreRepository(repoId, version, certificate)) require(newRepoId == repoId) { "New repoId $newRepoId did not match old $repoId" } insertRepoTables(repoId, repository) } diff --git a/database/src/test/java/org/fdroid/database/ReflectionTest.kt b/database/src/test/java/org/fdroid/database/ReflectionTest.kt index bdc7ad6e0..cdfc89203 100644 --- a/database/src/test/java/org/fdroid/database/ReflectionTest.kt +++ b/database/src/test/java/org/fdroid/database/ReflectionTest.kt @@ -14,7 +14,7 @@ class ReflectionTest { @Test fun testRepository() { - val repo = getRandomRepo().toCoreRepository() + val repo = getRandomRepo().toCoreRepository(version = 42) val icon = getRandomFileV2() val description = if (Random.nextBoolean()) mapOf("de" to null, "en" to "foo") else null val json = """ From 5a015fca3a0fd1b9085c593c8a0f3432b001ee95 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 11 Apr 2022 13:10:13 -0300 Subject: [PATCH 15/42] [db] Enable strict API mode and ktlint for database library --- database/build.gradle | 8 +- .../java/org/fdroid/database/AppTest.kt | 2 +- .../java/org/fdroid/database/DbTest.kt | 4 +- .../org/fdroid/database/IndexV1InsertTest.kt | 2 +- .../org/fdroid/database/IndexV2InsertTest.kt | 2 +- .../org/fdroid/database/RepositoryDiffTest.kt | 32 +++----- .../org/fdroid/database/RepositoryTest.kt | 2 +- .../org/fdroid/database/UpdateCheckerTest.kt | 2 +- .../java/org/fdroid/database/VersionTest.kt | 2 +- .../org/fdroid/database/test/TestUtils.kt | 2 +- .../src/main/java/org/fdroid/database/App.kt | 76 ++++++++++--------- .../main/java/org/fdroid/database/AppDao.kt | 55 +++++--------- .../org/fdroid/database/FDroidDatabase.kt | 12 +-- .../java/org/fdroid/database/Repository.kt | 35 ++++----- .../java/org/fdroid/database/RepositoryDao.kt | 32 ++++---- .../java/org/fdroid/database/UpdateChecker.kt | 4 +- .../main/java/org/fdroid/database/Version.kt | 46 ++++++----- .../java/org/fdroid/database/VersionDao.kt | 12 +-- .../org/fdroid/download/DownloaderFactory.kt | 7 +- .../java/org/fdroid/index/v1/IndexUpdater.kt | 12 +-- .../org/fdroid/index/v1/IndexV1Updater.kt | 4 +- .../org/fdroid/database/ReflectionTest.kt | 2 +- 22 files changed, 175 insertions(+), 180 deletions(-) diff --git a/database/build.gradle b/database/build.gradle index 3282ca8d3..7869215b9 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -2,11 +2,11 @@ plugins { id 'kotlin-android' id 'com.android.library' id 'kotlin-kapt' -// id "org.jlleitschuh.gradle.ktlint" version "10.2.1" + id "org.jlleitschuh.gradle.ktlint" version "10.2.1" } android { - compileSdkVersion 30 + compileSdkVersion 31 defaultConfig { minSdkVersion 22 @@ -41,6 +41,8 @@ android { } kotlinOptions { jvmTarget = '1.8' + freeCompilerArgs += "-Xexplicit-api=strict" + freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" } aaptOptions { // needed only for instrumentation tests: assets.openFd() @@ -81,3 +83,5 @@ dependencies { androidTestImplementation 'commons-io:commons-io:2.6' } + +apply from: "${rootProject.rootDir}/gradle/ktlint.gradle" diff --git a/database/src/androidTest/java/org/fdroid/database/AppTest.kt b/database/src/androidTest/java/org/fdroid/database/AppTest.kt index 6c81ace11..9bcea19ed 100644 --- a/database/src/androidTest/java/org/fdroid/database/AppTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/AppTest.kt @@ -18,7 +18,7 @@ import kotlin.test.assertTrue import kotlin.test.fail @RunWith(AndroidJUnit4::class) -class AppTest : DbTest() { +internal class AppTest : DbTest() { @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() diff --git a/database/src/androidTest/java/org/fdroid/database/DbTest.kt b/database/src/androidTest/java/org/fdroid/database/DbTest.kt index 9f4db479b..116d7104f 100644 --- a/database/src/androidTest/java/org/fdroid/database/DbTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/DbTest.kt @@ -7,6 +7,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import io.mockk.every import io.mockk.mockkObject import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before @@ -14,7 +15,8 @@ import org.junit.runner.RunWith import java.io.IOException @RunWith(AndroidJUnit4::class) -abstract class DbTest { +@OptIn(ExperimentalCoroutinesApi::class) +internal abstract class DbTest { internal lateinit var repoDao: RepositoryDaoInt internal lateinit var appDao: AppDaoInt diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt index 12576e6d4..58259a64b 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt @@ -17,7 +17,7 @@ import kotlin.test.assertTrue import kotlin.test.fail @RunWith(AndroidJUnit4::class) -class IndexV1InsertTest : DbTest() { +internal class IndexV1InsertTest : DbTest() { @Test fun testStreamIndexV1IntoDb() { diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt index 49b09061d..141a6cc45 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt @@ -14,7 +14,7 @@ import kotlin.test.assertFailsWith import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) -class IndexV2InsertTest : DbTest() { +internal class IndexV2InsertTest : DbTest() { @Test fun testStreamIndexV2IntoDb() { diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt index f2cad81ba..4fa401a6d 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt @@ -26,7 +26,7 @@ import kotlin.test.assertEquals * Tests that repository diffs get applied to the database correctly. */ @RunWith(AndroidJUnit4::class) -class RepositoryDiffTest : DbTest() { +internal class RepositoryDiffTest : DbTest() { private val j = Json @@ -37,8 +37,7 @@ class RepositoryDiffTest : DbTest() { val json = """ { "timestamp": $updateTimestamp - } - """.trimIndent() + }""".trimIndent() testDiff(repo, json) { repos -> assertEquals(updateTimestamp, repos[0].timestamp) assertRepoEquals(repo.copy(timestamp = updateTimestamp), repos[0]) @@ -63,8 +62,7 @@ class RepositoryDiffTest : DbTest() { val json = """ { "timestamp": $updateTimestamp - } - """.trimIndent() + }""".trimIndent() // decode diff from JSON and update DB with it val diff = j.parseToJsonElement(json).jsonObject // Json.decodeFromString(json) @@ -85,8 +83,7 @@ class RepositoryDiffTest : DbTest() { val json = """ { "icon": ${Json.encodeToString(updateIcon)} - } - """.trimIndent() + }""".trimIndent() testDiff(repo, json) { repos -> assertEquals(updateIcon, repos[0].icon) assertRepoEquals(repo.copy(icon = updateIcon), repos[0]) @@ -100,8 +97,7 @@ class RepositoryDiffTest : DbTest() { val json = """ { "icon": { "name": "${updateIcon.name}" } - } - """.trimIndent() + }""".trimIndent() testDiff(repo, json) { repos -> assertEquals(updateIcon, repos[0].icon) assertRepoEquals(repo.copy(icon = updateIcon), repos[0]) @@ -114,8 +110,7 @@ class RepositoryDiffTest : DbTest() { val json = """ { "icon": null - } - """.trimIndent() + }""".trimIndent() testDiff(repo, json) { repos -> assertEquals(null, repos[0].icon) assertRepoEquals(repo.copy(icon = null), repos[0]) @@ -133,8 +128,7 @@ class RepositoryDiffTest : DbTest() { val json = """ { "mirrors": ${Json.encodeToString(updateMirrors)} - } - """.trimIndent() + }""".trimIndent() testDiff(repo, json) { repos -> val expectedMirrors = updateMirrors.map { mirror -> mirror.toMirror(repos[0].repoId) @@ -151,8 +145,7 @@ class RepositoryDiffTest : DbTest() { val json = """ { "description": ${Json.encodeToString(updateText)} - } - """.trimIndent() + }""".trimIndent() val expectedText = if (updateText == null) emptyMap() else mapOf("en" to "foo") testDiff(repo, json) { repos -> assertEquals(expectedText, repos[0].description) @@ -175,8 +168,7 @@ class RepositoryDiffTest : DbTest() { val json = """ { "antiFeatures": ${Json.encodeToString(antiFeatures)} - } - """.trimIndent() + }""".trimIndent() testDiff(repo, json) { repos -> val expectedFeatures = repo.antiFeatures.applyDiff(antiFeatures) val expectedRepoAntiFeatures = @@ -206,8 +198,7 @@ class RepositoryDiffTest : DbTest() { val json = """ { "categories": ${Json.encodeToString(categories)} - } - """.trimIndent() + }""".trimIndent() testDiff(repo, json) { repos -> val expectedFeatures = repo.categories.applyDiff(categories) val expectedRepoCategories = @@ -236,8 +227,7 @@ class RepositoryDiffTest : DbTest() { val json = """ { "releaseChannels": ${Json.encodeToString(releaseChannels)} - } - """.trimIndent() + }""".trimIndent() testDiff(repo, json) { repos -> val expectedFeatures = repo.releaseChannels.applyDiff(releaseChannels) val expectedRepoReleaseChannels = diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt index 454c0af2b..be4ed360d 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt @@ -14,7 +14,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) -class RepositoryTest : DbTest() { +internal class RepositoryTest : DbTest() { @Test fun insertAndDeleteTwoRepos() { diff --git a/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt b/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt index d33b0edb7..07d84215c 100644 --- a/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt @@ -13,7 +13,7 @@ import kotlin.time.ExperimentalTime import kotlin.time.measureTime @RunWith(AndroidJUnit4::class) -class UpdateCheckerTest : DbTest() { +internal class UpdateCheckerTest : DbTest() { private lateinit var context: Context private lateinit var updateChecker: UpdateChecker diff --git a/database/src/androidTest/java/org/fdroid/database/VersionTest.kt b/database/src/androidTest/java/org/fdroid/database/VersionTest.kt index 51586e780..5db57b37e 100644 --- a/database/src/androidTest/java/org/fdroid/database/VersionTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/VersionTest.kt @@ -15,7 +15,7 @@ import kotlin.test.assertEquals import kotlin.test.fail @RunWith(AndroidJUnit4::class) -class VersionTest : DbTest() { +internal class VersionTest : DbTest() { @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() diff --git a/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt b/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt index f096a1fa7..7479ccf47 100644 --- a/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt +++ b/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt @@ -14,7 +14,7 @@ import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.test.assertEquals -object TestUtils { +internal object TestUtils { fun assertRepoEquals(repoV2: RepoV2, repo: Repository) { val repoId = repo.repoId diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt index 4fd4e1867..78175da12 100644 --- a/database/src/main/java/org/fdroid/database/App.kt +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -27,7 +27,7 @@ import org.fdroid.index.v2.Screenshots onDelete = ForeignKey.CASCADE, )], ) -data class AppMetadata( +public data class AppMetadata( val repoId: Long, val packageId: String, val added: Long, @@ -57,7 +57,7 @@ data class AppMetadata( val isCompatible: Boolean, ) -fun MetadataV2.toAppMetadata( +internal fun MetadataV2.toAppMetadata( repoId: Long, packageId: String, isCompatible: Boolean = false, @@ -83,7 +83,7 @@ fun MetadataV2.toAppMetadata( isCompatible = isCompatible, ) -data class App( +public data class App( val metadata: AppMetadata, val icon: LocalizedFileV2? = null, val featureGraphic: LocalizedFileV2? = null, @@ -91,37 +91,43 @@ data class App( val tvBanner: LocalizedFileV2? = null, val screenshots: Screenshots? = null, ) { - public fun getName(localeList: LocaleListCompat) = metadata.name.getBestLocale(localeList) - public fun getSummary(localeList: LocaleListCompat) = metadata.summary.getBestLocale(localeList) - public fun getDescription(localeList: LocaleListCompat) = + public fun getName(localeList: LocaleListCompat): String? = + metadata.name.getBestLocale(localeList) + + public fun getSummary(localeList: LocaleListCompat): String? = + metadata.summary.getBestLocale(localeList) + + public fun getDescription(localeList: LocaleListCompat): String? = metadata.description.getBestLocale(localeList) - public fun getVideo(localeList: LocaleListCompat) = metadata.video.getBestLocale(localeList) + public fun getVideo(localeList: LocaleListCompat): String? = + metadata.video.getBestLocale(localeList) - public fun getIcon(localeList: LocaleListCompat) = icon.getBestLocale(localeList) - public fun getFeatureGraphic(localeList: LocaleListCompat) = + public fun getIcon(localeList: LocaleListCompat): FileV2? = icon.getBestLocale(localeList) + public fun getFeatureGraphic(localeList: LocaleListCompat): FileV2? = featureGraphic.getBestLocale(localeList) - public fun getPromoGraphic(localeList: LocaleListCompat) = + public fun getPromoGraphic(localeList: LocaleListCompat): FileV2? = promoGraphic.getBestLocale(localeList) - public fun getTvBanner(localeList: LocaleListCompat) = tvBanner.getBestLocale(localeList) + public fun getTvBanner(localeList: LocaleListCompat): FileV2? = + tvBanner.getBestLocale(localeList) // TODO remove ?.map { it.name } when client can handle FileV2 - public fun getPhoneScreenshots(localeList: LocaleListCompat) = - screenshots?.phone.getBestLocale(localeList)?.map { it.name }?.toTypedArray() + public fun getPhoneScreenshots(localeList: LocaleListCompat): List = + screenshots?.phone.getBestLocale(localeList)?.map { it.name } ?: emptyList() - public fun getSevenInchScreenshots(localeList: LocaleListCompat) = - screenshots?.sevenInch.getBestLocale(localeList)?.map { it.name }?.toTypedArray() + public fun getSevenInchScreenshots(localeList: LocaleListCompat): List = + screenshots?.sevenInch.getBestLocale(localeList)?.map { it.name } ?: emptyList() - public fun getTenInchScreenshots(localeList: LocaleListCompat) = - screenshots?.tenInch.getBestLocale(localeList)?.map { it.name }?.toTypedArray() + public fun getTenInchScreenshots(localeList: LocaleListCompat): List = + screenshots?.tenInch.getBestLocale(localeList)?.map { it.name } ?: emptyList() - public fun getTvScreenshots(localeList: LocaleListCompat) = - screenshots?.tv.getBestLocale(localeList)?.map { it.name }?.toTypedArray() + public fun getTvScreenshots(localeList: LocaleListCompat): List = + screenshots?.tv.getBestLocale(localeList)?.map { it.name } ?: emptyList() - public fun getWearScreenshots(localeList: LocaleListCompat) = - screenshots?.wear.getBestLocale(localeList)?.map { it.name }?.toTypedArray() + public fun getWearScreenshots(localeList: LocaleListCompat): List = + screenshots?.wear.getBestLocale(localeList)?.map { it.name } ?: emptyList() } public data class AppOverviewItem( @@ -137,9 +143,9 @@ public data class AppOverviewItem( ) internal val localizedIcon: List? = null, ) { - public fun getName(localeList: LocaleListCompat) = name.getBestLocale(localeList) - public fun getSummary(localeList: LocaleListCompat) = summary.getBestLocale(localeList) - public fun getIcon(localeList: LocaleListCompat) = + public fun getName(localeList: LocaleListCompat): String? = name.getBestLocale(localeList) + public fun getSummary(localeList: LocaleListCompat): String? = summary.getBestLocale(localeList) + public fun getIcon(localeList: LocaleListCompat): String? = localizedIcon?.toLocalizedFileV2().getBestLocale(localeList)?.name } @@ -180,7 +186,7 @@ public data class AppListItem @JvmOverloads constructor( return fromStringToMapOfLocalizedTextV2(antiFeatures)?.map { it.key } ?: emptyList() } - public fun getIcon(localeList: LocaleListCompat) = + public fun getIcon(localeList: LocaleListCompat): String? = localizedIcon?.toLocalizedFileV2().getBestLocale(localeList)?.name } @@ -196,8 +202,8 @@ public data class UpdatableApp( ) internal val localizedIcon: List? = null, ) { - public fun getName(localeList: LocaleListCompat) = name.getBestLocale(localeList) - public fun getIcon(localeList: LocaleListCompat) = + public fun getName(localeList: LocaleListCompat): String? = name.getBestLocale(localeList) + public fun getIcon(localeList: LocaleListCompat): FileV2? = localizedIcon?.toLocalizedFileV2().getBestLocale(localeList) } @@ -214,7 +220,7 @@ internal fun Map?.getBestLocale(localeList: LocaleListCompat): T? } } -interface IFile { +internal interface IFile { val type: String val locale: String val name: String @@ -231,7 +237,7 @@ interface IFile { onDelete = ForeignKey.CASCADE, )], ) -data class LocalizedFile( +internal data class LocalizedFile( val repoId: Long, val packageId: String, override val type: String, @@ -241,7 +247,7 @@ data class LocalizedFile( override val size: Long? = null, ) : IFile -fun LocalizedFileV2.toLocalizedFile( +internal fun LocalizedFileV2.toLocalizedFile( repoId: Long, packageId: String, type: String, @@ -257,7 +263,7 @@ fun LocalizedFileV2.toLocalizedFile( ) } -fun List.toLocalizedFileV2(type: String? = null): LocalizedFileV2? { +internal fun List.toLocalizedFileV2(type: String? = null): LocalizedFileV2? { return (if (type != null) filter { file -> file.type == type } else this).associate { file -> file.locale to FileV2( name = file.name, @@ -272,7 +278,7 @@ fun List.toLocalizedFileV2(type: String? = null): LocalizedFileV2? { @DatabaseView("""SELECT * FROM LocalizedFile JOIN RepositoryPreferences AS prefs USING (repoId) WHERE type='icon' GROUP BY repoId, packageId, locale HAVING MAX(prefs.weight)""") -data class LocalizedIcon( +public data class LocalizedIcon( val repoId: Long, val packageId: String, override val type: String, @@ -291,7 +297,7 @@ data class LocalizedIcon( onDelete = ForeignKey.CASCADE, )], ) -data class LocalizedFileList( +internal data class LocalizedFileList( val repoId: Long, val packageId: String, val type: String, @@ -301,7 +307,7 @@ data class LocalizedFileList( val size: Long? = null, ) -fun LocalizedFileListV2.toLocalizedFileList( +internal fun LocalizedFileListV2.toLocalizedFileList( repoId: Long, packageId: String, type: String, @@ -319,7 +325,7 @@ fun LocalizedFileListV2.toLocalizedFileList( } } -fun List.toLocalizedFileListV2(type: String): LocalizedFileListV2? { +internal fun List.toLocalizedFileListV2(type: String): LocalizedFileListV2? { val map = HashMap>() iterator().forEach { file -> if (file.type != type) return@forEach diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index 115c75bca..d9511743d 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -22,25 +22,29 @@ import org.fdroid.index.v2.MetadataV2 import org.fdroid.index.v2.Screenshots public interface AppDao { - fun insert(repoId: Long, packageId: String, app: MetadataV2) + public fun insert(repoId: Long, packageId: String, app: MetadataV2) /** * Gets the app from the DB. If more than one app with this [packageId] exists, * the one from the repository with the highest weight is returned. */ - fun getApp(packageId: String): LiveData - fun getApp(repoId: Long, packageId: String): App? - fun getAppOverviewItems(limit: Int = 200): LiveData> - fun getAppOverviewItems(category: String, limit: Int = 50): LiveData> - fun getAppListItems(packageManager: PackageManager): LiveData> - fun getAppListItems( + public fun getApp(packageId: String): LiveData + public fun getApp(repoId: Long, packageId: String): App? + public fun getAppOverviewItems(limit: Int = 200): LiveData> + public fun getAppOverviewItems( + category: String, + limit: Int = 50, + ): LiveData> + + public fun getAppListItems(packageManager: PackageManager): LiveData> + public fun getAppListItems( packageManager: PackageManager, category: String, ): LiveData> - fun getInstalledAppListItems(packageManager: PackageManager): LiveData> + public fun getInstalledAppListItems(packageManager: PackageManager): LiveData> - fun getNumberOfAppsInCategory(category: String): Int + public fun getNumberOfAppsInCategory(category: String): Int } @Dao @@ -87,7 +91,8 @@ internal interface AppDaoInt : AppDao { /** * This is needed to support v1 streaming and shouldn't be used for something else. */ - @Query("UPDATE AppMetadata SET preferredSigner = :preferredSigner WHERE repoId = :repoId AND packageId = :packageId") + @Query("""UPDATE AppMetadata SET preferredSigner = :preferredSigner + WHERE repoId = :repoId AND packageId = :packageId""") fun updatePreferredSigner(repoId: Long, packageId: String, preferredSigner: String?) /** @@ -175,32 +180,6 @@ internal interface AppDaoInt : AppDao { @Query("SELECT * FROM LocalizedFileList") fun getLocalizedFileLists(): List - // sort order from F-Droid - //table + "." + Cols.IS_LOCALIZED + " DESC" - //+ ", " + table + "." + Cols.NAME + " IS NULL ASC" - //+ ", CASE WHEN " + table + "." + Cols.ICON + " IS NULL" - //+ " AND " + table + "." + Cols.ICON_URL + " IS NULL" - //+ " THEN 1 ELSE 0 END" - //+ ", " + table + "." + Cols.SUMMARY + " IS NULL ASC" - //+ ", " + table + "." + Cols.DESCRIPTION + " IS NULL ASC" - //+ ", CASE WHEN " + table + "." + Cols.PHONE_SCREENSHOTS + " IS NULL" - //+ " AND " + table + "." + Cols.SEVEN_INCH_SCREENSHOTS + " IS NULL" - //+ " AND " + table + "." + Cols.TEN_INCH_SCREENSHOTS + " IS NULL" - //+ " AND " + table + "." + Cols.TV_SCREENSHOTS + " IS NULL" - //+ " AND " + table + "." + Cols.WEAR_SCREENSHOTS + " IS NULL" - //+ " AND " + table + "." + Cols.FEATURE_GRAPHIC + " IS NULL" - //+ " AND " + table + "." + Cols.PROMO_GRAPHIC + " IS NULL" - //+ " AND " + table + "." + Cols.TV_BANNER + " IS NULL" - //+ " THEN 1 ELSE 0 END" - //+ ", CASE WHEN date(" + added + ") >= date(" + lastUpdated + ")" - //+ " AND date((SELECT " + RepoTable.Cols.LAST_UPDATED + " FROM " + RepoTable.NAME - //+ " WHERE _id=" + table + "." + Cols.REPO_ID - //+ " ),'-" + AppCardController.DAYS_TO_CONSIDER_NEW + " days') " - //+ " < date(" + lastUpdated + ")" - //+ " THEN 0 ELSE 1 END" - //+ ", " + table + "." + Cols.WHATSNEW + " IS NULL ASC" - //+ ", " + lastUpdated + " DESC" - //+ ", " + added + " ASC"); @Transaction @Query("""SELECT repoId, packageId, added, app.lastUpdated, app.name, summary FROM AppMetadata AS app @@ -279,7 +258,9 @@ internal interface AppDaoInt : AppDao { GROUP BY packageId HAVING MAX(pref.weight)""") fun getAppListItems(packageNames: List): LiveData> - override fun getInstalledAppListItems(packageManager: PackageManager): LiveData> { + override fun getInstalledAppListItems( + packageManager: PackageManager, + ): LiveData> { val installedPackages = packageManager.getInstalledPackages(0) .associateBy { packageInfo -> packageInfo.packageName } val packageNames = installedPackages.keys.toList() diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index 937b4ee68..bb63c3864 100644 --- a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -45,14 +45,14 @@ internal abstract class FDroidDatabaseInt internal constructor() : RoomDatabase( } public interface FDroidDatabase { - fun getRepositoryDao(): RepositoryDao - fun getAppDao(): AppDao - fun getVersionDao(): VersionDao - fun runInTransaction(body: Runnable) + public fun getRepositoryDao(): RepositoryDao + public fun getAppDao(): AppDao + public fun getVersionDao(): VersionDao + public fun runInTransaction(body: Runnable) } public fun interface FDroidFixture { - fun prePopulateDb(db: FDroidDatabase) + public fun prePopulateDb(db: FDroidDatabase) } public object FDroidDatabaseHolder { @@ -81,7 +81,7 @@ public object FDroidDatabaseHolder { FDroidDatabaseInt::class.java, name, ).fallbackToDestructiveMigration() - //.allowMainThreadQueries() // TODO remove before release + // .allowMainThreadQueries() // TODO remove before release if (fixture != null) builder.addCallback(FixtureCallback(fixture)) val instance = builder.build() INSTANCE = instance diff --git a/database/src/main/java/org/fdroid/database/Repository.kt b/database/src/main/java/org/fdroid/database/Repository.kt index a81422555..ab246aacb 100644 --- a/database/src/main/java/org/fdroid/database/Repository.kt +++ b/database/src/main/java/org/fdroid/database/Repository.kt @@ -17,7 +17,7 @@ import org.fdroid.index.v2.ReleaseChannelV2 import org.fdroid.index.v2.RepoV2 @Entity -data class CoreRepository( +public data class CoreRepository( @PrimaryKey(autoGenerate = true) val repoId: Long = 0, val name: String, @Embedded(prefix = "icon_") val icon: FileV2?, @@ -28,7 +28,7 @@ data class CoreRepository( val certificate: String?, ) -fun RepoV2.toCoreRepository( +internal fun RepoV2.toCoreRepository( repoId: Long = 0, version: Int, certificate: String? = null, @@ -43,7 +43,7 @@ fun RepoV2.toCoreRepository( certificate = certificate, ) -data class Repository( +public data class Repository( @Embedded internal val repository: CoreRepository, @Relation( parentColumn = "repoId", @@ -98,7 +98,7 @@ data class Repository( /** * Returns official and user-added mirrors without the [disabledMirrors]. */ - fun getMirrors(): List { + public fun getMirrors(): List { return getAllMirrors(true).filter { !disabledMirrors.contains(it.baseUrl) } @@ -108,7 +108,7 @@ data class Repository( * Returns all mirrors, including [disabledMirrors]. */ @JvmOverloads - fun getAllMirrors(includeUserMirrors: Boolean = true): List { + public fun getAllMirrors(includeUserMirrors: Boolean = true): List { // FIXME decide whether we need to add our own address here return listOf(org.fdroid.download.Mirror(address)) + mirrors.map { it.toDownloadMirror() @@ -117,7 +117,8 @@ data class Repository( } else emptyList() } - fun getDescription(localeList: LocaleListCompat) = description.getBestLocale(localeList) + public fun getDescription(localeList: LocaleListCompat): String? = + description.getBestLocale(localeList) } @Entity( @@ -129,18 +130,18 @@ data class Repository( onDelete = ForeignKey.CASCADE, )], ) -data class Mirror( +public data class Mirror( val repoId: Long, val url: String, val location: String? = null, ) { - fun toDownloadMirror() = org.fdroid.download.Mirror( + public fun toDownloadMirror(): org.fdroid.download.Mirror = org.fdroid.download.Mirror( baseUrl = url, location = location, ) } -fun MirrorV2.toMirror(repoId: Long) = Mirror( +internal fun MirrorV2.toMirror(repoId: Long) = Mirror( repoId = repoId, url = url, location = location, @@ -155,7 +156,7 @@ fun MirrorV2.toMirror(repoId: Long) = Mirror( onDelete = ForeignKey.CASCADE, )], ) -data class AntiFeature( +public data class AntiFeature( val repoId: Long, val id: String, @Embedded(prefix = "icon_") val icon: FileV2? = null, @@ -163,7 +164,7 @@ data class AntiFeature( val description: LocalizedTextV2, ) -fun Map.toRepoAntiFeatures(repoId: Long) = map { +internal fun Map.toRepoAntiFeatures(repoId: Long) = map { AntiFeature( repoId = repoId, id = it.key, @@ -182,7 +183,7 @@ fun Map.toRepoAntiFeatures(repoId: Long) = map { onDelete = ForeignKey.CASCADE, )], ) -data class Category( +public data class Category( val repoId: Long, val id: String, @Embedded(prefix = "icon_") val icon: FileV2? = null, @@ -190,7 +191,7 @@ data class Category( val description: LocalizedTextV2, ) -fun Map.toRepoCategories(repoId: Long) = map { +internal fun Map.toRepoCategories(repoId: Long) = map { Category( repoId = repoId, id = it.key, @@ -209,7 +210,7 @@ fun Map.toRepoCategories(repoId: Long) = map { onDelete = ForeignKey.CASCADE, )], ) -data class ReleaseChannel( +public data class ReleaseChannel( val repoId: Long, val id: String, @Embedded(prefix = "icon_") val icon: FileV2? = null, @@ -217,7 +218,7 @@ data class ReleaseChannel( val description: LocalizedTextV2, ) -fun Map.toRepoReleaseChannel(repoId: Long) = map { +internal fun Map.toRepoReleaseChannel(repoId: Long) = map { ReleaseChannel( repoId = repoId, id = it.key, @@ -227,7 +228,7 @@ fun Map.toRepoReleaseChannel(repoId: Long) = map { } @Entity -data class RepositoryPreferences( +public data class RepositoryPreferences( @PrimaryKey internal val repoId: Long, val weight: Int, val enabled: Boolean = true, @@ -243,7 +244,7 @@ data class RepositoryPreferences( /** * A [Repository] which the [FDroidDatabase] gets pre-populated with. */ -data class InitialRepository( +public data class InitialRepository( val name: String, val address: String, val description: String, diff --git a/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/database/src/main/java/org/fdroid/database/RepositoryDao.kt index 04ce0ec80..8dc64af5e 100644 --- a/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -20,30 +20,30 @@ import org.fdroid.index.v2.ReflectionDiffer.applyDiff import org.fdroid.index.v2.RepoV2 public interface RepositoryDao { - fun insert(initialRepo: InitialRepository) + public fun insert(initialRepo: InitialRepository) /** * Use when replacing an existing repo with a full index. * This removes all existing index data associated with this repo from the database. */ - fun replace(repoId: Long, repository: RepoV2, version: Int, certificate: String?) + public fun replace(repoId: Long, repository: RepoV2, version: Int, certificate: String?) - fun getRepository(repoId: Long): Repository? - fun insertEmptyRepo( + public fun getRepository(repoId: Long): Repository? + public fun insertEmptyRepo( address: String, username: String? = null, password: String? = null, ): Long - fun deleteRepository(repoId: Long) - fun getRepositories(): List - fun getLiveRepositories(): LiveData> - fun countAppsPerRepository(repoId: Long): Int - fun setRepositoryEnabled(repoId: Long, enabled: Boolean) - fun updateUserMirrors(repoId: Long, mirrors: List) - fun updateUsernameAndPassword(repoId: Long, username: String?, password: String?) - fun updateDisabledMirrors(repoId: Long, disabledMirrors: List) - fun getLiveCategories(): LiveData> + public fun deleteRepository(repoId: Long) + public fun getRepositories(): List + public fun getLiveRepositories(): LiveData> + public fun countAppsPerRepository(repoId: Long): Int + public fun setRepositoryEnabled(repoId: Long, enabled: Boolean) + public fun updateUserMirrors(repoId: Long, mirrors: List) + public fun updateUsernameAndPassword(repoId: Long, username: String?, password: String?) + public fun updateDisabledMirrors(repoId: Long, disabledMirrors: List) + public fun getLiveCategories(): LiveData> } @Dao @@ -254,10 +254,12 @@ internal interface RepositoryDaoInt : RepositoryDao { @Query("UPDATE RepositoryPreferences SET userMirrors = :mirrors WHERE repoId = :repoId") override fun updateUserMirrors(repoId: Long, mirrors: List) - @Query("UPDATE RepositoryPreferences SET username = :username, password = :password WHERE repoId = :repoId") + @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") + @Query("""UPDATE RepositoryPreferences SET disabledMirrors = :disabledMirrors + WHERE repoId = :repoId""") override fun updateDisabledMirrors(repoId: Long, disabledMirrors: List) @Transaction diff --git a/database/src/main/java/org/fdroid/database/UpdateChecker.kt b/database/src/main/java/org/fdroid/database/UpdateChecker.kt index 1d1bcc332..a50a4b0a7 100644 --- a/database/src/main/java/org/fdroid/database/UpdateChecker.kt +++ b/database/src/main/java/org/fdroid/database/UpdateChecker.kt @@ -22,7 +22,7 @@ public class UpdateChecker( * @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. */ - fun getUpdatableApps(releaseChannels: List? = null): List { + public fun getUpdatableApps(releaseChannels: List? = null): List { val updatableApps = ArrayList() @Suppress("DEPRECATION") // we'll use this as long as it works, new one was broken @@ -52,7 +52,7 @@ public class UpdateChecker( * If this is null or empty, only versions without channel (stable) will be considered. */ @SuppressLint("PackageManagerGetSignatures") - fun getUpdate(packageName: String, releaseChannels: List? = null): AppVersion? { + public fun getUpdate(packageName: String, releaseChannels: List? = null): AppVersion? { val versions = versionDao.getVersions(listOf(packageName)) if (versions.isEmpty()) return null val packageInfo = try { diff --git a/database/src/main/java/org/fdroid/database/Version.kt b/database/src/main/java/org/fdroid/database/Version.kt index 6b53232cb..e1e44a238 100644 --- a/database/src/main/java/org/fdroid/database/Version.kt +++ b/database/src/main/java/org/fdroid/database/Version.kt @@ -25,7 +25,7 @@ import org.fdroid.index.v2.UsesSdkV2 onDelete = ForeignKey.CASCADE, )], ) -data class Version( +public data class Version( val repoId: Long, val packageId: String, val versionId: String, @@ -38,14 +38,14 @@ data class Version( val whatsNew: LocalizedTextV2? = null, val isCompatible: Boolean, ) { - fun toAppVersion(versionedStrings: List) = AppVersion( + internal fun toAppVersion(versionedStrings: List): AppVersion = AppVersion( version = this, usesPermission = versionedStrings.getPermissions(this), usesPermissionSdk23 = versionedStrings.getPermissionsSdk23(this), ) } -fun PackageVersionV2.toVersion( +internal fun PackageVersionV2.toVersion( repoId: Long, packageId: String, versionId: String, @@ -64,23 +64,31 @@ fun PackageVersionV2.toVersion( isCompatible = isCompatible, ) -data class AppVersion( - val version: Version, +public data class AppVersion( + internal val version: Version, val usesPermission: List? = null, val usesPermissionSdk23: List? = null, ) { - val packageId get() = version.packageId - val featureNames get() = version.manifest.features?.toTypedArray() ?: emptyArray() - val nativeCode get() = version.manifest.nativecode?.toTypedArray() ?: emptyArray() - val antiFeatureNames: Array + public val repoId: Long get() = version.repoId + public val packageId: String get() = version.packageId + 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 featureNames: List get() = version.manifest.features ?: emptyList() + public val nativeCode: List get() = version.manifest.nativecode ?: emptyList() + public val releaseChannels: List = version.releaseChannels ?: emptyList() + val antiFeatureNames: List get() { - return version.antiFeatures?.map { it.key }?.toTypedArray() ?: emptyArray() + return version.antiFeatures?.map { it.key } ?: emptyList() } - fun getWhatsNew(localeList: LocaleListCompat) = version.whatsNew.getBestLocale(localeList) + public fun getWhatsNew(localeList: LocaleListCompat): String? = + version.whatsNew.getBestLocale(localeList) } -data class AppManifest( +public data class AppManifest( val versionName: String, val versionCode: Long, @Embedded(prefix = "usesSdk_") val usesSdk: UsesSdkV2? = null, @@ -100,7 +108,7 @@ data class AppManifest( ) } -fun ManifestV2.toManifest() = AppManifest( +internal fun ManifestV2.toManifest() = AppManifest( versionName = versionName, versionCode = versionCode, usesSdk = usesSdk, @@ -110,7 +118,7 @@ fun ManifestV2.toManifest() = AppManifest( features = features.map { it.name }, ) -enum class VersionedStringType { +internal enum class VersionedStringType { PERMISSION, PERMISSION_SDK_23, } @@ -124,7 +132,7 @@ enum class VersionedStringType { onDelete = ForeignKey.CASCADE, )], ) -data class VersionedString( +internal data class VersionedString( val repoId: Long, val packageId: String, val versionId: String, @@ -133,7 +141,7 @@ data class VersionedString( val version: Int? = null, ) -fun List.toVersionedString( +internal fun List.toVersionedString( version: Version, type: VersionedStringType, ) = map { permission -> @@ -147,12 +155,12 @@ fun List.toVersionedString( ) } -fun ManifestV2.getVersionedStrings(version: Version): List { +internal fun ManifestV2.getVersionedStrings(version: Version): List { return usesPermission.toVersionedString(version, PERMISSION) + usesPermissionSdk23.toVersionedString(version, PERMISSION_SDK_23) } -fun List.getPermissions(version: Version) = mapNotNull { v -> +internal fun List.getPermissions(version: Version) = mapNotNull { v -> v.map(version, PERMISSION) { PermissionV2( name = v.name, @@ -161,7 +169,7 @@ fun List.getPermissions(version: Version) = mapNotNull { v -> } } -fun List.getPermissionsSdk23(version: Version) = mapNotNull { v -> +internal fun List.getPermissionsSdk23(version: Version) = mapNotNull { v -> v.map(version, PERMISSION_SDK_23) { PermissionV2( name = v.name, diff --git a/database/src/main/java/org/fdroid/database/VersionDao.kt b/database/src/main/java/org/fdroid/database/VersionDao.kt index 4709b7c10..9e15e335d 100644 --- a/database/src/main/java/org/fdroid/database/VersionDao.kt +++ b/database/src/main/java/org/fdroid/database/VersionDao.kt @@ -15,15 +15,15 @@ import org.fdroid.database.FDroidDatabaseHolder.dispatcher import org.fdroid.index.v2.PackageVersionV2 public interface VersionDao { - fun insert( + public fun insert( repoId: Long, packageId: String, packageVersions: Map, checkIfCompatible: (PackageVersionV2) -> Boolean, ) - fun getAppVersions(packageId: String): LiveData> - fun getAppVersions(repoId: Long, packageId: String): List + public fun getAppVersions(packageId: String): LiveData> + public fun getAppVersions(repoId: Long, packageId: String): List } @Dao @@ -107,7 +107,8 @@ internal interface VersionDaoInt : VersionDao { @Query("SELECT * FROM VersionedString WHERE repoId = :repoId AND packageId = :packageId") fun getVersionedStrings(repoId: Long, packageId: String): List - @Query("SELECT * FROM VersionedString WHERE repoId = :repoId AND packageId = :packageId AND versionId = :versionId") + @Query("""SELECT * FROM VersionedString + WHERE repoId = :repoId AND packageId = :packageId AND versionId = :versionId""") fun getVersionedStrings( repoId: Long, packageId: String, @@ -115,7 +116,8 @@ internal interface VersionDaoInt : VersionDao { ): List @VisibleForTesting - @Query("DELETE FROM Version WHERE repoId = :repoId AND packageId = :packageId AND versionId = :versionId") + @Query("""DELETE FROM Version + WHERE repoId = :repoId AND packageId = :packageId AND versionId = :versionId""") fun deleteAppVersion(repoId: Long, packageId: String, versionId: String) @Query("SELECT COUNT(*) FROM Version") diff --git a/database/src/main/java/org/fdroid/download/DownloaderFactory.kt b/database/src/main/java/org/fdroid/download/DownloaderFactory.kt index b687b1f2a..57450df20 100644 --- a/database/src/main/java/org/fdroid/download/DownloaderFactory.kt +++ b/database/src/main/java/org/fdroid/download/DownloaderFactory.kt @@ -6,6 +6,9 @@ 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 { /** @@ -14,7 +17,7 @@ public abstract class DownloaderFactory { * See https://gitlab.com/fdroid/fdroidclient/-/issues/1708 for why this is still needed. */ @Throws(IOException::class) - fun createWithTryFirstMirror(repo: Repository, uri: Uri, destFile: File): Downloader { + public fun createWithTryFirstMirror(repo: Repository, uri: Uri, destFile: File): Downloader { val tryFirst = repo.getMirrors().find { mirror -> mirror.baseUrl == repo.address } @@ -26,7 +29,7 @@ public abstract class DownloaderFactory { } @Throws(IOException::class) - abstract fun create(repo: Repository, uri: Uri, destFile: File): Downloader + public abstract fun create(repo: Repository, uri: Uri, destFile: File): Downloader @Throws(IOException::class) protected abstract fun create( diff --git a/database/src/main/java/org/fdroid/index/v1/IndexUpdater.kt b/database/src/main/java/org/fdroid/index/v1/IndexUpdater.kt index 823f0b305..1d8725faa 100644 --- a/database/src/main/java/org/fdroid/index/v1/IndexUpdater.kt +++ b/database/src/main/java/org/fdroid/index/v1/IndexUpdater.kt @@ -10,16 +10,12 @@ public enum class IndexUpdateResult { } public interface IndexUpdateListener { - fun onDownloadProgress(bytesRead: Long, totalBytes: Long) - fun onStartProcessing() - + public fun onDownloadProgress(bytesRead: Long, totalBytes: Long) + public fun onStartProcessing() } -public class IndexUpdater { -} +public class IndexUpdater - -public fun Repository.getCanonicalUri(): Uri = Uri.parse(address) - .buildUpon() +public fun Repository.getCanonicalUri(): Uri = Uri.parse(address).buildUpon() .appendPath(SIGNED_FILE_NAME) .build() diff --git a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt index 7369d0fc7..f7e2ccafe 100644 --- a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt +++ b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt @@ -22,7 +22,7 @@ public class IndexV1Updater( private val db: FDroidDatabaseInt = database as FDroidDatabaseInt @Throws(IOException::class, InterruptedException::class) - fun updateNewRepo( + public fun updateNewRepo( repoId: Long, expectedSigningFingerprint: String?, updateListener: IndexUpdateListener? = null, @@ -31,7 +31,7 @@ public class IndexV1Updater( } @Throws(IOException::class, InterruptedException::class) - fun update( + public fun update( repoId: Long, certificate: String, updateListener: IndexUpdateListener? = null, diff --git a/database/src/test/java/org/fdroid/database/ReflectionTest.kt b/database/src/test/java/org/fdroid/database/ReflectionTest.kt index cdfc89203..4aabee308 100644 --- a/database/src/test/java/org/fdroid/database/ReflectionTest.kt +++ b/database/src/test/java/org/fdroid/database/ReflectionTest.kt @@ -10,7 +10,7 @@ import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals -class ReflectionTest { +internal class ReflectionTest { @Test fun testRepository() { From 536607da228bfe425e18c7a65e2bd1771402ed04 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 11 Apr 2022 16:02:09 -0300 Subject: [PATCH 16/42] [db] store localized name and summary in DB otherwise we can't really sort *all* apps by name in an efficient manner --- .../java/org/fdroid/database/AppTest.kt | 8 +- .../java/org/fdroid/database/DbTest.kt | 4 + .../src/main/java/org/fdroid/database/App.kt | 51 ++++---- .../main/java/org/fdroid/database/AppDao.kt | 112 +++++++++++++++--- .../org/fdroid/database/DbStreamReceiver.kt | 9 +- .../org/fdroid/database/DbV1StreamReceiver.kt | 9 +- .../org/fdroid/database/FDroidDatabase.kt | 19 +++ 7 files changed, 159 insertions(+), 53 deletions(-) diff --git a/database/src/androidTest/java/org/fdroid/database/AppTest.kt b/database/src/androidTest/java/org/fdroid/database/AppTest.kt index 9bcea19ed..295fc1672 100644 --- a/database/src/androidTest/java/org/fdroid/database/AppTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/AppTest.kt @@ -8,6 +8,7 @@ import org.fdroid.test.TestAppUtils.getRandomMetadataV2 import org.fdroid.test.TestRepoUtils.getRandomFileV2 import org.fdroid.test.TestRepoUtils.getRandomRepo import org.fdroid.test.TestUtils.getRandomString +import org.fdroid.test.TestVersionUtils.getRandomPackageVersionV2 import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -64,8 +65,10 @@ internal class AppTest : DbTest() { val app1 = getRandomMetadataV2().copy(name = name1, icon = icons1) val app2 = getRandomMetadataV2().copy(name = name2, icon = icons2) val app3 = getRandomMetadataV2().copy(name = name3, icon = null) - appDao.insert(repoId, packageId1, app1) - appDao.insert(repoId, packageId2, app2) + appDao.insert(repoId, packageId1, app1, locales) + appDao.insert(repoId, packageId2, app2, locales) + versionDao.insert(repoId, packageId1, "1", getRandomPackageVersionV2(), true) + versionDao.insert(repoId, packageId2, "2", getRandomPackageVersionV2(), true) // icons of both apps are returned correctly val apps = appDao.getAppOverviewItems().getOrAwaitValue() ?: fail() @@ -77,6 +80,7 @@ internal class AppTest : DbTest() { // app without icon is not returned appDao.insert(repoId, packageId3, app3) + versionDao.insert(repoId, packageId3, "3", getRandomPackageVersionV2(), true) val apps3 = appDao.getAppOverviewItems().getOrAwaitValue() ?: fail() assertEquals(2, apps3.size) assertEquals(icons1, diff --git a/database/src/androidTest/java/org/fdroid/database/DbTest.kt b/database/src/androidTest/java/org/fdroid/database/DbTest.kt index 116d7104f..66e1dc813 100644 --- a/database/src/androidTest/java/org/fdroid/database/DbTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/DbTest.kt @@ -1,6 +1,7 @@ package org.fdroid.database import android.content.Context +import androidx.core.os.LocaleListCompat import androidx.room.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -13,6 +14,7 @@ import org.junit.After import org.junit.Before import org.junit.runner.RunWith import java.io.IOException +import java.util.Locale @RunWith(AndroidJUnit4::class) @OptIn(ExperimentalCoroutinesApi::class) @@ -24,6 +26,8 @@ internal abstract class DbTest { internal lateinit var db: FDroidDatabaseInt private val testCoroutineDispatcher = Dispatchers.Unconfined + protected val locales = LocaleListCompat.create(Locale.US) + @Before open fun createDb() { val context = ApplicationProvider.getApplicationContext() diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt index 78175da12..14e98431d 100644 --- a/database/src/main/java/org/fdroid/database/App.kt +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -1,13 +1,15 @@ package org.fdroid.database +import android.content.res.Resources +import androidx.core.os.ConfigurationCompat.getLocales import androidx.core.os.LocaleListCompat +import androidx.room.ColumnInfo import androidx.room.DatabaseView import androidx.room.Embedded import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Ignore import androidx.room.Relation -import org.fdroid.database.Converters.fromStringToLocalizedTextV2 import org.fdroid.database.Converters.fromStringToMapOfLocalizedTextV2 import org.fdroid.index.v2.Author import org.fdroid.index.v2.Donation @@ -61,6 +63,7 @@ internal fun MetadataV2.toAppMetadata( repoId: Long, packageId: String, isCompatible: Boolean = false, + locales: LocaleListCompat = getLocales(Resources.getSystem().configuration), ) = AppMetadata( repoId = repoId, packageId = packageId, @@ -69,6 +72,8 @@ internal fun MetadataV2.toAppMetadata( name = name, summary = summary, description = description, + localizedName = name.getBestLocale(locales), + localizedSummary = summary.getBestLocale(locales), webSite = webSite, changelog = changelog, license = license, @@ -91,12 +96,8 @@ public data class App( val tvBanner: LocalizedFileV2? = null, val screenshots: Screenshots? = null, ) { - public fun getName(localeList: LocaleListCompat): String? = - metadata.name.getBestLocale(localeList) - - public fun getSummary(localeList: LocaleListCompat): String? = - metadata.summary.getBestLocale(localeList) - + public fun getName(): String? = metadata.localizedName + public fun getSummary(): String? = metadata.localizedSummary public fun getDescription(localeList: LocaleListCompat): String? = metadata.description.getBestLocale(localeList) @@ -135,25 +136,30 @@ public data class AppOverviewItem( public val packageId: String, public val added: Long, public val lastUpdated: Long, - internal val name: LocalizedTextV2? = null, - internal val summary: LocalizedTextV2? = null, + @ColumnInfo(name = "localizedName") + public val name: String? = null, + @ColumnInfo(name = "localizedSummary") + public val summary: String? = null, + internal val antiFeatures: Map? = null, @Relation( parentColumn = "packageId", entityColumn = "packageId", ) internal val localizedIcon: List? = null, ) { - public fun getName(localeList: LocaleListCompat): String? = name.getBestLocale(localeList) - public fun getSummary(localeList: LocaleListCompat): String? = summary.getBestLocale(localeList) public fun getIcon(localeList: LocaleListCompat): String? = localizedIcon?.toLocalizedFileV2().getBestLocale(localeList)?.name + + val antiFeatureNames: List get() = antiFeatures?.map { it.key } ?: emptyList() } -public data class AppListItem @JvmOverloads constructor( +public data class AppListItem constructor( public val repoId: Long, public val packageId: String, - internal val name: String?, - internal val summary: String?, + @ColumnInfo(name = "localizedName") + public val name: String? = null, + @ColumnInfo(name = "localizedSummary") + public val summary: String? = null, internal val antiFeatures: String?, @Relation( parentColumn = "packageId", @@ -167,21 +173,11 @@ public data class AppListItem @JvmOverloads constructor( /** * The name of the installed version, null if this app is not installed. */ - @Ignore + @get:Ignore public val installedVersionName: String? = null, - @Ignore + @get:Ignore public val installedVersionCode: Long? = null, ) { - public fun getName(localeList: LocaleListCompat): String? { - // queries for this class return a larger number, so we convert on demand - return fromStringToLocalizedTextV2(name).getBestLocale(localeList) - } - - public fun getSummary(localeList: LocaleListCompat): String? { - // queries for this class return a larger number, so we convert on demand - return fromStringToLocalizedTextV2(summary).getBestLocale(localeList) - } - public fun getAntiFeatureNames(): List { return fromStringToMapOfLocalizedTextV2(antiFeatures)?.map { it.key } ?: emptyList() } @@ -194,7 +190,7 @@ public data class UpdatableApp( public val packageId: String, public val installedVersionCode: Long, public val upgrade: AppVersion, - internal val name: LocalizedTextV2? = null, + public val name: String? = null, public val summary: String? = null, @Relation( parentColumn = "packageId", @@ -202,7 +198,6 @@ public data class UpdatableApp( ) internal val localizedIcon: List? = null, ) { - public fun getName(localeList: LocaleListCompat): String? = name.getBestLocale(localeList) public fun getIcon(localeList: LocaleListCompat): FileV2? = localizedIcon?.toLocalizedFileV2().getBestLocale(localeList) } diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index d9511743d..363fac2bd 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -2,7 +2,10 @@ package org.fdroid.database import android.content.pm.PackageInfo import android.content.pm.PackageManager +import android.content.res.Resources import androidx.annotation.VisibleForTesting +import androidx.core.os.ConfigurationCompat.getLocales +import androidx.core.os.LocaleListCompat import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.distinctUntilChanged @@ -22,7 +25,12 @@ import org.fdroid.index.v2.MetadataV2 import org.fdroid.index.v2.Screenshots public interface AppDao { - public fun insert(repoId: Long, packageId: String, app: MetadataV2) + public fun insert( + repoId: Long, + packageId: String, + app: MetadataV2, + locales: LocaleListCompat = getLocales(Resources.getSystem().configuration), + ) /** * Gets the app from the DB. If more than one app with this [packageId] exists, @@ -36,10 +44,15 @@ public interface AppDao { limit: Int = 50, ): LiveData> - public fun getAppListItems(packageManager: PackageManager): LiveData> + public fun getAppListItems( + packageManager: PackageManager, + sortOrder: AppListSortOrder, + ): LiveData> + public fun getAppListItems( packageManager: PackageManager, category: String, + sortOrder: AppListSortOrder, ): LiveData> public fun getInstalledAppListItems(packageManager: PackageManager): LiveData> @@ -47,11 +60,20 @@ public interface AppDao { public fun getNumberOfAppsInCategory(category: String): Int } +public enum class AppListSortOrder { + LAST_UPDATED, NAME +} + @Dao internal interface AppDaoInt : AppDao { @Transaction - override fun insert(repoId: Long, packageId: String, app: MetadataV2) { + override fun insert( + repoId: Long, + packageId: String, + app: MetadataV2, + locales: LocaleListCompat, + ) { insert(app.toAppMetadata(repoId, packageId, false)) app.icon.insert(repoId, packageId, "icon") app.featureGraphic.insert(repoId, packageId, "featureGraphic") @@ -109,6 +131,10 @@ internal interface AppDaoInt : AppDao { WHERE repoId = :repoId""") fun updateCompatibility(repoId: Long) + @Query("""UPDATE AppMetadata SET localizedName = :name, localizedSummary = :summary + WHERE repoId = :repoId AND packageId = :packageId""") + fun updateAppMetadata(repoId: Long, packageId: String, name: String?, summary: String?) + override fun getApp(packageId: String): LiveData { return getRepoIdForPackage(packageId).distinctUntilChanged().switchMap { repoId -> if (repoId == null) MutableLiveData(null) @@ -181,28 +207,40 @@ internal interface AppDaoInt : AppDao { fun getLocalizedFileLists(): List @Transaction - @Query("""SELECT repoId, packageId, added, app.lastUpdated, app.name, summary + @Query("""SELECT repoId, packageId, app.added, app.lastUpdated, localizedName, + localizedSummary, version.antiFeatures FROM AppMetadata AS app JOIN RepositoryPreferences AS pref USING (repoId) + JOIN Version AS version USING (repoId, packageId) JOIN LocalizedIcon AS icon USING (repoId, packageId) - WHERE pref.enabled = 1 GROUP BY packageId - ORDER BY app.name IS NULL ASC, summary IS NULL ASC, app.lastUpdated DESC, added ASC + WHERE pref.enabled = 1 + GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) + ORDER BY localizedName IS NULL ASC, localizedSummary IS NULL ASC, app.lastUpdated DESC, app.added ASC LIMIT :limit""") override fun getAppOverviewItems(limit: Int): LiveData> @Transaction // TODO maybe it makes sense to split categories into their own table for this? - @Query("""SELECT repoId, packageId, added, app.lastUpdated, app.name, summary + @Query("""SELECT repoId, packageId, app.added, app.lastUpdated, localizedName, + localizedSummary, version.antiFeatures FROM AppMetadata AS app JOIN RepositoryPreferences AS pref USING (repoId) + JOIN Version AS version USING (repoId, packageId) JOIN LocalizedIcon AS icon USING (repoId, packageId) - WHERE pref.enabled = 1 AND categories LIKE '%' || :category || '%' GROUP BY packageId - ORDER BY app.name IS NULL ASC, summary IS NULL ASC, app.lastUpdated DESC, added ASC + WHERE pref.enabled = 1 AND categories LIKE '%' || :category || '%' + GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) + ORDER BY localizedName IS NULL ASC, localizedSummary IS NULL ASC, app.lastUpdated DESC, app.added ASC LIMIT :limit""") override fun getAppOverviewItems(category: String, limit: Int): LiveData> - override fun getAppListItems(packageManager: PackageManager): LiveData> { - return getAppListItems().map(packageManager) + override fun getAppListItems( + packageManager: PackageManager, + sortOrder: AppListSortOrder, + ): LiveData> { + return when (sortOrder) { + AppListSortOrder.LAST_UPDATED -> getAppListItemsByLastUpdated().map(packageManager) + AppListSortOrder.NAME -> getAppListItemsByName().map(packageManager) + } } private fun LiveData>.map( @@ -221,41 +259,75 @@ internal interface AppDaoInt : AppDao { @Transaction @Query(""" - SELECT repoId, packageId, app.name, summary, version.antiFeatures, app.isCompatible + SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures, + app.isCompatible + FROM AppMetadata AS app + JOIN Version AS version USING (repoId, packageId) + JOIN RepositoryPreferences AS pref USING (repoId) + WHERE pref.enabled = 1 + GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) + ORDER BY localizedName COLLATE NOCASE ASC""") + fun getAppListItemsByName(): LiveData> + + @Transaction + @Query(""" + SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures, + app.isCompatible FROM AppMetadata AS app JOIN Version AS version USING (repoId, packageId) JOIN RepositoryPreferences AS pref USING (repoId) WHERE pref.enabled = 1 GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) ORDER BY app.lastUpdated DESC""") - fun getAppListItems(): LiveData> + fun getAppListItemsByLastUpdated(): LiveData> override fun getAppListItems( packageManager: PackageManager, category: String, + sortOrder: AppListSortOrder, ): LiveData> { - return getAppListItems(category).map(packageManager) + return when (sortOrder) { + AppListSortOrder.LAST_UPDATED -> { + getAppListItemsByLastUpdated(category).map(packageManager) + } + AppListSortOrder.NAME -> getAppListItemsByName(category).map(packageManager) + } } // TODO maybe it makes sense to split categories into their own table for this? @Transaction @Query(""" - SELECT repoId, packageId, app.name, summary, version.antiFeatures, app.isCompatible + SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures, + app.isCompatible FROM AppMetadata AS app JOIN Version AS version USING (repoId, packageId) JOIN RepositoryPreferences AS pref USING (repoId) WHERE pref.enabled = 1 AND categories LIKE '%' || :category || '%' GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) ORDER BY app.lastUpdated DESC""") - fun getAppListItems(category: String): LiveData> + fun getAppListItemsByLastUpdated(category: String): LiveData> + + // TODO maybe it makes sense to split categories into their own table for this? + @Transaction + @Query(""" + SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures, + app.isCompatible + FROM AppMetadata AS app + JOIN Version AS version USING (repoId, packageId) + JOIN RepositoryPreferences AS pref USING (repoId) + WHERE pref.enabled = 1 AND categories LIKE '%' || :category || '%' + GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) + ORDER BY localizedName COLLATE NOCASE ASC""") + fun getAppListItemsByName(category: String): LiveData> @Transaction @SuppressWarnings(CURSOR_MISMATCH) // no anti-features needed here - @Query("""SELECT repoId, packageId, app.name, summary, app.isCompatible + @Query("""SELECT repoId, packageId, localizedName, localizedSummary, app.isCompatible FROM AppMetadata AS app JOIN RepositoryPreferences AS pref USING (repoId) WHERE pref.enabled = 1 AND packageId IN (:packageNames) - GROUP BY packageId HAVING MAX(pref.weight)""") + GROUP BY packageId HAVING MAX(pref.weight) + ORDER BY localizedName COLLATE NOCASE ASC""") fun getAppListItems(packageNames: List): LiveData> override fun getInstalledAppListItems( @@ -276,7 +348,9 @@ internal interface AppDaoInt : AppDao { * Used by [UpdateChecker] to get specific apps with available updates. */ @Transaction - @Query("""SELECT repoId, packageId, added, app.lastUpdated, app.name, summary + @SuppressWarnings(CURSOR_MISMATCH) // no anti-features needed here + @Query("""SELECT repoId, packageId, added, app.lastUpdated, localizedName, + localizedSummary FROM AppMetadata AS app WHERE repoId = :repoId AND packageId = :packageId""") fun getAppOverviewItem(repoId: Long, packageId: String): AppOverviewItem? diff --git a/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt index 4fdcddaf1..b5304505e 100644 --- a/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt @@ -1,5 +1,8 @@ package org.fdroid.database +import android.content.res.Resources +import androidx.core.os.ConfigurationCompat.getLocales +import androidx.core.os.LocaleListCompat import org.fdroid.CompatibilityChecker import org.fdroid.index.v2.IndexStreamReceiver import org.fdroid.index.v2.PackageV2 @@ -10,19 +13,21 @@ internal class DbStreamReceiver( private val compatibilityChecker: CompatibilityChecker, ) : IndexStreamReceiver { + private val locales: LocaleListCompat = getLocales(Resources.getSystem().configuration) + override fun receive(repoId: Long, repo: RepoV2, version: Int, certificate: String?) { db.getRepositoryDao().replace(repoId, repo, version, certificate) } override fun receive(repoId: Long, packageId: String, p: PackageV2) { - db.getAppDao().insert(repoId, packageId, p.metadata) + db.getAppDao().insert(repoId, packageId, p.metadata, locales) db.getVersionDao().insert(repoId, packageId, p.versions) { compatibilityChecker.isCompatible(it.manifest) } } override fun onStreamEnded(repoId: Long) { - db.getAppDao().updateCompatibility(repoId) + db.afterUpdatingRepo(repoId) } } diff --git a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt index a77c8326c..84cd5231a 100644 --- a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt @@ -1,5 +1,8 @@ package org.fdroid.database +import android.content.res.Resources +import androidx.core.os.ConfigurationCompat.getLocales +import androidx.core.os.LocaleListCompat import org.fdroid.CompatibilityChecker import org.fdroid.index.v1.IndexV1StreamReceiver import org.fdroid.index.v2.AntiFeatureV2 @@ -14,12 +17,14 @@ internal class DbV1StreamReceiver( private val compatibilityChecker: CompatibilityChecker, ) : IndexV1StreamReceiver { + private val locales: LocaleListCompat = getLocales(Resources.getSystem().configuration) + override fun receive(repoId: Long, repo: RepoV2, version: Int, certificate: String?) { db.getRepositoryDao().replace(repoId, repo, version, certificate) } override fun receive(repoId: Long, packageId: String, m: MetadataV2) { - db.getAppDao().insert(repoId, packageId, m) + db.getAppDao().insert(repoId, packageId, m, locales) } override fun receive(repoId: Long, packageId: String, v: Map) { @@ -39,7 +44,7 @@ internal class DbV1StreamReceiver( repoDao.insertCategories(categories.toRepoCategories(repoId)) repoDao.insertReleaseChannels(releaseChannels.toRepoReleaseChannel(repoId)) - db.getAppDao().updateCompatibility(repoId) + db.afterUpdatingRepo(repoId) } override fun updateAppMetadata(repoId: Long, packageId: String, preferredSigner: String?) { diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index bb63c3864..5e36cc911 100644 --- a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -1,7 +1,9 @@ package org.fdroid.database import android.content.Context +import android.content.res.Resources import android.util.Log +import androidx.core.os.ConfigurationCompat.getLocales import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase @@ -42,12 +44,29 @@ internal abstract class FDroidDatabaseInt internal constructor() : RoomDatabase( abstract override fun getRepositoryDao(): RepositoryDaoInt abstract override fun getAppDao(): AppDaoInt abstract override fun getVersionDao(): VersionDaoInt + fun afterUpdatingRepo(repoId: Long) { + getAppDao().updateCompatibility(repoId) + } } public interface FDroidDatabase { public fun getRepositoryDao(): RepositoryDao public fun getAppDao(): AppDao public fun getVersionDao(): VersionDao + public fun afterLocalesChanged() { + val appDao = getAppDao() as AppDaoInt + val locales = getLocales(Resources.getSystem().configuration) + runInTransaction { + appDao.getAppMetadata().forEach { appMetadata -> + appDao.updateAppMetadata( + repoId = appMetadata.repoId, + packageId = appMetadata.packageId, + name = appMetadata.name.getBestLocale(locales), + summary = appMetadata.summary.getBestLocale(locales), + ) + } + } + } public fun runInTransaction(body: Runnable) } From 691cd7242db2374a31412f07b02b8e146f16675d Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 19 Apr 2022 16:45:57 -0300 Subject: [PATCH 17/42] [db] Let UpdateChecker also check for known vulnerabilities --- database/src/main/java/org/fdroid/database/App.kt | 5 +++++ database/src/main/java/org/fdroid/database/UpdateChecker.kt | 5 +++++ database/src/main/java/org/fdroid/database/Version.kt | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt index 14e98431d..3f2d6739c 100644 --- a/database/src/main/java/org/fdroid/database/App.kt +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -190,6 +190,11 @@ public data class UpdatableApp( public val packageId: String, public val installedVersionCode: Long, public val upgrade: 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 val name: String? = null, public val summary: String? = null, @Relation( diff --git a/database/src/main/java/org/fdroid/database/UpdateChecker.kt b/database/src/main/java/org/fdroid/database/UpdateChecker.kt index a50a4b0a7..1dbd067ba 100644 --- a/database/src/main/java/org/fdroid/database/UpdateChecker.kt +++ b/database/src/main/java/org/fdroid/database/UpdateChecker.kt @@ -85,6 +85,10 @@ public class UpdateChecker( }?.toSet() } versions.iterator().forEach versions@{ version -> + // if the installed version has a known vulnerability, we return it as well + if (version.manifest.versionCode == versionCode && version.hasKnownVulnerability) { + return version + } // if version code is not higher than installed skip package as list is sorted if (version.manifest.versionCode <= versionCode) return null // check release channels if they are not empty @@ -121,6 +125,7 @@ public class UpdateChecker( packageId = version.packageId, installedVersionCode = installedVersionCode, upgrade = version.toAppVersion(versionedStrings), + hasKnownVulnerability = version.hasKnownVulnerability, name = appOverviewItem.name, summary = appOverviewItem.summary, localizedIcon = appOverviewItem.localizedIcon, diff --git a/database/src/main/java/org/fdroid/database/Version.kt b/database/src/main/java/org/fdroid/database/Version.kt index e1e44a238..bf39de18b 100644 --- a/database/src/main/java/org/fdroid/database/Version.kt +++ b/database/src/main/java/org/fdroid/database/Version.kt @@ -16,6 +16,8 @@ import org.fdroid.index.v2.PermissionV2 import org.fdroid.index.v2.SignatureV2 import org.fdroid.index.v2.UsesSdkV2 +private const val ANTI_FEATURE_KNOWN_VULNERABILITY = "KnownVuln" + @Entity( primaryKeys = ["repoId", "packageId", "versionId"], foreignKeys = [ForeignKey( @@ -38,6 +40,9 @@ public data class Version( val whatsNew: LocalizedTextV2? = null, val isCompatible: Boolean, ) { + val hasKnownVulnerability: Boolean + get() = antiFeatures?.contains(ANTI_FEATURE_KNOWN_VULNERABILITY) == true + internal fun toAppVersion(versionedStrings: List): AppVersion = AppVersion( version = this, usesPermission = versionedStrings.getPermissions(this), From 770ed6ae81da0817f44492442ee8e465f107e180 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 21 Apr 2022 16:32:53 -0300 Subject: [PATCH 18/42] [db] Add app preferences and let UpdateChecker use them --- .../main/java/org/fdroid/database/AppPrefs.kt | 35 +++++++++++++++++++ .../java/org/fdroid/database/AppPrefsDao.kt | 33 +++++++++++++++++ .../org/fdroid/database/FDroidDatabase.kt | 13 ++++--- .../java/org/fdroid/database/UpdateChecker.kt | 31 ++++++++++------ .../java/org/fdroid/database/VersionDao.kt | 13 +++++-- 5 files changed, 107 insertions(+), 18 deletions(-) create mode 100644 database/src/main/java/org/fdroid/database/AppPrefs.kt create mode 100644 database/src/main/java/org/fdroid/database/AppPrefsDao.kt diff --git a/database/src/main/java/org/fdroid/database/AppPrefs.kt b/database/src/main/java/org/fdroid/database/AppPrefs.kt new file mode 100644 index 000000000..c32a0bb3c --- /dev/null +++ b/database/src/main/java/org/fdroid/database/AppPrefs.kt @@ -0,0 +1,35 @@ +package org.fdroid.database + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +public data class AppPrefs( + @PrimaryKey + val packageId: String, + 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? = null, +) { + public val ignoreAllUpdates: Boolean get() = ignoreVersionCodeUpdate == Long.MAX_VALUE + public val releaseChannels: List get() = appPrefReleaseChannels ?: emptyList() + public fun shouldIgnoreUpdate(versionCode: Long): Boolean = + ignoreVersionCodeUpdate >= versionCode + + public fun toggleIgnoreAllUpdates(): AppPrefs = copy( + ignoreVersionCodeUpdate = if (ignoreAllUpdates) 0 else Long.MAX_VALUE, + ) + + public fun toggleIgnoreVersionCodeUpdate(versionCode: Long): AppPrefs = copy( + ignoreVersionCodeUpdate = if (shouldIgnoreUpdate(versionCode)) 0 else versionCode, + ) + + 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) } + }, + ) +} diff --git a/database/src/main/java/org/fdroid/database/AppPrefsDao.kt b/database/src/main/java/org/fdroid/database/AppPrefsDao.kt new file mode 100644 index 000000000..1710329e6 --- /dev/null +++ b/database/src/main/java/org/fdroid/database/AppPrefsDao.kt @@ -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 + public fun update(appPrefs: AppPrefs) +} + +@Dao +internal interface AppPrefsDaoInt : AppPrefsDao { + + override fun getAppPrefs(packageName: String): LiveData { + return getLiveAppPrefs(packageName).distinctUntilChanged().map { data -> + data ?: AppPrefs(packageName) + } + } + + @Query("SELECT * FROM AppPrefs WHERE packageId = :packageName") + fun getLiveAppPrefs(packageName: String): LiveData + + @Query("SELECT * FROM AppPrefs WHERE packageId = :packageName") + fun getAppPrefsOrNull(packageName: String): AppPrefs? + + @Insert(onConflict = REPLACE) + override fun update(appPrefs: AppPrefs) +} diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index 5e36cc911..c2711c818 100644 --- a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.res.Resources import android.util.Log import androidx.core.os.ConfigurationCompat.getLocales +import androidx.room.AutoMigration import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase @@ -15,7 +16,7 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @Database( - version = 1, // TODO set version to 1 before release and wipe old schemas + version = 2, // TODO set version to 1 before release and wipe old schemas entities = [ // repo CoreRepository::class, @@ -31,12 +32,14 @@ import kotlinx.coroutines.launch // versions Version::class, VersionedString::class, + // app user preferences + AppPrefs::class, ], views = [ LocalizedIcon::class, ], autoMigrations = [ - // AutoMigration (from = 1, to = 2) // seems to require Java 11 + AutoMigration(from = 1, to = 2) ], ) @TypeConverters(Converters::class) @@ -44,6 +47,7 @@ internal abstract class FDroidDatabaseInt internal constructor() : RoomDatabase( abstract override fun getRepositoryDao(): RepositoryDaoInt abstract override fun getAppDao(): AppDaoInt abstract override fun getVersionDao(): VersionDaoInt + abstract override fun getAppPrefsDao(): AppPrefsDaoInt fun afterUpdatingRepo(repoId: Long) { getAppDao().updateCompatibility(repoId) } @@ -53,6 +57,7 @@ public interface FDroidDatabase { public fun getRepositoryDao(): RepositoryDao public fun getAppDao(): AppDao public fun getVersionDao(): VersionDao + public fun getAppPrefsDao(): AppPrefsDao public fun afterLocalesChanged() { val appDao = getAppDao() as AppDaoInt val locales = getLocales(Resources.getSystem().configuration) @@ -67,6 +72,7 @@ public interface FDroidDatabase { } } } + public fun runInTransaction(body: Runnable) } @@ -99,8 +105,7 @@ public object FDroidDatabaseHolder { context.applicationContext, FDroidDatabaseInt::class.java, name, - ).fallbackToDestructiveMigration() - // .allowMainThreadQueries() // TODO remove before release + ) if (fixture != null) builder.addCallback(FixtureCallback(fixture)) val instance = builder.build() INSTANCE = instance diff --git a/database/src/main/java/org/fdroid/database/UpdateChecker.kt b/database/src/main/java/org/fdroid/database/UpdateChecker.kt index 1dbd067ba..b7eee6de5 100644 --- a/database/src/main/java/org/fdroid/database/UpdateChecker.kt +++ b/database/src/main/java/org/fdroid/database/UpdateChecker.kt @@ -15,6 +15,7 @@ public class UpdateChecker( 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) /** @@ -34,8 +35,9 @@ public class UpdateChecker( list.add(version) } installedPackages.iterator().forEach { packageInfo -> - val versions = versionsByPackage[packageInfo.packageName] ?: return@forEach // continue - val version = getVersion(versions, packageInfo, releaseChannels) + val packageName = packageInfo.packageName + val versions = versionsByPackage[packageName] ?: return@forEach // continue + val version = getVersion(versions, packageName, packageInfo, releaseChannels) if (version != null) { val versionCode = packageInfo.getVersionCode() val app = getUpdatableApp(version, versionCode) @@ -61,7 +63,7 @@ public class UpdateChecker( } catch (e: PackageManager.NameNotFoundException) { null } - val version = getVersion(versions, packageInfo, releaseChannels) ?: return null + val version = getVersion(versions, packageName, packageInfo, releaseChannels) ?: return null val versionedStrings = versionDao.getVersionedStrings( repoId = version.repoId, packageId = version.packageId, @@ -72,6 +74,7 @@ public class UpdateChecker( private fun getVersion( versions: List, + packageName: String, packageInfo: PackageInfo?, releaseChannels: List?, ): Version? { @@ -84,6 +87,7 @@ public class UpdateChecker( IndexUtils.getPackageSignature(it.toByteArray()) }?.toSet() } + val appPrefs by lazy { appPrefsDao.getAppPrefsOrNull(packageName) } versions.iterator().forEach versions@{ version -> // if the installed version has a known vulnerability, we return it as well if (version.manifest.versionCode == versionCode && version.hasKnownVulnerability) { @@ -91,15 +95,20 @@ public class UpdateChecker( } // if version code is not higher than installed skip package as list is sorted if (version.manifest.versionCode <= versionCode) return null - // check release channels if they are not empty - if (!version.releaseChannels.isNullOrEmpty()) { - // if release channels are not empty (stable) don't consider this version - if (releaseChannels == null) return@versions - // don't consider version with non-matching release channel - if (releaseChannels.intersect(version.releaseChannels).isEmpty()) return@versions - } // skip incompatible versions - if (!compatibilityChecker.isCompatible(version.manifest)) return@versions + if (!compatibilityChecker.isCompatible(version.manifest.toManifestV2())) return@versions + // only check release channels if they are not empty + if (!version.releaseChannels.isNullOrEmpty()) { + // add release channels from AppPrefs into the ones we allow + val channels = releaseChannels?.toMutableSet() ?: LinkedHashSet() + if (!appPrefs?.releaseChannels.isNullOrEmpty()) { + channels.addAll(appPrefs!!.releaseChannels) + } + // if allowed releases channels are empty (only stable) don't consider this version + if (channels.isEmpty()) return@versions + // don't consider version with non-matching release channel + if (channels.intersect(version.releaseChannels).isEmpty()) return@versions + } val canInstall = if (packageInfo == null) { true // take first one with highest version code and repo weight } else { diff --git a/database/src/main/java/org/fdroid/database/VersionDao.kt b/database/src/main/java/org/fdroid/database/VersionDao.kt index 9e15e335d..acfd7498e 100644 --- a/database/src/main/java/org/fdroid/database/VersionDao.kt +++ b/database/src/main/java/org/fdroid/database/VersionDao.kt @@ -91,11 +91,18 @@ internal interface VersionDaoInt : VersionDao { @Query("SELECT * FROM Version WHERE repoId = :repoId AND packageId = :packageId") fun getVersions(repoId: Long, packageId: String): List + /** + * 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) - WHERE pref.enabled = 1 AND packageId IN (:packageNames) - ORDER BY manifest_versionCode DESC, pref.weight DESC""") + JOIN RepositoryPreferences USING (repoId) + LEFT JOIN AppPrefs USING (packageId) + WHERE RepositoryPreferences.enabled = 1 AND + manifest_versionCode > COALESCE(AppPrefs.ignoreVersionCodeUpdate, 0) AND + packageId IN (:packageNames) + ORDER BY manifest_versionCode DESC, RepositoryPreferences.weight DESC""") fun getVersions(packageNames: List): List @RewriteQueriesToDropUnusedColumns From 1beb6d5bb285169196e4321b3dd22fe191b96c1f Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 28 Apr 2022 08:47:01 -0300 Subject: [PATCH 19/42] [db] explicitly clear repo data before updating with full index In JSON, keys can come in any order, so we need to handle the case that we receive packages before the repo metadata. Now we explicitly clear data and rename the insert method to insertOrReplace in order to make it clear that data gets replaced. Also, we pass the repoId into the constructor of the DB stream receivers to make clear that one receiver is meant to receive a single pre-existing repo. --- .../java/org/fdroid/database/AppTest.kt | 10 +-- .../org/fdroid/database/IndexV1InsertTest.kt | 68 ++++++++++++++++--- .../org/fdroid/database/IndexV2InsertTest.kt | 23 ++++--- .../org/fdroid/database/RepositoryDiffTest.kt | 6 +- .../org/fdroid/database/RepositoryTest.kt | 18 +++-- .../org/fdroid/database/UpdateCheckerTest.kt | 8 +-- .../java/org/fdroid/database/VersionTest.kt | 4 +- .../org/fdroid/database/DbStreamReceiver.kt | 33 --------- .../org/fdroid/database/DbV1StreamReceiver.kt | 18 +++-- .../org/fdroid/database/DbV2StreamReceiver.kt | 52 ++++++++++++++ .../java/org/fdroid/database/RepositoryDao.kt | 34 +++++++--- .../org/fdroid/index/v1/IndexV1Updater.kt | 4 +- 12 files changed, 184 insertions(+), 94 deletions(-) delete mode 100644 database/src/main/java/org/fdroid/database/DbStreamReceiver.kt create mode 100644 database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt diff --git a/database/src/androidTest/java/org/fdroid/database/AppTest.kt b/database/src/androidTest/java/org/fdroid/database/AppTest.kt index 295fc1672..b2b4e69f7 100644 --- a/database/src/androidTest/java/org/fdroid/database/AppTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/AppTest.kt @@ -28,7 +28,7 @@ internal class AppTest : DbTest() { @Test fun insertGetDeleteSingleApp() { - val repoId = repoDao.insert(getRandomRepo()) + val repoId = repoDao.insertOrReplace(getRandomRepo()) val metadataV2 = getRandomMetadataV2() appDao.insert(repoId, packageId, metadataV2) @@ -53,7 +53,7 @@ internal class AppTest : DbTest() { @Test fun testAppOverViewItem() { - val repoId = repoDao.insert(getRandomRepo()) + val repoId = repoDao.insertOrReplace(getRandomRepo()) val packageId1 = getRandomString() val packageId2 = getRandomString() val packageId3 = getRandomString() @@ -90,7 +90,7 @@ internal class AppTest : DbTest() { assertNull(apps3.find { it.packageId == packageId3 }) // app4 is the same as app1 and thus will not be shown again - val repoId2 = repoDao.insert(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) val app4 = getRandomMetadataV2().copy(name = name2, icon = icons2) appDao.insert(repoId2, packageId1, app4) val apps4 = appDao.getAppOverviewItems().getOrAwaitValue() ?: fail() @@ -99,8 +99,8 @@ internal class AppTest : DbTest() { @Test fun testAppByRepoWeight() { - val repoId1 = repoDao.insert(getRandomRepo()) - val repoId2 = repoDao.insert(getRandomRepo()) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) val metadata1 = getRandomMetadataV2() val metadata2 = metadata1.copy(lastUpdated = metadata1.lastUpdated + 1) diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt index 58259a64b..48b9b76cd 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt @@ -6,8 +6,16 @@ import androidx.test.core.app.ApplicationProvider.getApplicationContext import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.serialization.SerializationException import org.apache.commons.io.input.CountingInputStream +import org.fdroid.CompatibilityChecker import org.fdroid.index.v1.IndexV1StreamProcessor -import org.fdroid.index.v2.IndexStreamProcessor +import org.fdroid.index.v1.IndexV1StreamReceiver +import org.fdroid.index.v2.AntiFeatureV2 +import org.fdroid.index.v2.CategoryV2 +import org.fdroid.index.v2.IndexV2StreamProcessor +import org.fdroid.index.v2.MetadataV2 +import org.fdroid.index.v2.PackageVersionV2 +import org.fdroid.index.v2.ReleaseChannelV2 +import org.fdroid.index.v2.RepoV2 import org.junit.Test import org.junit.runner.RunWith import kotlin.math.roundToInt @@ -25,7 +33,8 @@ internal class IndexV1InsertTest : DbTest() { val fileSize = c.resources.assets.openFd("index-v1.json").use { it.length } val inputStream = CountingInputStream(c.resources.assets.open("index-v1.json")) var currentByteCount: Long = 0 - val indexProcessor = IndexV1StreamProcessor(DbV1StreamReceiver(db) { true }, null) { + val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") + val streamReceiver = TestStreamReceiver(repoId) { val bytesRead = inputStream.byteCount val bytesSinceLastCall = bytesRead - currentByteCount if (bytesSinceLastCall > 0) { @@ -36,13 +45,12 @@ internal class IndexV1InsertTest : DbTest() { // the stream gets read in big chunks, but ensure they are not too big, e.g. entire file assertTrue(bytesSinceLastCall < 600_000, "$bytesSinceLastCall") currentByteCount = bytesRead - bytesRead } + val indexProcessor = IndexV1StreamProcessor(streamReceiver, null) db.runInTransaction { - val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") inputStream.use { indexStream -> - indexProcessor.process(repoId, indexStream) + indexProcessor.process(indexStream) } } assertTrue(repoDao.getRepositories().size == 1) @@ -112,11 +120,11 @@ internal class IndexV1InsertTest : DbTest() { private fun insertV2ForComparison(version: Int) { val c = getApplicationContext() val inputStream = CountingInputStream(c.resources.assets.open("index-v2.json")) - val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db) { true }, null) + val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") + val indexProcessor = IndexV2StreamProcessor(DbV2StreamReceiver(db, { true }, repoId), null) db.runInTransaction { - val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") inputStream.use { indexStream -> - indexProcessor.process(repoId, version, indexStream) + indexProcessor.process(version, indexStream) } } } @@ -125,15 +133,17 @@ internal class IndexV1InsertTest : DbTest() { fun testExceptionWhileStreamingDoesNotSaveIntoDb() { val c = getApplicationContext() val cIn = CountingInputStream(c.resources.assets.open("index-v1.json")) - val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db) { true }, null) { + val compatibilityChecker = CompatibilityChecker { if (cIn.byteCount > 824096) throw SerializationException() - cIn.byteCount + true } + val indexProcessor = + IndexV2StreamProcessor(DbV2StreamReceiver(db, compatibilityChecker, 1), null) assertFailsWith { db.runInTransaction { cIn.use { indexStream -> - indexProcessor.process(1, 42, indexStream) + indexProcessor.process(42, indexStream) } } } @@ -145,4 +155,40 @@ internal class IndexV1InsertTest : DbTest() { assertTrue(versionDao.countVersionedStrings() == 0) } + inner class TestStreamReceiver( + repoId: Long, + private val callback: () -> Unit, + ) : IndexV1StreamReceiver { + private val streamReceiver = DbV1StreamReceiver(db, { true }, repoId) + override fun receive(repo: RepoV2, version: Int, certificate: String?) { + streamReceiver.receive(repo, version, certificate) + callback() + } + + override fun receive(packageId: String, m: MetadataV2) { + streamReceiver.receive(packageId, m) + callback() + } + + override fun receive(packageId: String, v: Map) { + streamReceiver.receive(packageId, v) + callback() + } + + override fun updateRepo( + antiFeatures: Map, + categories: Map, + releaseChannels: Map, + ) { + streamReceiver.updateRepo(antiFeatures, categories, releaseChannels) + callback() + } + + override fun updateAppMetadata(packageId: String, preferredSigner: String?) { + streamReceiver.updateAppMetadata(packageId, preferredSigner) + callback() + } + + } + } diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt index 141a6cc45..3446dee29 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt @@ -6,7 +6,8 @@ import androidx.test.core.app.ApplicationProvider.getApplicationContext import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.serialization.SerializationException import org.apache.commons.io.input.CountingInputStream -import org.fdroid.index.v2.IndexStreamProcessor +import org.fdroid.CompatibilityChecker +import org.fdroid.index.v2.IndexV2StreamProcessor import org.junit.Test import org.junit.runner.RunWith import kotlin.math.roundToInt @@ -22,7 +23,7 @@ internal class IndexV2InsertTest : DbTest() { val fileSize = c.resources.assets.openFd("index-v2.json").use { it.length } val inputStream = CountingInputStream(c.resources.assets.open("index-v2.json")) var currentByteCount: Long = 0 - val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db) { true }, null) { + val compatibilityChecker = CompatibilityChecker { val bytesRead = inputStream.byteCount val bytesSinceLastCall = bytesRead - currentByteCount if (bytesSinceLastCall > 0) { @@ -33,13 +34,15 @@ internal class IndexV2InsertTest : DbTest() { // the stream gets read in big chunks, but ensure they are not too big, e.g. entire file assertTrue(bytesSinceLastCall < 400_000, "$bytesSinceLastCall") currentByteCount = bytesRead - bytesRead + true } + val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") + val streamReceiver = DbV2StreamReceiver(db, compatibilityChecker, repoId) + val indexProcessor = IndexV2StreamProcessor(streamReceiver, null) db.runInTransaction { - val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") inputStream.use { indexStream -> - indexProcessor.process(repoId, 42, indexStream) + indexProcessor.process(42, indexStream) } } assertTrue(repoDao.getRepositories().size == 1) @@ -60,15 +63,17 @@ internal class IndexV2InsertTest : DbTest() { fun testExceptionWhileStreamingDoesNotSaveIntoDb() { val c = getApplicationContext() val cIn = CountingInputStream(c.resources.assets.open("index-v2.json")) - val indexProcessor = IndexStreamProcessor(DbStreamReceiver(db) { true }, null) { + val compatibilityChecker = CompatibilityChecker { if (cIn.byteCount > 824096) throw SerializationException() - cIn.byteCount + true } - assertFailsWith { db.runInTransaction { + val repoId = db.getRepositoryDao().insertEmptyRepo("http://example.org") + val streamReceiver = DbV2StreamReceiver(db, compatibilityChecker, repoId) + val indexProcessor = IndexV2StreamProcessor(streamReceiver, null) cIn.use { indexStream -> - indexProcessor.process(1, 42, indexStream) + indexProcessor.process(42, indexStream) } } } diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt index 4fa401a6d..962e86b4d 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt @@ -48,10 +48,10 @@ internal class RepositoryDiffTest : DbTest() { fun timestampDiffTwoReposInDb() { // insert repo val repo = getRandomRepo() - repoDao.insert(repo) + repoDao.insertOrReplace(repo) // insert another repo before updating - repoDao.insert(getRandomRepo()) + repoDao.insertOrReplace(getRandomRepo()) // check that the repo got added and retrieved as expected var repos = repoDao.getRepositories().sortedBy { it.repoId } @@ -244,7 +244,7 @@ internal class RepositoryDiffTest : DbTest() { private fun testDiff(repo: RepoV2, json: String, repoChecker: (List) -> Unit) { // insert repo - repoDao.insert(repo) + repoDao.insertOrReplace(repo) // check that the repo got added and retrieved as expected var repos = repoDao.getRepositories() diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt index be4ed360d..dd9e189bb 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt @@ -20,7 +20,7 @@ internal class RepositoryTest : DbTest() { fun insertAndDeleteTwoRepos() { // insert first repo val repo1 = getRandomRepo() - val repoId1 = repoDao.insert(repo1) + val repoId1 = repoDao.insertOrReplace(repo1) // check that first repo got added and retrieved as expected var repos = repoDao.getRepositories() @@ -31,7 +31,7 @@ internal class RepositoryTest : DbTest() { // insert second repo val repo2 = getRandomRepo() - val repoId2 = repoDao.insert(repo2) + val repoId2 = repoDao.insertOrReplace(repo2) // check that both repos got added and retrieved as expected repos = repoDao.getRepositories().sortedBy { it.repoId } @@ -58,8 +58,8 @@ internal class RepositoryTest : DbTest() { } @Test - fun replacingRepoRemovesAllAssociatedData() { - val repoId = repoDao.insert(getRandomRepo()) + fun clearingRepoRemovesAllAssociatedData() { + val repoId = repoDao.insertOrReplace(getRandomRepo()) val repositoryPreferences = repoDao.getRepositoryPreferences(repoId) val packageId = getRandomString() val versionId = getRandomString() @@ -72,22 +72,20 @@ internal class RepositoryTest : DbTest() { assertEquals(1, versionDao.getAppVersions(repoId, packageId).size) assertTrue(versionDao.getVersionedStrings(repoId, packageId).isNotEmpty()) - val cert = getRandomString() - repoDao.replace(repoId, getRandomRepo(), 42, cert) + repoDao.clear(repoId) assertEquals(1, repoDao.getRepositories().size) assertEquals(0, appDao.getAppMetadata().size) assertEquals(0, appDao.getLocalizedFiles().size) assertEquals(0, appDao.getLocalizedFileLists().size) assertEquals(0, versionDao.getAppVersions(repoId, packageId).size) assertEquals(0, versionDao.getVersionedStrings(repoId, packageId).size) - - assertEquals(cert, repoDao.getRepository(repoId)?.certificate) + // preferences are not touched by clearing assertEquals(repositoryPreferences, repoDao.getRepositoryPreferences(repoId)) } @Test - fun certGetsUpdates() { - val repoId = repoDao.insert(getRandomRepo()) + fun certGetsUpdated() { + val repoId = repoDao.insertOrReplace(getRandomRepo()) assertEquals(1, repoDao.getRepositories().size) assertEquals(null, repoDao.getRepositories()[0].certificate) diff --git a/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt b/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt index 07d84215c..9cd38ae1d 100644 --- a/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt @@ -25,16 +25,16 @@ internal class UpdateCheckerTest : DbTest() { updateChecker = UpdateChecker(db, context.packageManager) } - @OptIn(ExperimentalTime::class) @Test + @OptIn(ExperimentalTime::class) fun testGetUpdates() { val inputStream = CountingInputStream(context.resources.assets.open("index-v1.json")) - val indexProcessor = IndexV1StreamProcessor(DbV1StreamReceiver(db) { true }, null) + val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") + val indexProcessor = IndexV1StreamProcessor(DbV1StreamReceiver(db, { true }, repoId), null) db.runInTransaction { - val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") inputStream.use { indexStream -> - indexProcessor.process(repoId, indexStream) + indexProcessor.process(indexStream) } } diff --git a/database/src/androidTest/java/org/fdroid/database/VersionTest.kt b/database/src/androidTest/java/org/fdroid/database/VersionTest.kt index 5db57b37e..8468685b5 100644 --- a/database/src/androidTest/java/org/fdroid/database/VersionTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/VersionTest.kt @@ -25,7 +25,7 @@ internal class VersionTest : DbTest() { @Test fun insertGetDeleteSingleVersion() { - val repoId = repoDao.insert(getRandomRepo()) + val repoId = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId, packageId, getRandomMetadataV2()) val packageVersion = getRandomPackageVersionV2() val isCompatible = Random.nextBoolean() @@ -57,7 +57,7 @@ internal class VersionTest : DbTest() { @Test fun insertGetDeleteTwoVersions() { // insert two versions along with required objects - val repoId = repoDao.insert(getRandomRepo()) + val repoId = repoDao.insertOrReplace(getRandomRepo()) appDao.insert(repoId, packageId, getRandomMetadataV2()) val packageVersion1 = getRandomPackageVersionV2() val packageVersion2 = getRandomPackageVersionV2() diff --git a/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt deleted file mode 100644 index b5304505e..000000000 --- a/database/src/main/java/org/fdroid/database/DbStreamReceiver.kt +++ /dev/null @@ -1,33 +0,0 @@ -package org.fdroid.database - -import android.content.res.Resources -import androidx.core.os.ConfigurationCompat.getLocales -import androidx.core.os.LocaleListCompat -import org.fdroid.CompatibilityChecker -import org.fdroid.index.v2.IndexStreamReceiver -import org.fdroid.index.v2.PackageV2 -import org.fdroid.index.v2.RepoV2 - -internal class DbStreamReceiver( - private val db: FDroidDatabaseInt, - private val compatibilityChecker: CompatibilityChecker, -) : IndexStreamReceiver { - - private val locales: LocaleListCompat = getLocales(Resources.getSystem().configuration) - - override fun receive(repoId: Long, repo: RepoV2, version: Int, certificate: String?) { - db.getRepositoryDao().replace(repoId, repo, version, certificate) - } - - override fun receive(repoId: Long, packageId: String, p: PackageV2) { - db.getAppDao().insert(repoId, packageId, p.metadata, locales) - db.getVersionDao().insert(repoId, packageId, p.versions) { - compatibilityChecker.isCompatible(it.manifest) - } - } - - override fun onStreamEnded(repoId: Long) { - db.afterUpdatingRepo(repoId) - } - -} diff --git a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt index 84cd5231a..5a0cbe8cd 100644 --- a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt @@ -12,29 +12,35 @@ 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 compatibilityChecker: CompatibilityChecker, + private val repoId: Long, ) : IndexV1StreamReceiver { private val locales: LocaleListCompat = getLocales(Resources.getSystem().configuration) - override fun receive(repoId: Long, repo: RepoV2, version: Int, certificate: String?) { - db.getRepositoryDao().replace(repoId, repo, version, certificate) + override fun receive(repo: RepoV2, version: Int, certificate: String?) { + db.getRepositoryDao().clear(repoId) + db.getRepositoryDao().update(repoId, repo, version, certificate) } - override fun receive(repoId: Long, packageId: String, m: MetadataV2) { + override fun receive(packageId: String, m: MetadataV2) { db.getAppDao().insert(repoId, packageId, m, locales) } - override fun receive(repoId: Long, packageId: String, v: Map) { + override fun receive(packageId: String, v: Map) { db.getVersionDao().insert(repoId, packageId, v) { compatibilityChecker.isCompatible(it.manifest) } } override fun updateRepo( - repoId: Long, antiFeatures: Map, categories: Map, releaseChannels: Map, @@ -47,7 +53,7 @@ internal class DbV1StreamReceiver( db.afterUpdatingRepo(repoId) } - override fun updateAppMetadata(repoId: Long, packageId: String, preferredSigner: String?) { + override fun updateAppMetadata(packageId: String, preferredSigner: String?) { db.getAppDao().updatePreferredSigner(repoId, packageId, preferredSigner) } diff --git a/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt new file mode 100644 index 000000000..c06d612a9 --- /dev/null +++ b/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt @@ -0,0 +1,52 @@ +package org.fdroid.database + +import android.content.res.Resources +import androidx.core.os.ConfigurationCompat.getLocales +import androidx.core.os.LocaleListCompat +import org.fdroid.CompatibilityChecker +import org.fdroid.index.v2.IndexV2StreamReceiver +import org.fdroid.index.v2.PackageV2 +import org.fdroid.index.v2.RepoV2 + +internal class DbV2StreamReceiver( + private val db: FDroidDatabaseInt, + private val compatibilityChecker: CompatibilityChecker, + private val repoId: Long, +) : IndexV2StreamReceiver { + + private val locales: LocaleListCompat = getLocales(Resources.getSystem().configuration) + private var clearedRepoData = false + + @Synchronized + override fun receive(repo: RepoV2, version: Int, certificate: String?) { + clearRepoDataIfNeeded() + db.getRepositoryDao().update(repoId, repo, version, certificate) + } + + @Synchronized + override fun receive(packageId: String, p: PackageV2) { + clearRepoDataIfNeeded() + db.getAppDao().insert(repoId, packageId, p.metadata, locales) + db.getVersionDao().insert(repoId, packageId, 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 + } + } + +} diff --git a/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/database/src/main/java/org/fdroid/database/RepositoryDao.kt index 8dc64af5e..ccb8815c9 100644 --- a/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -25,8 +25,15 @@ public interface RepositoryDao { /** * Use when replacing an existing repo with a full index. * This removes all existing index data associated with this repo from the database. + * @throws IllegalStateException if no repo with the given [repoId] exists. */ - public fun replace(repoId: Long, repository: RepoV2, version: Int, certificate: String?) + public fun clear(repoId: Long) + + /** + * Updates an existing repo with new data from a full index update. + * Call [clear] first to ensure old data was removed. + */ + public fun update(repoId: Long, repository: RepoV2, version: Int, certificate: String?) public fun getRepository(repoId: Long): Repository? public fun insertEmptyRepo( @@ -50,7 +57,10 @@ public interface RepositoryDao { internal interface RepositoryDaoInt : RepositoryDao { @Insert(onConflict = REPLACE) - fun insert(repository: CoreRepository): Long + fun insertOrReplace(repository: CoreRepository): Long + + @Update + fun update(repository: CoreRepository) @Insert(onConflict = REPLACE) fun insertMirrors(mirrors: List) @@ -78,7 +88,7 @@ internal interface RepositoryDaoInt : RepositoryDao { description = mapOf("en-US" to initialRepo.description), certificate = initialRepo.certificate, ) - val repoId = insert(repo) + val repoId = insertOrReplace(repo) val repositoryPreferences = RepositoryPreferences( repoId = repoId, weight = initialRepo.weight, @@ -102,7 +112,7 @@ internal interface RepositoryDaoInt : RepositoryDao { version = null, certificate = null, ) - val repoId = insert(repo) + val repoId = insertOrReplace(repo) val currentMaxWeight = getMaxRepositoryWeight() val repositoryPreferences = RepositoryPreferences( repoId = repoId, @@ -117,8 +127,8 @@ internal interface RepositoryDaoInt : RepositoryDao { @Transaction @VisibleForTesting - fun insert(repository: RepoV2): Long { - val repoId = insert(repository.toCoreRepository(version = 0)) + fun insertOrReplace(repository: RepoV2): Long { + val repoId = insertOrReplace(repository.toCoreRepository(version = 0)) insertRepositoryPreferences(repoId) insertRepoTables(repoId, repository) return repoId @@ -131,9 +141,15 @@ internal interface RepositoryDaoInt : RepositoryDao { } @Transaction - override fun replace(repoId: Long, repository: RepoV2, version: Int, certificate: String?) { - val newRepoId = insert(repository.toCoreRepository(repoId, version, certificate)) - require(newRepoId == repoId) { "New repoId $newRepoId did not match old $repoId" } + override 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 update(repoId: Long, repository: RepoV2, version: Int, certificate: String?) { + update(repository.toCoreRepository(repoId, version, certificate)) insertRepoTables(repoId, repository) } diff --git a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt index f7e2ccafe..e8e947a09 100644 --- a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt +++ b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt @@ -65,9 +65,9 @@ public class IndexV1Updater( db.runInTransaction { val cert = verifier.getStreamAndVerify { inputStream -> updateListener?.onStartProcessing() // TODO maybe do more fine-grained reporting - val streamReceiver = DbV1StreamReceiver(db, compatibilityChecker) + val streamReceiver = DbV1StreamReceiver(db, compatibilityChecker, repoId) val streamProcessor = IndexV1StreamProcessor(streamReceiver, certificate) - streamProcessor.process(repoId, inputStream) + streamProcessor.process(inputStream) } // update certificate, if we didn't have any before if (certificate == null) { From 9e03f1290d6e705aac79dcecb47e88a61ae2f40a Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 29 Apr 2022 16:43:06 -0300 Subject: [PATCH 20/42] [db] allow clearing all repos used for panic responder in F-Droid --- .../org/fdroid/database/RepositoryTest.kt | 12 ++++++++++ .../java/org/fdroid/database/RepositoryDao.kt | 23 ++++++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt index dd9e189bb..73a580248 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt @@ -57,6 +57,18 @@ internal class RepositoryTest : DbTest() { assertNull(repoDao.getRepositoryPreferences(repoId2)) } + @Test + fun insertTwoReposAndClearAll() { + val repo1 = getRandomRepo() + val repo2 = getRandomRepo() + repoDao.insertOrReplace(repo1) + repoDao.insertOrReplace(repo2) + assertEquals(2, repoDao.getRepositories().size) + + repoDao.clearAll() + assertEquals(0, repoDao.getRepositories().size) + } + @Test fun clearingRepoRemovesAllAssociatedData() { val repoId = repoDao.insertOrReplace(getRandomRepo()) diff --git a/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/database/src/main/java/org/fdroid/database/RepositoryDao.kt index ccb8815c9..e65b141a8 100644 --- a/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -20,15 +20,24 @@ import org.fdroid.index.v2.ReflectionDiffer.applyDiff import org.fdroid.index.v2.RepoV2 public interface RepositoryDao { + /** + * Inserts a new [InitialRepository] from a fixture. + */ public fun insert(initialRepo: InitialRepository) /** * Use when replacing an existing repo with a full index. - * This removes all existing index data associated with this repo from the database. + * 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. */ public fun clear(repoId: Long) + /** + * Removes all repos and their preferences. + */ + public fun clearAll() + /** * Updates an existing repo with new data from a full index update. * Call [clear] first to ensure old data was removed. @@ -147,6 +156,12 @@ internal interface RepositoryDaoInt : RepositoryDao { insertOrReplace(repo.repository) } + @Transaction + override fun clearAll() { + deleteAllCoreRepositories() + deleteAllRepositoryPreferences() + } + @Transaction override fun update(repoId: Long, repository: RepoV2, version: Int, certificate: String?) { update(repository.toCoreRepository(repoId, version, certificate)) @@ -356,7 +371,13 @@ internal interface RepositoryDaoInt : RepositoryDao { @Query("DELETE FROM CoreRepository WHERE repoId = :repoId") fun deleteCoreRepository(repoId: Long) + @Query("DELETE FROM CoreRepository") + fun deleteAllCoreRepositories() + @Query("DELETE FROM RepositoryPreferences WHERE repoId = :repoId") fun deleteRepositoryPreferences(repoId: Long) + @Query("DELETE FROM RepositoryPreferences") + fun deleteAllRepositoryPreferences() + } From 94aa1d98241ba6fead954d07315b62886d0a8fd8 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 29 Apr 2022 17:23:14 -0300 Subject: [PATCH 21/42] [db] Add webBaseUrl to Repository and test another auto-migration with it --- .../src/main/java/org/fdroid/database/FDroidDatabase.kt | 8 ++++++-- database/src/main/java/org/fdroid/database/Repository.kt | 3 +++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index c2711c818..c9c1349bf 100644 --- a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -16,7 +16,7 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @Database( - version = 2, // TODO set version to 1 before release and wipe old schemas + version = 3, // TODO set version to 1 before release and wipe old schemas entities = [ // repo CoreRepository::class, @@ -38,8 +38,12 @@ import kotlinx.coroutines.launch views = [ LocalizedIcon::class, ], + exportSchema = true, autoMigrations = [ - AutoMigration(from = 1, to = 2) + // TODO remove auto-migrations + AutoMigration(from = 1, to = 2), + AutoMigration(from = 1, to = 3), + AutoMigration(from = 2, to = 3), ], ) @TypeConverters(Converters::class) diff --git a/database/src/main/java/org/fdroid/database/Repository.kt b/database/src/main/java/org/fdroid/database/Repository.kt index ab246aacb..7d4146dcc 100644 --- a/database/src/main/java/org/fdroid/database/Repository.kt +++ b/database/src/main/java/org/fdroid/database/Repository.kt @@ -22,6 +22,7 @@ public data class CoreRepository( val name: String, @Embedded(prefix = "icon_") val icon: FileV2?, val address: String, + val webBaseUrl: String? = null, val timestamp: Long, val version: Int?, val description: LocalizedTextV2 = emptyMap(), @@ -37,6 +38,7 @@ internal fun RepoV2.toCoreRepository( name = name, icon = icon, address = address, + webBaseUrl = webBaseUrl, timestamp = timestamp, version = version, description = description, @@ -75,6 +77,7 @@ public data class Repository( val name: String get() = repository.name val icon: FileV2? get() = repository.icon val address: String get() = repository.address + val webBaseUrl: String? get() = repository.webBaseUrl val timestamp: Long get() = repository.timestamp val version: Int get() = repository.version ?: 0 val description: LocalizedTextV2 get() = repository.description From b6fb74a1fe16d61f47cbdf567954cdb2fd5c8319 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 3 May 2022 15:12:04 -0300 Subject: [PATCH 22/42] [db] remove grouping of author and donation fields at the explicit request of Hans who feels strongly about it. --- .../java/org/fdroid/database/AppTest.kt | 2 -- .../src/main/java/org/fdroid/database/App.kt | 29 ++++++++++++++----- .../org/fdroid/database/FDroidDatabase.kt | 4 +-- .../main/java/org/fdroid/database/Version.kt | 4 +-- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/database/src/androidTest/java/org/fdroid/database/AppTest.kt b/database/src/androidTest/java/org/fdroid/database/AppTest.kt index b2b4e69f7..0c26910cb 100644 --- a/database/src/androidTest/java/org/fdroid/database/AppTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/AppTest.kt @@ -34,8 +34,6 @@ internal class AppTest : DbTest() { val app = appDao.getApp(repoId, packageId) ?: fail() val metadata = metadataV2.toAppMetadata(repoId, packageId) - assertEquals(metadata.author, app.metadata.author) - assertEquals(metadata.donation, app.metadata.donation) assertEquals(metadata, app.metadata) assertEquals(metadataV2.icon, app.icon) assertEquals(metadataV2.featureGraphic, app.featureGraphic) diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt index 3f2d6739c..f16d8bc30 100644 --- a/database/src/main/java/org/fdroid/database/App.kt +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -5,14 +5,11 @@ import androidx.core.os.ConfigurationCompat.getLocales import androidx.core.os.LocaleListCompat import androidx.room.ColumnInfo import androidx.room.DatabaseView -import androidx.room.Embedded import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Ignore import androidx.room.Relation import org.fdroid.database.Converters.fromStringToMapOfLocalizedTextV2 -import org.fdroid.index.v2.Author -import org.fdroid.index.v2.Donation import org.fdroid.index.v2.FileV2 import org.fdroid.index.v2.LocalizedFileListV2 import org.fdroid.index.v2.LocalizedFileV2 @@ -47,8 +44,17 @@ public data class AppMetadata( val translation: String? = null, val preferredSigner: String? = null, // TODO use platformSig if an APK matches it val video: LocalizedTextV2? = null, - @Embedded(prefix = "author_") val author: Author? = Author(), - @Embedded(prefix = "donation_") val donation: Donation? = Donation(), + val authorName: String? = null, + val authorEmail: String? = null, + val authorWebSite: String? = null, + val authorPhone: String? = null, + val donate: List? = null, + val liberapayID: String? = null, + val liberapay: String? = null, + val openCollective: String? = null, + val bitcoin: String? = null, + val litecoin: String? = null, + val flattrID: String? = null, val categories: List? = null, /** * Whether the app is compatible with the current device. @@ -82,8 +88,17 @@ internal fun MetadataV2.toAppMetadata( translation = translation, preferredSigner = preferredSigner, video = video, - author = if (author?.isNull == true) null else author, - donation = if (donation?.isNull == true) null else donation, + 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, ) diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index c9c1349bf..ef51f92b8 100644 --- a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -16,7 +16,7 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @Database( - version = 3, // TODO set version to 1 before release and wipe old schemas + version = 4, // TODO set version to 1 before release and wipe old schemas entities = [ // repo CoreRepository::class, @@ -109,7 +109,7 @@ public object FDroidDatabaseHolder { context.applicationContext, FDroidDatabaseInt::class.java, name, - ) + ).fallbackToDestructiveMigration() // TODO remove before release if (fixture != null) builder.addCallback(FixtureCallback(fixture)) val instance = builder.build() INSTANCE = instance diff --git a/database/src/main/java/org/fdroid/database/Version.kt b/database/src/main/java/org/fdroid/database/Version.kt index bf39de18b..45ca10d9a 100644 --- a/database/src/main/java/org/fdroid/database/Version.kt +++ b/database/src/main/java/org/fdroid/database/Version.kt @@ -108,7 +108,7 @@ public data class AppManifest( usesSdk = usesSdk, maxSdkVersion = maxSdkVersion, signer = signer, - nativeCode = nativecode ?: emptyList(), + nativecode = nativecode ?: emptyList(), features = features?.map { FeatureV2(it) } ?: emptyList(), ) } @@ -119,7 +119,7 @@ internal fun ManifestV2.toManifest() = AppManifest( usesSdk = usesSdk, maxSdkVersion = maxSdkVersion, signer = signer, - nativecode = nativeCode, + nativecode = nativecode, features = features.map { it.name }, ) From 28df05c2c16dc2f6902161ac9dd1e25224c0f5a5 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 4 May 2022 10:47:33 -0300 Subject: [PATCH 23/42] [db] use prefix and postfix when serializing lists of strings into the DB This makes for safer LIKE queries --- .../main/java/org/fdroid/database/AppDao.kt | 8 ++--- .../java/org/fdroid/database/Converters.kt | 11 +++++-- .../org/fdroid/database/ConvertersTest.kt | 31 +++++++++++++++++++ 3 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 database/src/test/java/org/fdroid/database/ConvertersTest.kt diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index 363fac2bd..d2632bef8 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -227,7 +227,7 @@ internal interface AppDaoInt : AppDao { JOIN RepositoryPreferences AS pref USING (repoId) JOIN Version AS version USING (repoId, packageId) JOIN LocalizedIcon AS icon USING (repoId, packageId) - WHERE pref.enabled = 1 AND categories LIKE '%' || :category || '%' + WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) ORDER BY localizedName IS NULL ASC, localizedSummary IS NULL ASC, app.lastUpdated DESC, app.added ASC LIMIT :limit""") @@ -302,7 +302,7 @@ internal interface AppDaoInt : AppDao { FROM AppMetadata AS app JOIN Version AS version USING (repoId, packageId) JOIN RepositoryPreferences AS pref USING (repoId) - WHERE pref.enabled = 1 AND categories LIKE '%' || :category || '%' + WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) ORDER BY app.lastUpdated DESC""") fun getAppListItemsByLastUpdated(category: String): LiveData> @@ -315,7 +315,7 @@ internal interface AppDaoInt : AppDao { FROM AppMetadata AS app JOIN Version AS version USING (repoId, packageId) JOIN RepositoryPreferences AS pref USING (repoId) - WHERE pref.enabled = 1 AND categories LIKE '%' || :category || '%' + WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) ORDER BY localizedName COLLATE NOCASE ASC""") fun getAppListItemsByName(category: String): LiveData> @@ -341,7 +341,7 @@ internal interface AppDaoInt : AppDao { @Query("""SELECT COUNT(DISTINCT packageId) FROM AppMetadata JOIN RepositoryPreferences AS pref USING (repoId) - WHERE pref.enabled = 1 AND categories LIKE '%' || :category || '%'""") + WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%'""") override fun getNumberOfAppsInCategory(category: String): Int /** diff --git a/database/src/main/java/org/fdroid/database/Converters.kt b/database/src/main/java/org/fdroid/database/Converters.kt index 80a06b62d..7a8800e1d 100644 --- a/database/src/main/java/org/fdroid/database/Converters.kt +++ b/database/src/main/java/org/fdroid/database/Converters.kt @@ -4,11 +4,14 @@ 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) @@ -34,12 +37,16 @@ internal object Converters { @TypeConverter fun fromStringToListString(value: String?): List { - return value?.split(',') ?: emptyList() + return value?.split(',')?.filter { it.isNotEmpty() } ?: emptyList() } @TypeConverter fun listStringToString(text: List?): String? { if (text.isNullOrEmpty()) return null - return text.joinToString(",") { it.replace(',', '_') } + return text.joinToString( + prefix = ",", + separator = ",", + postfix = ",", + ) { it.replace(',', '_') } } } diff --git a/database/src/test/java/org/fdroid/database/ConvertersTest.kt b/database/src/test/java/org/fdroid/database/ConvertersTest.kt new file mode 100644 index 000000000..230ac7bd6 --- /dev/null +++ b/database/src/test/java/org/fdroid/database/ConvertersTest.kt @@ -0,0 +1,31 @@ +package org.fdroid.database + +import org.fdroid.test.TestUtils.getRandomList +import org.fdroid.test.TestUtils.getRandomString +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +internal class ConvertersTest { + + @Test + fun testListConversion() { + val list = getRandomList { getRandomString() } + + val str = Converters.listStringToString(list) + val convertedList = Converters.fromStringToListString(str) + assertEquals(list, convertedList) + } + + @Test + fun testEmptyListConversion() { + val list = emptyList() + + val str = Converters.listStringToString(list) + assertNull(str) + assertNull(Converters.listStringToString(null)) + val convertedList = Converters.fromStringToListString(str) + assertEquals(list, convertedList) + } + +} From aecff91ddaa0d993eb188da228859a5149d5dd92 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 4 May 2022 10:50:52 -0300 Subject: [PATCH 24/42] [db] make repo icon and name localizable One of those last minute requests for changing index structure... We don't bother with making a new table for localizable repo icons. They just get serialized into the DB. If we ever need to update the structure, we can consider wiping the icons. They can get updated with a full index update. --- .../java/org/fdroid/database/Converters.kt | 10 ++++++ .../org/fdroid/database/FDroidDatabase.kt | 2 +- .../java/org/fdroid/database/Repository.kt | 16 ++++++--- .../java/org/fdroid/database/RepositoryDao.kt | 6 ++-- .../org/fdroid/database/ReflectionTest.kt | 34 ------------------- 5 files changed, 26 insertions(+), 42 deletions(-) delete mode 100644 database/src/test/java/org/fdroid/database/ReflectionTest.kt diff --git a/database/src/main/java/org/fdroid/database/Converters.kt b/database/src/main/java/org/fdroid/database/Converters.kt index 7a8800e1d..774e2e2e9 100644 --- a/database/src/main/java/org/fdroid/database/Converters.kt +++ b/database/src/main/java/org/fdroid/database/Converters.kt @@ -25,6 +25,16 @@ internal object Converters { 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? { return value?.let { json.decodeFromString(mapOfLocalizedTextV2Serializer, it) } diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index ef51f92b8..7ec3f7402 100644 --- a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -16,7 +16,7 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @Database( - version = 4, // TODO set version to 1 before release and wipe old schemas + version = 5, // TODO set version to 1 before release and wipe old schemas entities = [ // repo CoreRepository::class, diff --git a/database/src/main/java/org/fdroid/database/Repository.kt b/database/src/main/java/org/fdroid/database/Repository.kt index 7d4146dcc..a16b782a8 100644 --- a/database/src/main/java/org/fdroid/database/Repository.kt +++ b/database/src/main/java/org/fdroid/database/Repository.kt @@ -11,6 +11,7 @@ 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 @@ -19,12 +20,13 @@ import org.fdroid.index.v2.RepoV2 @Entity public data class CoreRepository( @PrimaryKey(autoGenerate = true) val repoId: Long = 0, - val name: String, - @Embedded(prefix = "icon_") val icon: FileV2?, + val name: LocalizedTextV2 = emptyMap(), + val icon: LocalizedFileV2?, val address: String, val webBaseUrl: String? = null, val timestamp: Long, val version: Int?, + val maxAge: Int?, val description: LocalizedTextV2 = emptyMap(), val certificate: String?, ) @@ -41,6 +43,7 @@ internal fun RepoV2.toCoreRepository( webBaseUrl = webBaseUrl, timestamp = timestamp, version = version, + maxAge = null, description = description, certificate = certificate, ) @@ -74,13 +77,13 @@ public data class Repository( internal val preferences: RepositoryPreferences, ) { val repoId: Long get() = repository.repoId - val name: String get() = repository.name - val icon: FileV2? get() = repository.icon + internal val name: LocalizedTextV2 get() = repository.name + internal val icon: LocalizedFileV2? get() = repository.icon val address: String get() = repository.address val webBaseUrl: String? get() = repository.webBaseUrl val timestamp: Long get() = repository.timestamp val version: Int get() = repository.version ?: 0 - val description: LocalizedTextV2 get() = repository.description + internal val description: LocalizedTextV2 get() = repository.description val certificate: String? get() = repository.certificate val weight: Int get() = preferences.weight @@ -120,8 +123,11 @@ public data class Repository( } else emptyList() } + public fun getName(localeList: LocaleListCompat): String? = name.getBestLocale(localeList) public fun getDescription(localeList: LocaleListCompat): String? = description.getBestLocale(localeList) + + public fun getIcon(localeList: LocaleListCompat): FileV2? = icon.getBestLocale(localeList) } @Entity( diff --git a/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/database/src/main/java/org/fdroid/database/RepositoryDao.kt index e65b141a8..047cf6645 100644 --- a/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -89,11 +89,12 @@ internal interface RepositoryDaoInt : RepositoryDao { @Transaction override fun insert(initialRepo: InitialRepository) { val repo = CoreRepository( - name = initialRepo.name, + name = mapOf("en-US" to initialRepo.name), address = initialRepo.address, icon = null, timestamp = -1, version = initialRepo.version, + maxAge = null, description = mapOf("en-US" to initialRepo.description), certificate = initialRepo.certificate, ) @@ -114,11 +115,12 @@ internal interface RepositoryDaoInt : RepositoryDao { password: String?, ): Long { val repo = CoreRepository( - name = address, + name = mapOf("en-US" to address), icon = null, address = address, timestamp = System.currentTimeMillis(), version = null, + maxAge = null, certificate = null, ) val repoId = insertOrReplace(repo) diff --git a/database/src/test/java/org/fdroid/database/ReflectionTest.kt b/database/src/test/java/org/fdroid/database/ReflectionTest.kt deleted file mode 100644 index 4aabee308..000000000 --- a/database/src/test/java/org/fdroid/database/ReflectionTest.kt +++ /dev/null @@ -1,34 +0,0 @@ -package org.fdroid.database - -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonObject -import org.fdroid.index.v2.ReflectionDiffer.applyDiff -import org.fdroid.test.TestRepoUtils.getRandomFileV2 -import org.fdroid.test.TestRepoUtils.getRandomRepo -import kotlin.random.Random -import kotlin.test.Test -import kotlin.test.assertEquals - -internal class ReflectionTest { - - @Test - fun testRepository() { - val repo = getRandomRepo().toCoreRepository(version = 42) - val icon = getRandomFileV2() - val description = if (Random.nextBoolean()) mapOf("de" to null, "en" to "foo") else null - val json = """ - { - "name": "test", - "timestamp": ${Long.MAX_VALUE}, - "icon": ${Json.encodeToString(icon)}, - "description": ${Json.encodeToString(description)} - } - """.trimIndent() - val diff = Json.parseToJsonElement(json).jsonObject - val diffed = applyDiff(repo, diff) - println(diffed) - assertEquals(Long.MAX_VALUE, diffed.timestamp) - } - -} From 93ff390f07cf27bbb45fa618674befad5c4eb3e2 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 5 May 2022 17:43:52 -0300 Subject: [PATCH 25/42] [db] Implement diffing via DbV2DiffStreamReceiver --- .../java/org/fdroid/database/DbTest.kt | 36 +- .../org/fdroid/database/IndexV1InsertTest.kt | 168 +++------ .../org/fdroid/database/IndexV2DiffTest.kt | 327 ++++++++++++++++++ .../org/fdroid/database/IndexV2InsertTest.kt | 89 ++--- .../org/fdroid/database/RepositoryDiffTest.kt | 41 --- .../org/fdroid/database/UpdateCheckerTest.kt | 20 +- .../org/fdroid/database/test/TestUtils.kt | 59 ++++ .../src/main/java/org/fdroid/database/App.kt | 27 +- .../main/java/org/fdroid/database/AppDao.kt | 144 +++++++- .../java/org/fdroid/database/DbDiffUtils.kt | 125 +++++++ .../org/fdroid/database/DbV1StreamReceiver.kt | 2 +- .../fdroid/database/DbV2DiffStreamReceiver.kt | 40 +++ .../org/fdroid/database/DbV2StreamReceiver.kt | 2 +- .../java/org/fdroid/database/RepositoryDao.kt | 79 ++--- .../java/org/fdroid/database/VersionDao.kt | 121 ++++++- .../org/fdroid/index/v1/IndexV1Updater.kt | 2 +- .../org/fdroid/database/ConvertersTest.kt | 10 + 17 files changed, 1000 insertions(+), 292 deletions(-) create mode 100644 database/src/androidTest/java/org/fdroid/database/IndexV2DiffTest.kt create mode 100644 database/src/main/java/org/fdroid/database/DbDiffUtils.kt create mode 100644 database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt diff --git a/database/src/androidTest/java/org/fdroid/database/DbTest.kt b/database/src/androidTest/java/org/fdroid/database/DbTest.kt index 66e1dc813..1cfe12ec5 100644 --- a/database/src/androidTest/java/org/fdroid/database/DbTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/DbTest.kt @@ -1,6 +1,7 @@ package org.fdroid.database import android.content.Context +import android.content.res.AssetManager import androidx.core.os.LocaleListCompat import androidx.room.Room import androidx.test.core.app.ApplicationProvider @@ -10,11 +11,19 @@ import io.mockk.mockkObject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.setMain +import org.fdroid.database.test.TestUtils.assertRepoEquals +import org.fdroid.database.test.TestUtils.toMetadataV2 +import org.fdroid.database.test.TestUtils.toPackageVersionV2 +import org.fdroid.index.v2.IndexV2 +import org.fdroid.test.TestUtils.sort +import org.fdroid.test.TestUtils.sorted import org.junit.After import org.junit.Before import org.junit.runner.RunWith import java.io.IOException import java.util.Locale +import kotlin.test.assertEquals +import kotlin.test.fail @RunWith(AndroidJUnit4::class) @OptIn(ExperimentalCoroutinesApi::class) @@ -26,11 +35,12 @@ internal abstract class DbTest { internal lateinit var db: FDroidDatabaseInt private val testCoroutineDispatcher = Dispatchers.Unconfined + protected val context: Context = ApplicationProvider.getApplicationContext() + protected val assets: AssetManager = context.resources.assets protected val locales = LocaleListCompat.create(Locale.US) @Before open fun createDb() { - val context = ApplicationProvider.getApplicationContext() db = Room.inMemoryDatabaseBuilder(context, FDroidDatabaseInt::class.java).build() repoDao = db.getRepositoryDao() appDao = db.getAppDao() @@ -48,4 +58,28 @@ internal abstract class DbTest { db.close() } + /** + * Asserts that data associated with the given [repoId] is equal to the given [index]. + */ + protected fun assertDbEquals(repoId: Long, index: IndexV2) { + val repo = repoDao.getRepository(repoId) ?: fail() + val sortedIndex = index.sorted() + assertRepoEquals(sortedIndex.repo, repo) + assertEquals(sortedIndex.packages.size, appDao.countApps(), "number of packages") + sortedIndex.packages.forEach { (packageName, packageV2) -> + assertEquals( + packageV2.metadata, + appDao.getApp(repoId, packageName)?.toMetadataV2()?.sort() + ) + val versions = versionDao.getAppVersions(repoId, packageName).map { + it.toPackageVersionV2() + }.associateBy { it.file.sha256 } + assertEquals(packageV2.versions.size, versions.size, "number of versions") + packageV2.versions.forEach { (versionId, packageVersionV2) -> + val version = versions[versionId] ?: fail() + assertEquals(packageVersionV2, version) + } + } + } + } diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt index 48b9b76cd..655c32ff4 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt @@ -1,149 +1,88 @@ package org.fdroid.database -import android.content.Context -import android.util.Log -import androidx.test.core.app.ApplicationProvider.getApplicationContext import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.serialization.SerializationException import org.apache.commons.io.input.CountingInputStream -import org.fdroid.CompatibilityChecker +import org.fdroid.index.IndexConverter import org.fdroid.index.v1.IndexV1StreamProcessor import org.fdroid.index.v1.IndexV1StreamReceiver import org.fdroid.index.v2.AntiFeatureV2 import org.fdroid.index.v2.CategoryV2 -import org.fdroid.index.v2.IndexV2StreamProcessor import org.fdroid.index.v2.MetadataV2 import org.fdroid.index.v2.PackageVersionV2 import org.fdroid.index.v2.ReleaseChannelV2 import org.fdroid.index.v2.RepoV2 +import org.fdroid.test.TestDataEmptyV1 +import org.fdroid.test.TestDataMaxV1 +import org.fdroid.test.TestDataMidV1 +import org.fdroid.test.TestDataMinV1 import org.junit.Test import org.junit.runner.RunWith -import kotlin.math.roundToInt import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertTrue -import kotlin.test.fail @RunWith(AndroidJUnit4::class) internal class IndexV1InsertTest : DbTest() { - @Test - fun testStreamIndexV1IntoDb() { - val c = getApplicationContext() - val fileSize = c.resources.assets.openFd("index-v1.json").use { it.length } - val inputStream = CountingInputStream(c.resources.assets.open("index-v1.json")) - var currentByteCount: Long = 0 - val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") - val streamReceiver = TestStreamReceiver(repoId) { - val bytesRead = inputStream.byteCount - val bytesSinceLastCall = bytesRead - currentByteCount - if (bytesSinceLastCall > 0) { - val percent = ((bytesRead.toDouble() / fileSize) * 100).roundToInt() - Log.e("IndexV1InsertTest", - "Stream bytes read: $bytesRead ($percent%) +$bytesSinceLastCall") - } - // the stream gets read in big chunks, but ensure they are not too big, e.g. entire file - assertTrue(bytesSinceLastCall < 600_000, "$bytesSinceLastCall") - currentByteCount = bytesRead - } - val indexProcessor = IndexV1StreamProcessor(streamReceiver, null) + private val indexConverter = IndexConverter() + @Test + fun testStreamEmptyIntoDb() { + val repoId = streamIndex("resources/index-empty-v1.json") + assertEquals(1, repoDao.getRepositories().size) + val index = indexConverter.toIndexV2(TestDataEmptyV1.index) + assertDbEquals(repoId, index) + } + + @Test + fun testStreamMinIntoDb() { + val repoId = streamIndex("resources/index-min-v1.json") + assertTrue(repoDao.getRepositories().size == 1) + val index = indexConverter.toIndexV2(TestDataMinV1.index) + assertDbEquals(repoId, index) + } + + @Test + fun testStreamMidIntoDb() { + val repoId = streamIndex("resources/index-mid-v1.json") + assertTrue(repoDao.getRepositories().size == 1) + val index = indexConverter.toIndexV2(TestDataMidV1.index) + assertDbEquals(repoId, index) + } + + @Test + fun testStreamMaxIntoDb() { + val repoId = streamIndex("resources/index-max-v1.json") + assertTrue(repoDao.getRepositories().size == 1) + val index = indexConverter.toIndexV2(TestDataMaxV1.index) + assertDbEquals(repoId, index) + } + + private fun streamIndex(path: String): Long { + val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") + val streamReceiver = TestStreamReceiver(repoId) + val indexProcessor = IndexV1StreamProcessor(streamReceiver, null) db.runInTransaction { - inputStream.use { indexStream -> + assets.open(path).use { indexStream -> indexProcessor.process(indexStream) } } - assertTrue(repoDao.getRepositories().size == 1) - assertTrue(appDao.countApps() > 0) - assertTrue(appDao.countLocalizedFiles() > 0) - assertTrue(appDao.countLocalizedFileLists() > 0) - assertTrue(versionDao.countAppVersions() > 0) - assertTrue(versionDao.countVersionedStrings() > 0) - - println("Apps: " + appDao.countApps()) - println("LocalizedFiles: " + appDao.countLocalizedFiles()) - println("LocalizedFileLists: " + appDao.countLocalizedFileLists()) - println("Versions: " + versionDao.countAppVersions()) - println("Perms/Features: " + versionDao.countVersionedStrings()) - - val version = repoDao.getRepositories()[0].repository.version ?: fail() - insertV2ForComparison(version) - - val repo1 = repoDao.getRepository(1) ?: fail() - val repo2 = repoDao.getRepository(2) ?: fail() - assertEquals(repo1.repository, repo2.repository.copy(repoId = 1)) - assertEquals(repo1.mirrors, repo2.mirrors.map { it.copy(repoId = 1) }) - // TODO enable when better test data -// assertEquals(repo1.antiFeatures, repo2.antiFeatures) -// assertEquals(repo1.categories, repo2.categories) -// assertEquals(repo1.releaseChannels, repo2.releaseChannels) - - val appMetadata = appDao.getAppMetadata() - val appMetadata1 = appMetadata.count { it.repoId == 1L } - val appMetadata2 = appMetadata.count { it.repoId == 2L } - assertEquals(appMetadata1, appMetadata2) - - val localizedFiles = appDao.getLocalizedFiles() - val localizedFiles1 = localizedFiles.count { it.repoId == 1L } - val localizedFiles2 = localizedFiles.count { it.repoId == 2L } - assertEquals(localizedFiles1, localizedFiles2) - - val localizedFileLists = appDao.getLocalizedFileLists() - val localizedFileLists1 = localizedFileLists.count { it.repoId == 1L } - val localizedFileLists2 = localizedFileLists.count { it.repoId == 2L } - assertEquals(localizedFileLists1, localizedFileLists2) - - appMetadata.filter { it.repoId == 2L }.forEach { m -> - val metadata1 = appDao.getAppMetadata(1, m.packageId) - val metadata2 = appDao.getAppMetadata(2, m.packageId) - assertEquals(metadata1, metadata2.copy(repoId = 1, isCompatible = true)) - - val lFiles1 = appDao.getLocalizedFiles(1, m.packageId).toSet() - val lFiles2 = appDao.getLocalizedFiles(2, m.packageId) - assertEquals(lFiles1, lFiles2.map { it.copy(repoId = 1) }.toSet()) - - val lFileLists1 = appDao.getLocalizedFileLists(1, m.packageId).toSet() - val lFileLists2 = appDao.getLocalizedFileLists(2, m.packageId) - assertEquals(lFileLists1, lFileLists2.map { it.copy(repoId = 1) }.toSet()) - - val version1 = versionDao.getVersions(1, m.packageId).toSet() - val version2 = versionDao.getVersions(2, m.packageId) - assertEquals(version1, version2.map { it.copy(repoId = 1) }.toSet()) - - val vStrings1 = versionDao.getVersionedStrings(1, m.packageId).toSet() - val vStrings2 = versionDao.getVersionedStrings(2, m.packageId) - assertEquals(vStrings1, vStrings2.map { it.copy(repoId = 1) }.toSet()) - } - } - - @Suppress("SameParameterValue") - private fun insertV2ForComparison(version: Int) { - val c = getApplicationContext() - val inputStream = CountingInputStream(c.resources.assets.open("index-v2.json")) - val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") - val indexProcessor = IndexV2StreamProcessor(DbV2StreamReceiver(db, { true }, repoId), null) - db.runInTransaction { - inputStream.use { indexStream -> - indexProcessor.process(version, indexStream) - } - } + return repoId } @Test fun testExceptionWhileStreamingDoesNotSaveIntoDb() { - val c = getApplicationContext() - val cIn = CountingInputStream(c.resources.assets.open("index-v1.json")) - val compatibilityChecker = CompatibilityChecker { - if (cIn.byteCount > 824096) throw SerializationException() - true - } - val indexProcessor = - IndexV2StreamProcessor(DbV2StreamReceiver(db, compatibilityChecker, 1), null) - + val cIn = CountingInputStream(assets.open("resources/index-max-v1.json")) assertFailsWith { db.runInTransaction { + val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") + val streamReceiver = TestStreamReceiver(repoId) { + if (cIn.byteCount > 0) throw SerializationException() + } + val indexProcessor = IndexV1StreamProcessor(streamReceiver, null) cIn.use { indexStream -> - indexProcessor.process(42, indexStream) + indexProcessor.process(indexStream) } } } @@ -155,11 +94,12 @@ internal class IndexV1InsertTest : DbTest() { assertTrue(versionDao.countVersionedStrings() == 0) } + @Suppress("DEPRECATION") inner class TestStreamReceiver( repoId: Long, - private val callback: () -> Unit, + private val callback: () -> Unit = {}, ) : IndexV1StreamReceiver { - private val streamReceiver = DbV1StreamReceiver(db, { true }, repoId) + private val streamReceiver = DbV1StreamReceiver(db, repoId) { true } override fun receive(repo: RepoV2, version: Int, certificate: String?) { streamReceiver.receive(repo, version, certificate) callback() diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV2DiffTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV2DiffTest.kt new file mode 100644 index 000000000..cebdebe31 --- /dev/null +++ b/database/src/androidTest/java/org/fdroid/database/IndexV2DiffTest.kt @@ -0,0 +1,327 @@ +package org.fdroid.database + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.serialization.SerializationException +import org.fdroid.index.IndexParser +import org.fdroid.index.parseV2 +import org.fdroid.index.v2.IndexV2 +import org.fdroid.index.v2.IndexV2DiffStreamProcessor +import org.fdroid.index.v2.IndexV2StreamProcessor +import org.fdroid.test.TestDataMaxV2 +import org.fdroid.test.TestDataMidV2 +import org.fdroid.test.TestDataMinV2 +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.RunWith +import java.io.ByteArrayInputStream +import java.io.InputStream +import kotlin.test.assertFailsWith + +@RunWith(AndroidJUnit4::class) +internal class IndexV2DiffTest : DbTest() { + + @Test + @Ignore("use for testing specific index on demand") + fun testBrokenIndexDiff() { + val endPath = "resources/tmp/index-end.json" + val endIndex = IndexParser.parseV2(assets.open(endPath)) + testDiff( + startPath = "resources/tmp/index-start.json", + diffPath = "resources/tmp/diff.json", + endIndex = endIndex, + ) + } + + @Test + fun testEmptyToMin() = testDiff( + startPath = "resources/index-empty-v2.json", + diffPath = "resources/diff-empty-min/23.json", + endIndex = TestDataMinV2.index, + ) + + @Test + fun testEmptyToMid() = testDiff( + startPath = "resources/index-empty-v2.json", + diffPath = "resources/diff-empty-mid/23.json", + endIndex = TestDataMidV2.index, + ) + + @Test + fun testEmptyToMax() = testDiff( + startPath = "resources/index-empty-v2.json", + diffPath = "resources/diff-empty-max/23.json", + endIndex = TestDataMaxV2.index, + ) + + @Test + fun testMinToMid() = testDiff( + startPath = "resources/index-min-v2.json", + diffPath = "resources/diff-empty-mid/42.json", + endIndex = TestDataMidV2.index, + ) + + @Test + fun testMinToMax() = testDiff( + startPath = "resources/index-min-v2.json", + diffPath = "resources/diff-empty-max/42.json", + endIndex = TestDataMaxV2.index, + ) + + @Test + fun testMidToMax() = testDiff( + startPath = "resources/index-mid-v2.json", + diffPath = "resources/diff-empty-max/1337.json", + endIndex = TestDataMaxV2.index, + ) + + @Test + fun testMinRemoveApp() { + val diffJson = """{ + "packages": { + "org.fdroid.min1": null + } + }""".trimIndent() + testJsonDiff( + startPath = "resources/index-min-v2.json", + diff = diffJson, + endIndex = TestDataMinV2.index.copy(packages = emptyMap()), + ) + } + + @Test + fun testMinNoMetadataRemoveVersion() { + val diffJson = """{ + "packages": { + "org.fdroid.min1": { + "metadata": { + "added": 0 + }, + "versions": { + "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf": null + } + } + } + }""".trimIndent() + testJsonDiff( + startPath = "resources/index-min-v2.json", + diff = diffJson, + endIndex = TestDataMinV2.index.copy( + packages = TestDataMinV2.index.packages.mapValues { + it.value.copy(versions = emptyMap()) + } + ), + ) + } + + @Test + fun testMinNoVersionsUnknownKey() { + val diffJson = """{ + "packages": { + "org.fdroid.min1": { + "metadata": { + "added": 42 + }, + "unknownKey": "should get ignored" + } + } + }""".trimIndent() + testJsonDiff( + startPath = "resources/index-min-v2.json", + diff = diffJson, + endIndex = TestDataMinV2.index.copy( + packages = TestDataMinV2.index.packages.mapValues { + it.value.copy(metadata = it.value.metadata.copy(added = 42)) + } + ), + ) + } + + @Test + fun testMinRemoveMetadata() { + val diffJson = """{ + "packages": { + "org.fdroid.min1": { + "metadata": null + } + }, + "unknownKey": "should get ignored" + }""".trimIndent() + testJsonDiff( + startPath = "resources/index-min-v2.json", + diff = diffJson, + endIndex = TestDataMinV2.index.copy( + packages = emptyMap() + ), + ) + } + + @Test + fun testMinRemoveVersions() { + val diffJson = """{ + "packages": { + "org.fdroid.min1": { + "versions": null + } + } + }""".trimIndent() + testJsonDiff( + startPath = "resources/index-min-v2.json", + diff = diffJson, + endIndex = TestDataMinV2.index.copy( + packages = TestDataMinV2.index.packages.mapValues { + it.value.copy(versions = emptyMap()) + } + ), + ) + } + + @Test + fun testMinNoMetadataNoVersion() { + val diffJson = """{ + "packages": { + "org.fdroid.min1": { + } + } + }""".trimIndent() + testJsonDiff( + startPath = "resources/index-min-v2.json", + diff = diffJson, + endIndex = TestDataMinV2.index, + ) + } + + @Test + fun testAppDenyKeyList() { + val diffRepoIdJson = """{ + "packages": { + "org.fdroid.min1": { + "metadata": { + "repoId": 1 + } + } + } + }""".trimIndent() + assertFailsWith { + testJsonDiff( + startPath = "resources/index-min-v2.json", + diff = diffRepoIdJson, + endIndex = TestDataMinV2.index, + ) + } + val diffPackageIdJson = """{ + "packages": { + "org.fdroid.min1": { + "metadata": { + "packageId": "foo" + } + } + } + }""".trimIndent() + assertFailsWith { + testJsonDiff( + startPath = "resources/index-min-v2.json", + diff = diffPackageIdJson, + endIndex = TestDataMinV2.index, + ) + } + } + + @Test + fun testVersionsDenyKeyList() { + assertFailsWith { + testJsonDiff( + startPath = "resources/index-min-v2.json", + diff = getMinVersionJson(""""packageId": "foo""""), + endIndex = TestDataMinV2.index, + ) + } + assertFailsWith { + testJsonDiff( + startPath = "resources/index-min-v2.json", + diff = getMinVersionJson(""""repoId": 1"""), + endIndex = TestDataMinV2.index, + ) + } + assertFailsWith { + testJsonDiff( + startPath = "resources/index-min-v2.json", + diff = getMinVersionJson(""""versionId": "bar""""), + endIndex = TestDataMinV2.index, + ) + } + } + + private fun getMinVersionJson(insert: String) = """{ + "packages": { + "org.fdroid.min1": { + "versions": { + "824a109b2352138c3699760e1683385d0ed50ce526fc7982f8d65757743374bf": { + $insert + } + } + } + }""".trimIndent() + + @Test + fun testMidRemoveScreenshots() { + val diffRepoIdJson = """{ + "packages": { + "org.fdroid.fdroid": { + "metadata": { + "screenshots": null + } + } + } + }""".trimIndent() + val fdroidPackage = TestDataMidV2.packages["org.fdroid.fdroid"]!!.copy( + metadata = TestDataMidV2.packages["org.fdroid.fdroid"]!!.metadata.copy( + screenshots = null, + ) + ) + testJsonDiff( + startPath = "resources/index-mid-v2.json", + diff = diffRepoIdJson, + endIndex = TestDataMidV2.index.copy( + packages = mapOf( + TestDataMidV2.packageName1 to TestDataMidV2.app1, + TestDataMidV2.packageName2 to fdroidPackage, + ) + ), + ) + } + + private fun testJsonDiff(startPath: String, diff: String, endIndex: IndexV2) { + testDiff(startPath, ByteArrayInputStream(diff.toByteArray()), endIndex) + } + + private fun testDiff(startPath: String, diffPath: String, endIndex: IndexV2) { + testDiff(startPath, assets.open(diffPath), endIndex) + } + + private fun testDiff(startPath: String, diffStream: InputStream, endIndex: IndexV2) { + // stream start index into the DB + val repoId = streamIndex(startPath) + + // apply diff stream to the DB + val streamReceiver = DbV2DiffStreamReceiver(db, repoId) { true } + val streamProcessor = IndexV2DiffStreamProcessor(streamReceiver) + db.runInTransaction { + streamProcessor.process(diffStream) + } + // assert that changed DB data is equal to given endIndex + assertDbEquals(repoId, endIndex) + } + + private fun streamIndex(path: String): Long { + val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") + val streamReceiver = DbV2StreamReceiver(db, repoId) { true } + val indexProcessor = IndexV2StreamProcessor(streamReceiver, null) + db.runInTransaction { + assets.open(path).use { indexStream -> + indexProcessor.process(42, indexStream) + } + } + return repoId + } + +} diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt index 3446dee29..2264ecc28 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt @@ -1,16 +1,17 @@ package org.fdroid.database -import android.content.Context -import android.util.Log -import androidx.test.core.app.ApplicationProvider.getApplicationContext import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.serialization.SerializationException import org.apache.commons.io.input.CountingInputStream import org.fdroid.CompatibilityChecker import org.fdroid.index.v2.IndexV2StreamProcessor +import org.fdroid.test.TestDataEmptyV2 +import org.fdroid.test.TestDataMaxV2 +import org.fdroid.test.TestDataMidV2 +import org.fdroid.test.TestDataMinV2 import org.junit.Test import org.junit.runner.RunWith -import kotlin.math.roundToInt +import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertTrue @@ -18,59 +19,63 @@ import kotlin.test.assertTrue internal class IndexV2InsertTest : DbTest() { @Test - fun testStreamIndexV2IntoDb() { - val c = getApplicationContext() - val fileSize = c.resources.assets.openFd("index-v2.json").use { it.length } - val inputStream = CountingInputStream(c.resources.assets.open("index-v2.json")) - var currentByteCount: Long = 0 - val compatibilityChecker = CompatibilityChecker { - val bytesRead = inputStream.byteCount - val bytesSinceLastCall = bytesRead - currentByteCount - if (bytesSinceLastCall > 0) { - val percent = ((bytesRead.toDouble() / fileSize) * 100).roundToInt() - Log.e("IndexV2InsertTest", - "Stream bytes read: $bytesRead ($percent%) +$bytesSinceLastCall") - } - // the stream gets read in big chunks, but ensure they are not too big, e.g. entire file - assertTrue(bytesSinceLastCall < 400_000, "$bytesSinceLastCall") - currentByteCount = bytesRead - true - } - val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") - val streamReceiver = DbV2StreamReceiver(db, compatibilityChecker, repoId) - val indexProcessor = IndexV2StreamProcessor(streamReceiver, null) + fun testStreamEmptyIntoDb() { + val repoId = streamIndex("resources/index-empty-v2.json") + assertEquals(1, repoDao.getRepositories().size) + assertDbEquals(repoId, TestDataEmptyV2.index) + } + @Test + fun testStreamMinIntoDb() { + val repoId = streamIndex("resources/index-min-v2.json") + assertEquals(1, repoDao.getRepositories().size) + assertDbEquals(repoId, TestDataMinV2.index) + } + + @Test + fun testStreamMinReorderedIntoDb() { + val repoId = streamIndex("resources/index-min-reordered-v2.json") + assertEquals(1, repoDao.getRepositories().size) + assertDbEquals(repoId, TestDataMinV2.index) + } + + @Test + fun testStreamMidIntoDb() { + val repoId = streamIndex("resources/index-mid-v2.json") + assertEquals(1, repoDao.getRepositories().size) + assertDbEquals(repoId, TestDataMidV2.index) + } + + @Test + fun testStreamMaxIntoDb() { + val repoId = streamIndex("resources/index-max-v2.json") + assertEquals(1, repoDao.getRepositories().size) + assertDbEquals(repoId, TestDataMaxV2.index) + } + + private fun streamIndex(path: String): Long { + val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") + val streamReceiver = DbV2StreamReceiver(db, repoId) { true } + val indexProcessor = IndexV2StreamProcessor(streamReceiver, null) db.runInTransaction { - inputStream.use { indexStream -> + assets.open(path).use { indexStream -> indexProcessor.process(42, indexStream) } } - assertTrue(repoDao.getRepositories().size == 1) - assertTrue(appDao.countApps() > 0) - assertTrue(appDao.countLocalizedFiles() > 0) - assertTrue(appDao.countLocalizedFileLists() > 0) - assertTrue(versionDao.countAppVersions() > 0) - assertTrue(versionDao.countVersionedStrings() > 0) - - println("Apps: " + appDao.countApps()) - println("LocalizedFiles: " + appDao.countLocalizedFiles()) - println("LocalizedFileLists: " + appDao.countLocalizedFileLists()) - println("Versions: " + versionDao.countAppVersions()) - println("Perms/Features: " + versionDao.countVersionedStrings()) + return repoId } @Test fun testExceptionWhileStreamingDoesNotSaveIntoDb() { - val c = getApplicationContext() - val cIn = CountingInputStream(c.resources.assets.open("index-v2.json")) + val cIn = CountingInputStream(assets.open("resources/index-max-v2.json")) val compatibilityChecker = CompatibilityChecker { - if (cIn.byteCount > 824096) throw SerializationException() + if (cIn.byteCount > 0) throw SerializationException() true } assertFailsWith { db.runInTransaction { val repoId = db.getRepositoryDao().insertEmptyRepo("http://example.org") - val streamReceiver = DbV2StreamReceiver(db, compatibilityChecker, repoId) + val streamReceiver = DbV2StreamReceiver(db, repoId, compatibilityChecker) val indexProcessor = IndexV2StreamProcessor(streamReceiver, null) cIn.use { indexStream -> indexProcessor.process(42, indexStream) diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt index 962e86b4d..7640ec8ae 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt @@ -76,47 +76,6 @@ internal class RepositoryDiffTest : DbTest() { assertRepoEquals(repo.copy(timestamp = updateTimestamp), repos[0]) } - @Test - fun iconDiff() { - val repo = getRandomRepo() - val updateIcon = getRandomFileV2() - val json = """ - { - "icon": ${Json.encodeToString(updateIcon)} - }""".trimIndent() - testDiff(repo, json) { repos -> - assertEquals(updateIcon, repos[0].icon) - assertRepoEquals(repo.copy(icon = updateIcon), repos[0]) - } - } - - @Test - fun iconPartialDiff() { - val repo = getRandomRepo() - val updateIcon = repo.icon!!.copy(name = getRandomString()) - val json = """ - { - "icon": { "name": "${updateIcon.name}" } - }""".trimIndent() - testDiff(repo, json) { repos -> - assertEquals(updateIcon, repos[0].icon) - assertRepoEquals(repo.copy(icon = updateIcon), repos[0]) - } - } - - @Test - fun iconRemoval() { - val repo = getRandomRepo() - val json = """ - { - "icon": null - }""".trimIndent() - testDiff(repo, json) { repos -> - assertEquals(null, repos[0].icon) - assertRepoEquals(repo.copy(icon = null), repos[0]) - } - } - @Test fun mirrorDiff() { val repo = getRandomRepo() diff --git a/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt b/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt index 9cd38ae1d..a6eec8696 100644 --- a/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt @@ -1,11 +1,8 @@ package org.fdroid.database -import android.content.Context import android.util.Log -import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.apache.commons.io.input.CountingInputStream -import org.fdroid.index.v1.IndexV1StreamProcessor +import org.fdroid.index.v2.IndexV2StreamProcessor import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -15,29 +12,26 @@ import kotlin.time.measureTime @RunWith(AndroidJUnit4::class) internal class UpdateCheckerTest : DbTest() { - private lateinit var context: Context private lateinit var updateChecker: UpdateChecker @Before override fun createDb() { super.createDb() - context = ApplicationProvider.getApplicationContext() + // TODO mock packageManager and maybe move to unit tests updateChecker = UpdateChecker(db, context.packageManager) } @Test @OptIn(ExperimentalTime::class) fun testGetUpdates() { - val inputStream = CountingInputStream(context.resources.assets.open("index-v1.json")) - val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") - val indexProcessor = IndexV1StreamProcessor(DbV1StreamReceiver(db, { true }, repoId), null) - db.runInTransaction { - inputStream.use { indexStream -> - indexProcessor.process(indexStream) + val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") + val streamReceiver = DbV2StreamReceiver(db, repoId) { true } + val indexProcessor = IndexV2StreamProcessor(streamReceiver, null) + assets.open("resources/index-max-v2.json").use { indexStream -> + indexProcessor.process(42, indexStream) } } - val duration = measureTime { updateChecker.getUpdatableApps() } diff --git a/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt b/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt index 7479ccf47..e2e7724f7 100644 --- a/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt +++ b/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt @@ -2,12 +2,18 @@ package org.fdroid.database.test import androidx.lifecycle.LiveData import androidx.lifecycle.Observer +import org.fdroid.database.App +import org.fdroid.database.AppVersion import org.fdroid.database.Repository import org.fdroid.database.toCoreRepository import org.fdroid.database.toMirror import org.fdroid.database.toRepoAntiFeatures import org.fdroid.database.toRepoCategories import org.fdroid.database.toRepoReleaseChannel +import org.fdroid.index.v2.FeatureV2 +import org.fdroid.index.v2.ManifestV2 +import org.fdroid.index.v2.MetadataV2 +import org.fdroid.index.v2.PackageVersionV2 import org.fdroid.index.v2.RepoV2 import org.junit.Assert import java.util.concurrent.CountDownLatch @@ -36,6 +42,59 @@ internal object TestUtils { assertEquals(coreRepo, repo.repository) } + internal fun App.toMetadataV2() = MetadataV2( + added = metadata.added, + lastUpdated = metadata.lastUpdated, + name = metadata.name, + summary = metadata.summary, + description = metadata.description, + webSite = metadata.webSite, + changelog = metadata.changelog, + license = metadata.license, + sourceCode = metadata.sourceCode, + issueTracker = metadata.issueTracker, + translation = metadata.translation, + preferredSigner = metadata.preferredSigner, + video = metadata.video, + authorName = metadata.authorName, + authorEmail = metadata.authorEmail, + authorWebSite = metadata.authorWebSite, + authorPhone = metadata.authorPhone, + donate = metadata.donate ?: emptyList(), + liberapayID = metadata.liberapayID, + liberapay = metadata.liberapay, + openCollective = metadata.openCollective, + bitcoin = metadata.bitcoin, + litecoin = metadata.litecoin, + flattrID = metadata.flattrID, + categories = metadata.categories ?: emptyList(), + icon = icon, + featureGraphic = featureGraphic, + promoGraphic = promoGraphic, + tvBanner = tvBanner, + screenshots = screenshots, + ) + + fun AppVersion.toPackageVersionV2() = PackageVersionV2( + added = added, + file = file, + src = src, + manifest = ManifestV2( + versionName = manifest.versionName, + versionCode = manifest.versionCode, + usesSdk = manifest.usesSdk, + maxSdkVersion = manifest.maxSdkVersion, + signer = manifest.signer, + usesPermission = usesPermission?.sortedBy { it.name } ?: emptyList(), + usesPermissionSdk23 = usesPermissionSdk23?.sortedBy { it.name } ?: emptyList(), + nativecode = manifest.nativecode?.sorted() ?: emptyList(), + features = manifest.features?.map { FeatureV2(it) } ?: emptyList(), + ), + releaseChannels = releaseChannels, + antiFeatures = version.antiFeatures ?: emptyMap(), + whatsNew = version.whatsNew ?: emptyMap(), + ) + fun LiveData.getOrAwaitValue(): T? { val data = arrayOfNulls(1) val latch = CountDownLatch(1) diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt index f16d8bc30..b318feb63 100644 --- a/database/src/main/java/org/fdroid/database/App.kt +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -327,19 +327,24 @@ internal fun LocalizedFileListV2.toLocalizedFileList( packageId: String, type: String, ): List = flatMap { (locale, files) -> - files.map { file -> - LocalizedFileList( - repoId = repoId, - packageId = packageId, - type = type, - locale = locale, - name = file.name, - sha256 = file.sha256, - size = file.size, - ) - } + files.map { file -> file.toLocalizedFileList(repoId, packageId, type, locale) } } +internal fun FileV2.toLocalizedFileList( + repoId: Long, + packageId: String, + type: String, + locale: String, +) = LocalizedFileList( + repoId = repoId, + packageId = packageId, + type = type, + locale = locale, + name = name, + sha256 = sha256, + size = size, +) + internal fun List.toLocalizedFileListV2(type: String): LocalizedFileListV2? { val map = HashMap>() iterator().forEach { file -> diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index d2632bef8..9939cf3bb 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -18,10 +18,20 @@ import androidx.room.OnConflictStrategy 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.database.DbDiffUtils.diffAndUpdateListTable +import org.fdroid.database.DbDiffUtils.diffAndUpdateTable import org.fdroid.database.FDroidDatabaseHolder.dispatcher +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 import org.fdroid.index.v2.Screenshots public interface AppDao { @@ -64,6 +74,23 @@ 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("packageId", "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("packageId", "repoId", "type") + @Dao internal interface AppDaoInt : AppDao { @@ -110,6 +137,95 @@ internal interface AppDaoInt : AppDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insertLocalizedFileLists(localizedFiles: List) + @Transaction + fun updateApp( + repoId: Long, + packageId: String, + jsonObject: JsonObject?, + locales: LocaleListCompat, + ) { + if (jsonObject == null) { + // this app is gone, we need to delete it + deleteAppMetadata(repoId, packageId) + return + } + val metadata = getAppMetadata(repoId, packageId) + if (metadata == null) { // new app + val metadataV2: MetadataV2 = json.decodeFromJsonElement(jsonObject) + insert(repoId, packageId, 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, packageId) + localizedFiles.diffAndUpdate(repoId, packageId, "icon", jsonObject) + localizedFiles.diffAndUpdate(repoId, packageId, "featureGraphic", jsonObject) + localizedFiles.diffAndUpdate(repoId, packageId, "promoGraphic", jsonObject) + localizedFiles.diffAndUpdate(repoId, packageId, "tvBanner", jsonObject) + // diff localizedFileLists + val screenshots = jsonObject["screenshots"] + if (screenshots is JsonNull) { + deleteLocalizedFileLists(repoId, packageId) + } else if (screenshots is JsonObject) { + diffAndUpdateLocalizedFileList(repoId, packageId, "phone", screenshots) + diffAndUpdateLocalizedFileList(repoId, packageId, "sevenInch", screenshots) + diffAndUpdateLocalizedFileList(repoId, packageId, "tenInch", screenshots) + diffAndUpdateLocalizedFileList(repoId, packageId, "wear", screenshots) + diffAndUpdateLocalizedFileList(repoId, packageId, "tv", screenshots) + } + } + } + + private fun List.diffAndUpdate( + repoId: Long, + packageId: 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, packageId, type, locale, "") }, + deleteAll = { deleteLocalizedFiles(repoId, packageId, type) }, + deleteOne = { locale -> deleteLocalizedFile(repoId, packageId, type, locale) }, + insertReplace = { list -> insert(list) }, + isNewItemValid = { it.name.isNotEmpty() }, + keyDenyList = DENY_FILE_LIST, + ) + + private fun diffAndUpdateLocalizedFileList( + repoId: Long, + packageId: String, + type: String, + jsonObject: JsonObject, + ) { + diffAndUpdateListTable( + jsonObject = jsonObject, + jsonObjectKey = type, + listParser = { locale, jsonArray -> + json.decodeFromJsonElement>(jsonArray).map { + it.toLocalizedFileList(repoId, packageId, type, locale) + } + }, + deleteAll = { deleteLocalizedFileLists(repoId, packageId, type) }, + deleteList = { locale -> deleteLocalizedFileList(repoId, packageId, type, locale) }, + insertNewList = { _, fileLists -> insertLocalizedFileLists(fileLists) }, + ) + } + /** * This is needed to support v1 streaming and shouldn't be used for something else. */ @@ -135,6 +251,9 @@ internal interface AppDaoInt : AppDao { WHERE repoId = :repoId AND packageId = :packageId""") fun updateAppMetadata(repoId: Long, packageId: String, name: String?, summary: String?) + @Update + fun updateAppMetadata(appMetadata: AppMetadata): Int + override fun getApp(packageId: String): LiveData { return getRepoIdForPackage(packageId).distinctUntilChanged().switchMap { repoId -> if (repoId == null) MutableLiveData(null) @@ -160,7 +279,7 @@ internal interface AppDaoInt : AppDao { @Transaction override fun getApp(repoId: Long, packageId: String): App? { - val metadata = getAppMetadata(repoId, packageId) + val metadata = getAppMetadata(repoId, packageId) ?: return null val localizedFiles = getLocalizedFiles(repoId, packageId) val localizedFileList = getLocalizedFileLists(repoId, packageId) return getApp(metadata, localizedFiles, localizedFileList) @@ -189,7 +308,7 @@ internal interface AppDaoInt : AppDao { fun getLiveAppMetadata(repoId: Long, packageId: String): LiveData @Query("SELECT * FROM AppMetadata WHERE repoId = :repoId AND packageId = :packageId") - fun getAppMetadata(repoId: Long, packageId: String): AppMetadata + fun getAppMetadata(repoId: Long, packageId: String): AppMetadata? @Query("SELECT * FROM AppMetadata") fun getAppMetadata(): List @@ -354,10 +473,29 @@ internal interface AppDaoInt : AppDao { FROM AppMetadata AS app WHERE repoId = :repoId AND packageId = :packageId""") fun getAppOverviewItem(repoId: Long, packageId: String): AppOverviewItem? - @VisibleForTesting @Query("DELETE FROM AppMetadata WHERE repoId = :repoId AND packageId = :packageId") fun deleteAppMetadata(repoId: Long, packageId: String) + @Query("""DELETE FROM LocalizedFile + WHERE repoId = :repoId AND packageId = :packageId AND type = :type""") + fun deleteLocalizedFiles(repoId: Long, packageId: String, type: String) + + @Query("""DELETE FROM LocalizedFile + WHERE repoId = :repoId AND packageId = :packageId AND type = :type AND locale = :locale""") + fun deleteLocalizedFile(repoId: Long, packageId: String, type: String, locale: String) + + @Query("""DELETE FROM LocalizedFileList + WHERE repoId = :repoId AND packageId = :packageId""") + fun deleteLocalizedFileLists(repoId: Long, packageId: String) + + @Query("""DELETE FROM LocalizedFileList + WHERE repoId = :repoId AND packageId = :packageId AND type = :type""") + fun deleteLocalizedFileLists(repoId: Long, packageId: String, type: String) + + @Query("""DELETE FROM LocalizedFileList + WHERE repoId = :repoId AND packageId = :packageId AND type = :type AND locale = :locale""") + fun deleteLocalizedFileList(repoId: Long, packageId: String, type: String, locale: String) + @VisibleForTesting @Query("SELECT COUNT(*) FROM AppMetadata") fun countApps(): Int diff --git a/database/src/main/java/org/fdroid/database/DbDiffUtils.kt b/database/src/main/java/org/fdroid/database/DbDiffUtils.kt new file mode 100644 index 000000000..cfc1374f5 --- /dev/null +++ b/database/src/main/java/org/fdroid/database/DbDiffUtils.kt @@ -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 diffAndUpdateTable( + jsonObject: JsonObject, + jsonObjectKey: String, + itemList: List, + itemFinder: (String, T) -> Boolean, + newItem: (String) -> T, + deleteAll: () -> Unit, + deleteOne: (String) -> Unit, + insertReplace: (List) -> Unit, + isNewItemValid: (T) -> Boolean = { true }, + keyDenyList: List? = 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 diffAndUpdateListTable( + jsonObject: JsonObject, + jsonObjectKey: String, + listParser: (String, JsonArray) -> List, + deleteAll: () -> Unit, + deleteList: (String) -> Unit, + insertNewList: (String, List) -> 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 diffAndUpdateListTable( + jsonObject: JsonObject, + jsonObjectKey: String, + listParser: (JsonArray) -> List, + deleteList: () -> Unit, + insertNewList: (List) -> 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?) { + list?.forEach { forbiddenKey -> + if (containsKey(forbiddenKey)) throw SerializationException(forbiddenKey) + } + } + +} diff --git a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt index 5a0cbe8cd..5bcbb0f41 100644 --- a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt @@ -19,8 +19,8 @@ import org.fdroid.index.v2.RepoV2 @Deprecated("Use DbV2StreamReceiver instead") internal class DbV1StreamReceiver( private val db: FDroidDatabaseInt, - private val compatibilityChecker: CompatibilityChecker, private val repoId: Long, + private val compatibilityChecker: CompatibilityChecker, ) : IndexV1StreamReceiver { private val locales: LocaleListCompat = getLocales(Resources.getSystem().configuration) diff --git a/database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt new file mode 100644 index 000000000..7ea910b5d --- /dev/null +++ b/database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt @@ -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(repoJsonObject: JsonObject) { + db.getRepositoryDao().updateRepository(repoId, repoJsonObject) + } + + override fun receivePackageMetadataDiff(packageId: String, packageJsonObject: JsonObject?) { + db.getAppDao().updateApp(repoId, packageId, packageJsonObject, locales) + } + + override fun receiveVersionsDiff( + packageId: String, + versionsDiffMap: Map?, + ) { + db.getVersionDao().update(repoId, packageId, versionsDiffMap) { + compatibilityChecker.isCompatible(it) + } + } + + @Synchronized + override fun onStreamEnded() { + db.afterUpdatingRepo(repoId) + } + +} diff --git a/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt index c06d612a9..c7c77aac6 100644 --- a/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt @@ -10,8 +10,8 @@ import org.fdroid.index.v2.RepoV2 internal class DbV2StreamReceiver( private val db: FDroidDatabaseInt, - private val compatibilityChecker: CompatibilityChecker, private val repoId: Long, + private val compatibilityChecker: CompatibilityChecker, ) : IndexV2StreamReceiver { private val locales: LocaleListCompat = getLocales(Resources.getSystem().configuration) diff --git a/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/database/src/main/java/org/fdroid/database/RepositoryDao.kt index 047cf6645..01dc3278b 100644 --- a/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -9,11 +9,10 @@ import androidx.room.Query import androidx.room.RewriteQueriesToDropUnusedColumns import androidx.room.Transaction import androidx.room.Update -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement -import kotlinx.serialization.json.jsonObject +import org.fdroid.database.DbDiffUtils.diffAndUpdateListTable +import org.fdroid.database.DbDiffUtils.diffAndUpdateTable import org.fdroid.index.IndexParser.json import org.fdroid.index.v2.MirrorV2 import org.fdroid.index.v2.ReflectionDiffer.applyDiff @@ -187,23 +186,25 @@ internal interface RepositoryDaoInt : RepositoryDao { val repo = getRepository(repoId) ?: error("Repo $repoId does not exist") // update repo with JSON diff updateRepository(applyDiff(repo.repository, jsonObject)) - // replace mirror list, if it is in the diff - if (jsonObject.containsKey("mirrors")) { - val mirrorArray = jsonObject["mirrors"] as JsonArray - val mirrors = json.decodeFromJsonElement>(mirrorArray).map { - it.toMirror(repoId) - } - // delete and re-insert mirrors, because it is easier than diffing - deleteMirrors(repoId) - insertMirrors(mirrors) - } + // replace mirror list (if it is in the diff) + diffAndUpdateListTable( + jsonObject = jsonObject, + jsonObjectKey = "mirrors", + listParser = { mirrorArray -> + json.decodeFromJsonElement>(mirrorArray).map { + it.toMirror(repoId) + } + }, + deleteList = { deleteMirrors(repoId) }, + insertNewList = { mirrors -> insertMirrors(mirrors) }, + ) // diff and update the antiFeatures diffAndUpdateTable( jsonObject = jsonObject, - key = "antiFeatures", + jsonObjectKey = "antiFeatures", itemList = repo.antiFeatures, + itemFinder = { key, item -> item.id == key }, newItem = { key -> AntiFeature(repoId, key, null, emptyMap(), emptyMap()) }, - keyGetter = { item -> item.id }, deleteAll = { deleteAntiFeatures(repoId) }, deleteOne = { key -> deleteAntiFeature(repoId, key) }, insertReplace = { list -> insertAntiFeatures(list) }, @@ -211,10 +212,10 @@ internal interface RepositoryDaoInt : RepositoryDao { // diff and update the categories diffAndUpdateTable( jsonObject = jsonObject, - key = "categories", + jsonObjectKey = "categories", itemList = repo.categories, + itemFinder = { key, item -> item.id == key }, newItem = { key -> Category(repoId, key, null, emptyMap(), emptyMap()) }, - keyGetter = { item -> item.id }, deleteAll = { deleteCategories(repoId) }, deleteOne = { key -> deleteCategory(repoId, key) }, insertReplace = { list -> insertCategories(list) }, @@ -222,56 +223,16 @@ internal interface RepositoryDaoInt : RepositoryDao { // diff and update the releaseChannels diffAndUpdateTable( jsonObject = jsonObject, - key = "releaseChannels", + jsonObjectKey = "releaseChannels", itemList = repo.releaseChannels, + itemFinder = { key, item -> item.id == key }, newItem = { key -> ReleaseChannel(repoId, key, null, emptyMap(), emptyMap()) }, - keyGetter = { item -> item.id }, deleteAll = { deleteReleaseChannels(repoId) }, deleteOne = { key -> deleteReleaseChannel(repoId, key) }, insertReplace = { list -> insertReleaseChannels(list) }, ) } - /** - * Applies the diff from [JsonObject] identified by the given [key] of the given [jsonObject] - * to the given [itemList] and updates the DB as needed. - * - * @param newItem A function to produce a new [T] which typically contains the primary key(s). - */ - private fun diffAndUpdateTable( - jsonObject: JsonObject, - key: String, - itemList: List, - newItem: (String) -> T, - keyGetter: (T) -> String, - deleteAll: () -> Unit, - deleteOne: (String) -> Unit, - insertReplace: (List) -> Unit, - ) { - if (!jsonObject.containsKey(key)) return - if (jsonObject[key] == JsonNull) { - deleteAll() - } else { - val features = jsonObject[key]?.jsonObject ?: error("no $key object") - val list = itemList.toMutableList() - features.entries.forEach { (key, value) -> - if (value is JsonNull) { - list.removeAll { keyGetter(it) == key } - deleteOne(key) - } else { - val index = list.indexOfFirst { keyGetter(it) == key } - val item = if (index == -1) null else list[index] - if (item == null) { - list.add(applyDiff(newItem(key), value.jsonObject)) - } else { - list[index] = applyDiff(item, value.jsonObject) - } - } - } - insertReplace(list) - } - } - @Update fun updateRepository(repo: CoreRepository): Int diff --git a/database/src/main/java/org/fdroid/database/VersionDao.kt b/database/src/main/java/org/fdroid/database/VersionDao.kt index acfd7498e..de82509e7 100644 --- a/database/src/main/java/org/fdroid/database/VersionDao.kt +++ b/database/src/main/java/org/fdroid/database/VersionDao.kt @@ -1,18 +1,28 @@ package org.fdroid.database -import androidx.annotation.VisibleForTesting import androidx.lifecycle.LiveData import androidx.lifecycle.distinctUntilChanged import androidx.lifecycle.liveData import androidx.lifecycle.map import androidx.room.Dao import androidx.room.Insert -import androidx.room.OnConflictStrategy +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.FDroidDatabaseHolder.dispatcher +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.ManifestV2 import org.fdroid.index.v2.PackageVersionV2 +import org.fdroid.index.v2.PermissionV2 +import org.fdroid.index.v2.ReflectionDiffer public interface VersionDao { public fun insert( @@ -26,6 +36,15 @@ public interface VersionDao { public fun getAppVersions(repoId: Long, packageId: String): List } +/** + * 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("packageId", "repoId", "versionId") + @Dao internal interface VersionDaoInt : VersionDao { @@ -56,12 +75,85 @@ internal interface VersionDaoInt : VersionDao { insert(packageVersion.manifest.getVersionedStrings(version)) } - @Insert(onConflict = OnConflictStrategy.REPLACE) + @Insert(onConflict = REPLACE) fun insert(version: Version) - @Insert(onConflict = OnConflictStrategy.REPLACE) + @Insert(onConflict = REPLACE) fun insert(versionedString: List) + @Update + fun update(version: Version) + + fun update( + repoId: Long, + packageId: String, + versionsDiffMap: Map?, + checkIfCompatible: (ManifestV2) -> Boolean, + ) { + if (versionsDiffMap == null) { // no more versions, delete all + deleteAppVersion(repoId, packageId) + } else versionsDiffMap.forEach { (versionId, jsonObject) -> + if (jsonObject == null) { // delete individual version + deleteAppVersion(repoId, packageId, versionId) + } else { + val version = getVersion(repoId, packageId, versionId) + if (version == null) { // new version, parse normally + val packageVersionV2: PackageVersionV2 = + json.decodeFromJsonElement(jsonObject) + val isCompatible = checkIfCompatible(packageVersionV2.manifest) + insert(repoId, packageId, versionId, packageVersionV2, isCompatible) + } else { // diff against existing version + diffVersion(version, jsonObject, checkIfCompatible) + } + } + } // end forEach + } + + private fun diffVersion( + version: Version, + jsonObject: JsonObject, + checkIfCompatible: (ManifestV2) -> Boolean, + ) { + // ensure that diff does not include internal keys + DENY_LIST.forEach { forbiddenKey -> + println("$forbiddenKey ${jsonObject.keys}") + if (jsonObject.containsKey(forbiddenKey)) { + throw SerializationException(forbiddenKey) + } + } + // diff version + val diffedVersion = ReflectionDiffer.applyDiff(version, jsonObject) + val isCompatible = checkIfCompatible(diffedVersion.manifest.toManifestV2()) + 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.packageId, 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 = json.decodeFromJsonElement(permissionArray) + list.toVersionedString(version, type) + }, + deleteList = { + deleteVersionedStrings(version.repoId, version.packageId, version.versionId, type) + }, + insertNewList = { versionedStrings -> insert(versionedStrings) }, + ) + override fun getAppVersions( packageId: String, ): LiveData> = liveData(dispatcher) { @@ -81,6 +173,10 @@ internal interface VersionDaoInt : VersionDao { } } + @Query("""SELECT * FROM Version + WHERE repoId = :repoId AND packageId = :packageId AND versionId = :versionId""") + fun getVersion(repoId: Long, packageId: String, versionId: String): Version? + @RewriteQueriesToDropUnusedColumns @Query("""SELECT * FROM Version JOIN RepositoryPreferences AS pref USING (repoId) @@ -122,11 +218,26 @@ internal interface VersionDaoInt : VersionDao { versionId: String, ): List - @VisibleForTesting + @Query("""DELETE FROM Version WHERE repoId = :repoId AND packageId = :packageId""") + fun deleteAppVersion(repoId: Long, packageId: String) + @Query("""DELETE FROM Version WHERE repoId = :repoId AND packageId = :packageId AND versionId = :versionId""") fun deleteAppVersion(repoId: Long, packageId: String, versionId: String) + @Query("""DELETE FROM VersionedString + WHERE repoId = :repoId AND packageId = :packageId AND versionId = :versionId""") + fun deleteVersionedStrings(repoId: Long, packageId: String, versionId: String) + + @Query("""DELETE FROM VersionedString WHERE repoId = :repoId + AND packageId = :packageId AND versionId = :versionId AND type = :type""") + fun deleteVersionedStrings( + repoId: Long, + packageId: String, + versionId: String, + type: VersionedStringType, + ) + @Query("SELECT COUNT(*) FROM Version") fun countAppVersions(): Int diff --git a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt index e8e947a09..8a3765133 100644 --- a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt +++ b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt @@ -65,7 +65,7 @@ public class IndexV1Updater( db.runInTransaction { val cert = verifier.getStreamAndVerify { inputStream -> updateListener?.onStartProcessing() // TODO maybe do more fine-grained reporting - val streamReceiver = DbV1StreamReceiver(db, compatibilityChecker, repoId) + val streamReceiver = DbV1StreamReceiver(db, repoId, compatibilityChecker) val streamProcessor = IndexV1StreamProcessor(streamReceiver, certificate) streamProcessor.process(inputStream) } diff --git a/database/src/test/java/org/fdroid/database/ConvertersTest.kt b/database/src/test/java/org/fdroid/database/ConvertersTest.kt index 230ac7bd6..37e46bfb1 100644 --- a/database/src/test/java/org/fdroid/database/ConvertersTest.kt +++ b/database/src/test/java/org/fdroid/database/ConvertersTest.kt @@ -1,5 +1,6 @@ package org.fdroid.database +import org.fdroid.test.TestRepoUtils.getRandomLocalizedFileV2 import org.fdroid.test.TestUtils.getRandomList import org.fdroid.test.TestUtils.getRandomString import kotlin.test.Test @@ -28,4 +29,13 @@ internal class ConvertersTest { assertEquals(list, convertedList) } + @Test + fun testFileV2Conversion() { + val file = getRandomLocalizedFileV2() + + val str = Converters.localizedFileV2toString(file) + val convertedFile = Converters.fromStringToLocalizedFileV2(str) + assertEquals(file, convertedFile) + } + } From 55d78fab01dc97d98cc23388815201221073409f Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 19 May 2022 16:17:34 -0300 Subject: [PATCH 26/42] [db] Add IndexV2Updater and RepoUpdater The IndexV2Updater fetches the new entry.jar, verifies it and then either processes a diff or the full index. The RepoUpdater is a convenience class that encapsulates updating repos with potentially more than one format. It tries v2 first (in case the repo has upgraded) and falls back to v1 if the repo isn't known to support v2 already (downgrade protection). --- .../java/org/fdroid/database/DbTest.kt | 37 +++ .../org/fdroid/database/IndexV1InsertTest.kt | 6 +- .../org/fdroid/database/IndexV2DiffTest.kt | 17 +- .../org/fdroid/database/IndexV2InsertTest.kt | 28 +- .../org/fdroid/database/RepositoryDiffTest.kt | 4 +- .../org/fdroid/database/UpdateCheckerTest.kt | 6 +- .../org/fdroid/database/test/TestUtils.kt | 7 +- .../org/fdroid/index/v2/IndexV2UpdaterTest.kt | 280 ++++++++++++++++++ .../org/fdroid/database/DbV1StreamReceiver.kt | 5 +- .../fdroid/database/DbV2DiffStreamReceiver.kt | 4 +- .../org/fdroid/database/DbV2StreamReceiver.kt | 15 +- .../org/fdroid/database/FDroidDatabase.kt | 3 +- .../java/org/fdroid/database/Repository.kt | 14 +- .../java/org/fdroid/database/RepositoryDao.kt | 44 +-- .../java/org/fdroid/index/IndexUpdater.kt | 69 +++++ .../main/java/org/fdroid/index/RepoUpdater.kt | 74 +++++ .../java/org/fdroid/index/v1/IndexUpdater.kt | 21 -- .../org/fdroid/index/v1/IndexV1Updater.kt | 78 +++-- .../org/fdroid/index/v2/IndexV2Updater.kt | 116 ++++++++ 19 files changed, 687 insertions(+), 141 deletions(-) create mode 100644 database/src/androidTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt create mode 100644 database/src/main/java/org/fdroid/index/IndexUpdater.kt create mode 100644 database/src/main/java/org/fdroid/index/RepoUpdater.kt delete mode 100644 database/src/main/java/org/fdroid/index/v1/IndexUpdater.kt create mode 100644 database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt diff --git a/database/src/androidTest/java/org/fdroid/database/DbTest.kt b/database/src/androidTest/java/org/fdroid/database/DbTest.kt index 1cfe12ec5..558b87495 100644 --- a/database/src/androidTest/java/org/fdroid/database/DbTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/DbTest.kt @@ -14,9 +14,12 @@ import kotlinx.coroutines.test.setMain import org.fdroid.database.test.TestUtils.assertRepoEquals import org.fdroid.database.test.TestUtils.toMetadataV2 import org.fdroid.database.test.TestUtils.toPackageVersionV2 +import org.fdroid.index.v1.IndexV1StreamProcessor import org.fdroid.index.v2.IndexV2 +import org.fdroid.index.v2.IndexV2FullStreamProcessor import org.fdroid.test.TestUtils.sort import org.fdroid.test.TestUtils.sorted +import org.fdroid.test.VerifierConstants.CERTIFICATE import org.junit.After import org.junit.Before import org.junit.runner.RunWith @@ -58,6 +61,40 @@ internal abstract class DbTest { db.close() } + protected fun streamIndexV1IntoDb( + indexAssetPath: String, + address: String = "https://f-droid.org/repo", + certificate: String = CERTIFICATE, + lastTimestamp: Long = -1, + ): Long { + val repoId = db.getRepositoryDao().insertEmptyRepo(address) + val streamReceiver = DbV1StreamReceiver(db, repoId) { true } + val indexProcessor = IndexV1StreamProcessor(streamReceiver, certificate, lastTimestamp) + db.runInTransaction { + assets.open(indexAssetPath).use { indexStream -> + indexProcessor.process(indexStream) + } + } + return repoId + } + + protected fun streamIndexV2IntoDb( + indexAssetPath: String, + address: String = "https://f-droid.org/repo", + version: Long = 42L, + certificate: String = CERTIFICATE, + ): Long { + val repoId = db.getRepositoryDao().insertEmptyRepo(address) + val streamReceiver = DbV2StreamReceiver(db, repoId) { true } + val indexProcessor = IndexV2FullStreamProcessor(streamReceiver, certificate) + db.runInTransaction { + assets.open(indexAssetPath).use { indexStream -> + indexProcessor.process(version, indexStream) {} + } + } + return repoId + } + /** * Asserts that data associated with the given [repoId] is equal to the given [index]. */ diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt index 655c32ff4..e7909ce87 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt @@ -62,7 +62,7 @@ internal class IndexV1InsertTest : DbTest() { private fun streamIndex(path: String): Long { val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") val streamReceiver = TestStreamReceiver(repoId) - val indexProcessor = IndexV1StreamProcessor(streamReceiver, null) + val indexProcessor = IndexV1StreamProcessor(streamReceiver, null, -1) db.runInTransaction { assets.open(path).use { indexStream -> indexProcessor.process(indexStream) @@ -80,7 +80,7 @@ internal class IndexV1InsertTest : DbTest() { val streamReceiver = TestStreamReceiver(repoId) { if (cIn.byteCount > 0) throw SerializationException() } - val indexProcessor = IndexV1StreamProcessor(streamReceiver, null) + val indexProcessor = IndexV1StreamProcessor(streamReceiver, null, -1) cIn.use { indexStream -> indexProcessor.process(indexStream) } @@ -100,7 +100,7 @@ internal class IndexV1InsertTest : DbTest() { private val callback: () -> Unit = {}, ) : IndexV1StreamReceiver { private val streamReceiver = DbV1StreamReceiver(db, repoId) { true } - override fun receive(repo: RepoV2, version: Int, certificate: String?) { + override fun receive(repo: RepoV2, version: Long, certificate: String?) { streamReceiver.receive(repo, version, certificate) callback() } diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV2DiffTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV2DiffTest.kt index cebdebe31..d598b1d31 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV2DiffTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/IndexV2DiffTest.kt @@ -6,7 +6,6 @@ import org.fdroid.index.IndexParser import org.fdroid.index.parseV2 import org.fdroid.index.v2.IndexV2 import org.fdroid.index.v2.IndexV2DiffStreamProcessor -import org.fdroid.index.v2.IndexV2StreamProcessor import org.fdroid.test.TestDataMaxV2 import org.fdroid.test.TestDataMidV2 import org.fdroid.test.TestDataMinV2 @@ -300,28 +299,16 @@ internal class IndexV2DiffTest : DbTest() { private fun testDiff(startPath: String, diffStream: InputStream, endIndex: IndexV2) { // stream start index into the DB - val repoId = streamIndex(startPath) + val repoId = streamIndexV2IntoDb(startPath) // apply diff stream to the DB val streamReceiver = DbV2DiffStreamReceiver(db, repoId) { true } val streamProcessor = IndexV2DiffStreamProcessor(streamReceiver) db.runInTransaction { - streamProcessor.process(diffStream) + streamProcessor.process(42, diffStream) {} } // assert that changed DB data is equal to given endIndex assertDbEquals(repoId, endIndex) } - private fun streamIndex(path: String): Long { - val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") - val streamReceiver = DbV2StreamReceiver(db, repoId) { true } - val indexProcessor = IndexV2StreamProcessor(streamReceiver, null) - db.runInTransaction { - assets.open(path).use { indexStream -> - indexProcessor.process(42, indexStream) - } - } - return repoId - } - } diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt b/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt index 2264ecc28..48b902b7e 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt @@ -4,7 +4,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.serialization.SerializationException import org.apache.commons.io.input.CountingInputStream import org.fdroid.CompatibilityChecker -import org.fdroid.index.v2.IndexV2StreamProcessor +import org.fdroid.index.v2.IndexV2FullStreamProcessor import org.fdroid.test.TestDataEmptyV2 import org.fdroid.test.TestDataMaxV2 import org.fdroid.test.TestDataMidV2 @@ -20,51 +20,39 @@ internal class IndexV2InsertTest : DbTest() { @Test fun testStreamEmptyIntoDb() { - val repoId = streamIndex("resources/index-empty-v2.json") + val repoId = streamIndexV2IntoDb("resources/index-empty-v2.json") assertEquals(1, repoDao.getRepositories().size) assertDbEquals(repoId, TestDataEmptyV2.index) } @Test fun testStreamMinIntoDb() { - val repoId = streamIndex("resources/index-min-v2.json") + val repoId = streamIndexV2IntoDb("resources/index-min-v2.json") assertEquals(1, repoDao.getRepositories().size) assertDbEquals(repoId, TestDataMinV2.index) } @Test fun testStreamMinReorderedIntoDb() { - val repoId = streamIndex("resources/index-min-reordered-v2.json") + val repoId = streamIndexV2IntoDb("resources/index-min-reordered-v2.json") assertEquals(1, repoDao.getRepositories().size) assertDbEquals(repoId, TestDataMinV2.index) } @Test fun testStreamMidIntoDb() { - val repoId = streamIndex("resources/index-mid-v2.json") + val repoId = streamIndexV2IntoDb("resources/index-mid-v2.json") assertEquals(1, repoDao.getRepositories().size) assertDbEquals(repoId, TestDataMidV2.index) } @Test fun testStreamMaxIntoDb() { - val repoId = streamIndex("resources/index-max-v2.json") + val repoId = streamIndexV2IntoDb("resources/index-max-v2.json") assertEquals(1, repoDao.getRepositories().size) assertDbEquals(repoId, TestDataMaxV2.index) } - private fun streamIndex(path: String): Long { - val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") - val streamReceiver = DbV2StreamReceiver(db, repoId) { true } - val indexProcessor = IndexV2StreamProcessor(streamReceiver, null) - db.runInTransaction { - assets.open(path).use { indexStream -> - indexProcessor.process(42, indexStream) - } - } - return repoId - } - @Test fun testExceptionWhileStreamingDoesNotSaveIntoDb() { val cIn = CountingInputStream(assets.open("resources/index-max-v2.json")) @@ -76,9 +64,9 @@ internal class IndexV2InsertTest : DbTest() { db.runInTransaction { val repoId = db.getRepositoryDao().insertEmptyRepo("http://example.org") val streamReceiver = DbV2StreamReceiver(db, repoId, compatibilityChecker) - val indexProcessor = IndexV2StreamProcessor(streamReceiver, null) + val indexProcessor = IndexV2FullStreamProcessor(streamReceiver, "") cIn.use { indexStream -> - indexProcessor.process(42, indexStream) + indexProcessor.process(42, indexStream) {} } } } diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt b/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt index 7640ec8ae..5e3f60bc4 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt @@ -66,7 +66,7 @@ internal class RepositoryDiffTest : DbTest() { // decode diff from JSON and update DB with it val diff = j.parseToJsonElement(json).jsonObject // Json.decodeFromString(json) - repoDao.updateRepository(repoId, diff) + repoDao.updateRepository(repoId, 42, diff) // fetch repos again and check that the result is as expected repos = repoDao.getRepositories().sortedBy { it.repoId } @@ -212,7 +212,7 @@ internal class RepositoryDiffTest : DbTest() { // decode diff from JSON and update DB with it val diff = j.parseToJsonElement(json).jsonObject - repoDao.updateRepository(repoId, diff) + repoDao.updateRepository(repoId, 42, diff) // fetch repos again and check that the result is as expected repos = repoDao.getRepositories().sortedBy { it.repoId } diff --git a/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt b/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt index a6eec8696..5a864664b 100644 --- a/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt @@ -2,7 +2,7 @@ package org.fdroid.database import android.util.Log import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.fdroid.index.v2.IndexV2StreamProcessor +import org.fdroid.index.v2.IndexV2FullStreamProcessor import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -27,9 +27,9 @@ internal class UpdateCheckerTest : DbTest() { db.runInTransaction { val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") val streamReceiver = DbV2StreamReceiver(db, repoId) { true } - val indexProcessor = IndexV2StreamProcessor(streamReceiver, null) + val indexProcessor = IndexV2FullStreamProcessor(streamReceiver, "") assets.open("resources/index-max-v2.json").use { indexStream -> - indexProcessor.process(42, indexStream) + indexProcessor.process(42, indexStream) {} } } val duration = measureTime { diff --git a/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt b/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt index e2e7724f7..3ef65d8de 100644 --- a/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt +++ b/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt @@ -37,8 +37,11 @@ internal object TestUtils { val expectedReleaseChannels = repoV2.releaseChannels.toRepoReleaseChannel(repoId).toSet() assertEquals(expectedReleaseChannels, repo.releaseChannels.toSet()) // core repo - val coreRepo = repoV2.toCoreRepository(version = repo.repository.version!!) - .copy(repoId = repoId) + val coreRepo = repoV2.toCoreRepository( + version = repo.repository.version!!.toLong(), + formatVersion = repo.repository.formatVersion, + certificate = repo.repository.certificate, + ).copy(repoId = repoId) assertEquals(coreRepo, repo.repository) } diff --git a/database/src/androidTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt b/database/src/androidTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt new file mode 100644 index 000000000..e04b6dc81 --- /dev/null +++ b/database/src/androidTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt @@ -0,0 +1,280 @@ +package org.fdroid.index.v2 + +import android.net.Uri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import org.fdroid.CompatibilityChecker +import org.fdroid.database.DbTest +import org.fdroid.database.IndexFormatVersion.TWO +import org.fdroid.database.Repository +import org.fdroid.download.Downloader +import org.fdroid.download.DownloaderFactory +import org.fdroid.index.IndexUpdateResult +import org.fdroid.index.SigningException +import org.fdroid.index.TempFileProvider +import org.fdroid.test.TestDataEntryV2 +import org.fdroid.test.TestDataMaxV2 +import org.fdroid.test.TestDataMidV2 +import org.fdroid.test.TestDataMinV2 +import org.fdroid.test.VerifierConstants.CERTIFICATE +import org.fdroid.test.VerifierConstants.FINGERPRINT +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +@RunWith(AndroidJUnit4::class) +internal class IndexV2UpdaterTest : DbTest() { + + @get:Rule + var tmpFolder: TemporaryFolder = TemporaryFolder() + + private val tempFileProvider: TempFileProvider = mockk() + private val downloaderFactory: DownloaderFactory = mockk() + private val downloader: Downloader = mockk() + private val compatibilityChecker: CompatibilityChecker = CompatibilityChecker { true } + private lateinit var indexUpdater: IndexV2Updater + + @Before + override fun createDb() { + super.createDb() + indexUpdater = IndexV2Updater(db, tempFileProvider, downloaderFactory, compatibilityChecker) + } + + @Test + fun testFullIndexEmptyToMin() { + val repoId = repoDao.insertEmptyRepo("http://example.org") + val repo = prepareUpdate( + repoId = repoId, + entryPath = "resources/diff-empty-min/entry.jar", + jsonPath = "resources/index-min-v2.json", + entryFileV2 = TestDataEntryV2.emptyToMin.index + ) + val result = indexUpdater.updateNewRepo(repo, FINGERPRINT).noError() + assertEquals(IndexUpdateResult.Processed, result) + assertDbEquals(repoId, TestDataMinV2.index) + + // check that certificate and format version got entered + val updatedRepo = repoDao.getRepository(repoId) ?: fail() + assertEquals(TWO, updatedRepo.formatVersion) + assertEquals(CERTIFICATE, updatedRepo.certificate) + } + + @Test + fun testFullIndexEmptyToMid() { + val repoId = repoDao.insertEmptyRepo("http://example.org") + val repo = prepareUpdate( + repoId = repoId, + entryPath = "resources/diff-empty-mid/entry.jar", + jsonPath = "resources/index-mid-v2.json", + entryFileV2 = TestDataEntryV2.emptyToMid.index + ) + val result = indexUpdater.updateNewRepo(repo, FINGERPRINT).noError() + assertEquals(IndexUpdateResult.Processed, result) + assertDbEquals(repoId, TestDataMidV2.index) + } + + @Test + fun testFullIndexEmptyToMax() { + val repoId = repoDao.insertEmptyRepo("http://example.org") + val repo = prepareUpdate( + repoId = repoId, + entryPath = "resources/diff-empty-max/entry.jar", + jsonPath = "resources/index-max-v2.json", + entryFileV2 = TestDataEntryV2.emptyToMax.index + ) + val result = indexUpdater.updateNewRepo(repo, FINGERPRINT).noError() + assertEquals(IndexUpdateResult.Processed, result) + assertDbEquals(repoId, TestDataMaxV2.index) + } + + @Test + fun testDiffMinToMid() { + val repoId = streamIndexV2IntoDb("resources/index-min-v2.json") + val repo = prepareUpdate( + repoId = repoId, + entryPath = "resources/diff-empty-mid/entry.jar", + jsonPath = "resources/diff-empty-mid/42.json", + entryFileV2 = TestDataEntryV2.emptyToMid.diffs["42"] ?: fail() + ) + val result = indexUpdater.update(repo).noError() + assertEquals(IndexUpdateResult.Processed, result) + assertDbEquals(repoId, TestDataMidV2.index) + } + + @Test + fun testDiffEmptyToMin() { + val repoId = streamIndexV2IntoDb("resources/index-empty-v2.json") + repoDao.updateRepository(repoId, CERTIFICATE) + val repo = prepareUpdate( + repoId = repoId, + entryPath = "resources/diff-empty-min/entry.jar", + jsonPath = "resources/diff-empty-min/23.json", + entryFileV2 = TestDataEntryV2.emptyToMin.diffs["23"] ?: fail() + ) + val result = indexUpdater.update(repo).noError() + assertEquals(IndexUpdateResult.Processed, result) + assertDbEquals(repoId, TestDataMinV2.index) + } + + @Test + fun testDiffMidToMax() { + val repoId = streamIndexV2IntoDb("resources/index-mid-v2.json") + repoDao.updateRepository(repoId, CERTIFICATE) + val repo = prepareUpdate( + repoId = repoId, + entryPath = "resources/diff-empty-max/entry.jar", + jsonPath = "resources/diff-empty-max/1337.json", + entryFileV2 = TestDataEntryV2.emptyToMax.diffs["1337"] ?: fail() + ) + val result = indexUpdater.update(repo).noError() + assertEquals(IndexUpdateResult.Processed, result) + assertDbEquals(repoId, TestDataMaxV2.index) + } + + @Test + fun testSameTimestampUnchanged() { + val repoId = streamIndexV2IntoDb("resources/index-min-v2.json") + repoDao.updateRepository(repoId, CERTIFICATE) + val repo = prepareUpdate( + repoId = repoId, + entryPath = "resources/diff-empty-min/entry.jar", + jsonPath = "resources/diff-empty-min/23.json", + entryFileV2 = TestDataEntryV2.emptyToMin.diffs["23"] ?: fail() + ) + val result = indexUpdater.update(repo).noError() + assertEquals(IndexUpdateResult.Unchanged, result) + assertDbEquals(repoId, TestDataMinV2.index) + } + + @Test + fun testHigherTimestampUnchanged() { + val repoId = streamIndexV2IntoDb("resources/index-mid-v2.json") + repoDao.updateRepository(repoId, CERTIFICATE) + val repo = prepareUpdate( + repoId = repoId, + entryPath = "resources/diff-empty-min/entry.jar", + jsonPath = "resources/diff-empty-min/23.json", + entryFileV2 = TestDataEntryV2.emptyToMin.diffs["23"] ?: fail() + ) + val result = indexUpdater.update(repo).noError() + assertEquals(IndexUpdateResult.Unchanged, result) + assertDbEquals(repoId, TestDataMidV2.index) + } + + @Test + fun testNoDiffFoundIndexFallback() { + val repoId = streamIndexV2IntoDb("resources/index-empty-v2.json") + repoDao.updateRepository(repoId, CERTIFICATE) + // fake timestamp of internal repo, so we will fail to find a diff in entry.json + val newRepo = repoDao.getRepository(repoId)?.repository?.copy(timestamp = 22) ?: fail() + repoDao.updateRepository(newRepo) + val repo = prepareUpdate( + repoId = repoId, + entryPath = "resources/diff-empty-min/entry.jar", + jsonPath = "resources/index-min-v2.json", + entryFileV2 = TestDataEntryV2.emptyToMin.index + ) + val result = indexUpdater.update(repo).noError() + assertEquals(IndexUpdateResult.Processed, result) + assertDbEquals(repoId, TestDataMinV2.index) + } + + @Test + fun testWrongFingerprint() { + val repoId = repoDao.insertEmptyRepo("http://example.org") + val repo = prepareUpdate( + repoId = repoId, + entryPath = "resources/diff-empty-min/entry.jar", + jsonPath = "resources/index-min-v2.json", + entryFileV2 = TestDataEntryV2.emptyToMin.index + ) + val result = indexUpdater.updateNewRepo(repo, "wrong fingerprint") + assertTrue(result is IndexUpdateResult.Error) + assertTrue(result.e is SigningException) + } + + @Test + fun testNormalUpdateOnRepoWithMissingFingerprint() { + val repoId = repoDao.insertEmptyRepo("http://example.org") + val repo = prepareUpdate( + repoId = repoId, + entryPath = "resources/diff-empty-min/entry.jar", + jsonPath = "resources/index-min-v2.json", + entryFileV2 = TestDataEntryV2.emptyToMin.index + ) + val result = indexUpdater.update(repo) + assertTrue(result is IndexUpdateResult.Error) + assertTrue(result.e is IllegalArgumentException) + } + + /** + * Ensures that a v1 repo can't use a diff when upgrading to v1, + * but must use a full index update. + */ + @Test + fun testV1ToV2ForcesFullUpdateEvenIfDiffExists() { + val repoId = streamIndexV1IntoDb("resources/index-min-v1.json") + val repo = prepareUpdate( + repoId = repoId, + entryPath = "resources/diff-empty-mid/entry.jar", + jsonPath = "resources/index-mid-v2.json", + entryFileV2 = TestDataEntryV2.emptyToMid.index, + ) + val result = indexUpdater.update(repo).noError() + assertEquals(IndexUpdateResult.Processed, result) + assertDbEquals(repoId, TestDataMidV2.index) + + // check that format version got upgraded + val updatedRepo = repoDao.getRepository(repoId) ?: fail() + assertEquals(TWO, updatedRepo.formatVersion) + } + + private fun prepareUpdate( + repoId: Long, + entryPath: String, + jsonPath: String, + entryFileV2: EntryFileV2, + ): Repository { + val entryFile = tmpFolder.newFile() + val indexFile = tmpFolder.newFile() + val repo = repoDao.getRepository(repoId) ?: fail() + val entryUri = Uri.parse("${repo.address}/entry.jar") + val indexUri = Uri.parse("${repo.address}/${entryFileV2.name.trimStart('/')}") + + assets.open(entryPath).use { inputStream -> + entryFile.outputStream().use { inputStream.copyTo(it) } + } + assets.open(jsonPath).use { inputStream -> + indexFile.outputStream().use { inputStream.copyTo(it) } + } + + every { tempFileProvider.createTempFile() } returnsMany listOf(entryFile, indexFile) + every { + downloaderFactory.createWithTryFirstMirror(repo, entryUri, entryFile) + } returns downloader + every { downloader.download(-1) } just Runs + every { + downloaderFactory.createWithTryFirstMirror(repo, indexUri, indexFile) + } returns downloader + every { downloader.download(entryFileV2.size, entryFileV2.sha256) } just Runs + + return repo + } + + /** + * Easier for debugging, if we throw the index error. + */ + private fun IndexUpdateResult.noError(): IndexUpdateResult { + if (this is IndexUpdateResult.Error) throw e + return this + } + +} diff --git a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt index 5bcbb0f41..9519130ce 100644 --- a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt @@ -4,6 +4,7 @@ import android.content.res.Resources import androidx.core.os.ConfigurationCompat.getLocales import androidx.core.os.LocaleListCompat import org.fdroid.CompatibilityChecker +import org.fdroid.database.IndexFormatVersion.ONE import org.fdroid.index.v1.IndexV1StreamReceiver import org.fdroid.index.v2.AntiFeatureV2 import org.fdroid.index.v2.CategoryV2 @@ -25,9 +26,9 @@ internal class DbV1StreamReceiver( private val locales: LocaleListCompat = getLocales(Resources.getSystem().configuration) - override fun receive(repo: RepoV2, version: Int, certificate: String?) { + override fun receive(repo: RepoV2, version: Long, certificate: String?) { db.getRepositoryDao().clear(repoId) - db.getRepositoryDao().update(repoId, repo, version, certificate) + db.getRepositoryDao().update(repoId, repo, version, ONE, certificate) } override fun receive(packageId: String, m: MetadataV2) { diff --git a/database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt index 7ea910b5d..689d33c69 100644 --- a/database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt @@ -15,8 +15,8 @@ internal class DbV2DiffStreamReceiver( private val locales: LocaleListCompat = getLocales(Resources.getSystem().configuration) - override fun receiveRepoDiff(repoJsonObject: JsonObject) { - db.getRepositoryDao().updateRepository(repoId, repoJsonObject) + override fun receiveRepoDiff(version: Long, repoJsonObject: JsonObject) { + db.getRepositoryDao().updateRepository(repoId, version, repoJsonObject) } override fun receivePackageMetadataDiff(packageId: String, packageJsonObject: JsonObject?) { diff --git a/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt index c7c77aac6..0e278895d 100644 --- a/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt @@ -3,7 +3,10 @@ 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.database.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 @@ -16,15 +19,23 @@ internal class DbV2StreamReceiver( 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: Int, certificate: String?) { + override fun receive(repo: RepoV2, version: Long, certificate: String) { + repo.walkFiles(nonNullFileV2) clearRepoDataIfNeeded() - db.getRepositoryDao().update(repoId, repo, version, certificate) + db.getRepositoryDao().update(repoId, repo, version, TWO, certificate) } @Synchronized override fun receive(packageId: String, p: PackageV2) { + p.walkFiles(nonNullFileV2) clearRepoDataIfNeeded() db.getAppDao().insert(repoId, packageId, p.metadata, locales) db.getVersionDao().insert(repoId, packageId, p.versions) { diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index 7ec3f7402..9ac207a7f 100644 --- a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -16,7 +16,7 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @Database( - version = 5, // TODO set version to 1 before release and wipe old schemas + version = 6, // TODO set version to 1 before release and wipe old schemas entities = [ // repo CoreRepository::class, @@ -44,6 +44,7 @@ import kotlinx.coroutines.launch AutoMigration(from = 1, to = 2), AutoMigration(from = 1, to = 3), AutoMigration(from = 2, to = 3), + AutoMigration(from = 5, to = 6), ], ) @TypeConverters(Converters::class) diff --git a/database/src/main/java/org/fdroid/database/Repository.kt b/database/src/main/java/org/fdroid/database/Repository.kt index a16b782a8..e3faadf23 100644 --- a/database/src/main/java/org/fdroid/database/Repository.kt +++ b/database/src/main/java/org/fdroid/database/Repository.kt @@ -17,6 +17,8 @@ import org.fdroid.index.v2.MirrorV2 import org.fdroid.index.v2.ReleaseChannelV2 import org.fdroid.index.v2.RepoV2 +public enum class IndexFormatVersion { ONE, TWO } + @Entity public data class CoreRepository( @PrimaryKey(autoGenerate = true) val repoId: Long = 0, @@ -25,7 +27,8 @@ public data class CoreRepository( val address: String, val webBaseUrl: String? = null, val timestamp: Long, - val version: Int?, + val version: Long?, + val formatVersion: IndexFormatVersion?, val maxAge: Int?, val description: LocalizedTextV2 = emptyMap(), val certificate: String?, @@ -33,7 +36,8 @@ public data class CoreRepository( internal fun RepoV2.toCoreRepository( repoId: Long = 0, - version: Int, + version: Long, + formatVersion: IndexFormatVersion? = null, certificate: String? = null, ) = CoreRepository( repoId = repoId, @@ -43,6 +47,7 @@ internal fun RepoV2.toCoreRepository( webBaseUrl = webBaseUrl, timestamp = timestamp, version = version, + formatVersion = formatVersion, maxAge = null, description = description, certificate = certificate, @@ -82,7 +87,8 @@ public data class Repository( val address: String get() = repository.address val webBaseUrl: String? get() = repository.webBaseUrl val timestamp: Long get() = repository.timestamp - val version: Int get() = repository.version ?: 0 + val version: Long get() = repository.version ?: 0 + val formatVersion: IndexFormatVersion? get() = repository.formatVersion internal val description: LocalizedTextV2 get() = repository.description val certificate: String? get() = repository.certificate @@ -258,7 +264,7 @@ public data class InitialRepository( val address: String, val description: String, val certificate: String, - val version: Int, + val version: Long, val enabled: Boolean, val weight: Int, ) diff --git a/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/database/src/main/java/org/fdroid/database/RepositoryDao.kt index 01dc3278b..66520fd51 100644 --- a/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -24,25 +24,11 @@ public interface RepositoryDao { */ public fun insert(initialRepo: InitialRepository) - /** - * 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. - */ - public fun clear(repoId: Long) - /** * Removes all repos and their preferences. */ public fun clearAll() - /** - * Updates an existing repo with new data from a full index update. - * Call [clear] first to ensure old data was removed. - */ - public fun update(repoId: Long, repository: RepoV2, version: Int, certificate: String?) - public fun getRepository(repoId: Long): Repository? public fun insertEmptyRepo( address: String, @@ -93,6 +79,7 @@ internal interface RepositoryDaoInt : RepositoryDao { icon = null, timestamp = -1, version = initialRepo.version, + formatVersion = null, maxAge = null, description = mapOf("en-US" to initialRepo.description), certificate = initialRepo.certificate, @@ -117,8 +104,9 @@ internal interface RepositoryDaoInt : RepositoryDao { name = mapOf("en-US" to address), icon = null, address = address, - timestamp = System.currentTimeMillis(), + timestamp = -1, version = null, + formatVersion = null, maxAge = null, certificate = null, ) @@ -150,8 +138,14 @@ internal interface RepositoryDaoInt : RepositoryDao { insert(repositoryPreferences) } + /** + * 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 - override fun clear(repoId: Long) { + 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) @@ -163,9 +157,19 @@ internal interface RepositoryDaoInt : RepositoryDao { deleteAllRepositoryPreferences() } + /** + * Updates an existing repo with new data from a full index update. + * Call [clear] first to ensure old data was removed. + */ @Transaction - override fun update(repoId: Long, repository: RepoV2, version: Int, certificate: String?) { - update(repository.toCoreRepository(repoId, version, certificate)) + fun update( + repoId: Long, + repository: RepoV2, + version: Long, + formatVersion: IndexFormatVersion, + certificate: String?, + ) { + update(repository.toCoreRepository(repoId, version, formatVersion, certificate)) insertRepoTables(repoId, repository) } @@ -181,11 +185,11 @@ internal interface RepositoryDaoInt : RepositoryDao { override fun getRepository(repoId: Long): Repository? @Transaction - fun updateRepository(repoId: Long, jsonObject: JsonObject) { + 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)) + updateRepository(applyDiff(repo.repository, jsonObject).copy(version = version)) // replace mirror list (if it is in the diff) diffAndUpdateListTable( jsonObject = jsonObject, diff --git a/database/src/main/java/org/fdroid/index/IndexUpdater.kt b/database/src/main/java/org/fdroid/index/IndexUpdater.kt new file mode 100644 index 000000000..bc2fd37b8 --- /dev/null +++ b/database/src/main/java/org/fdroid/index/IndexUpdater.kt @@ -0,0 +1,69 @@ +package org.fdroid.index + +import org.fdroid.database.IndexFormatVersion +import org.fdroid.database.Repository +import org.fdroid.download.Downloader +import org.fdroid.download.NotFoundException +import java.io.File +import java.io.IOException + +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 TempFileProvider { + @Throws(IOException::class) + public fun createTempFile(): File +} + +public abstract class IndexUpdater { + + public abstract val formatVersion: IndexFormatVersion + + public fun updateNewRepo( + repo: Repository, + expectedSigningFingerprint: String?, + ): IndexUpdateResult = catchExceptions { + update(repo, null, expectedSigningFingerprint) + } + + 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) + } +} diff --git a/database/src/main/java/org/fdroid/index/RepoUpdater.kt b/database/src/main/java/org/fdroid/index/RepoUpdater.kt new file mode 100644 index 000000000..954488d15 --- /dev/null +++ b/database/src/main/java/org/fdroid/index/RepoUpdater.kt @@ -0,0 +1,74 @@ +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 + +public class RepoUpdater( + tempDir: File, + db: FDroidDatabase, + downloaderFactory: DownloaderFactory, + 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(db, tempFileProvider, downloaderFactory, compatibilityChecker, listener), + IndexV1Updater(db, tempFileProvider, downloaderFactory, compatibilityChecker, listener), + ) + + 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}")) + } + +} diff --git a/database/src/main/java/org/fdroid/index/v1/IndexUpdater.kt b/database/src/main/java/org/fdroid/index/v1/IndexUpdater.kt deleted file mode 100644 index 1d8725faa..000000000 --- a/database/src/main/java/org/fdroid/index/v1/IndexUpdater.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.fdroid.index.v1 - -import android.net.Uri -import org.fdroid.database.Repository - -public enum class IndexUpdateResult { - UNCHANGED, - PROCESSED, - NOT_FOUND, -} - -public interface IndexUpdateListener { - public fun onDownloadProgress(bytesRead: Long, totalBytes: Long) - public fun onStartProcessing() -} - -public class IndexUpdater - -public fun Repository.getCanonicalUri(): Uri = Uri.parse(address).buildUpon() - .appendPath(SIGNED_FILE_NAME) - .build() diff --git a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt index 8a3765133..b2124284c 100644 --- a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt +++ b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt @@ -1,89 +1,79 @@ package org.fdroid.index.v1 -import android.content.Context +import android.net.Uri import org.fdroid.CompatibilityChecker import org.fdroid.database.DbV1StreamReceiver import org.fdroid.database.FDroidDatabase import org.fdroid.database.FDroidDatabaseInt +import org.fdroid.database.IndexFormatVersion +import org.fdroid.database.IndexFormatVersion.ONE +import org.fdroid.database.Repository import org.fdroid.download.DownloaderFactory -import java.io.File -import java.io.IOException +import org.fdroid.index.IndexUpdateListener +import org.fdroid.index.IndexUpdateResult +import org.fdroid.index.IndexUpdater +import org.fdroid.index.TempFileProvider +import org.fdroid.index.setIndexUpdateListener internal const val SIGNED_FILE_NAME = "index-v1.jar" -// TODO should this live here and cause a dependency on download lib or in dedicated module? +@Suppress("DEPRECATION") public class IndexV1Updater( - private val context: Context, database: FDroidDatabase, + private val tempFileProvider: TempFileProvider, private val downloaderFactory: DownloaderFactory, private val compatibilityChecker: CompatibilityChecker, -) { + private val listener: IndexUpdateListener? = null, +) : IndexUpdater() { + public override val formatVersion: IndexFormatVersion = ONE private val db: FDroidDatabaseInt = database as FDroidDatabaseInt - @Throws(IOException::class, InterruptedException::class) - public fun updateNewRepo( - repoId: Long, - expectedSigningFingerprint: String?, - updateListener: IndexUpdateListener? = null, - ): IndexUpdateResult { - return update(repoId, null, expectedSigningFingerprint, updateListener) - } - - @Throws(IOException::class, InterruptedException::class) - public fun update( - repoId: Long, - certificate: String, - updateListener: IndexUpdateListener? = null, - ): IndexUpdateResult { - return update(repoId, certificate, null, updateListener) - } - - @Throws(IOException::class, InterruptedException::class) - private fun update( - repoId: Long, + override fun update( + repo: Repository, certificate: String?, fingerprint: String?, - updateListener: IndexUpdateListener?, ): IndexUpdateResult { - val repo = - db.getRepositoryDao().getRepository(repoId) ?: error("Unexpected repoId: $repoId") - val uri = repo.getCanonicalUri() - val file = File.createTempFile("dl-", "", context.cacheDir) + // don't allow repository downgrades + val formatVersion = repo.repository.formatVersion + require(formatVersion == null || formatVersion == ONE) { + "Format downgrade not allowed for ${repo.address}" + } + val uri = Uri.parse(repo.address).buildUpon().appendPath(SIGNED_FILE_NAME).build() + val file = tempFileProvider.createTempFile() val downloader = downloaderFactory.createWithTryFirstMirror(repo, uri, file).apply { cacheTag = repo.lastETag - updateListener?.let { setListener(updateListener::onDownloadProgress) } + setIndexUpdateListener(listener, repo) } try { downloader.download() - // TODO in MirrorChooser don't try again on 404 - // when tryFirstMirror is set == isRepoDownload - if (!downloader.hasChanged()) return IndexUpdateResult.UNCHANGED + if (!downloader.hasChanged()) return IndexUpdateResult.Unchanged val eTag = downloader.cacheTag val verifier = IndexV1Verifier(file, certificate, fingerprint) db.runInTransaction { - val cert = verifier.getStreamAndVerify { inputStream -> - updateListener?.onStartProcessing() // TODO maybe do more fine-grained reporting - val streamReceiver = DbV1StreamReceiver(db, repoId, compatibilityChecker) - val streamProcessor = IndexV1StreamProcessor(streamReceiver, certificate) + 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) { - db.getRepositoryDao().updateRepository(repoId, cert) + repoDao.updateRepository(repo.repoId, cert) } // update RepositoryPreferences with timestamp and ETag (for v1) val updatedPrefs = repo.preferences.copy( lastUpdated = System.currentTimeMillis(), lastETag = eTag, ) - db.getRepositoryDao().updateRepositoryPreferences(updatedPrefs) + repoDao.updateRepositoryPreferences(updatedPrefs) } } finally { file.delete() } - return IndexUpdateResult.PROCESSED + return IndexUpdateResult.Processed } - } diff --git a/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt b/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt new file mode 100644 index 000000000..5141d508f --- /dev/null +++ b/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt @@ -0,0 +1,116 @@ +package org.fdroid.index.v2 + +import android.net.Uri +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.IndexFormatVersion +import org.fdroid.database.IndexFormatVersion.ONE +import org.fdroid.database.IndexFormatVersion.TWO +import org.fdroid.database.Repository +import org.fdroid.download.DownloaderFactory +import org.fdroid.index.IndexParser +import org.fdroid.index.IndexUpdateListener +import org.fdroid.index.IndexUpdateResult +import org.fdroid.index.IndexUpdater +import org.fdroid.index.TempFileProvider +import org.fdroid.index.parseEntryV2 +import org.fdroid.index.setIndexUpdateListener + +internal const val SIGNED_FILE_NAME = "entry.jar" + +private fun Repository.getUri(fileName: String): Uri = Uri.parse(address).buildUpon() + .appendEncodedPath(fileName.trimStart('/')) + .build() + +public class IndexV2Updater( + database: FDroidDatabase, + private val tempFileProvider: TempFileProvider, + private val downloaderFactory: DownloaderFactory, + 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 { + val uri = repo.getUri(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 = repo.getUri(entryFile.name) + 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 + } +} From 890dc45718043c37519eb4db0ef33f5121b2f55d Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 2 Jun 2022 13:58:39 -0300 Subject: [PATCH 27/42] [db] Implement interfaces from index library so we can use UpdateChecker and CompatibilityChecker with our DB classes --- ...eCheckerTest.kt => DbUpdateCheckerTest.kt} | 6 +- .../main/java/org/fdroid/database/AppDao.kt | 2 +- .../main/java/org/fdroid/database/AppPrefs.kt | 7 +- .../{UpdateChecker.kt => DbUpdateChecker.kt} | 76 ++++++++----------- .../main/java/org/fdroid/database/Version.kt | 36 ++++----- .../java/org/fdroid/database/VersionDao.kt | 10 +-- 6 files changed, 59 insertions(+), 78 deletions(-) rename database/src/androidTest/java/org/fdroid/database/{UpdateCheckerTest.kt => DbUpdateCheckerTest.kt} (86%) rename database/src/main/java/org/fdroid/database/{UpdateChecker.kt => DbUpdateChecker.kt} (62%) diff --git a/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt b/database/src/androidTest/java/org/fdroid/database/DbUpdateCheckerTest.kt similarity index 86% rename from database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt rename to database/src/androidTest/java/org/fdroid/database/DbUpdateCheckerTest.kt index 5a864664b..4f58159c4 100644 --- a/database/src/androidTest/java/org/fdroid/database/UpdateCheckerTest.kt +++ b/database/src/androidTest/java/org/fdroid/database/DbUpdateCheckerTest.kt @@ -10,15 +10,15 @@ import kotlin.time.ExperimentalTime import kotlin.time.measureTime @RunWith(AndroidJUnit4::class) -internal class UpdateCheckerTest : DbTest() { +internal class DbUpdateCheckerTest : DbTest() { - private lateinit var updateChecker: UpdateChecker + private lateinit var updateChecker: DbUpdateChecker @Before override fun createDb() { super.createDb() // TODO mock packageManager and maybe move to unit tests - updateChecker = UpdateChecker(db, context.packageManager) + updateChecker = DbUpdateChecker(db, context.packageManager) } @Test diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index 9939cf3bb..e5e5b3919 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -464,7 +464,7 @@ internal interface AppDaoInt : AppDao { override fun getNumberOfAppsInCategory(category: String): Int /** - * Used by [UpdateChecker] to get specific apps with available updates. + * Used by [DbUpdateChecker] to get specific apps with available updates. */ @Transaction @SuppressWarnings(CURSOR_MISMATCH) // no anti-features needed here diff --git a/database/src/main/java/org/fdroid/database/AppPrefs.kt b/database/src/main/java/org/fdroid/database/AppPrefs.kt index c32a0bb3c..11593c5a1 100644 --- a/database/src/main/java/org/fdroid/database/AppPrefs.kt +++ b/database/src/main/java/org/fdroid/database/AppPrefs.kt @@ -2,18 +2,19 @@ package org.fdroid.database import androidx.room.Entity import androidx.room.PrimaryKey +import org.fdroid.PackagePreference @Entity public data class AppPrefs( @PrimaryKey val packageId: String, - val ignoreVersionCodeUpdate: Long = 0, + 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? = null, -) { +) : PackagePreference { public val ignoreAllUpdates: Boolean get() = ignoreVersionCodeUpdate == Long.MAX_VALUE - public val releaseChannels: List get() = appPrefReleaseChannels ?: emptyList() + public override val releaseChannels: List get() = appPrefReleaseChannels ?: emptyList() public fun shouldIgnoreUpdate(versionCode: Long): Boolean = ignoreVersionCodeUpdate >= versionCode diff --git a/database/src/main/java/org/fdroid/database/UpdateChecker.kt b/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt similarity index 62% rename from database/src/main/java/org/fdroid/database/UpdateChecker.kt rename to database/src/main/java/org/fdroid/database/DbUpdateChecker.kt index b7eee6de5..cdf2cae30 100644 --- a/database/src/main/java/org/fdroid/database/UpdateChecker.kt +++ b/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt @@ -6,9 +6,10 @@ import android.content.pm.PackageManager import android.content.pm.PackageManager.GET_SIGNATURES import android.os.Build import org.fdroid.CompatibilityCheckerImpl -import org.fdroid.index.IndexUtils +import org.fdroid.PackagePreference +import org.fdroid.UpdateChecker -public class UpdateChecker( +public class DbUpdateChecker( db: FDroidDatabase, private val packageManager: PackageManager, ) { @@ -17,6 +18,7 @@ public class UpdateChecker( 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. @@ -37,7 +39,7 @@ public class UpdateChecker( installedPackages.iterator().forEach { packageInfo -> val packageName = packageInfo.packageName val versions = versionsByPackage[packageName] ?: return@forEach // continue - val version = getVersion(versions, packageName, packageInfo, releaseChannels) + val version = getVersion(versions, packageName, packageInfo, null, releaseChannels) if (version != null) { val versionCode = packageInfo.getVersionCode() val app = getUpdatableApp(version, versionCode) @@ -48,13 +50,17 @@ public class UpdateChecker( } /** - * Returns an [AppVersion] for the given [packageName] that is an update + * 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 getUpdate(packageName: String, releaseChannels: List? = null): AppVersion? { + public fun getSuggestedVersion( + packageName: String, + preferredSigner: String? = null, + releaseChannels: List? = null, + ): AppVersion? { val versions = versionDao.getVersions(listOf(packageName)) if (versions.isEmpty()) return null val packageInfo = try { @@ -63,7 +69,8 @@ public class UpdateChecker( } catch (e: PackageManager.NameNotFoundException) { null } - val version = getVersion(versions, packageName, packageInfo, releaseChannels) ?: return null + val version = getVersion(versions, packageName, packageInfo, preferredSigner, + releaseChannels) ?: return null val versionedStrings = versionDao.getVersionedStrings( repoId = version.repoId, packageId = version.packageId, @@ -76,50 +83,27 @@ public class UpdateChecker( versions: List, packageName: String, packageInfo: PackageInfo?, + preferredSigner: String?, releaseChannels: List?, ): Version? { - val versionCode = packageInfo?.getVersionCode() ?: 0 - // the below is rather expensive, so we only do that when there's update candidates - // TODO handle signingInfo.signingCertificateHistory as well - @Suppress("DEPRECATION") - val signatures by lazy { - packageInfo?.signatures?.map { - IndexUtils.getPackageSignature(it.toByteArray()) - }?.toSet() + val preferencesGetter: (() -> PackagePreference?) = { + appPrefsDao.getAppPrefsOrNull(packageName) } - val appPrefs by lazy { appPrefsDao.getAppPrefsOrNull(packageName) } - versions.iterator().forEach versions@{ version -> - // if the installed version has a known vulnerability, we return it as well - if (version.manifest.versionCode == versionCode && version.hasKnownVulnerability) { - return version - } - // if version code is not higher than installed skip package as list is sorted - if (version.manifest.versionCode <= versionCode) return null - // skip incompatible versions - if (!compatibilityChecker.isCompatible(version.manifest.toManifestV2())) return@versions - // only check release channels if they are not empty - if (!version.releaseChannels.isNullOrEmpty()) { - // add release channels from AppPrefs into the ones we allow - val channels = releaseChannels?.toMutableSet() ?: LinkedHashSet() - if (!appPrefs?.releaseChannels.isNullOrEmpty()) { - channels.addAll(appPrefs!!.releaseChannels) - } - // if allowed releases channels are empty (only stable) don't consider this version - if (channels.isEmpty()) return@versions - // don't consider version with non-matching release channel - if (channels.intersect(version.releaseChannels).isEmpty()) return@versions - } - val canInstall = if (packageInfo == null) { - true // take first one with highest version code and repo weight - } else { - // TODO also support AppPrefs with ignoring updates - val versionSignatures = version.manifest.signer?.sha256?.toSet() - signatures == versionSignatures - } - // no need to see other versions, we got the highest version code per sorting - if (canInstall) return version + 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, + ) } - return null } private fun getUpdatableApp(version: Version, installedVersionCode: Long): UpdatableApp? { diff --git a/database/src/main/java/org/fdroid/database/Version.kt b/database/src/main/java/org/fdroid/database/Version.kt index 45ca10d9a..7aaa85268 100644 --- a/database/src/main/java/org/fdroid/database/Version.kt +++ b/database/src/main/java/org/fdroid/database/Version.kt @@ -6,18 +6,18 @@ import androidx.room.Entity import androidx.room.ForeignKey import org.fdroid.database.VersionedStringType.PERMISSION import org.fdroid.database.VersionedStringType.PERMISSION_SDK_23 -import org.fdroid.index.v2.FeatureV2 +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.SignatureV2 +import org.fdroid.index.v2.SignerV2 import org.fdroid.index.v2.UsesSdkV2 -private const val ANTI_FEATURE_KNOWN_VULNERABILITY = "KnownVuln" - @Entity( primaryKeys = ["repoId", "packageId", "versionId"], foreignKeys = [ForeignKey( @@ -35,12 +35,15 @@ public data class Version( @Embedded(prefix = "file_") val file: FileV1, @Embedded(prefix = "src_") val src: FileV2? = null, @Embedded(prefix = "manifest_") val manifest: AppManifest, - val releaseChannels: List? = emptyList(), + override val releaseChannels: List? = emptyList(), val antiFeatures: Map? = null, val whatsNew: LocalizedTextV2? = null, val isCompatible: Boolean, -) { - val hasKnownVulnerability: 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): AppVersion = AppVersion( @@ -97,20 +100,13 @@ public data class AppManifest( val versionName: String, val versionCode: Long, @Embedded(prefix = "usesSdk_") val usesSdk: UsesSdkV2? = null, - val maxSdkVersion: Int? = null, - @Embedded(prefix = "signer_") val signer: SignatureV2? = null, - val nativecode: List? = emptyList(), + override val maxSdkVersion: Int? = null, + @Embedded(prefix = "signer_") val signer: SignerV2? = null, + override val nativecode: List? = emptyList(), val features: List? = emptyList(), -) { - internal fun toManifestV2(): ManifestV2 = ManifestV2( - versionName = versionName, - versionCode = versionCode, - usesSdk = usesSdk, - maxSdkVersion = maxSdkVersion, - signer = signer, - nativecode = nativecode ?: emptyList(), - features = features?.map { FeatureV2(it) } ?: emptyList(), - ) +) : PackageManifest { + override val minSdkVersion: Int? get() = usesSdk?.minSdkVersion + override val featureNames: List? get() = features } internal fun ManifestV2.toManifest() = AppManifest( diff --git a/database/src/main/java/org/fdroid/database/VersionDao.kt b/database/src/main/java/org/fdroid/database/VersionDao.kt index de82509e7..e68501660 100644 --- a/database/src/main/java/org/fdroid/database/VersionDao.kt +++ b/database/src/main/java/org/fdroid/database/VersionDao.kt @@ -19,7 +19,7 @@ import org.fdroid.database.FDroidDatabaseHolder.dispatcher 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.ManifestV2 +import org.fdroid.index.v2.PackageManifest import org.fdroid.index.v2.PackageVersionV2 import org.fdroid.index.v2.PermissionV2 import org.fdroid.index.v2.ReflectionDiffer @@ -88,7 +88,7 @@ internal interface VersionDaoInt : VersionDao { repoId: Long, packageId: String, versionsDiffMap: Map?, - checkIfCompatible: (ManifestV2) -> Boolean, + checkIfCompatible: (PackageManifest) -> Boolean, ) { if (versionsDiffMap == null) { // no more versions, delete all deleteAppVersion(repoId, packageId) @@ -100,7 +100,7 @@ internal interface VersionDaoInt : VersionDao { if (version == null) { // new version, parse normally val packageVersionV2: PackageVersionV2 = json.decodeFromJsonElement(jsonObject) - val isCompatible = checkIfCompatible(packageVersionV2.manifest) + val isCompatible = checkIfCompatible(packageVersionV2.packageManifest) insert(repoId, packageId, versionId, packageVersionV2, isCompatible) } else { // diff against existing version diffVersion(version, jsonObject, checkIfCompatible) @@ -112,7 +112,7 @@ internal interface VersionDaoInt : VersionDao { private fun diffVersion( version: Version, jsonObject: JsonObject, - checkIfCompatible: (ManifestV2) -> Boolean, + checkIfCompatible: (PackageManifest) -> Boolean, ) { // ensure that diff does not include internal keys DENY_LIST.forEach { forbiddenKey -> @@ -123,7 +123,7 @@ internal interface VersionDaoInt : VersionDao { } // diff version val diffedVersion = ReflectionDiffer.applyDiff(version, jsonObject) - val isCompatible = checkIfCompatible(diffedVersion.manifest.toManifestV2()) + val isCompatible = checkIfCompatible(diffedVersion.packageManifest) update(diffedVersion.copy(isCompatible = isCompatible)) // diff versioned strings val manifest = jsonObject["manifest"] From db275bf218d18d196880be0d638c6e4712cea35c Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 6 Jun 2022 16:10:01 -0300 Subject: [PATCH 28/42] [db] Run tests also locally with roboelectric This is much faster and doesn't require a device. However, the test should also continue to run on-device as this is the sqlite version used in practice. --- database/build.gradle | 25 ++++-- database/src/androidTest/assets/resources | 1 - .../fdroid/database/DbUpdateCheckerTest.kt | 41 ---------- .../java/org/fdroid/database/AppTest.kt | 2 +- .../java/org/fdroid/database/DbTest.kt | 20 ++--- .../fdroid/database/DbUpdateCheckerTest.kt | 82 +++++++++++++++++++ .../org/fdroid/database/IndexV1InsertTest.kt | 10 +-- .../org/fdroid/database/IndexV2DiffTest.kt | 54 ++++++------ .../org/fdroid/database/IndexV2InsertTest.kt | 12 +-- .../org/fdroid/database/RepositoryDiffTest.kt | 2 +- .../org/fdroid/database/RepositoryTest.kt | 2 +- .../java/org/fdroid/database}/TestUtils.kt | 10 +-- .../java/org/fdroid/database/VersionTest.kt | 2 +- .../org/fdroid/index/v2/IndexV2UpdaterTest.kt | 62 +++++++------- .../java/org/fdroid/database/VersionDao.kt | 1 - gradle/verification-metadata.xml | 35 ++++++-- 16 files changed, 213 insertions(+), 148 deletions(-) delete mode 120000 database/src/androidTest/assets/resources delete mode 100644 database/src/androidTest/java/org/fdroid/database/DbUpdateCheckerTest.kt rename database/src/{androidTest => dbTest}/java/org/fdroid/database/AppTest.kt (99%) rename database/src/{androidTest => dbTest}/java/org/fdroid/database/DbTest.kt (88%) create mode 100644 database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt rename database/src/{androidTest => dbTest}/java/org/fdroid/database/IndexV1InsertTest.kt (93%) rename database/src/{androidTest => dbTest}/java/org/fdroid/database/IndexV2DiffTest.kt (84%) rename database/src/{androidTest => dbTest}/java/org/fdroid/database/IndexV2InsertTest.kt (84%) rename database/src/{androidTest => dbTest}/java/org/fdroid/database/RepositoryDiffTest.kt (99%) rename database/src/{androidTest => dbTest}/java/org/fdroid/database/RepositoryTest.kt (98%) rename database/src/{androidTest/java/org/fdroid/database/test => dbTest/java/org/fdroid/database}/TestUtils.kt (92%) rename database/src/{androidTest => dbTest}/java/org/fdroid/database/VersionTest.kt (98%) rename database/src/{androidTest => dbTest}/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt (82%) diff --git a/database/build.gradle b/database/build.gradle index 7869215b9..695cb22ca 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -29,16 +29,25 @@ android { } sourceSets { androidTest { + java.srcDirs += "src/dbTest/java" java.srcDirs += "src/sharedTest/kotlin" + assets.srcDirs += "src/sharedTest/resources" } test { + java.srcDirs += "src/dbTest/java" java.srcDirs += "src/sharedTest/kotlin" + assets.srcDirs += "src/sharedTest/resources" } } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + testOptions { + unitTests { + includeAndroidResources = true + } + } kotlinOptions { jvmTarget = '1.8' freeCompilerArgs += "-Xexplicit-api=strict" @@ -58,7 +67,7 @@ dependencies { implementation project(":download") implementation project(":index") - implementation 'androidx.core:core-ktx:1.5.0' + implementation 'androidx.core:core-ktx:1.8.0' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1' def room_version = "2.4.2" @@ -71,16 +80,20 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2" - testImplementation 'junit:junit:4.13.1' + testImplementation 'junit:junit:4.13.2' + testImplementation 'io.mockk:mockk:1.12.4' testImplementation 'org.jetbrains.kotlin:kotlin-test' + testImplementation 'androidx.test:core:1.4.0' + testImplementation 'androidx.test.ext:junit:1.1.3' + testImplementation 'androidx.arch.core:core-testing:2.1.0' + testImplementation 'org.robolectric:robolectric:4.8.1' + testImplementation 'commons-io:commons-io:2.6' - androidTestImplementation 'io.mockk:mockk-android:1.12.3' + androidTestImplementation 'io.mockk:mockk-android:1.12.3' // 1.12.4 has strange error androidTestImplementation 'org.jetbrains.kotlin:kotlin-test' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.arch.core:core-testing:2.1.0' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' - androidTestImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.5.2' - + androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' androidTestImplementation 'commons-io:commons-io:2.6' } diff --git a/database/src/androidTest/assets/resources b/database/src/androidTest/assets/resources deleted file mode 120000 index 522d03381..000000000 --- a/database/src/androidTest/assets/resources +++ /dev/null @@ -1 +0,0 @@ -../../sharedTest/resources \ No newline at end of file diff --git a/database/src/androidTest/java/org/fdroid/database/DbUpdateCheckerTest.kt b/database/src/androidTest/java/org/fdroid/database/DbUpdateCheckerTest.kt deleted file mode 100644 index 4f58159c4..000000000 --- a/database/src/androidTest/java/org/fdroid/database/DbUpdateCheckerTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.fdroid.database - -import android.util.Log -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.fdroid.index.v2.IndexV2FullStreamProcessor -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith -import kotlin.time.ExperimentalTime -import kotlin.time.measureTime - -@RunWith(AndroidJUnit4::class) -internal class DbUpdateCheckerTest : DbTest() { - - private lateinit var updateChecker: DbUpdateChecker - - @Before - override fun createDb() { - super.createDb() - // TODO mock packageManager and maybe move to unit tests - updateChecker = DbUpdateChecker(db, context.packageManager) - } - - @Test - @OptIn(ExperimentalTime::class) - fun testGetUpdates() { - db.runInTransaction { - val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") - val streamReceiver = DbV2StreamReceiver(db, repoId) { true } - val indexProcessor = IndexV2FullStreamProcessor(streamReceiver, "") - assets.open("resources/index-max-v2.json").use { indexStream -> - indexProcessor.process(42, indexStream) {} - } - } - val duration = measureTime { - updateChecker.getUpdatableApps() - } - Log.e("TEST", "$duration") - } - -} diff --git a/database/src/androidTest/java/org/fdroid/database/AppTest.kt b/database/src/dbTest/java/org/fdroid/database/AppTest.kt similarity index 99% rename from database/src/androidTest/java/org/fdroid/database/AppTest.kt rename to database/src/dbTest/java/org/fdroid/database/AppTest.kt index 0c26910cb..92a20b8f2 100644 --- a/database/src/androidTest/java/org/fdroid/database/AppTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/AppTest.kt @@ -2,7 +2,7 @@ package org.fdroid.database import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.fdroid.database.test.TestUtils.getOrAwaitValue +import org.fdroid.database.TestUtils.getOrAwaitValue import org.fdroid.test.TestAppUtils.assertScreenshotsEqual import org.fdroid.test.TestAppUtils.getRandomMetadataV2 import org.fdroid.test.TestRepoUtils.getRandomFileV2 diff --git a/database/src/androidTest/java/org/fdroid/database/DbTest.kt b/database/src/dbTest/java/org/fdroid/database/DbTest.kt similarity index 88% rename from database/src/androidTest/java/org/fdroid/database/DbTest.kt rename to database/src/dbTest/java/org/fdroid/database/DbTest.kt index 558b87495..36295b058 100644 --- a/database/src/androidTest/java/org/fdroid/database/DbTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/DbTest.kt @@ -4,16 +4,14 @@ import android.content.Context import android.content.res.AssetManager import androidx.core.os.LocaleListCompat import androidx.room.Room -import androidx.test.core.app.ApplicationProvider -import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.core.app.ApplicationProvider.getApplicationContext import io.mockk.every import io.mockk.mockkObject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.setMain -import org.fdroid.database.test.TestUtils.assertRepoEquals -import org.fdroid.database.test.TestUtils.toMetadataV2 -import org.fdroid.database.test.TestUtils.toPackageVersionV2 +import org.fdroid.database.TestUtils.assertRepoEquals +import org.fdroid.database.TestUtils.toMetadataV2 +import org.fdroid.database.TestUtils.toPackageVersionV2 import org.fdroid.index.v1.IndexV1StreamProcessor import org.fdroid.index.v2.IndexV2 import org.fdroid.index.v2.IndexV2FullStreamProcessor @@ -22,13 +20,11 @@ import org.fdroid.test.TestUtils.sorted import org.fdroid.test.VerifierConstants.CERTIFICATE import org.junit.After import org.junit.Before -import org.junit.runner.RunWith import java.io.IOException import java.util.Locale import kotlin.test.assertEquals import kotlin.test.fail -@RunWith(AndroidJUnit4::class) @OptIn(ExperimentalCoroutinesApi::class) internal abstract class DbTest { @@ -38,19 +34,19 @@ internal abstract class DbTest { internal lateinit var db: FDroidDatabaseInt private val testCoroutineDispatcher = Dispatchers.Unconfined - protected val context: Context = ApplicationProvider.getApplicationContext() + protected val context: Context = getApplicationContext() protected val assets: AssetManager = context.resources.assets protected val locales = LocaleListCompat.create(Locale.US) @Before open fun createDb() { - db = Room.inMemoryDatabaseBuilder(context, FDroidDatabaseInt::class.java).build() + db = Room.inMemoryDatabaseBuilder(context, FDroidDatabaseInt::class.java) + .allowMainThreadQueries() + .build() repoDao = db.getRepositoryDao() appDao = db.getAppDao() versionDao = db.getVersionDao() - Dispatchers.setMain(testCoroutineDispatcher) - mockkObject(FDroidDatabaseHolder) every { FDroidDatabaseHolder.dispatcher } returns testCoroutineDispatcher } diff --git a/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt b/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt new file mode 100644 index 000000000..7d2d59965 --- /dev/null +++ b/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt @@ -0,0 +1,82 @@ +package org.fdroid.database + +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.every +import io.mockk.mockk +import org.fdroid.index.RELEASE_CHANNEL_BETA +import org.fdroid.test.TestDataMidV2 +import org.fdroid.test.TestDataMinV2 +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@RunWith(AndroidJUnit4::class) +internal class DbUpdateCheckerTest : DbTest() { + + private lateinit var updateChecker: DbUpdateChecker + private val packageManager: PackageManager = mockk() + + private val packageInfo = PackageInfo().apply { + packageName = TestDataMinV2.packageName + @Suppress("DEPRECATION") + versionCode = 0 + } + + @Before + override fun createDb() { + super.createDb() + every { packageManager.systemAvailableFeatures } returns emptyArray() + updateChecker = DbUpdateChecker(db, packageManager) + } + + @Test + fun testSuggestedVersion() { + val repoId = streamIndexV2IntoDb("index-min-v2.json") + every { + packageManager.getPackageInfo(packageInfo.packageName, any()) + } returns packageInfo + val appVersion = updateChecker.getSuggestedVersion(packageInfo.packageName) + val expectedVersion = TestDataMinV2.version.toVersion( + repoId = repoId, + packageId = packageInfo.packageName, + versionId = TestDataMinV2.version.file.sha256, + isCompatible = true, + ) + assertEquals(appVersion!!.version, expectedVersion) + } + + @Test + fun testSuggestedVersionRespectsReleaseChannels() { + streamIndexV2IntoDb("index-mid-v2.json") + every { packageManager.getPackageInfo(packageInfo.packageName, any()) } returns null + + // no suggestion version, because all beta + val appVersion1 = updateChecker.getSuggestedVersion(packageInfo.packageName) + assertNull(appVersion1) + + // now suggests only available version + val appVersion2 = updateChecker.getSuggestedVersion( + packageName = packageInfo.packageName, + releaseChannels = listOf(RELEASE_CHANNEL_BETA), + preferredSigner = TestDataMidV2.version1_2.signer!!.sha256[0], + ) + assertEquals(TestDataMidV2.version1_2.versionCode, appVersion2!!.version.versionCode) + } + + @Test + fun testGetUpdatableApps() { + streamIndexV2IntoDb("index-min-v2.json") + every { packageManager.getInstalledPackages(any()) } returns listOf(packageInfo) + + val appVersions = updateChecker.getUpdatableApps() + assertEquals(1, appVersions.size) + assertEquals(0, appVersions[0].installedVersionCode) + assertEquals(TestDataMinV2.packageName, appVersions[0].packageId) + assertEquals(TestDataMinV2.version.file.sha256, appVersions[0].upgrade.version.versionId) + } + +} diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt b/database/src/dbTest/java/org/fdroid/database/IndexV1InsertTest.kt similarity index 93% rename from database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt rename to database/src/dbTest/java/org/fdroid/database/IndexV1InsertTest.kt index e7909ce87..2124e983a 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV1InsertTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/IndexV1InsertTest.kt @@ -29,7 +29,7 @@ internal class IndexV1InsertTest : DbTest() { @Test fun testStreamEmptyIntoDb() { - val repoId = streamIndex("resources/index-empty-v1.json") + val repoId = streamIndex("index-empty-v1.json") assertEquals(1, repoDao.getRepositories().size) val index = indexConverter.toIndexV2(TestDataEmptyV1.index) assertDbEquals(repoId, index) @@ -37,7 +37,7 @@ internal class IndexV1InsertTest : DbTest() { @Test fun testStreamMinIntoDb() { - val repoId = streamIndex("resources/index-min-v1.json") + val repoId = streamIndex("index-min-v1.json") assertTrue(repoDao.getRepositories().size == 1) val index = indexConverter.toIndexV2(TestDataMinV1.index) assertDbEquals(repoId, index) @@ -45,7 +45,7 @@ internal class IndexV1InsertTest : DbTest() { @Test fun testStreamMidIntoDb() { - val repoId = streamIndex("resources/index-mid-v1.json") + val repoId = streamIndex("index-mid-v1.json") assertTrue(repoDao.getRepositories().size == 1) val index = indexConverter.toIndexV2(TestDataMidV1.index) assertDbEquals(repoId, index) @@ -53,7 +53,7 @@ internal class IndexV1InsertTest : DbTest() { @Test fun testStreamMaxIntoDb() { - val repoId = streamIndex("resources/index-max-v1.json") + val repoId = streamIndex("index-max-v1.json") assertTrue(repoDao.getRepositories().size == 1) val index = indexConverter.toIndexV2(TestDataMaxV1.index) assertDbEquals(repoId, index) @@ -73,7 +73,7 @@ internal class IndexV1InsertTest : DbTest() { @Test fun testExceptionWhileStreamingDoesNotSaveIntoDb() { - val cIn = CountingInputStream(assets.open("resources/index-max-v1.json")) + val cIn = CountingInputStream(assets.open("index-max-v1.json")) assertFailsWith { db.runInTransaction { val repoId = db.getRepositoryDao().insertEmptyRepo("https://f-droid.org/repo") diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV2DiffTest.kt b/database/src/dbTest/java/org/fdroid/database/IndexV2DiffTest.kt similarity index 84% rename from database/src/androidTest/java/org/fdroid/database/IndexV2DiffTest.kt rename to database/src/dbTest/java/org/fdroid/database/IndexV2DiffTest.kt index d598b1d31..898c5483a 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV2DiffTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/IndexV2DiffTest.kt @@ -22,54 +22,54 @@ internal class IndexV2DiffTest : DbTest() { @Test @Ignore("use for testing specific index on demand") fun testBrokenIndexDiff() { - val endPath = "resources/tmp/index-end.json" + val endPath = "tmp/index-end.json" val endIndex = IndexParser.parseV2(assets.open(endPath)) testDiff( - startPath = "resources/tmp/index-start.json", - diffPath = "resources/tmp/diff.json", + startPath = "tmp/index-start.json", + diffPath = "tmp/diff.json", endIndex = endIndex, ) } @Test fun testEmptyToMin() = testDiff( - startPath = "resources/index-empty-v2.json", - diffPath = "resources/diff-empty-min/23.json", + startPath = "index-empty-v2.json", + diffPath = "diff-empty-min/23.json", endIndex = TestDataMinV2.index, ) @Test fun testEmptyToMid() = testDiff( - startPath = "resources/index-empty-v2.json", - diffPath = "resources/diff-empty-mid/23.json", + startPath = "index-empty-v2.json", + diffPath = "diff-empty-mid/23.json", endIndex = TestDataMidV2.index, ) @Test fun testEmptyToMax() = testDiff( - startPath = "resources/index-empty-v2.json", - diffPath = "resources/diff-empty-max/23.json", + startPath = "index-empty-v2.json", + diffPath = "diff-empty-max/23.json", endIndex = TestDataMaxV2.index, ) @Test fun testMinToMid() = testDiff( - startPath = "resources/index-min-v2.json", - diffPath = "resources/diff-empty-mid/42.json", + startPath = "index-min-v2.json", + diffPath = "diff-empty-mid/42.json", endIndex = TestDataMidV2.index, ) @Test fun testMinToMax() = testDiff( - startPath = "resources/index-min-v2.json", - diffPath = "resources/diff-empty-max/42.json", + startPath = "index-min-v2.json", + diffPath = "diff-empty-max/42.json", endIndex = TestDataMaxV2.index, ) @Test fun testMidToMax() = testDiff( - startPath = "resources/index-mid-v2.json", - diffPath = "resources/diff-empty-max/1337.json", + startPath = "index-mid-v2.json", + diffPath = "diff-empty-max/1337.json", endIndex = TestDataMaxV2.index, ) @@ -81,7 +81,7 @@ internal class IndexV2DiffTest : DbTest() { } }""".trimIndent() testJsonDiff( - startPath = "resources/index-min-v2.json", + startPath = "index-min-v2.json", diff = diffJson, endIndex = TestDataMinV2.index.copy(packages = emptyMap()), ) @@ -102,7 +102,7 @@ internal class IndexV2DiffTest : DbTest() { } }""".trimIndent() testJsonDiff( - startPath = "resources/index-min-v2.json", + startPath = "index-min-v2.json", diff = diffJson, endIndex = TestDataMinV2.index.copy( packages = TestDataMinV2.index.packages.mapValues { @@ -125,7 +125,7 @@ internal class IndexV2DiffTest : DbTest() { } }""".trimIndent() testJsonDiff( - startPath = "resources/index-min-v2.json", + startPath = "index-min-v2.json", diff = diffJson, endIndex = TestDataMinV2.index.copy( packages = TestDataMinV2.index.packages.mapValues { @@ -146,7 +146,7 @@ internal class IndexV2DiffTest : DbTest() { "unknownKey": "should get ignored" }""".trimIndent() testJsonDiff( - startPath = "resources/index-min-v2.json", + startPath = "index-min-v2.json", diff = diffJson, endIndex = TestDataMinV2.index.copy( packages = emptyMap() @@ -164,7 +164,7 @@ internal class IndexV2DiffTest : DbTest() { } }""".trimIndent() testJsonDiff( - startPath = "resources/index-min-v2.json", + startPath = "index-min-v2.json", diff = diffJson, endIndex = TestDataMinV2.index.copy( packages = TestDataMinV2.index.packages.mapValues { @@ -183,7 +183,7 @@ internal class IndexV2DiffTest : DbTest() { } }""".trimIndent() testJsonDiff( - startPath = "resources/index-min-v2.json", + startPath = "index-min-v2.json", diff = diffJson, endIndex = TestDataMinV2.index, ) @@ -202,7 +202,7 @@ internal class IndexV2DiffTest : DbTest() { }""".trimIndent() assertFailsWith { testJsonDiff( - startPath = "resources/index-min-v2.json", + startPath = "index-min-v2.json", diff = diffRepoIdJson, endIndex = TestDataMinV2.index, ) @@ -218,7 +218,7 @@ internal class IndexV2DiffTest : DbTest() { }""".trimIndent() assertFailsWith { testJsonDiff( - startPath = "resources/index-min-v2.json", + startPath = "index-min-v2.json", diff = diffPackageIdJson, endIndex = TestDataMinV2.index, ) @@ -229,21 +229,21 @@ internal class IndexV2DiffTest : DbTest() { fun testVersionsDenyKeyList() { assertFailsWith { testJsonDiff( - startPath = "resources/index-min-v2.json", + startPath = "index-min-v2.json", diff = getMinVersionJson(""""packageId": "foo""""), endIndex = TestDataMinV2.index, ) } assertFailsWith { testJsonDiff( - startPath = "resources/index-min-v2.json", + startPath = "index-min-v2.json", diff = getMinVersionJson(""""repoId": 1"""), endIndex = TestDataMinV2.index, ) } assertFailsWith { testJsonDiff( - startPath = "resources/index-min-v2.json", + startPath = "index-min-v2.json", diff = getMinVersionJson(""""versionId": "bar""""), endIndex = TestDataMinV2.index, ) @@ -278,7 +278,7 @@ internal class IndexV2DiffTest : DbTest() { ) ) testJsonDiff( - startPath = "resources/index-mid-v2.json", + startPath = "index-mid-v2.json", diff = diffRepoIdJson, endIndex = TestDataMidV2.index.copy( packages = mapOf( diff --git a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt b/database/src/dbTest/java/org/fdroid/database/IndexV2InsertTest.kt similarity index 84% rename from database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt rename to database/src/dbTest/java/org/fdroid/database/IndexV2InsertTest.kt index 48b902b7e..63e1e042f 100644 --- a/database/src/androidTest/java/org/fdroid/database/IndexV2InsertTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/IndexV2InsertTest.kt @@ -20,42 +20,42 @@ internal class IndexV2InsertTest : DbTest() { @Test fun testStreamEmptyIntoDb() { - val repoId = streamIndexV2IntoDb("resources/index-empty-v2.json") + val repoId = streamIndexV2IntoDb("index-empty-v2.json") assertEquals(1, repoDao.getRepositories().size) assertDbEquals(repoId, TestDataEmptyV2.index) } @Test fun testStreamMinIntoDb() { - val repoId = streamIndexV2IntoDb("resources/index-min-v2.json") + val repoId = streamIndexV2IntoDb("index-min-v2.json") assertEquals(1, repoDao.getRepositories().size) assertDbEquals(repoId, TestDataMinV2.index) } @Test fun testStreamMinReorderedIntoDb() { - val repoId = streamIndexV2IntoDb("resources/index-min-reordered-v2.json") + val repoId = streamIndexV2IntoDb("index-min-reordered-v2.json") assertEquals(1, repoDao.getRepositories().size) assertDbEquals(repoId, TestDataMinV2.index) } @Test fun testStreamMidIntoDb() { - val repoId = streamIndexV2IntoDb("resources/index-mid-v2.json") + val repoId = streamIndexV2IntoDb("index-mid-v2.json") assertEquals(1, repoDao.getRepositories().size) assertDbEquals(repoId, TestDataMidV2.index) } @Test fun testStreamMaxIntoDb() { - val repoId = streamIndexV2IntoDb("resources/index-max-v2.json") + val repoId = streamIndexV2IntoDb("index-max-v2.json") assertEquals(1, repoDao.getRepositories().size) assertDbEquals(repoId, TestDataMaxV2.index) } @Test fun testExceptionWhileStreamingDoesNotSaveIntoDb() { - val cIn = CountingInputStream(assets.open("resources/index-max-v2.json")) + val cIn = CountingInputStream(assets.open("index-max-v2.json")) val compatibilityChecker = CompatibilityChecker { if (cIn.byteCount > 0) throw SerializationException() true diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt b/database/src/dbTest/java/org/fdroid/database/RepositoryDiffTest.kt similarity index 99% rename from database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt rename to database/src/dbTest/java/org/fdroid/database/RepositoryDiffTest.kt index 5e3f60bc4..433964e44 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryDiffTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/RepositoryDiffTest.kt @@ -4,7 +4,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject -import org.fdroid.database.test.TestUtils.assertRepoEquals +import org.fdroid.database.TestUtils.assertRepoEquals import org.fdroid.index.v2.AntiFeatureV2 import org.fdroid.index.v2.CategoryV2 import org.fdroid.index.v2.ReleaseChannelV2 diff --git a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt b/database/src/dbTest/java/org/fdroid/database/RepositoryTest.kt similarity index 98% rename from database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt rename to database/src/dbTest/java/org/fdroid/database/RepositoryTest.kt index 73a580248..bfd0248a6 100644 --- a/database/src/androidTest/java/org/fdroid/database/RepositoryTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/RepositoryTest.kt @@ -1,7 +1,7 @@ package org.fdroid.database import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.fdroid.database.test.TestUtils.assertRepoEquals +import org.fdroid.database.TestUtils.assertRepoEquals import org.fdroid.test.TestAppUtils.getRandomMetadataV2 import org.fdroid.test.TestRepoUtils.getRandomRepo import org.fdroid.test.TestUtils.getRandomString diff --git a/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt b/database/src/dbTest/java/org/fdroid/database/TestUtils.kt similarity index 92% rename from database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt rename to database/src/dbTest/java/org/fdroid/database/TestUtils.kt index 3ef65d8de..6c03fd856 100644 --- a/database/src/androidTest/java/org/fdroid/database/test/TestUtils.kt +++ b/database/src/dbTest/java/org/fdroid/database/TestUtils.kt @@ -1,15 +1,7 @@ -package org.fdroid.database.test +package org.fdroid.database import androidx.lifecycle.LiveData import androidx.lifecycle.Observer -import org.fdroid.database.App -import org.fdroid.database.AppVersion -import org.fdroid.database.Repository -import org.fdroid.database.toCoreRepository -import org.fdroid.database.toMirror -import org.fdroid.database.toRepoAntiFeatures -import org.fdroid.database.toRepoCategories -import org.fdroid.database.toRepoReleaseChannel import org.fdroid.index.v2.FeatureV2 import org.fdroid.index.v2.ManifestV2 import org.fdroid.index.v2.MetadataV2 diff --git a/database/src/androidTest/java/org/fdroid/database/VersionTest.kt b/database/src/dbTest/java/org/fdroid/database/VersionTest.kt similarity index 98% rename from database/src/androidTest/java/org/fdroid/database/VersionTest.kt rename to database/src/dbTest/java/org/fdroid/database/VersionTest.kt index 8468685b5..549e53456 100644 --- a/database/src/androidTest/java/org/fdroid/database/VersionTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/VersionTest.kt @@ -2,7 +2,7 @@ package org.fdroid.database import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.fdroid.database.test.TestUtils.getOrAwaitValue +import org.fdroid.database.TestUtils.getOrAwaitValue import org.fdroid.test.TestAppUtils.getRandomMetadataV2 import org.fdroid.test.TestRepoUtils.getRandomRepo import org.fdroid.test.TestUtils.getRandomString diff --git a/database/src/androidTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt b/database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt similarity index 82% rename from database/src/androidTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt rename to database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt index e04b6dc81..03194ed6a 100644 --- a/database/src/androidTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt +++ b/database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt @@ -53,8 +53,8 @@ internal class IndexV2UpdaterTest : DbTest() { val repoId = repoDao.insertEmptyRepo("http://example.org") val repo = prepareUpdate( repoId = repoId, - entryPath = "resources/diff-empty-min/entry.jar", - jsonPath = "resources/index-min-v2.json", + entryPath = "diff-empty-min/entry.jar", + jsonPath = "index-min-v2.json", entryFileV2 = TestDataEntryV2.emptyToMin.index ) val result = indexUpdater.updateNewRepo(repo, FINGERPRINT).noError() @@ -72,8 +72,8 @@ internal class IndexV2UpdaterTest : DbTest() { val repoId = repoDao.insertEmptyRepo("http://example.org") val repo = prepareUpdate( repoId = repoId, - entryPath = "resources/diff-empty-mid/entry.jar", - jsonPath = "resources/index-mid-v2.json", + entryPath = "diff-empty-mid/entry.jar", + jsonPath = "index-mid-v2.json", entryFileV2 = TestDataEntryV2.emptyToMid.index ) val result = indexUpdater.updateNewRepo(repo, FINGERPRINT).noError() @@ -86,8 +86,8 @@ internal class IndexV2UpdaterTest : DbTest() { val repoId = repoDao.insertEmptyRepo("http://example.org") val repo = prepareUpdate( repoId = repoId, - entryPath = "resources/diff-empty-max/entry.jar", - jsonPath = "resources/index-max-v2.json", + entryPath = "diff-empty-max/entry.jar", + jsonPath = "index-max-v2.json", entryFileV2 = TestDataEntryV2.emptyToMax.index ) val result = indexUpdater.updateNewRepo(repo, FINGERPRINT).noError() @@ -97,11 +97,11 @@ internal class IndexV2UpdaterTest : DbTest() { @Test fun testDiffMinToMid() { - val repoId = streamIndexV2IntoDb("resources/index-min-v2.json") + val repoId = streamIndexV2IntoDb("index-min-v2.json") val repo = prepareUpdate( repoId = repoId, - entryPath = "resources/diff-empty-mid/entry.jar", - jsonPath = "resources/diff-empty-mid/42.json", + entryPath = "diff-empty-mid/entry.jar", + jsonPath = "diff-empty-mid/42.json", entryFileV2 = TestDataEntryV2.emptyToMid.diffs["42"] ?: fail() ) val result = indexUpdater.update(repo).noError() @@ -111,12 +111,12 @@ internal class IndexV2UpdaterTest : DbTest() { @Test fun testDiffEmptyToMin() { - val repoId = streamIndexV2IntoDb("resources/index-empty-v2.json") + val repoId = streamIndexV2IntoDb("index-empty-v2.json") repoDao.updateRepository(repoId, CERTIFICATE) val repo = prepareUpdate( repoId = repoId, - entryPath = "resources/diff-empty-min/entry.jar", - jsonPath = "resources/diff-empty-min/23.json", + entryPath = "diff-empty-min/entry.jar", + jsonPath = "diff-empty-min/23.json", entryFileV2 = TestDataEntryV2.emptyToMin.diffs["23"] ?: fail() ) val result = indexUpdater.update(repo).noError() @@ -126,12 +126,12 @@ internal class IndexV2UpdaterTest : DbTest() { @Test fun testDiffMidToMax() { - val repoId = streamIndexV2IntoDb("resources/index-mid-v2.json") + val repoId = streamIndexV2IntoDb("index-mid-v2.json") repoDao.updateRepository(repoId, CERTIFICATE) val repo = prepareUpdate( repoId = repoId, - entryPath = "resources/diff-empty-max/entry.jar", - jsonPath = "resources/diff-empty-max/1337.json", + entryPath = "diff-empty-max/entry.jar", + jsonPath = "diff-empty-max/1337.json", entryFileV2 = TestDataEntryV2.emptyToMax.diffs["1337"] ?: fail() ) val result = indexUpdater.update(repo).noError() @@ -141,12 +141,12 @@ internal class IndexV2UpdaterTest : DbTest() { @Test fun testSameTimestampUnchanged() { - val repoId = streamIndexV2IntoDb("resources/index-min-v2.json") + val repoId = streamIndexV2IntoDb("index-min-v2.json") repoDao.updateRepository(repoId, CERTIFICATE) val repo = prepareUpdate( repoId = repoId, - entryPath = "resources/diff-empty-min/entry.jar", - jsonPath = "resources/diff-empty-min/23.json", + entryPath = "diff-empty-min/entry.jar", + jsonPath = "diff-empty-min/23.json", entryFileV2 = TestDataEntryV2.emptyToMin.diffs["23"] ?: fail() ) val result = indexUpdater.update(repo).noError() @@ -156,12 +156,12 @@ internal class IndexV2UpdaterTest : DbTest() { @Test fun testHigherTimestampUnchanged() { - val repoId = streamIndexV2IntoDb("resources/index-mid-v2.json") + val repoId = streamIndexV2IntoDb("index-mid-v2.json") repoDao.updateRepository(repoId, CERTIFICATE) val repo = prepareUpdate( repoId = repoId, - entryPath = "resources/diff-empty-min/entry.jar", - jsonPath = "resources/diff-empty-min/23.json", + entryPath = "diff-empty-min/entry.jar", + jsonPath = "diff-empty-min/23.json", entryFileV2 = TestDataEntryV2.emptyToMin.diffs["23"] ?: fail() ) val result = indexUpdater.update(repo).noError() @@ -171,15 +171,15 @@ internal class IndexV2UpdaterTest : DbTest() { @Test fun testNoDiffFoundIndexFallback() { - val repoId = streamIndexV2IntoDb("resources/index-empty-v2.json") + val repoId = streamIndexV2IntoDb("index-empty-v2.json") repoDao.updateRepository(repoId, CERTIFICATE) // fake timestamp of internal repo, so we will fail to find a diff in entry.json val newRepo = repoDao.getRepository(repoId)?.repository?.copy(timestamp = 22) ?: fail() repoDao.updateRepository(newRepo) val repo = prepareUpdate( repoId = repoId, - entryPath = "resources/diff-empty-min/entry.jar", - jsonPath = "resources/index-min-v2.json", + entryPath = "diff-empty-min/entry.jar", + jsonPath = "index-min-v2.json", entryFileV2 = TestDataEntryV2.emptyToMin.index ) val result = indexUpdater.update(repo).noError() @@ -192,8 +192,8 @@ internal class IndexV2UpdaterTest : DbTest() { val repoId = repoDao.insertEmptyRepo("http://example.org") val repo = prepareUpdate( repoId = repoId, - entryPath = "resources/diff-empty-min/entry.jar", - jsonPath = "resources/index-min-v2.json", + entryPath = "diff-empty-min/entry.jar", + jsonPath = "index-min-v2.json", entryFileV2 = TestDataEntryV2.emptyToMin.index ) val result = indexUpdater.updateNewRepo(repo, "wrong fingerprint") @@ -206,8 +206,8 @@ internal class IndexV2UpdaterTest : DbTest() { val repoId = repoDao.insertEmptyRepo("http://example.org") val repo = prepareUpdate( repoId = repoId, - entryPath = "resources/diff-empty-min/entry.jar", - jsonPath = "resources/index-min-v2.json", + entryPath = "diff-empty-min/entry.jar", + jsonPath = "index-min-v2.json", entryFileV2 = TestDataEntryV2.emptyToMin.index ) val result = indexUpdater.update(repo) @@ -221,11 +221,11 @@ internal class IndexV2UpdaterTest : DbTest() { */ @Test fun testV1ToV2ForcesFullUpdateEvenIfDiffExists() { - val repoId = streamIndexV1IntoDb("resources/index-min-v1.json") + val repoId = streamIndexV1IntoDb("index-min-v1.json") val repo = prepareUpdate( repoId = repoId, - entryPath = "resources/diff-empty-mid/entry.jar", - jsonPath = "resources/index-mid-v2.json", + entryPath = "diff-empty-mid/entry.jar", + jsonPath = "index-mid-v2.json", entryFileV2 = TestDataEntryV2.emptyToMid.index, ) val result = indexUpdater.update(repo).noError() diff --git a/database/src/main/java/org/fdroid/database/VersionDao.kt b/database/src/main/java/org/fdroid/database/VersionDao.kt index e68501660..a26c724af 100644 --- a/database/src/main/java/org/fdroid/database/VersionDao.kt +++ b/database/src/main/java/org/fdroid/database/VersionDao.kt @@ -116,7 +116,6 @@ internal interface VersionDaoInt : VersionDao { ) { // ensure that diff does not include internal keys DENY_LIST.forEach { forbiddenKey -> - println("$forbiddenKey ${jsonObject.keys}") if (jsonObject.containsKey(forbiddenKey)) { throw SerializationException(forbiddenKey) } diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 1592bb576..a8cccac91 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -418,6 +418,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + @@ -913,6 +938,11 @@ + + + + + @@ -4177,11 +4207,6 @@ - - - - - From 0a88975c278422e19abe9eb6e8b15967fd41b809 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 8 Jun 2022 10:52:31 -0300 Subject: [PATCH 29/42] [db] improve loading of versions faster permission loading and more tests --- .../dbTest/java/org/fdroid/database/DbTest.kt | 2 + .../java/org/fdroid/database/TestUtils.kt | 4 + .../java/org/fdroid/database/VersionTest.kt | 194 ++++++++++++++---- .../org/fdroid/database/DbUpdateChecker.kt | 4 +- .../main/java/org/fdroid/database/Version.kt | 20 +- .../java/org/fdroid/database/VersionDao.kt | 110 +++++----- 6 files changed, 226 insertions(+), 108 deletions(-) diff --git a/database/src/dbTest/java/org/fdroid/database/DbTest.kt b/database/src/dbTest/java/org/fdroid/database/DbTest.kt index 36295b058..33e3c4344 100644 --- a/database/src/dbTest/java/org/fdroid/database/DbTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/DbTest.kt @@ -30,6 +30,7 @@ internal abstract class DbTest { internal lateinit var repoDao: RepositoryDaoInt internal lateinit var appDao: AppDaoInt + internal lateinit var appPrefsDao: AppPrefsDaoInt internal lateinit var versionDao: VersionDaoInt internal lateinit var db: FDroidDatabaseInt private val testCoroutineDispatcher = Dispatchers.Unconfined @@ -45,6 +46,7 @@ internal abstract class DbTest { .build() repoDao = db.getRepositoryDao() appDao = db.getAppDao() + appPrefsDao = db.getAppPrefsDao() versionDao = db.getVersionDao() mockkObject(FDroidDatabaseHolder) diff --git a/database/src/dbTest/java/org/fdroid/database/TestUtils.kt b/database/src/dbTest/java/org/fdroid/database/TestUtils.kt index 6c03fd856..2ce212fac 100644 --- a/database/src/dbTest/java/org/fdroid/database/TestUtils.kt +++ b/database/src/dbTest/java/org/fdroid/database/TestUtils.kt @@ -11,6 +11,7 @@ import org.junit.Assert import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.test.assertEquals +import kotlin.test.fail internal object TestUtils { @@ -106,4 +107,7 @@ internal object TestUtils { return data[0] as T? } + fun LiveData.getOrFail(): T { + return getOrAwaitValue() ?: fail() + } } diff --git a/database/src/dbTest/java/org/fdroid/database/VersionTest.kt b/database/src/dbTest/java/org/fdroid/database/VersionTest.kt index 549e53456..5fe727c49 100644 --- a/database/src/dbTest/java/org/fdroid/database/VersionTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/VersionTest.kt @@ -2,7 +2,8 @@ package org.fdroid.database import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.fdroid.database.TestUtils.getOrAwaitValue +import org.fdroid.database.TestUtils.getOrFail +import org.fdroid.index.v2.PackageVersionV2 import org.fdroid.test.TestAppUtils.getRandomMetadataV2 import org.fdroid.test.TestRepoUtils.getRandomRepo import org.fdroid.test.TestUtils.getRandomString @@ -20,24 +21,46 @@ internal class VersionTest : DbTest() { @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() - private val packageId = getRandomString() - private val versionId = getRandomString() + private val packageName = getRandomString() + private val packageVersion1 = getRandomPackageVersionV2() + private val packageVersion2 = getRandomPackageVersionV2() + private val packageVersion3 = getRandomPackageVersionV2() + private val versionId1 = packageVersion1.file.sha256 + private val versionId2 = packageVersion2.file.sha256 + private val versionId3 = packageVersion3.file.sha256 + private val isCompatible1 = Random.nextBoolean() + private val isCompatible2 = Random.nextBoolean() + private val packageVersions = mapOf( + versionId1 to packageVersion1, + versionId2 to packageVersion2, + ) + + private fun getVersion1(repoId: Long) = + packageVersion1.toVersion(repoId, packageName, versionId1, isCompatible1) + + private fun getVersion2(repoId: Long) = + packageVersion2.toVersion(repoId, packageName, versionId2, isCompatible2) + + private val compatChecker: (PackageVersionV2) -> Boolean = { + when (it.file.sha256) { + versionId1 -> isCompatible1 + versionId2 -> isCompatible2 + else -> fail() + } + } @Test fun insertGetDeleteSingleVersion() { val repoId = repoDao.insertOrReplace(getRandomRepo()) - appDao.insert(repoId, packageId, getRandomMetadataV2()) - val packageVersion = getRandomPackageVersionV2() - val isCompatible = Random.nextBoolean() - versionDao.insert(repoId, packageId, versionId, packageVersion, isCompatible) + appDao.insert(repoId, packageName, getRandomMetadataV2()) + versionDao.insert(repoId, packageName, versionId1, packageVersion1, isCompatible1) - val appVersions = versionDao.getAppVersions(repoId, packageId) + val appVersions = versionDao.getAppVersions(repoId, packageName) assertEquals(1, appVersions.size) val appVersion = appVersions[0] - assertEquals(versionId, appVersion.version.versionId) - val version = packageVersion.toVersion(repoId, packageId, versionId, isCompatible) - assertEquals(version, appVersion.version) - val manifest = packageVersion.manifest + assertEquals(versionId1, appVersion.version.versionId) + assertEquals(getVersion1(repoId), appVersion.version) + val manifest = packageVersion1.manifest assertEquals(manifest.usesPermission.toSet(), appVersion.usesPermission?.toSet()) assertEquals(manifest.usesPermissionSdk23.toSet(), appVersion.usesPermissionSdk23?.toSet()) assertEquals( @@ -45,42 +68,35 @@ internal class VersionTest : DbTest() { appVersion.version.manifest.features?.toSet() ) - val versionedStrings = versionDao.getVersionedStrings(repoId, packageId) + val versionedStrings = versionDao.getVersionedStrings(repoId, packageName) val expectedSize = manifest.usesPermission.size + manifest.usesPermissionSdk23.size assertEquals(expectedSize, versionedStrings.size) - versionDao.deleteAppVersion(repoId, packageId, versionId) - assertEquals(0, versionDao.getAppVersions(repoId, packageId).size) - assertEquals(0, versionDao.getVersionedStrings(repoId, packageId).size) + versionDao.deleteAppVersion(repoId, packageName, versionId1) + assertEquals(0, versionDao.getAppVersions(repoId, packageName).size) + assertEquals(0, versionDao.getVersionedStrings(repoId, packageName).size) } @Test fun insertGetDeleteTwoVersions() { // insert two versions along with required objects val repoId = repoDao.insertOrReplace(getRandomRepo()) - appDao.insert(repoId, packageId, getRandomMetadataV2()) - val packageVersion1 = getRandomPackageVersionV2() - val packageVersion2 = getRandomPackageVersionV2() - val version1 = getRandomString() - val version2 = getRandomString() - val isCompatible1 = Random.nextBoolean() - val isCompatible2 = Random.nextBoolean() - versionDao.insert(repoId, packageId, version1, packageVersion1, isCompatible1) - versionDao.insert(repoId, packageId, version2, packageVersion2, isCompatible2) + appDao.insert(repoId, packageName, getRandomMetadataV2()) + versionDao.insert(repoId, packageName, versionId1, packageVersion1, isCompatible1) + versionDao.insert(repoId, packageName, versionId2, packageVersion2, isCompatible2) // get app versions from DB and assign them correctly - val appVersions = versionDao.getAppVersions(packageId).getOrAwaitValue() ?: fail() + val appVersions = versionDao.getAppVersions(packageName).getOrFail() assertEquals(2, appVersions.size) - val appVersion = if (version1 == appVersions[0].version.versionId) { + val appVersion = if (versionId1 == appVersions[0].version.versionId) { appVersions[0] } else appVersions[1] - val appVersion2 = if (version2 == appVersions[0].version.versionId) { + val appVersion2 = if (versionId2 == appVersions[0].version.versionId) { appVersions[0] } else appVersions[1] // check first version matches - val exVersion1 = packageVersion1.toVersion(repoId, packageId, version1, isCompatible1) - assertEquals(exVersion1, appVersion.version) + assertEquals(getVersion1(repoId), appVersion.version) val manifest = packageVersion1.manifest assertEquals(manifest.usesPermission.toSet(), appVersion.usesPermission?.toSet()) assertEquals(manifest.usesPermissionSdk23.toSet(), appVersion.usesPermissionSdk23?.toSet()) @@ -90,8 +106,7 @@ internal class VersionTest : DbTest() { ) // check second version matches - val exVersion2 = packageVersion2.toVersion(repoId, packageId, version2, isCompatible2) - assertEquals(exVersion2, appVersion2.version) + assertEquals(getVersion2(repoId), appVersion2.version) val manifest2 = packageVersion2.manifest assertEquals(manifest2.usesPermission.toSet(), appVersion2.usesPermission?.toSet()) assertEquals(manifest2.usesPermissionSdk23.toSet(), @@ -102,9 +117,118 @@ internal class VersionTest : DbTest() { ) // delete app and check that all associated data also gets deleted - appDao.deleteAppMetadata(repoId, packageId) - assertEquals(0, versionDao.getAppVersions(repoId, packageId).size) - assertEquals(0, versionDao.getVersionedStrings(repoId, packageId).size) + appDao.deleteAppMetadata(repoId, packageName) + assertEquals(0, versionDao.getAppVersions(repoId, packageName).size) + assertEquals(0, versionDao.getVersionedStrings(repoId, packageName).size) + } + + @Test + fun versionsOnlyFromEnabledRepo() { + // insert two versions into the same repo + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName, getRandomMetadataV2()) + versionDao.insert(repoId, packageName, packageVersions, compatChecker) + assertEquals(2, versionDao.getAppVersions(packageName).getOrFail().size) + assertEquals(2, versionDao.getVersions(listOf(packageName)).size) + + // add another version into another repo + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId2, packageName, getRandomMetadataV2()) + versionDao.insert(repoId2, packageName, versionId3, packageVersion3, true) + assertEquals(3, versionDao.getAppVersions(packageName).getOrFail().size) + assertEquals(3, versionDao.getVersions(listOf(packageName)).size) + + // disable second repo + repoDao.setRepositoryEnabled(repoId2, false) + + // now only two versions get returned + assertEquals(2, versionDao.getAppVersions(packageName).getOrFail().size) + assertEquals(2, versionDao.getVersions(listOf(packageName)).size) + } + + @Test + fun versionsSortedByVersionCode() { + // insert three versions into the same repo + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName, getRandomMetadataV2()) + versionDao.insert(repoId, packageName, packageVersions, compatChecker) + versionDao.insert(repoId, packageName, versionId3, packageVersion3, true) + val versions1 = versionDao.getAppVersions(packageName).getOrFail() + val versions2 = versionDao.getVersions(listOf(packageName)) + assertEquals(3, versions1.size) + assertEquals(3, versions2.size) + + // check that they are sorted as expected + listOf( + packageVersion1.manifest.versionCode, + packageVersion2.manifest.versionCode, + packageVersion3.manifest.versionCode, + ).sortedDescending().forEachIndexed { i, versionCode -> + assertEquals(versionCode, versions1[i].version.manifest.versionCode) + assertEquals(versionCode, versions2[i].versionCode) + } + } + + @Test + fun getVersionsRespectsAppPrefsIgnore() { + // insert one version into the repo + val repoId = repoDao.insertOrReplace(getRandomRepo()) + val versionCode = Random.nextLong(1, Long.MAX_VALUE) + val packageVersion = getRandomPackageVersionV2(versionCode) + val versionId = packageVersion.file.sha256 + appDao.insert(repoId, packageName, getRandomMetadataV2()) + versionDao.insert(repoId, packageName, versionId, packageVersion, true) + assertEquals(1, versionDao.getVersions(listOf(packageName)).size) + + // default app prefs don't change result + var appPrefs = AppPrefs(packageName) + appPrefsDao.update(appPrefs) + assertEquals(1, versionDao.getVersions(listOf(packageName)).size) + + // ignore lower version code doesn't change result + appPrefs = appPrefs.toggleIgnoreVersionCodeUpdate(versionCode - 1) + appPrefsDao.update(appPrefs) + assertEquals(1, versionDao.getVersions(listOf(packageName)).size) + + // ignoring exact version code does change result + appPrefs = appPrefs.toggleIgnoreVersionCodeUpdate(versionCode) + appPrefsDao.update(appPrefs) + assertEquals(0, versionDao.getVersions(listOf(packageName)).size) + + // ignoring higher version code does change result + appPrefs = appPrefs.toggleIgnoreVersionCodeUpdate(versionCode + 1) + appPrefsDao.update(appPrefs) + assertEquals(0, versionDao.getVersions(listOf(packageName)).size) + + // ignoring all updates does change result + appPrefs = appPrefs.toggleIgnoreAllUpdates() + appPrefsDao.update(appPrefs) + assertEquals(0, versionDao.getVersions(listOf(packageName)).size) + + // not ignoring all updates brings back version + appPrefs = appPrefs.toggleIgnoreAllUpdates() + appPrefsDao.update(appPrefs) + assertEquals(1, versionDao.getVersions(listOf(packageName)).size) + } + + @Test + fun getVersionsConsidersOnlyGivenPackages() { + // insert two versions + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName, getRandomMetadataV2()) + versionDao.insert(repoId, packageName, packageVersions, compatChecker) + assertEquals(2, versionDao.getVersions(listOf(packageName)).size) + + // insert versions for a different package + val packageName2 = getRandomString() + appDao.insert(repoId, packageName2, getRandomMetadataV2()) + versionDao.insert(repoId, packageName2, packageVersions, compatChecker) + + // still only returns above versions + assertEquals(2, versionDao.getVersions(listOf(packageName)).size) + + // all versions are returned only if all packages are asked for + assertEquals(4, versionDao.getVersions(listOf(packageName, packageName2)).size) } } diff --git a/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt b/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt index cdf2cae30..f9ff28197 100644 --- a/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt +++ b/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt @@ -73,7 +73,7 @@ public class DbUpdateChecker( releaseChannels) ?: return null val versionedStrings = versionDao.getVersionedStrings( repoId = version.repoId, - packageId = version.packageId, + packageName = version.packageId, versionId = version.versionId, ) return version.toAppVersion(versionedStrings) @@ -109,7 +109,7 @@ public class DbUpdateChecker( private fun getUpdatableApp(version: Version, installedVersionCode: Long): UpdatableApp? { val versionedStrings = versionDao.getVersionedStrings( repoId = version.repoId, - packageId = version.packageId, + packageName = version.packageId, versionId = version.versionId, ) val appOverviewItem = diff --git a/database/src/main/java/org/fdroid/database/Version.kt b/database/src/main/java/org/fdroid/database/Version.kt index 7aaa85268..9b40bc545 100644 --- a/database/src/main/java/org/fdroid/database/Version.kt +++ b/database/src/main/java/org/fdroid/database/Version.kt @@ -4,6 +4,7 @@ import androidx.core.os.LocaleListCompat import androidx.room.Embedded import androidx.room.Entity import androidx.room.ForeignKey +import androidx.room.Relation import org.fdroid.database.VersionedStringType.PERMISSION import org.fdroid.database.VersionedStringType.PERMISSION_SDK_23 import org.fdroid.index.v2.ANTI_FEATURE_KNOWN_VULNERABILITY @@ -48,8 +49,7 @@ public data class Version( internal fun toAppVersion(versionedStrings: List): AppVersion = AppVersion( version = this, - usesPermission = versionedStrings.getPermissions(this), - usesPermissionSdk23 = versionedStrings.getPermissionsSdk23(this), + versionedStrings = versionedStrings, ) } @@ -72,10 +72,13 @@ internal fun PackageVersionV2.toVersion( isCompatible = isCompatible, ) -public data class AppVersion( - internal val version: Version, - val usesPermission: List? = null, - val usesPermissionSdk23: List? = null, +public data class AppVersion internal constructor( + @Embedded internal val version: Version, + @Relation( + parentColumn = "versionId", + entityColumn = "versionId", + ) + internal val versionedStrings: List?, ) { public val repoId: Long get() = version.repoId public val packageId: String get() = version.packageId @@ -84,9 +87,12 @@ public data class AppVersion( 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? get() = versionedStrings?.getPermissions(version) + public val usesPermissionSdk23: List? + get() = versionedStrings?.getPermissionsSdk23(version) public val featureNames: List get() = version.manifest.features ?: emptyList() public val nativeCode: List get() = version.manifest.nativecode ?: emptyList() - public val releaseChannels: List = version.releaseChannels ?: emptyList() + public val releaseChannels: List get() = version.releaseChannels ?: emptyList() val antiFeatureNames: List get() { return version.antiFeatures?.map { it.key } ?: emptyList() diff --git a/database/src/main/java/org/fdroid/database/VersionDao.kt b/database/src/main/java/org/fdroid/database/VersionDao.kt index a26c724af..80d5162b0 100644 --- a/database/src/main/java/org/fdroid/database/VersionDao.kt +++ b/database/src/main/java/org/fdroid/database/VersionDao.kt @@ -1,9 +1,6 @@ package org.fdroid.database import androidx.lifecycle.LiveData -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.liveData -import androidx.lifecycle.map import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy.REPLACE @@ -15,7 +12,6 @@ import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement -import org.fdroid.database.FDroidDatabaseHolder.dispatcher import org.fdroid.database.VersionedStringType.PERMISSION import org.fdroid.database.VersionedStringType.PERMISSION_SDK_23 import org.fdroid.index.IndexParser.json @@ -25,15 +21,20 @@ 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, - packageId: String, + packageName: String, packageVersions: Map, checkIfCompatible: (PackageVersionV2) -> Boolean, ) - public fun getAppVersions(packageId: String): LiveData> - public fun getAppVersions(repoId: Long, packageId: String): List + /** + * Returns a list of versions for the given [packageName] sorting by highest version code first. + */ + public fun getAppVersions(packageName: String): LiveData> } /** @@ -51,26 +52,26 @@ internal interface VersionDaoInt : VersionDao { @Transaction override fun insert( repoId: Long, - packageId: String, + packageName: String, packageVersions: Map, checkIfCompatible: (PackageVersionV2) -> Boolean, ) { // TODO maybe the number of queries here can be reduced packageVersions.entries.iterator().forEach { (versionId, packageVersion) -> val isCompatible = checkIfCompatible(packageVersion) - insert(repoId, packageId, versionId, packageVersion, isCompatible) + insert(repoId, packageName, versionId, packageVersion, isCompatible) } } @Transaction fun insert( repoId: Long, - packageId: String, + packageName: String, versionId: String, packageVersion: PackageVersionV2, isCompatible: Boolean, ) { - val version = packageVersion.toVersion(repoId, packageId, versionId, isCompatible) + val version = packageVersion.toVersion(repoId, packageName, versionId, isCompatible) insert(version) insert(packageVersion.manifest.getVersionedStrings(version)) } @@ -86,22 +87,22 @@ internal interface VersionDaoInt : VersionDao { fun update( repoId: Long, - packageId: String, + packageName: String, versionsDiffMap: Map?, checkIfCompatible: (PackageManifest) -> Boolean, ) { if (versionsDiffMap == null) { // no more versions, delete all - deleteAppVersion(repoId, packageId) + deleteAppVersion(repoId, packageName) } else versionsDiffMap.forEach { (versionId, jsonObject) -> if (jsonObject == null) { // delete individual version - deleteAppVersion(repoId, packageId, versionId) + deleteAppVersion(repoId, packageName, versionId) } else { - val version = getVersion(repoId, packageId, versionId) + 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, packageId, versionId, packageVersionV2, isCompatible) + insert(repoId, packageName, versionId, packageVersionV2, isCompatible) } else { // diff against existing version diffVersion(version, jsonObject, checkIfCompatible) } @@ -153,38 +154,25 @@ internal interface VersionDaoInt : VersionDao { insertNewList = { versionedStrings -> insert(versionedStrings) }, ) - override fun getAppVersions( - packageId: String, - ): LiveData> = liveData(dispatcher) { - // TODO we should probably react to changes of versioned strings as well - val versionedStrings = getVersionedStrings(packageId) - val liveData = getVersions(packageId).distinctUntilChanged().map { versions -> - versions.map { version -> version.toAppVersion(versionedStrings) } - } - emitSource(liveData) - } - @Transaction - override fun getAppVersions(repoId: Long, packageId: String): List { - val versionedStrings = getVersionedStrings(repoId, packageId) - return getVersions(repoId, packageId).map { version -> - version.toAppVersion(versionedStrings) - } - } - - @Query("""SELECT * FROM Version - WHERE repoId = :repoId AND packageId = :packageId AND versionId = :versionId""") - fun getVersion(repoId: Long, packageId: String, versionId: String): Version? - @RewriteQueriesToDropUnusedColumns @Query("""SELECT * FROM Version JOIN RepositoryPreferences AS pref USING (repoId) - WHERE pref.enabled = 1 AND packageId = :packageId - ORDER BY manifest_versionCode DESC""") - fun getVersions(packageId: String): LiveData> + WHERE pref.enabled = 1 AND packageId = :packageName + ORDER BY manifest_versionCode DESC, pref.weight DESC""") + override fun getAppVersions(packageName: String): LiveData> - @Query("SELECT * FROM Version WHERE repoId = :repoId AND packageId = :packageId") - fun getVersions(repoId: Long, packageId: String): List + /** + * Only use for testing, not sorted, does take disabled repos into account. + */ + @Transaction + @Query("""SELECT * FROM Version + WHERE repoId = :repoId AND packageId = :packageName""") + fun getAppVersions(repoId: Long, packageName: String): List + + @Query("""SELECT * FROM Version + WHERE repoId = :repoId AND packageId = :packageName AND versionId = :versionId""") + fun getVersion(repoId: Long, packageName: String, versionId: String): Version? /** * Used for finding versions that are an update, @@ -192,47 +180,41 @@ internal interface VersionDaoInt : VersionDao { */ @RewriteQueriesToDropUnusedColumns @Query("""SELECT * FROM Version - JOIN RepositoryPreferences USING (repoId) + JOIN RepositoryPreferences AS pref USING (repoId) LEFT JOIN AppPrefs USING (packageId) - WHERE RepositoryPreferences.enabled = 1 AND + WHERE pref.enabled = 1 AND manifest_versionCode > COALESCE(AppPrefs.ignoreVersionCodeUpdate, 0) AND packageId IN (:packageNames) - ORDER BY manifest_versionCode DESC, RepositoryPreferences.weight DESC""") + ORDER BY manifest_versionCode DESC, pref.weight DESC""") fun getVersions(packageNames: List): List - @RewriteQueriesToDropUnusedColumns - @Query("""SELECT * FROM VersionedString - JOIN RepositoryPreferences AS pref USING (repoId) - WHERE pref.enabled = 1 AND packageId = :packageId""") - fun getVersionedStrings(packageId: String): List - - @Query("SELECT * FROM VersionedString WHERE repoId = :repoId AND packageId = :packageId") - fun getVersionedStrings(repoId: Long, packageId: String): List + @Query("SELECT * FROM VersionedString WHERE repoId = :repoId AND packageId = :packageName") + fun getVersionedStrings(repoId: Long, packageName: String): List @Query("""SELECT * FROM VersionedString - WHERE repoId = :repoId AND packageId = :packageId AND versionId = :versionId""") + WHERE repoId = :repoId AND packageId = :packageName AND versionId = :versionId""") fun getVersionedStrings( repoId: Long, - packageId: String, + packageName: String, versionId: String, ): List - @Query("""DELETE FROM Version WHERE repoId = :repoId AND packageId = :packageId""") - fun deleteAppVersion(repoId: Long, packageId: String) + @Query("""DELETE FROM Version WHERE repoId = :repoId AND packageId = :packageName""") + fun deleteAppVersion(repoId: Long, packageName: String) @Query("""DELETE FROM Version - WHERE repoId = :repoId AND packageId = :packageId AND versionId = :versionId""") - fun deleteAppVersion(repoId: Long, packageId: String, versionId: String) + WHERE repoId = :repoId AND packageId = :packageName AND versionId = :versionId""") + fun deleteAppVersion(repoId: Long, packageName: String, versionId: String) @Query("""DELETE FROM VersionedString - WHERE repoId = :repoId AND packageId = :packageId AND versionId = :versionId""") - fun deleteVersionedStrings(repoId: Long, packageId: String, versionId: String) + WHERE repoId = :repoId AND packageId = :packageName AND versionId = :versionId""") + fun deleteVersionedStrings(repoId: Long, packageName: String, versionId: String) @Query("""DELETE FROM VersionedString WHERE repoId = :repoId - AND packageId = :packageId AND versionId = :versionId AND type = :type""") + AND packageId = :packageName AND versionId = :versionId AND type = :type""") fun deleteVersionedStrings( repoId: Long, - packageId: String, + packageName: String, versionId: String, type: VersionedStringType, ) From 7fe3f9c2c178874f003581d9c865ee9f73a3bf74 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 7 Jun 2022 17:15:44 -0300 Subject: [PATCH 30/42] [db] Add more tests (for locales, IndexV1, etc.) --- .../java/org/fdroid/database/AppTest.kt | 165 +++++++++-- .../org/fdroid/index/v1/IndexV1UpdaterTest.kt | 259 ++++++++++++++++++ .../src/main/java/org/fdroid/database/App.kt | 31 ++- .../main/java/org/fdroid/database/AppDao.kt | 20 +- .../org/fdroid/index/v1/IndexV1Updater.kt | 3 + .../java/org/fdroid/database/AppPrefsTest.kt | 72 +++++ ...r.at_corrupt_app_package_name_index-v1.jar | Bin 0 -> 4326 bytes ...at.or.at_corrupt_package_name_index-v1.jar | Bin 0 -> 4325 bytes .../resources/testy.at.or.at_index-v1.jar | Bin 0 -> 148234 bytes .../testy.at.or.at_no-.RSA_index-v1.jar | Bin 0 -> 22726 bytes .../testy.at.or.at_no-.SF_index-v1.jar | Bin 0 -> 24353 bytes ...testy.at.or.at_no-MANIFEST.MF_index-v1.jar | Bin 0 -> 24423 bytes .../testy.at.or.at_no-signature_index-v1.jar | Bin 0 -> 22162 bytes 13 files changed, 507 insertions(+), 43 deletions(-) create mode 100644 database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt create mode 100644 database/src/test/java/org/fdroid/database/AppPrefsTest.kt create mode 100644 index/src/sharedTest/resources/testy.at.or.at_corrupt_app_package_name_index-v1.jar create mode 100644 index/src/sharedTest/resources/testy.at.or.at_corrupt_package_name_index-v1.jar create mode 100644 index/src/sharedTest/resources/testy.at.or.at_index-v1.jar create mode 100644 index/src/sharedTest/resources/testy.at.or.at_no-.RSA_index-v1.jar create mode 100644 index/src/sharedTest/resources/testy.at.or.at_no-.SF_index-v1.jar create mode 100644 index/src/sharedTest/resources/testy.at.or.at_no-MANIFEST.MF_index-v1.jar create mode 100644 index/src/sharedTest/resources/testy.at.or.at_no-signature_index-v1.jar diff --git a/database/src/dbTest/java/org/fdroid/database/AppTest.kt b/database/src/dbTest/java/org/fdroid/database/AppTest.kt index 92a20b8f2..999e258c3 100644 --- a/database/src/dbTest/java/org/fdroid/database/AppTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/AppTest.kt @@ -14,7 +14,6 @@ import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertEquals import kotlin.test.assertNotEquals -import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.test.fail @@ -25,6 +24,35 @@ internal class AppTest : DbTest() { val instantTaskExecutorRule = InstantTaskExecutorRule() private val packageId = getRandomString() + private val packageId1 = getRandomString() + private val packageId2 = getRandomString() + private val packageId3 = getRandomString() + private val name1 = mapOf("en-US" to "1") + private val name2 = mapOf("en-US" to "2") + private val name3 = mapOf("en-US" to "3") + private val icons1 = mapOf("foo" to getRandomFileV2(), "bar" to getRandomFileV2()) + private val icons2 = mapOf("23" to getRandomFileV2(), "42" to getRandomFileV2()) + private val app1 = getRandomMetadataV2().copy( + name = name1, + icon = icons1, + summary = null, + lastUpdated = 10, + categories = listOf("A", "B") + ) + private val app2 = getRandomMetadataV2().copy( + name = name2, + icon = icons2, + summary = name2, + lastUpdated = 20, + categories = listOf("A") + ) + private val app3 = getRandomMetadataV2().copy( + name = name3, + icon = null, + summary = name3, + lastUpdated = 30, + categories = listOf("A", "B") + ) @Test fun insertGetDeleteSingleApp() { @@ -50,19 +78,8 @@ internal class AppTest : DbTest() { } @Test - fun testAppOverViewItem() { + fun testAppOverViewItemSortOrder() { val repoId = repoDao.insertOrReplace(getRandomRepo()) - val packageId1 = getRandomString() - val packageId2 = getRandomString() - val packageId3 = getRandomString() - val name1 = mapOf("en-US" to "1") - val name2 = mapOf("en-US" to "2") - val name3 = mapOf("en-US" to "3") - val icons1 = mapOf("foo" to getRandomFileV2(), "bar" to getRandomFileV2()) - val icons2 = mapOf("23" to getRandomFileV2(), "42" to getRandomFileV2()) - val app1 = getRandomMetadataV2().copy(name = name1, icon = icons1) - val app2 = getRandomMetadataV2().copy(name = name2, icon = icons2) - val app3 = getRandomMetadataV2().copy(name = name3, icon = null) appDao.insert(repoId, packageId1, app1, locales) appDao.insert(repoId, packageId2, app2, locales) versionDao.insert(repoId, packageId1, "1", getRandomPackageVersionV2(), true) @@ -71,28 +88,120 @@ internal class AppTest : DbTest() { // icons of both apps are returned correctly val apps = appDao.getAppOverviewItems().getOrAwaitValue() ?: fail() assertEquals(2, apps.size) - assertEquals(icons1, - apps.find { it.packageId == packageId1 }?.localizedIcon?.toLocalizedFileV2()) - assertEquals(icons2, - apps.find { it.packageId == packageId2 }?.localizedIcon?.toLocalizedFileV2()) + // app 2 is first, because has icon and summary + assertEquals(packageId2, apps[0].packageId) + assertEquals(icons2, apps[0].localizedIcon?.toLocalizedFileV2()) + // app 1 is next, because has icon + assertEquals(packageId1, apps[1].packageId) + assertEquals(icons1, apps[1].localizedIcon?.toLocalizedFileV2()) - // app without icon is not returned + // app without icon is returned last appDao.insert(repoId, packageId3, app3) versionDao.insert(repoId, packageId3, "3", getRandomPackageVersionV2(), true) val apps3 = appDao.getAppOverviewItems().getOrAwaitValue() ?: fail() - assertEquals(2, apps3.size) - assertEquals(icons1, - apps3.find { it.packageId == packageId1 }?.localizedIcon?.toLocalizedFileV2()) - assertEquals(icons2, - apps3.find { it.packageId == packageId2 }?.localizedIcon?.toLocalizedFileV2()) - assertNull(apps3.find { it.packageId == packageId3 }) + assertEquals(3, apps3.size) + assertEquals(packageId2, apps3[0].packageId) + assertEquals(packageId1, apps3[1].packageId) + assertEquals(packageId3, apps3[2].packageId) + assertEquals(emptyList(), apps3[2].localizedIcon) - // app4 is the same as app1 and thus will not be shown again + // app1b is the same as app1 (but in another repo) and thus will not be shown again val repoId2 = repoDao.insertOrReplace(getRandomRepo()) - val app4 = getRandomMetadataV2().copy(name = name2, icon = icons2) - appDao.insert(repoId2, packageId1, app4) + val app1b = app1.copy(name = name2, icon = icons2, summary = name2) + appDao.insert(repoId2, packageId1, app1b) + // note that we don't insert a version here val apps4 = appDao.getAppOverviewItems().getOrAwaitValue() ?: fail() - assertEquals(2, apps4.size) + assertEquals(3, apps4.size) + + // app3b is the same as app3, but has an icon, so is not last anymore + val app3b = app3.copy(icon = icons2) + appDao.insert(repoId2, packageId3, app3b) + // note that we don't insert a version here + val apps5 = appDao.getAppOverviewItems().getOrAwaitValue() ?: fail() + assertEquals(3, apps5.size) + assertEquals(packageId3, apps5[0].packageId) + assertEquals(emptyList(), apps5[0].antiFeatureNames) + assertEquals(packageId2, apps5[1].packageId) + assertEquals(packageId1, apps5[2].packageId) + } + + @Test + fun testAppOverViewItemSortOrderWithCategories() { + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageId1, app1, locales) + appDao.insert(repoId, packageId2, app2, locales) + versionDao.insert(repoId, packageId1, "1", getRandomPackageVersionV2(), true) + versionDao.insert(repoId, packageId2, "2", getRandomPackageVersionV2(), true) + + // icons of both apps are returned correctly + val apps = appDao.getAppOverviewItems("A").getOrAwaitValue() ?: fail() + assertEquals(2, apps.size) + // app 2 is first, because has icon and summary + assertEquals(packageId2, apps[0].packageId) + assertEquals(icons2, apps[0].localizedIcon?.toLocalizedFileV2()) + // app 1 is next, because has icon + assertEquals(packageId1, apps[1].packageId) + assertEquals(icons1, apps[1].localizedIcon?.toLocalizedFileV2()) + + // only one app is returned for category B + assertEquals(1, appDao.getAppOverviewItems("B").getOrAwaitValue()?.size ?: fail()) + + // app without icon is returned last + appDao.insert(repoId, packageId3, app3) + versionDao.insert(repoId, packageId3, "3", getRandomPackageVersionV2(), true) + val apps3 = appDao.getAppOverviewItems("A").getOrAwaitValue() ?: fail() + assertEquals(3, apps3.size) + assertEquals(packageId2, apps3[0].packageId) + assertEquals(packageId1, apps3[1].packageId) + assertEquals(packageId3, apps3[2].packageId) + assertEquals(emptyList(), apps3[2].localizedIcon) + + // app1b is the same as app1 (but in another repo) and thus will not be shown again + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val app1b = app1.copy(name = name2, icon = icons2, summary = name2) + appDao.insert(repoId2, packageId1, app1b) + // note that we don't insert a version here + val apps4 = appDao.getAppOverviewItems("A").getOrAwaitValue() ?: fail() + assertEquals(3, apps4.size) + + // app3b is the same as app3, but has an icon, so is not last anymore + val app3b = app3.copy(icon = icons2) + appDao.insert(repoId2, packageId3, app3b) + // note that we don't insert a version here + val apps5 = appDao.getAppOverviewItems("A").getOrAwaitValue() ?: fail() + assertEquals(3, apps5.size) + assertEquals(packageId3, apps5[0].packageId) + assertEquals(emptyList(), apps5[0].antiFeatureNames) + assertEquals(packageId2, apps5[1].packageId) + assertEquals(packageId1, apps5[2].packageId) + + // only two apps are returned for category B + assertEquals(2, appDao.getAppOverviewItems("B").getOrAwaitValue()?.size) + } + + @Test + fun testAppOverViewItemOnlyFromEnabledRepos() { + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageId1, app1, locales) + appDao.insert(repoId, packageId2, app2, locales) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId2, packageId3, app3, locales) + + // 3 apps from 2 repos + assertEquals(3, appDao.getAppOverviewItems().getOrAwaitValue()?.size) + assertEquals(3, appDao.getAppOverviewItems("A").getOrAwaitValue()?.size) + + // only 1 app after disabling first repo + repoDao.setRepositoryEnabled(repoId, false) + assertEquals(1, appDao.getAppOverviewItems().getOrAwaitValue()?.size) + assertEquals(1, appDao.getAppOverviewItems("A").getOrAwaitValue()?.size) + assertEquals(1, appDao.getAppOverviewItems("B").getOrAwaitValue()?.size) + + // no more apps after disabling all repos + repoDao.setRepositoryEnabled(repoId2, false) + assertEquals(0, appDao.getAppOverviewItems().getOrAwaitValue()?.size) + assertEquals(0, appDao.getAppOverviewItems("A").getOrAwaitValue()?.size) + assertEquals(0, appDao.getAppOverviewItems("B").getOrAwaitValue()?.size) } @Test diff --git a/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt b/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt new file mode 100644 index 000000000..2d4562d6a --- /dev/null +++ b/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt @@ -0,0 +1,259 @@ +package org.fdroid.index.v1 + +import android.Manifest +import android.net.Uri +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.Runs +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import org.fdroid.CompatibilityChecker +import org.fdroid.database.DbTest +import org.fdroid.database.Repository +import org.fdroid.database.TestUtils.getOrAwaitValue +import org.fdroid.database.TestUtils.getOrFail +import org.fdroid.download.Downloader +import org.fdroid.download.DownloaderFactory +import org.fdroid.index.IndexUpdateResult +import org.fdroid.index.SigningException +import org.fdroid.index.TempFileProvider +import org.fdroid.index.v2.ANTI_FEATURE_KNOWN_VULNERABILITY +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +@RunWith(AndroidJUnit4::class) +internal class IndexV1UpdaterTest : DbTest() { + + @get:Rule + var tmpFolder: TemporaryFolder = TemporaryFolder() + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + private val tempFileProvider: TempFileProvider = mockk() + private val downloaderFactory: DownloaderFactory = mockk() + private val downloader: Downloader = mockk() + private val compatibilityChecker: CompatibilityChecker = CompatibilityChecker { true } + private lateinit var indexUpdater: IndexV1Updater + + @Before + override fun createDb() { + super.createDb() + indexUpdater = IndexV1Updater(db, tempFileProvider, downloaderFactory, compatibilityChecker) + } + + @Test + fun testIndexV1Processing() { + val repoId = repoDao.insertEmptyRepo(TESTY_CANONICAL_URL) + val repo = repoDao.getRepository(repoId) ?: fail() + downloadIndex(repo, TESTY_JAR) + val result = indexUpdater.updateNewRepo(repo, TESTY_FINGERPRINT).noError() + assertIs(result) + + // repo got updated + val updatedRepo = repoDao.getRepository(repoId) ?: fail() + assertEquals(TESTY_CERT, updatedRepo.certificate) + assertEquals(TESTY_FINGERPRINT, updatedRepo.fingerprint) + + // some assertions ported from old IndexV1UpdaterTest + assertEquals(1, repoDao.getRepositories().size) + assertEquals(63, appDao.countApps()) + listOf("fake.app.one", "org.adaway", "This_does_not_exist").forEach { packageName -> + assertNull(appDao.getApp(packageName).getOrAwaitValue()) + } + appDao.getAppMetadata().forEach { app -> + val numVersions = versionDao.getVersions(listOf(app.packageId)).size + assertTrue(numVersions > 0) + } + assertEquals(1497639511824, updatedRepo.timestamp) + assertEquals(TESTY_CANONICAL_URL, updatedRepo.address) + assertEquals("non-public test repo", updatedRepo.name.values.first()) + assertEquals(18, updatedRepo.version) + assertEquals("/icons/fdroid-icon.png", updatedRepo.icon?.values?.first()?.name) + val description = "This is a repository of apps to be used with F-Droid. " + + "Applications in this repository are either official binaries built " + + "by the original application developers, or are binaries built " + + "from source by the admin of f-droid.org using the tools on " + + "https://gitlab.com/u/fdroid. " + assertEquals(description, updatedRepo.description.values.first()) + assertEquals( + setOf(TESTY_CANONICAL_URL, "http://frkcchxlcvnb4m5a.onion/fdroid/repo"), + updatedRepo.mirrors.map { it.url }.toSet(), + ) + + // Make sure the per-apk anti features which are new in index v1 get added correctly. + val wazeVersion = versionDao.getVersions(listOf("com.waze")).find { + it.manifest.versionCode == 1019841L + } + assertNotNull(wazeVersion) + assertEquals(setOf(ANTI_FEATURE_KNOWN_VULNERABILITY), wazeVersion.antiFeatures?.keys) + + val protoVersion = versionDao.getAppVersions("io.proto.player").getOrFail().find { + it.version.versionCode == 1110L + } + assertNotNull(protoVersion) + assertEquals("/io.proto.player-1.apk", protoVersion.version.file.name) + val perms = protoVersion.usesPermission?.map { it.name } ?: fail() + assertTrue(perms.contains(Manifest.permission.READ_EXTERNAL_STORAGE)) + assertTrue(perms.contains(Manifest.permission.WRITE_EXTERNAL_STORAGE)) + assertFalse(perms.contains(Manifest.permission.READ_CALENDAR)) + val icon = appDao.getApp("com.autonavi.minimap").getOrFail()?.icon?.values?.first()?.name + assertEquals("/com.autonavi.minimap/en-US/icon.png", icon) + + // update again and get unchanged + downloadIndex(updatedRepo, TESTY_JAR) + val result2 = indexUpdater.update(updatedRepo).noError() + assertIs(result2) + } + + @Test + fun testIndexV1WithWrongCert() { + val repoId = repoDao.insertEmptyRepo(TESTY_CANONICAL_URL) + val repo = repoDao.getRepository(repoId) ?: fail() + downloadIndex(repo, TESTY_JAR) + val result = indexUpdater.updateNewRepo(repo, "not the right fingerprint") + assertIs(result) + assertIs(result.e) + } + + @Test + fun testIndexV1WithOldTimestamp() { + val repoId = repoDao.insertEmptyRepo(TESTY_CANONICAL_URL) + val repo = repoDao.getRepository(repoId) ?: fail() + val futureRepo = + repo.copy(repository = repo.repository.copy(timestamp = System.currentTimeMillis())) + downloadIndex(futureRepo, TESTY_JAR) + val result = indexUpdater.updateNewRepo(futureRepo, TESTY_FINGERPRINT) + assertIs(result) + assertIs(result.e) + assertFalse((result.e as OldIndexException).isSameTimestamp) + } + + @Test + fun testIndexV1WithCorruptAppPackageName() { + val result = testBadTestyJar("testy.at.or.at_corrupt_app_package_name_index-v1.jar") + assertIs(result) + } + + @Test + fun testIndexV1WithCorruptPackageName() { + val result = testBadTestyJar("testy.at.or.at_corrupt_package_name_index-v1.jar") + assertIs(result) + } + + @Test + fun testIndexV1WithBadTestyJarNoManifest() { + val result = testBadTestyJar("testy.at.or.at_no-MANIFEST.MF_index-v1.jar") + assertIs(result) + assertIs(result.e) + } + + @Test + fun testIndexV1WithBadTestyJarNoSigningCert() { + val result = testBadTestyJar("testy.at.or.at_no-.RSA_index-v1.jar") + assertIs(result) + } + + @Test + fun testIndexV1WithBadTestyJarNoSignature() { + val result = testBadTestyJar("testy.at.or.at_no-.SF_index-v1.jar") + assertIs(result) + } + + @Test + fun testIndexV1WithBadTestyJarNoSignatureFiles() { + val result = testBadTestyJar("testy.at.or.at_no-signature_index-v1.jar") + assertIs(result) + assertIs(result.e) + } + + @Suppress("DEPRECATION") + private fun downloadIndex(repo: Repository, jar: String) { + val uri = Uri.parse("${repo.address}/index-v1.jar") + + val jarFile = tmpFolder.newFile() + assets.open(jar).use { inputStream -> + jarFile.outputStream().use { inputStream.copyTo(it) } + } + every { tempFileProvider.createTempFile() } returns jarFile + every { + downloaderFactory.createWithTryFirstMirror(repo, uri, jarFile) + } returns downloader + every { downloader.cacheTag = null } just Runs + every { downloader.download() } just Runs + every { downloader.hasChanged() } returns true + every { downloader.cacheTag } returns null + } + + private fun testBadTestyJar(jar: String): IndexUpdateResult { + val repoId = repoDao.insertEmptyRepo("http://example.org") + val repo = repoDao.getRepository(repoId) ?: fail() + downloadIndex(repo, jar) + return indexUpdater.updateNewRepo(repo, null) + } + + /** + * Easier for debugging, if we throw the index error. + */ + private fun IndexUpdateResult.noError(): IndexUpdateResult { + if (this is IndexUpdateResult.Error) throw e + return this + } + +} + +private const val TESTY_CANONICAL_URL = "http://testy.at.or.at/fdroid/repo" +private const val TESTY_JAR = "testy.at.or.at_index-v1.jar" +private const val TESTY_FINGERPRINT = + "818e469465f96b704e27be2fee4c63ab9f83ddf30e7a34c7371a4728d83b0bc1" +private const val TESTY_CERT = "308204e1308202c9a0030201020204483450fa300d06092a864886f70d01010b" + + "050030213110300e060355040b1307462d44726f6964310d300b060355040313" + + "04736f7661301e170d3136303832333133333131365a170d3434303130393133" + + "333131365a30213110300e060355040b1307462d44726f6964310d300b060355" + + "04031304736f766130820222300d06092a864886f70d01010105000382020f00" + + "3082020a0282020100dfdcd120f3ab224999dddf4ea33ea588d295e4d7130bef" + + "48c143e9d76e5c0e0e9e5d45e64208e35feebc79a83f08939dd6a343b7d1e217" + + "9930a105a1249ccd36d88ff3feffc6e4dc53dae0163a7876dd45ecc1ddb0adf5" + + "099aa56c1a84b52affcd45d0711ffa4de864f35ac0333ebe61ea8673eeda35a8" + + "8f6af678cc4d0f80b089338ac8f2a8279a64195c611d19445cab3fd1a020afed" + + "9bd739bb95142fb2c00a8f847db5ef3325c814f8eb741bacf86ed3907bfe6e45" + + "64d2de5895df0c263824e0b75407589bae2d3a4666c13b92102d8781a8ee9bb4" + + "a5a1a78c4a9c21efdaf5584da42e84418b28f5a81d0456a3dc5b420991801e6b" + + "21e38c99bbe018a5b2d690894a114bc860d35601416aa4dc52216aff8a288d47" + + "75cddf8b72d45fd2f87303a8e9c0d67e442530be28eaf139894337266e0b33d5" + + "7f949256ab32083bcc545bc18a83c9ab8247c12aea037e2b68dee31c734cb1f0" + + "4f241d3b94caa3a2b258ffaf8e6eae9fbbe029a934dc0a0859c5f12033481269" + + "3a1c09352340a39f2a678dbc1afa2a978bfee43afefcb7e224a58af2f3d647e5" + + "745db59061236b8af6fcfd93b3602f9e456978534f3a7851e800071bf56da804" + + "01c81d91c45f82568373af0576b1cc5eef9b85654124b6319770be3cdba3fbeb" + + "e3715e8918fb6c8966624f3d0e815effac3d2ee06dd34ab9c693218b2c7c06ba" + + "99d6b74d4f17b8c3cb0203010001a321301f301d0603551d0e04160414d62bee" + + "9f3798509546acc62eb1de14b08b954d4f300d06092a864886f70d01010b0500" + + "0382020100743f7c5692085895f9d1fffad390fb4202c15f123ed094df259185" + + "960fd6dadf66cb19851070f180297bba4e6996a4434616573b375cfee94fee73" + + "a4505a7ec29136b7e6c22e6436290e3686fe4379d4e3140ec6a08e70cfd3ed5b" + + "634a5eb5136efaaabf5f38e0432d3d79568a556970b8cfba2972f5d23a3856d8" + + "a981b9e9bbbbb88f35e708bde9cbc5f681cbd974085b9da28911296fe2579fa6" + + "4bbe9fa0b93475a7a8db051080b0c5fade0d1c018e7858cd4cbe95145b0620e2" + + "f632cbe0f8af9cbf22e2fdaa72245ae31b0877b07181cc69dd2df74454251d8d" + + "e58d25e76354abe7eb690f22e59b08795a8f2c98c578e0599503d90859276340" + + "72c82c9f82abd50fd12b8fd1a9d1954eb5cc0b4cfb5796b5aaec0356643b4a65" + + "a368442d92ef94edd3ac6a2b7fe3571b8cf9f462729228aab023ef9183f73792" + + "f5379633ccac51079177d604c6bc1873ada6f07d8da6d68c897e88a5fa5d63fd" + + "b8df820f46090e0716e7562dd3c140ba279a65b996f60addb0abe29d4bf2f5ab" + + "e89480771d492307b926d91f02f341b2148502903c43d40f3c6c86a811d06071" + + "1f0698b384acdcc0add44eb54e42962d3d041accc715afd49407715adc09350c" + + "b55e8d9281a3b0b6b5fcd91726eede9b7c8b13afdebb2c2b377629595f1096ba" + + "62fb14946dbac5f3c5f0b4e5b712e7acc7dcf6c46cdc5e6d6dfdeee55a0c92c2" + + "d70f080ac6" diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt index b318feb63..9974a5a6f 100644 --- a/database/src/main/java/org/fdroid/database/App.kt +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -224,17 +224,36 @@ public data class UpdatableApp( internal fun Map?.getBestLocale(localeList: LocaleListCompat): T? { if (isNullOrEmpty()) return null - val firstMatch = localeList.getFirstMatch(keys.toTypedArray()) ?: error("not empty: $keys") + val firstMatch = localeList.getFirstMatch(keys.toTypedArray()) ?: return null val tag = firstMatch.toLanguageTag() // try first matched tag first (usually has region tag, e.g. de-DE) return get(tag) ?: run { - // split away region tag and try language only - val langTag = tag.split('-')[0] - // try language, then English and then just take the first of the list - get(langTag) ?: get("en-US") ?: get("en") ?: values.first() + // split away stuff like script and try language and region only + val langCountryTag = "${firstMatch.language}-${firstMatch.country}" + getOrStartsWith(langCountryTag) ?: run { + // split away region tag and try language only + val langTag = firstMatch.language + // try language, then English and then just take the first of the list + getOrStartsWith(langTag) ?: get("en-US") ?: get("en") ?: values.first() + } } } +/** + * Returns the value from the map with the given key or if that key is not contained in the map, + * tries the first map key that starts with the given key. + * If nothing matches, null is returned. + * + * This is useful when looking for a language tag like `fr_CH` and falling back to `fr` + * in a map that has `fr_FR` as a key. + */ +private fun Map.getOrStartsWith(s: String): T? = get(s) ?: run { + entries.forEach { (key, value) -> + if (key.startsWith(s)) return value + } + return null +} + internal interface IFile { val type: String val locale: String @@ -288,8 +307,6 @@ internal fun List.toLocalizedFileV2(type: String? = null): LocalizedFileV }.ifEmpty { null } } -// TODO write test that ensures that in case of the same locale, -// only the one from the repo with higher weight is returned @DatabaseView("""SELECT * FROM LocalizedFile JOIN RepositoryPreferences AS prefs USING (repoId) WHERE type='icon' GROUP BY repoId, packageId, locale HAVING MAX(prefs.weight)""") diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index e5e5b3919..d77f5611e 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -330,11 +330,13 @@ internal interface AppDaoInt : AppDao { localizedSummary, version.antiFeatures FROM AppMetadata AS app JOIN RepositoryPreferences AS pref USING (repoId) - JOIN Version AS version USING (repoId, packageId) - JOIN LocalizedIcon AS icon USING (repoId, packageId) + LEFT JOIN Version AS version USING (repoId, packageId) + LEFT JOIN LocalizedIcon AS icon USING (repoId, packageId) WHERE pref.enabled = 1 - GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) - ORDER BY localizedName IS NULL ASC, localizedSummary IS NULL ASC, app.lastUpdated DESC, app.added ASC + GROUP BY packageId HAVING MAX(pref.weight) + AND MAX(COALESCE(version.manifest_versionCode, ${Long.MAX_VALUE})) + ORDER BY localizedName IS NULL ASC, icon.packageId IS NULL ASC, + localizedSummary IS NULL ASC, app.lastUpdated DESC LIMIT :limit""") override fun getAppOverviewItems(limit: Int): LiveData> @@ -344,11 +346,13 @@ internal interface AppDaoInt : AppDao { localizedSummary, version.antiFeatures FROM AppMetadata AS app JOIN RepositoryPreferences AS pref USING (repoId) - JOIN Version AS version USING (repoId, packageId) - JOIN LocalizedIcon AS icon USING (repoId, packageId) + LEFT JOIN Version AS version USING (repoId, packageId) + LEFT JOIN LocalizedIcon AS icon USING (repoId, packageId) WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' - GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) - ORDER BY localizedName IS NULL ASC, localizedSummary IS NULL ASC, app.lastUpdated DESC, app.added ASC + GROUP BY packageId HAVING MAX(pref.weight) + AND MAX(COALESCE(version.manifest_versionCode, ${Long.MAX_VALUE})) + ORDER BY localizedName IS NULL ASC, icon.packageId IS NULL ASC, + localizedSummary IS NULL ASC, app.lastUpdated DESC LIMIT :limit""") override fun getAppOverviewItems(category: String, limit: Int): LiveData> diff --git a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt index b2124284c..37083829a 100644 --- a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt +++ b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt @@ -71,6 +71,9 @@ public class IndexV1Updater( ) repoDao.updateRepositoryPreferences(updatedPrefs) } + } catch (e: OldIndexException) { + if (e.isSameTimestamp) return IndexUpdateResult.Unchanged + else throw e } finally { file.delete() } diff --git a/database/src/test/java/org/fdroid/database/AppPrefsTest.kt b/database/src/test/java/org/fdroid/database/AppPrefsTest.kt new file mode 100644 index 000000000..1e2944774 --- /dev/null +++ b/database/src/test/java/org/fdroid/database/AppPrefsTest.kt @@ -0,0 +1,72 @@ +package org.fdroid.database + +import org.fdroid.test.TestUtils.getRandomString +import org.junit.Test +import kotlin.random.Random +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +internal class AppPrefsTest { + + @Test + fun testDefaults() { + val prefs = AppPrefs(getRandomString()) + assertFalse(prefs.ignoreAllUpdates) + for (i in 1..1337L) assertFalse(prefs.shouldIgnoreUpdate(i)) + assertEquals(emptyList(), prefs.releaseChannels) + } + + @Test + fun testIgnoreVersionCodeUpdate() { + val ignoredCode = Random.nextLong(1, Long.MAX_VALUE - 1) + val prefs = AppPrefs(getRandomString(), ignoredCode) + assertFalse(prefs.ignoreAllUpdates) + assertTrue(prefs.shouldIgnoreUpdate(ignoredCode - 1)) + assertTrue(prefs.shouldIgnoreUpdate(ignoredCode)) + assertFalse(prefs.shouldIgnoreUpdate(ignoredCode + 1)) + + // after toggling, it is not ignored anymore + assertFalse(prefs.toggleIgnoreVersionCodeUpdate(ignoredCode) + .shouldIgnoreUpdate(ignoredCode)) + } + + @Test + fun testIgnoreAllUpdates() { + val prefs = AppPrefs(getRandomString()).toggleIgnoreAllUpdates() + assertTrue(prefs.ignoreAllUpdates) + assertTrue(prefs.shouldIgnoreUpdate(Random.nextLong())) + assertTrue(prefs.shouldIgnoreUpdate(Random.nextLong())) + assertTrue(prefs.shouldIgnoreUpdate(Random.nextLong())) + + // after toggling, all are not ignored anymore + val toggled = prefs.toggleIgnoreAllUpdates() + assertFalse(toggled.ignoreAllUpdates) + assertFalse(toggled.shouldIgnoreUpdate(Random.nextLong(1, Long.MAX_VALUE - 1))) + assertFalse(toggled.shouldIgnoreUpdate(Random.nextLong(1, Long.MAX_VALUE - 1))) + assertFalse(toggled.shouldIgnoreUpdate(Random.nextLong(1, Long.MAX_VALUE - 1))) + } + + @Test + fun testReleaseChannels() { + // no release channels initially + val prefs = AppPrefs(getRandomString()) + assertEquals(emptyList(), prefs.releaseChannels) + + // A gets toggled and is then in channels + val a = prefs.toggleReleaseChannel("A") + assertEquals(listOf("A"), a.releaseChannels) + + // toggling it off returns empty list again + assertEquals(emptyList(), a.toggleReleaseChannel("A").releaseChannels) + + // toggling A and B returns both + val ab = prefs.toggleReleaseChannel("A").toggleReleaseChannel("B") + assertEquals(setOf("A", "B"), ab.releaseChannels.toSet()) + + // toggling both off returns empty list again + assertEquals(emptyList(), + ab.toggleReleaseChannel("A").toggleReleaseChannel("B").releaseChannels) + } + +} diff --git a/index/src/sharedTest/resources/testy.at.or.at_corrupt_app_package_name_index-v1.jar b/index/src/sharedTest/resources/testy.at.or.at_corrupt_app_package_name_index-v1.jar new file mode 100644 index 0000000000000000000000000000000000000000..44611cedefb8988a56fde734826a4ebfe0ed7881 GIT binary patch literal 4326 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 literal 0 HcmV?d00001 diff --git a/index/src/sharedTest/resources/testy.at.or.at_corrupt_package_name_index-v1.jar b/index/src/sharedTest/resources/testy.at.or.at_corrupt_package_name_index-v1.jar new file mode 100644 index 0000000000000000000000000000000000000000..a087e839774fd60cd08d1c707e8c0a0e6d3c6673 GIT binary patch literal 4325 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 literal 0 HcmV?d00001 diff --git a/index/src/sharedTest/resources/testy.at.or.at_index-v1.jar b/index/src/sharedTest/resources/testy.at.or.at_index-v1.jar new file mode 100644 index 0000000000000000000000000000000000000000..3554722f5dd2ce6f949db3aa5fd1412dc72dfd78 GIT binary patch literal 148234 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 %j2j9Yt<_6&tv1gXSI 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 R!QXA%yWVm){xbf%`VWXF0+9d! literal 0 HcmV?d00001 diff --git a/index/src/sharedTest/resources/testy.at.or.at_no-.SF_index-v1.jar b/index/src/sharedTest/resources/testy.at.or.at_no-.SF_index-v1.jar new file mode 100644 index 0000000000000000000000000000000000000000..113a17e553895da12997d78cfdd8f90b2aa16516 GIT binary patch literal 24353 zcmV)?K!U$eO9KQH000OG0A{3!No*W@jDr9G0EYko02BZK08K?yK`lv6MlVf4PDw^Z zQ&cWZM)OU~%S=lxF3}B3Eh^5;&$Ci6)HC4aaxO|uEJ;n#b*cmjnCltD8yXoZX!sW; zCg-FoIOi7?qa`AHcCFZ7DDP-oQq*myb8R}&f=jZWq1$#Ie>bhj60}Z!Q zaPiakaWyVA^7ryFcL{d&it_LaH_oig3GsKe1(^T@6aWAS2mofJh)LnOPira% z0040Z000pH002!zR6#9CPDU?NPgX%LQd2>VlzBK5h#$ty!3<)Q2@$z#Fq&!HQf_h= zF^))%BIHVx72~Ket{GQ38Vq(a%0#mmve-yD*42mxv*V@^Q8|Bp&$G|&{(gV$pWpX+ z-}n2^`+QJ1NH8D2N{VI5Hz5E7Sb~FiqHqv5AOL_CMDg?SstSXElDs@9!M{y_5*&C2 zg#-VA!U5lwfPerX5NxU8u!R!(%L5QRh{&NuP{Mqm%V0rKh&j?U{8oT3TIitYKmmz@ zBW^``qh!Q|&{`;+GZ-`mgGOt)i3@3HpwKAYKhJ+tfdk6_^D%&z2ZRH{cn)*{APxxN znPd;huG8c%JRYC4qnxXFIG8&dwdLEix%%Ygo z4{*gFqe^*86^nYcK4h-%?RQSI9mhCgdeKqiW}n)|>CZO!3o3#nlNc)dy=MJkC$?`Y<18}k$6Wp2t%r9E!qNL19avhY@i zt-G!cIRlIW%h{LY0{2r-rCZ$Yog{=0dkub%08tlOS#hRHsFqWUUv%*%+R7n7|h}1?f6%6Q-CcgHS8if1=Ay6dvA1Bq98vDz7S~SHs-JCF z_tioOYo+X�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:)u) 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 literal 0 HcmV?d00001 diff --git a/index/src/sharedTest/resources/testy.at.or.at_no-MANIFEST.MF_index-v1.jar b/index/src/sharedTest/resources/testy.at.or.at_no-MANIFEST.MF_index-v1.jar new file mode 100644 index 0000000000000000000000000000000000000000..feee0f85da78c2745c6968ee41b9e7f09cb0fe9e GIT binary patch literal 24423 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 OTlwbHxG~QX5&Z|qQ!!uw literal 0 HcmV?d00001 From e0cabc449d50ac816b76fba8e74c349818deb160 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 9 Jun 2022 17:09:17 -0300 Subject: [PATCH 31/42] [db] simplify app queries and add more tests for AppDao --- .../java/org/fdroid/database/AppDaoTest.kt | 130 +++++++ .../org/fdroid/database/AppListItemsTest.kt | 331 ++++++++++++++++++ .../fdroid/database/AppOverviewItemsTest.kt | 297 ++++++++++++++++ .../java/org/fdroid/database/AppTest.kt | 227 +----------- .../org/fdroid/database/RepositoryTest.kt | 6 +- .../src/main/java/org/fdroid/database/App.kt | 113 +++--- .../main/java/org/fdroid/database/AppDao.kt | 253 ++++++------- .../org/fdroid/database/FDroidDatabase.kt | 11 +- .../main/java/org/fdroid/database/Version.kt | 9 + 9 files changed, 978 insertions(+), 399 deletions(-) create mode 100644 database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt create mode 100644 database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt create mode 100644 database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt diff --git a/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt b/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt new file mode 100644 index 000000000..b33d0444f --- /dev/null +++ b/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt @@ -0,0 +1,130 @@ +package org.fdroid.database + +import androidx.core.os.LocaleListCompat +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fdroid.database.TestUtils.getOrFail +import org.fdroid.database.TestUtils.toMetadataV2 +import org.fdroid.test.TestRepoUtils.getRandomRepo +import org.fdroid.test.TestUtils.sort +import org.fdroid.test.TestVersionUtils.getRandomPackageVersionV2 +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +@RunWith(AndroidJUnit4::class) +internal class AppDaoTest : AppTest() { + + @Test + fun insertGetDeleteSingleApp() { + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName, app1) + + assertEquals(app1, appDao.getApp(repoId, packageName)?.toMetadataV2()?.sort()) + assertEquals(app1, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) + + appDao.deleteAppMetadata(repoId, packageName) + assertEquals(0, appDao.countApps()) + assertEquals(0, appDao.countLocalizedFiles()) + assertEquals(0, appDao.countLocalizedFileLists()) + } + + @Test + fun testGetSameAppFromTwoRepos() { + // insert same app into three repos (repoId1 has highest weight) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId1, packageName, app1, locales) + appDao.insert(repoId2, packageName, app2, locales) + appDao.insert(repoId3, packageName, app3, locales) + + // ensure expected repo weights + val repoPrefs1 = repoDao.getRepositoryPreferences(repoId1) ?: fail() + val repoPrefs2 = repoDao.getRepositoryPreferences(repoId2) ?: fail() + val repoPrefs3 = repoDao.getRepositoryPreferences(repoId3) ?: fail() + assertTrue(repoPrefs2.weight < repoPrefs3.weight) + assertTrue(repoPrefs3.weight < repoPrefs1.weight) + + // each app gets returned as stored from each repo + assertEquals(app1, appDao.getApp(repoId1, packageName)?.toMetadataV2()?.sort()) + assertEquals(app2, appDao.getApp(repoId2, packageName)?.toMetadataV2()?.sort()) + assertEquals(app3, appDao.getApp(repoId3, packageName)?.toMetadataV2()?.sort()) + + // if repo is not given, app from repo with highest weight is returned + assertEquals(app1, appDao.getApp(packageName).getOrFail()?.toMetadataV2()?.sort()) + } + + @Test + fun testUpdateCompatibility() { + // insert two apps with one version each + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName, app1, locales) + + // without versions, app isn't compatible + assertEquals(false, appDao.getApp(repoId, packageName)?.metadata?.isCompatible) + appDao.updateCompatibility(repoId) + assertEquals(false, appDao.getApp(repoId, packageName)?.metadata?.isCompatible) + + // still incompatible with incompatible version + versionDao.insert(repoId, packageName, "1", getRandomPackageVersionV2(), false) + appDao.updateCompatibility(repoId) + assertEquals(false, appDao.getApp(repoId, packageName)?.metadata?.isCompatible) + + // only with at least one compatible version, the app becomes compatible + versionDao.insert(repoId, packageName, "2", getRandomPackageVersionV2(), true) + appDao.updateCompatibility(repoId) + assertEquals(true, appDao.getApp(repoId, packageName)?.metadata?.isCompatible) + } + + @Test + fun testAfterLocalesChanged() { + // insert app with German and French locales + val localesBefore = LocaleListCompat.forLanguageTags("de-DE") + val app = app1.copy( + name = mapOf("de-DE" to "de-DE", "fr-FR" to "fr-FR"), + summary = mapOf("de-DE" to "de-DE", "fr-FR" to "fr-FR"), + ) + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName, app, localesBefore) + + // device is set to German, so name and summary come out German + val appBefore = appDao.getApp(repoId, packageName) + assertEquals("de-DE", appBefore?.name) + assertEquals("de-DE", appBefore?.summary) + + // device gets switched to French + val localesAfter = LocaleListCompat.forLanguageTags("fr-FR") + db.afterLocalesChanged(localesAfter) + + // device is set to French now, so name and summary come out French + val appAfter = appDao.getApp(repoId, packageName) + assertEquals("fr-FR", appAfter?.name) + assertEquals("fr-FR", appAfter?.summary) + } + + @Test + fun testGetNumberOfAppsInCategory() { + val repoId = repoDao.insertOrReplace(getRandomRepo()) + + // app1 is in A and B + appDao.insert(repoId, packageName1, app1, locales) + assertEquals(1, appDao.getNumberOfAppsInCategory("A")) + assertEquals(1, appDao.getNumberOfAppsInCategory("B")) + assertEquals(0, appDao.getNumberOfAppsInCategory("C")) + + // app2 is in A + appDao.insert(repoId, packageName2, app2, locales) + assertEquals(2, appDao.getNumberOfAppsInCategory("A")) + assertEquals(1, appDao.getNumberOfAppsInCategory("B")) + assertEquals(0, appDao.getNumberOfAppsInCategory("C")) + + // app3 is in A and B + appDao.insert(repoId, packageName3, app3, locales) + assertEquals(3, appDao.getNumberOfAppsInCategory("A")) + assertEquals(2, appDao.getNumberOfAppsInCategory("B")) + assertEquals(0, appDao.getNumberOfAppsInCategory("C")) + } + +} diff --git a/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt b/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt new file mode 100644 index 000000000..6ad0aa7d4 --- /dev/null +++ b/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt @@ -0,0 +1,331 @@ +package org.fdroid.database + +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.every +import io.mockk.mockk +import org.fdroid.database.AppListSortOrder.LAST_UPDATED +import org.fdroid.database.AppListSortOrder.NAME +import org.fdroid.database.TestUtils.getOrFail +import org.fdroid.index.v2.MetadataV2 +import org.fdroid.test.TestRepoUtils.getRandomRepo +import org.fdroid.test.TestUtils.getRandomString +import org.fdroid.test.TestVersionUtils.getRandomPackageVersionV2 +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.random.Random +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +@RunWith(AndroidJUnit4::class) +internal class AppListItemsTest : AppTest() { + + private val pm: PackageManager = mockk() + + private val appPairs = listOf( + Pair(packageName1, app1), + Pair(packageName2, app2), + Pair(packageName3, app3), + ) + + @Test + fun testSortOrderByLastUpdated() { + // insert three apps in a random order + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName1, app1, locales) + appDao.insert(repoId, packageName3, app3, locales) + appDao.insert(repoId, packageName2, app2, locales) + + // nothing is installed + every { pm.getInstalledPackages(0) } returns emptyList() + + // get apps sorted by last updated + appDao.getAppListItems(pm, LAST_UPDATED).getOrFail().let { apps -> + assertEquals(3, apps.size) + // we expect apps to be sorted by last updated descending + appPairs.sortedByDescending { (_, metadataV2) -> + metadataV2.lastUpdated + }.forEachIndexed { i, pair -> + assertEquals(pair.first, apps[i].packageId) + assertEquals(pair.second, apps[i]) + } + } + } + + @Test + fun testSortOrderByName() { + // insert three apps + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName1, app1, locales) + appDao.insert(repoId, packageName2, app2, locales) + appDao.insert(repoId, packageName3, app3, locales) + + // nothing is installed + every { pm.getInstalledPackages(0) } returns emptyList() + + // get apps sorted by name ascending + appDao.getAppListItems(pm, NAME).getOrFail().let { apps -> + assertEquals(3, apps.size) + // we expect apps to be sorted by last updated descending + appPairs.sortedBy { (_, metadataV2) -> + metadataV2.name.getBestLocale(locales) + }.forEachIndexed { i, pair -> + assertEquals(pair.first, apps[i].packageId) + assertEquals(pair.second, apps[i]) + } + } + } + + @Test + fun testPackageManagerInfo() { + // insert two apps + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName1, app1, locales) + appDao.insert(repoId, packageName2, app2, locales) + + // one of the apps is installed + @Suppress("DEPRECATION") + val packageInfo2 = PackageInfo().apply { + packageName = packageName2 + versionName = getRandomString() + versionCode = Random.nextInt(1, Int.MAX_VALUE) + } + every { pm.getInstalledPackages(0) } returns listOf(packageInfo2) + + // get apps sorted by name and last update, test on both lists + listOf( + appDao.getAppListItems(pm, NAME).getOrFail(), + appDao.getAppListItems(pm, LAST_UPDATED).getOrFail(), + ).forEach { apps -> + assertEquals(2, apps.size) + // the installed app should have app data + val installed = if (apps[0].packageId == packageName1) apps[1] else apps[0] + val other = if (apps[0].packageId == packageName1) apps[0] else apps[1] + assertEquals(packageInfo2.versionName, installed.installedVersionName) + assertEquals(packageInfo2.getVersionCode(), installed.installedVersionCode) + assertNull(other.installedVersionName) + assertNull(other.installedVersionCode) + } + } + + @Test + fun testCompatibility() { + // insert two apps + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName1, app1, locales) + appDao.insert(repoId, packageName2, app2, locales) + + // both apps are not compatible + getItems { apps -> + assertEquals(2, apps.size) + assertFalse(apps[0].isCompatible) + assertFalse(apps[1].isCompatible) + } + + // each app gets a version + versionDao.insert(repoId, packageName1, "1", getRandomPackageVersionV2(), true) + versionDao.insert(repoId, packageName2, "1", getRandomPackageVersionV2(), false) + + // updating compatibility for apps + appDao.updateCompatibility(repoId) + + // now only one is not compatible + getItems { apps -> + assertEquals(2, apps.size) + if (apps[0].packageId == packageName1) { + assertTrue(apps[0].isCompatible) + assertFalse(apps[1].isCompatible) + } else { + assertFalse(apps[0].isCompatible) + assertTrue(apps[1].isCompatible) + } + } + } + + @Test + fun testAntiFeaturesFromHighestVersion() { + // insert one app with no versions + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName1, app1, locales) + + // app has no anti-features, because no version + getItems { apps -> + assertEquals(1, apps.size) + assertNull(apps[0].antiFeatures) + assertEquals(emptyList(), apps[0].getAntiFeatureNames()) + } + + // app gets a version + val version1 = getRandomPackageVersionV2(42) + versionDao.insert(repoId, packageName1, "1", version1, true) + + // app has now has the anti-features of the version + // note that installed versions don't contain anti-features, so they are ignored + getItems(alsoInstalled = false) { apps -> + assertEquals(1, apps.size) + assertEquals(version1.antiFeatures.map { it.key }, apps[0].getAntiFeatureNames()) + } + + // app gets another version + val version2 = getRandomPackageVersionV2(23) + versionDao.insert(repoId, packageName1, "2", version2, true) + + // app has now has the anti-features of the initial version still, because 2nd is lower + // note that installed versions don't contain anti-features, so they are ignored + getItems(alsoInstalled = false) { apps -> + assertEquals(1, apps.size) + assertEquals(version1.antiFeatures.map { it.key }, apps[0].getAntiFeatureNames()) + } + } + + @Test + fun testOnlyFromEnabledRepos() { + // insert two apps in two different repos + val repoId = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName1, app1, locales) + appDao.insert(repoId2, packageName2, app2, locales) + + // initially both apps get returned + getItems { apps -> + assertEquals(2, apps.size) + } + + // disable first repo + repoDao.setRepositoryEnabled(repoId, false) + + // now only app from enabled repo gets returned + getItems { apps -> + assertEquals(1, apps.size) + assertEquals(repoId2, apps[0].repoId) + } + } + + @Test + fun testFromRepoWithHighestWeight() { + // insert same app into three repos (repoId1 has highest weight) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val repoId3 = repoDao.insertOrReplace(getRandomRepo()) + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId2, packageName, app2, locales) + appDao.insert(repoId1, packageName, app1, locales) + appDao.insert(repoId3, packageName, app3, locales) + + // ensure expected repo weights + val repoPrefs1 = repoDao.getRepositoryPreferences(repoId1) ?: fail() + val repoPrefs2 = repoDao.getRepositoryPreferences(repoId2) ?: fail() + val repoPrefs3 = repoDao.getRepositoryPreferences(repoId3) ?: fail() + assertTrue(repoPrefs2.weight < repoPrefs3.weight) + assertTrue(repoPrefs3.weight < repoPrefs1.weight) + + // app from repo with highest weight is returned (app1) + getItems { apps -> + assertEquals(1, apps.size) + assertEquals(packageName, apps[0].packageId) + assertEquals(app1, apps[0]) + } + } + + @Test + fun testOnlyFromGivenCategories() { + // insert three apps + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName1, app1, locales) + appDao.insert(repoId, packageName2, app2, locales) + appDao.insert(repoId, packageName3, app3, locales) + + // only two apps are in category B + listOf( + appDao.getAppListItemsByName("B").getOrFail(), + appDao.getAppListItemsByLastUpdated("B").getOrFail(), + ).forEach { apps -> + assertEquals(2, apps.size) + assertNotEquals(packageName2, apps[0].packageId) + assertNotEquals(packageName2, apps[1].packageId) + } + + // no app is in category C + listOf( + appDao.getAppListItemsByName("C").getOrFail(), + appDao.getAppListItemsByLastUpdated("C").getOrFail(), + ).forEach { apps -> + assertEquals(0, apps.size) + } + } + + @Test + fun testGetInstalledAppListItems() { + // insert three apps + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName1, app1, locales) + appDao.insert(repoId, packageName2, app2, locales) + appDao.insert(repoId, packageName3, app3, locales) + + // define packageInfo for each test + @Suppress("DEPRECATION") + val packageInfo1 = PackageInfo().apply { + packageName = packageName1 + versionName = getRandomString() + versionCode = Random.nextInt(1, Int.MAX_VALUE) + } + val packageInfo2 = PackageInfo().apply { packageName = packageName2 } + val packageInfo3 = PackageInfo().apply { packageName = packageName3 } + + // all apps get returned, if we consider all of them installed + every { + pm.getInstalledPackages(0) + } returns listOf(packageInfo1, packageInfo2, packageInfo3) + assertEquals(3, appDao.getInstalledAppListItems(pm).getOrFail().size) + + // one apps get returned, if we consider only that one installed + every { pm.getInstalledPackages(0) } returns listOf(packageInfo1) + appDao.getInstalledAppListItems(pm).getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app1, apps[0]) + // version code and version name gets taken from supplied packageInfo + assertEquals(packageInfo1.getVersionCode(), apps[0].installedVersionCode) + assertEquals(packageInfo1.versionName, apps[0].installedVersionName) + } + + // no app gets returned, if we consider none installed + every { pm.getInstalledPackages(0) } returns emptyList() + appDao.getInstalledAppListItems(pm).getOrFail().let { apps -> + assertEquals(0, apps.size) + } + } + + /** + * Runs the given block on all getAppListItems* methods. + * Uses category "A" as all apps should be in that. + */ + private fun getItems(alsoInstalled: Boolean = true, block: (List) -> Unit) { + appDao.getAppListItemsByName().getOrFail().let(block) + appDao.getAppListItemsByName("A").getOrFail().let(block) + appDao.getAppListItemsByLastUpdated().getOrFail().let(block) + appDao.getAppListItemsByLastUpdated("A").getOrFail().let(block) + if (alsoInstalled) { + // everything is always considered to be installed + val packageInfo = + PackageInfo().apply { packageName = this@AppListItemsTest.packageName } + val packageInfo1 = PackageInfo().apply { packageName = packageName1 } + val packageInfo2 = PackageInfo().apply { packageName = packageName2 } + val packageInfo3 = PackageInfo().apply { packageName = packageName3 } + every { + pm.getInstalledPackages(0) + } returns listOf(packageInfo, packageInfo1, packageInfo2, packageInfo3) + appDao.getInstalledAppListItems(pm).getOrFail().let(block) + } + } + + private fun assertEquals(expected: MetadataV2, actual: AppListItem) { + assertEquals(expected.name.getBestLocale(locales), actual.name) + assertEquals(expected.summary.getBestLocale(locales), actual.summary) + assertEquals(expected.icon.getBestLocale(locales), actual.getIcon(locales)) + } + +} diff --git a/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt b/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt new file mode 100644 index 000000000..f0b319728 --- /dev/null +++ b/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt @@ -0,0 +1,297 @@ +package org.fdroid.database + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fdroid.database.TestUtils.getOrAwaitValue +import org.fdroid.database.TestUtils.getOrFail +import org.fdroid.index.v2.MetadataV2 +import org.fdroid.test.TestRepoUtils.getRandomRepo +import org.fdroid.test.TestVersionUtils.getRandomPackageVersionV2 +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@RunWith(AndroidJUnit4::class) +internal class AppOverviewItemsTest : AppTest() { + + @Test + fun testAntiFeatures() { + // insert one apps with without version + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName, app1, locales) + + // without version, anti-features are empty + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertNull(apps[0].antiFeatures) + } + + // with one version, the app has those anti-features + val version = getRandomPackageVersionV2(versionCode = 42) + versionDao.insert(repoId, packageName, "1", version, true) + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(version.antiFeatures, apps[0].antiFeatures) + } + + // with two versions, the app has the anti-features of the highest version + val version2 = getRandomPackageVersionV2(versionCode = 23) + versionDao.insert(repoId, packageName, "2", version2, true) + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(version.antiFeatures, apps[0].antiFeatures) + } + + // with three versions, the app has the anti-features of the highest version + val version3 = getRandomPackageVersionV2(versionCode = 1337) + versionDao.insert(repoId, packageName, "3", version3, true) + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(version3.antiFeatures, apps[0].antiFeatures) + } + } + + @Test + fun testIcons() { + // insert one app + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName, app1, locales) + + // icon is returned correctly + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app1.icon.getBestLocale(locales), apps[0].getIcon(locales)) + } + + // insert same app into another repo + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId2, packageName, app2, locales) + + // now icon is returned from app in second repo + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app2.icon.getBestLocale(locales), apps[0].getIcon(locales)) + } + } + + @Test + fun testLimit() { + // insert three apps + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName1, app1, locales) + appDao.insert(repoId, packageName2, app2, locales) + appDao.insert(repoId, packageName3, app3, locales) + + // limit is respected + for (i in 0..3) assertEquals(i, appDao.getAppOverviewItems(i).getOrFail().size) + assertEquals(3, appDao.getAppOverviewItems(42).getOrFail().size) + } + + @Test + fun testGetByRepoWeight() { + // insert one app with one version + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName, app1, locales) + versionDao.insert(repoId, packageName, "1", getRandomPackageVersionV2(2), true) + + // app is returned correctly + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app1, apps[0]) + } + + // add another app without version + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId2, packageName, app2, locales) + + // now second app from second repo is returned + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app2, apps[0]) + } + } + + @Test + fun testSortOrder() { + // insert two apps with one version each + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName1, app1, locales) + appDao.insert(repoId, packageName2, app2, locales) + versionDao.insert(repoId, packageName1, "1", getRandomPackageVersionV2(), true) + versionDao.insert(repoId, packageName2, "2", getRandomPackageVersionV2(), true) + + // icons of both apps are returned correctly + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(2, apps.size) + // app 2 is first, because has icon and summary + assertEquals(packageName2, apps[0].packageId) + assertEquals(icons2, apps[0].localizedIcon?.toLocalizedFileV2()) + // app 1 is next, because has icon + assertEquals(packageName1, apps[1].packageId) + assertEquals(icons1, apps[1].localizedIcon?.toLocalizedFileV2()) + } + + // app without icon is returned last + appDao.insert(repoId, packageName3, app3) + versionDao.insert(repoId, packageName3, "3", getRandomPackageVersionV2(), true) + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(3, apps.size) + assertEquals(packageName2, apps[0].packageId) + assertEquals(packageName1, apps[1].packageId) + assertEquals(packageName3, apps[2].packageId) + assertEquals(emptyList(), apps[2].localizedIcon) + } + + // app1b is the same as app1 (but in another repo) and thus will not be shown again + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val app1b = app1.copy(name = name2, icon = icons2, summary = name2) + appDao.insert(repoId2, packageName1, app1b) + // note that we don't insert a version here + assertEquals(3, appDao.getAppOverviewItems().getOrFail().size) + + // app3b is the same as app3, but has an icon, so is not last anymore + val app3b = app3.copy(icon = icons2) + appDao.insert(repoId2, packageName3, app3b) + // note that we don't insert a version here + appDao.getAppOverviewItems().getOrFail().let { apps -> + assertEquals(3, apps.size) + assertEquals(packageName3, apps[0].packageId) + assertEquals(emptyList(), apps[0].antiFeatureNames) + assertEquals(packageName2, apps[1].packageId) + assertEquals(packageName1, apps[2].packageId) + } + } + + @Test + fun testSortOrderWithCategories() { + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName1, app1, locales) + appDao.insert(repoId, packageName2, app2, locales) + versionDao.insert(repoId, packageName1, "1", getRandomPackageVersionV2(), true) + versionDao.insert(repoId, packageName2, "2", getRandomPackageVersionV2(), true) + + // icons of both apps are returned correctly + appDao.getAppOverviewItems("A").getOrFail().let { apps -> + assertEquals(2, apps.size) + // app 2 is first, because has icon and summary + assertEquals(packageName2, apps[0].packageId) + assertEquals(icons2, apps[0].localizedIcon?.toLocalizedFileV2()) + // app 1 is next, because has icon + assertEquals(packageName1, apps[1].packageId) + assertEquals(icons1, apps[1].localizedIcon?.toLocalizedFileV2()) + } + + // only one app is returned for category B + assertEquals(1, appDao.getAppOverviewItems("B").getOrFail().size) + + // app without icon is returned last + appDao.insert(repoId, packageName3, app3) + versionDao.insert(repoId, packageName3, "3", getRandomPackageVersionV2(), true) + appDao.getAppOverviewItems("A").getOrFail().let { apps -> + assertEquals(3, apps.size) + assertEquals(packageName2, apps[0].packageId) + assertEquals(packageName1, apps[1].packageId) + assertEquals(packageName3, apps[2].packageId) + assertEquals(emptyList(), apps[2].localizedIcon) + } + + // app1b is the same as app1 (but in another repo) and thus will not be shown again + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + val app1b = app1.copy(name = name2, icon = icons2, summary = name2) + appDao.insert(repoId2, packageName1, app1b) + // note that we don't insert a version here + assertEquals(3, appDao.getAppOverviewItems("A").getOrFail().size) + + // app3b is the same as app3, but has an icon, so is not last anymore + val app3b = app3.copy(icon = icons2) + appDao.insert(repoId2, packageName3, app3b) + // note that we don't insert a version here + appDao.getAppOverviewItems("A").getOrFail().let { apps -> + assertEquals(3, apps.size) + assertEquals(packageName3, apps[0].packageId) + assertEquals(emptyList(), apps[0].antiFeatureNames) + assertEquals(packageName2, apps[1].packageId) + assertEquals(packageName1, apps[2].packageId) + } + + // only two apps are returned for category B + assertEquals(2, appDao.getAppOverviewItems("B").getOrFail().size) + } + + @Test + fun testOnlyFromEnabledRepos() { + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName1, app1, locales) + appDao.insert(repoId, packageName2, app2, locales) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId2, packageName3, app3, locales) + + // 3 apps from 2 repos + assertEquals(3, appDao.getAppOverviewItems().getOrAwaitValue()?.size) + assertEquals(3, appDao.getAppOverviewItems("A").getOrAwaitValue()?.size) + + // only 1 app after disabling first repo + repoDao.setRepositoryEnabled(repoId, false) + assertEquals(1, appDao.getAppOverviewItems().getOrAwaitValue()?.size) + assertEquals(1, appDao.getAppOverviewItems("A").getOrAwaitValue()?.size) + assertEquals(1, appDao.getAppOverviewItems("B").getOrAwaitValue()?.size) + + // no more apps after disabling all repos + repoDao.setRepositoryEnabled(repoId2, false) + assertEquals(0, appDao.getAppOverviewItems().getOrAwaitValue()?.size) + assertEquals(0, appDao.getAppOverviewItems("A").getOrAwaitValue()?.size) + assertEquals(0, appDao.getAppOverviewItems("B").getOrAwaitValue()?.size) + } + + @Test + fun testGetAppOverviewItem() { + // insert three apps into two repos + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName1, app1, locales) + appDao.insert(repoId, packageName2, app2, locales) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId2, packageName3, app3, locales) + + // each app gets returned properly + assertEquals(app1, appDao.getAppOverviewItem(repoId, packageName1)) + assertEquals(app2, appDao.getAppOverviewItem(repoId, packageName2)) + assertEquals(app3, appDao.getAppOverviewItem(repoId2, packageName3)) + + // apps don't get returned from wrong repos + assertNull(appDao.getAppOverviewItem(repoId2, packageName1)) + assertNull(appDao.getAppOverviewItem(repoId2, packageName2)) + assertNull(appDao.getAppOverviewItem(repoId, packageName3)) + } + + @Test + fun testGetAppOverviewItemWithIcons() { + // insert one app (with overlapping icons) into two repos + val repoId1 = repoDao.insertOrReplace(getRandomRepo()) + val repoId2 = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId1, packageName, app1, locales) + appDao.insert(repoId2, packageName, app2, locales) + + // each app gets returned properly + assertEquals(app1, appDao.getAppOverviewItem(repoId1, packageName)) + assertEquals(app2, appDao.getAppOverviewItem(repoId2, packageName)) + + // disable second repo + repoDao.setRepositoryEnabled(repoId2, false) + + // each app still gets returned properly + assertEquals(app1, appDao.getAppOverviewItem(repoId1, packageName)) + assertEquals(app2, appDao.getAppOverviewItem(repoId2, packageName)) + } + + private fun assertEquals(expected: MetadataV2, actual: AppOverviewItem?) { + assertNotNull(actual) + assertEquals(expected.added, actual.added) + assertEquals(expected.lastUpdated, actual.lastUpdated) + assertEquals(expected.name.getBestLocale(locales), actual.name) + assertEquals(expected.summary.getBestLocale(locales), actual.summary) + assertEquals(expected.summary.getBestLocale(locales), actual.summary) + assertEquals(expected.icon.getBestLocale(locales), actual.getIcon(locales)) + } + +} diff --git a/database/src/dbTest/java/org/fdroid/database/AppTest.kt b/database/src/dbTest/java/org/fdroid/database/AppTest.kt index 999e258c3..bbaf51cda 100644 --- a/database/src/dbTest/java/org/fdroid/database/AppTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/AppTest.kt @@ -1,238 +1,47 @@ package org.fdroid.database import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.fdroid.database.TestUtils.getOrAwaitValue -import org.fdroid.test.TestAppUtils.assertScreenshotsEqual import org.fdroid.test.TestAppUtils.getRandomMetadataV2 import org.fdroid.test.TestRepoUtils.getRandomFileV2 -import org.fdroid.test.TestRepoUtils.getRandomRepo import org.fdroid.test.TestUtils.getRandomString -import org.fdroid.test.TestVersionUtils.getRandomPackageVersionV2 +import org.fdroid.test.TestUtils.sort import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import kotlin.test.assertTrue -import kotlin.test.fail -@RunWith(AndroidJUnit4::class) -internal class AppTest : DbTest() { +internal abstract class AppTest : DbTest() { @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() - private val packageId = getRandomString() - private val packageId1 = getRandomString() - private val packageId2 = getRandomString() - private val packageId3 = getRandomString() - private val name1 = mapOf("en-US" to "1") - private val name2 = mapOf("en-US" to "2") - private val name3 = mapOf("en-US" to "3") - private val icons1 = mapOf("foo" to getRandomFileV2(), "bar" to getRandomFileV2()) - private val icons2 = mapOf("23" to getRandomFileV2(), "42" to getRandomFileV2()) - private val app1 = getRandomMetadataV2().copy( + protected val packageName = getRandomString() + protected val packageName1 = getRandomString() + protected val packageName2 = getRandomString() + protected val packageName3 = getRandomString() + protected val name1 = mapOf("en-US" to "1") + protected val name2 = mapOf("en-US" to "2") + protected val name3 = mapOf("en-US" to "3") + // it is important for testing that the icons are sharing at least one locale + protected val icons1 = mapOf("en-US" to getRandomFileV2(), "bar" to getRandomFileV2()) + protected val icons2 = mapOf("en-US" to getRandomFileV2(), "42" to getRandomFileV2()) + protected val app1 = getRandomMetadataV2().copy( name = name1, icon = icons1, summary = null, lastUpdated = 10, categories = listOf("A", "B") - ) - private val app2 = getRandomMetadataV2().copy( + ).sort() + protected val app2 = getRandomMetadataV2().copy( name = name2, icon = icons2, summary = name2, lastUpdated = 20, categories = listOf("A") - ) - private val app3 = getRandomMetadataV2().copy( + ).sort() + protected val app3 = getRandomMetadataV2().copy( name = name3, icon = null, summary = name3, lastUpdated = 30, categories = listOf("A", "B") - ) - - @Test - fun insertGetDeleteSingleApp() { - val repoId = repoDao.insertOrReplace(getRandomRepo()) - val metadataV2 = getRandomMetadataV2() - appDao.insert(repoId, packageId, metadataV2) - - val app = appDao.getApp(repoId, packageId) ?: fail() - val metadata = metadataV2.toAppMetadata(repoId, packageId) - assertEquals(metadata, app.metadata) - assertEquals(metadataV2.icon, app.icon) - assertEquals(metadataV2.featureGraphic, app.featureGraphic) - assertEquals(metadataV2.promoGraphic, app.promoGraphic) - assertEquals(metadataV2.tvBanner, app.tvBanner) - assertScreenshotsEqual(metadataV2.screenshots, app.screenshots) - - assertEquals(metadata, appDao.getApp(packageId).getOrAwaitValue()?.metadata) - - appDao.deleteAppMetadata(repoId, packageId) - assertEquals(0, appDao.getAppMetadata().size) - assertEquals(0, appDao.getLocalizedFiles().size) - assertEquals(0, appDao.getLocalizedFileLists().size) - } - - @Test - fun testAppOverViewItemSortOrder() { - val repoId = repoDao.insertOrReplace(getRandomRepo()) - appDao.insert(repoId, packageId1, app1, locales) - appDao.insert(repoId, packageId2, app2, locales) - versionDao.insert(repoId, packageId1, "1", getRandomPackageVersionV2(), true) - versionDao.insert(repoId, packageId2, "2", getRandomPackageVersionV2(), true) - - // icons of both apps are returned correctly - val apps = appDao.getAppOverviewItems().getOrAwaitValue() ?: fail() - assertEquals(2, apps.size) - // app 2 is first, because has icon and summary - assertEquals(packageId2, apps[0].packageId) - assertEquals(icons2, apps[0].localizedIcon?.toLocalizedFileV2()) - // app 1 is next, because has icon - assertEquals(packageId1, apps[1].packageId) - assertEquals(icons1, apps[1].localizedIcon?.toLocalizedFileV2()) - - // app without icon is returned last - appDao.insert(repoId, packageId3, app3) - versionDao.insert(repoId, packageId3, "3", getRandomPackageVersionV2(), true) - val apps3 = appDao.getAppOverviewItems().getOrAwaitValue() ?: fail() - assertEquals(3, apps3.size) - assertEquals(packageId2, apps3[0].packageId) - assertEquals(packageId1, apps3[1].packageId) - assertEquals(packageId3, apps3[2].packageId) - assertEquals(emptyList(), apps3[2].localizedIcon) - - // app1b is the same as app1 (but in another repo) and thus will not be shown again - val repoId2 = repoDao.insertOrReplace(getRandomRepo()) - val app1b = app1.copy(name = name2, icon = icons2, summary = name2) - appDao.insert(repoId2, packageId1, app1b) - // note that we don't insert a version here - val apps4 = appDao.getAppOverviewItems().getOrAwaitValue() ?: fail() - assertEquals(3, apps4.size) - - // app3b is the same as app3, but has an icon, so is not last anymore - val app3b = app3.copy(icon = icons2) - appDao.insert(repoId2, packageId3, app3b) - // note that we don't insert a version here - val apps5 = appDao.getAppOverviewItems().getOrAwaitValue() ?: fail() - assertEquals(3, apps5.size) - assertEquals(packageId3, apps5[0].packageId) - assertEquals(emptyList(), apps5[0].antiFeatureNames) - assertEquals(packageId2, apps5[1].packageId) - assertEquals(packageId1, apps5[2].packageId) - } - - @Test - fun testAppOverViewItemSortOrderWithCategories() { - val repoId = repoDao.insertOrReplace(getRandomRepo()) - appDao.insert(repoId, packageId1, app1, locales) - appDao.insert(repoId, packageId2, app2, locales) - versionDao.insert(repoId, packageId1, "1", getRandomPackageVersionV2(), true) - versionDao.insert(repoId, packageId2, "2", getRandomPackageVersionV2(), true) - - // icons of both apps are returned correctly - val apps = appDao.getAppOverviewItems("A").getOrAwaitValue() ?: fail() - assertEquals(2, apps.size) - // app 2 is first, because has icon and summary - assertEquals(packageId2, apps[0].packageId) - assertEquals(icons2, apps[0].localizedIcon?.toLocalizedFileV2()) - // app 1 is next, because has icon - assertEquals(packageId1, apps[1].packageId) - assertEquals(icons1, apps[1].localizedIcon?.toLocalizedFileV2()) - - // only one app is returned for category B - assertEquals(1, appDao.getAppOverviewItems("B").getOrAwaitValue()?.size ?: fail()) - - // app without icon is returned last - appDao.insert(repoId, packageId3, app3) - versionDao.insert(repoId, packageId3, "3", getRandomPackageVersionV2(), true) - val apps3 = appDao.getAppOverviewItems("A").getOrAwaitValue() ?: fail() - assertEquals(3, apps3.size) - assertEquals(packageId2, apps3[0].packageId) - assertEquals(packageId1, apps3[1].packageId) - assertEquals(packageId3, apps3[2].packageId) - assertEquals(emptyList(), apps3[2].localizedIcon) - - // app1b is the same as app1 (but in another repo) and thus will not be shown again - val repoId2 = repoDao.insertOrReplace(getRandomRepo()) - val app1b = app1.copy(name = name2, icon = icons2, summary = name2) - appDao.insert(repoId2, packageId1, app1b) - // note that we don't insert a version here - val apps4 = appDao.getAppOverviewItems("A").getOrAwaitValue() ?: fail() - assertEquals(3, apps4.size) - - // app3b is the same as app3, but has an icon, so is not last anymore - val app3b = app3.copy(icon = icons2) - appDao.insert(repoId2, packageId3, app3b) - // note that we don't insert a version here - val apps5 = appDao.getAppOverviewItems("A").getOrAwaitValue() ?: fail() - assertEquals(3, apps5.size) - assertEquals(packageId3, apps5[0].packageId) - assertEquals(emptyList(), apps5[0].antiFeatureNames) - assertEquals(packageId2, apps5[1].packageId) - assertEquals(packageId1, apps5[2].packageId) - - // only two apps are returned for category B - assertEquals(2, appDao.getAppOverviewItems("B").getOrAwaitValue()?.size) - } - - @Test - fun testAppOverViewItemOnlyFromEnabledRepos() { - val repoId = repoDao.insertOrReplace(getRandomRepo()) - appDao.insert(repoId, packageId1, app1, locales) - appDao.insert(repoId, packageId2, app2, locales) - val repoId2 = repoDao.insertOrReplace(getRandomRepo()) - appDao.insert(repoId2, packageId3, app3, locales) - - // 3 apps from 2 repos - assertEquals(3, appDao.getAppOverviewItems().getOrAwaitValue()?.size) - assertEquals(3, appDao.getAppOverviewItems("A").getOrAwaitValue()?.size) - - // only 1 app after disabling first repo - repoDao.setRepositoryEnabled(repoId, false) - assertEquals(1, appDao.getAppOverviewItems().getOrAwaitValue()?.size) - assertEquals(1, appDao.getAppOverviewItems("A").getOrAwaitValue()?.size) - assertEquals(1, appDao.getAppOverviewItems("B").getOrAwaitValue()?.size) - - // no more apps after disabling all repos - repoDao.setRepositoryEnabled(repoId2, false) - assertEquals(0, appDao.getAppOverviewItems().getOrAwaitValue()?.size) - assertEquals(0, appDao.getAppOverviewItems("A").getOrAwaitValue()?.size) - assertEquals(0, appDao.getAppOverviewItems("B").getOrAwaitValue()?.size) - } - - @Test - fun testAppByRepoWeight() { - val repoId1 = repoDao.insertOrReplace(getRandomRepo()) - val repoId2 = repoDao.insertOrReplace(getRandomRepo()) - val metadata1 = getRandomMetadataV2() - val metadata2 = metadata1.copy(lastUpdated = metadata1.lastUpdated + 1) - - // app is only in one repo, so returns it's repoId - appDao.insert(repoId1, packageId, metadata1) - assertEquals(repoId1, appDao.getRepoIdForPackage(packageId).getOrAwaitValue()) - - // ensure second repo has a higher weight - val repoPrefs1 = repoDao.getRepositoryPreferences(repoId1) ?: fail() - val repoPrefs2 = repoDao.getRepositoryPreferences(repoId2) ?: fail() - assertTrue(repoPrefs1.weight < repoPrefs2.weight) - - // app is now in repo with higher weight, so it's repoId gets returned - appDao.insert(repoId2, packageId, metadata2) - assertEquals(repoId2, appDao.getRepoIdForPackage(packageId).getOrAwaitValue()) - assertEquals(appDao.getApp(repoId2, packageId)?.metadata, - appDao.getApp(packageId).getOrAwaitValue()?.metadata) - assertScreenshotsEqual(appDao.getApp(repoId2, packageId)?.screenshots, - appDao.getApp(packageId).getOrAwaitValue()?.screenshots) - assertEquals(appDao.getApp(repoId2, packageId)?.icon, - appDao.getApp(packageId).getOrAwaitValue()?.icon) - assertEquals(appDao.getApp(repoId2, packageId)?.featureGraphic, - appDao.getApp(packageId).getOrAwaitValue()?.featureGraphic) - assertNotEquals(appDao.getApp(repoId1, packageId), - appDao.getApp(packageId).getOrAwaitValue()) - } + ).sort() } diff --git a/database/src/dbTest/java/org/fdroid/database/RepositoryTest.kt b/database/src/dbTest/java/org/fdroid/database/RepositoryTest.kt index bfd0248a6..d2a3e8879 100644 --- a/database/src/dbTest/java/org/fdroid/database/RepositoryTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/RepositoryTest.kt @@ -86,9 +86,9 @@ internal class RepositoryTest : DbTest() { repoDao.clear(repoId) assertEquals(1, repoDao.getRepositories().size) - assertEquals(0, appDao.getAppMetadata().size) - assertEquals(0, appDao.getLocalizedFiles().size) - assertEquals(0, appDao.getLocalizedFileLists().size) + assertEquals(0, appDao.countApps()) + assertEquals(0, appDao.countLocalizedFiles()) + assertEquals(0, appDao.countLocalizedFileLists()) assertEquals(0, versionDao.getAppVersions(repoId, packageId).size) assertEquals(0, versionDao.getVersionedStrings(repoId, packageId).size) // preferences are not touched by clearing diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt index 9974a5a6f..11b0390d0 100644 --- a/database/src/main/java/org/fdroid/database/App.kt +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -5,6 +5,7 @@ import androidx.core.os.ConfigurationCompat.getLocales import androidx.core.os.LocaleListCompat import androidx.room.ColumnInfo import androidx.room.DatabaseView +import androidx.room.Embedded import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Ignore @@ -103,16 +104,54 @@ internal fun MetadataV2.toAppMetadata( isCompatible = isCompatible, ) -public data class App( - val metadata: AppMetadata, - val icon: LocalizedFileV2? = null, - val featureGraphic: LocalizedFileV2? = null, - val promoGraphic: LocalizedFileV2? = null, - val tvBanner: LocalizedFileV2? = null, - val screenshots: Screenshots? = null, +public data class App internal constructor( + @Embedded val metadata: AppMetadata, + @Relation( + parentColumn = "packageId", + entityColumn = "packageId", + ) + private val localizedFiles: List? = null, + @Relation( + parentColumn = "packageId", + entityColumn = "packageId", + ) + private val localizedFileLists: List? = null, ) { - public fun getName(): String? = metadata.localizedName - public fun getSummary(): String? = metadata.localizedSummary + val icon: LocalizedFileV2? get() = getLocalizedFile("icon") + val featureGraphic: LocalizedFileV2? get() = getLocalizedFile("featureGraphic") + val promoGraphic: LocalizedFileV2? get() = getLocalizedFile("promoGraphic") + val tvBanner: LocalizedFileV2? get() = getLocalizedFile("tvBanner") + 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>() + 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 val name: String? get() = metadata.localizedName + public val summary: String? get() = metadata.localizedSummary public fun getDescription(localeList: LocaleListCompat): String? = metadata.description.getBestLocale(localeList) @@ -162,8 +201,9 @@ public data class AppOverviewItem( ) internal val localizedIcon: List? = null, ) { - public fun getIcon(localeList: LocaleListCompat): String? = - localizedIcon?.toLocalizedFileV2().getBestLocale(localeList)?.name + public fun getIcon(localeList: LocaleListCompat): FileV2? = localizedIcon?.filter { icon -> + icon.repoId == repoId + }?.toLocalizedFileV2().getBestLocale(localeList) val antiFeatureNames: List get() = antiFeatures?.map { it.key } ?: emptyList() } @@ -197,8 +237,9 @@ public data class AppListItem constructor( return fromStringToMapOfLocalizedTextV2(antiFeatures)?.map { it.key } ?: emptyList() } - public fun getIcon(localeList: LocaleListCompat): String? = - localizedIcon?.toLocalizedFileV2().getBestLocale(localeList)?.name + public fun getIcon(localeList: LocaleListCompat): FileV2? = localizedIcon?.filter { icon -> + icon.repoId == repoId + }?.toLocalizedFileV2().getBestLocale(localeList) } public data class UpdatableApp( @@ -212,14 +253,11 @@ public data class UpdatableApp( public val hasKnownVulnerability: Boolean, public val name: String? = null, public val summary: String? = null, - @Relation( - parentColumn = "packageId", - entityColumn = "packageId", - ) internal val localizedIcon: List? = null, ) { - public fun getIcon(localeList: LocaleListCompat): FileV2? = - localizedIcon?.toLocalizedFileV2().getBestLocale(localeList) + public fun getIcon(localeList: LocaleListCompat): FileV2? = localizedIcon?.filter { icon -> + icon.repoId == upgrade.repoId + }?.toLocalizedFileV2().getBestLocale(localeList) } internal fun Map?.getBestLocale(localeList: LocaleListCompat): T? { @@ -297,19 +335,18 @@ internal fun LocalizedFileV2.toLocalizedFile( ) } -internal fun List.toLocalizedFileV2(type: String? = null): LocalizedFileV2? { - return (if (type != null) filter { file -> file.type == type } else this).associate { file -> - file.locale to FileV2( - name = file.name, - sha256 = file.sha256, - size = file.size, - ) - }.ifEmpty { null } -} +internal fun List.toLocalizedFileV2(): LocalizedFileV2? = associate { file -> + file.locale to FileV2( + name = file.name, + sha256 = file.sha256, + size = file.size, + ) +}.ifEmpty { null } -@DatabaseView("""SELECT * FROM LocalizedFile - JOIN RepositoryPreferences AS prefs USING (repoId) - WHERE type='icon' GROUP BY repoId, packageId, locale HAVING MAX(prefs.weight)""") +// 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'") public data class LocalizedIcon( val repoId: Long, val packageId: String, @@ -361,17 +398,3 @@ internal fun FileV2.toLocalizedFileList( sha256 = sha256, size = size, ) - -internal fun List.toLocalizedFileListV2(type: String): LocalizedFileListV2? { - val map = HashMap>() - iterator().forEach { file -> - if (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 } -} diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index d77f5611e..b174c45f9 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -7,14 +7,10 @@ import androidx.annotation.VisibleForTesting import androidx.core.os.ConfigurationCompat.getLocales import androidx.core.os.LocaleListCompat import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.distinctUntilChanged -import androidx.lifecycle.liveData import androidx.lifecycle.map -import androidx.lifecycle.switchMap import androidx.room.Dao import androidx.room.Insert -import androidx.room.OnConflictStrategy +import androidx.room.OnConflictStrategy.REPLACE import androidx.room.Query import androidx.room.RoomWarnings.CURSOR_MISMATCH import androidx.room.Transaction @@ -23,32 +19,64 @@ import kotlinx.serialization.SerializationException import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.decodeFromJsonElement +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.database.FDroidDatabaseHolder.dispatcher 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 -import org.fdroid.index.v2.Screenshots 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, - packageId: String, + packageName: String, app: MetadataV2, locales: LocaleListCompat = getLocales(Resources.getSystem().configuration), ) /** - * Gets the app from the DB. If more than one app with this [packageId] exists, + * 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(packageId: String): LiveData - public fun getApp(repoId: Long, packageId: String): App? + public fun getApp(packageName: String): LiveData + + /** + * 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> + + /** + * Returns a limited number of apps with limited data within the given [category]. + */ public fun getAppOverviewItems( category: String, limit: Int = 50, @@ -97,21 +125,21 @@ internal interface AppDaoInt : AppDao { @Transaction override fun insert( repoId: Long, - packageId: String, + packageName: String, app: MetadataV2, locales: LocaleListCompat, ) { - insert(app.toAppMetadata(repoId, packageId, false)) - app.icon.insert(repoId, packageId, "icon") - app.featureGraphic.insert(repoId, packageId, "featureGraphic") - app.promoGraphic.insert(repoId, packageId, "promoGraphic") - app.tvBanner.insert(repoId, packageId, "tvBanner") + 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, packageId, "phone") - it.sevenInch.insert(repoId, packageId, "sevenInch") - it.tenInch.insert(repoId, packageId, "tenInch") - it.wear.insert(repoId, packageId, "wear") - it.tv.insert(repoId, packageId, "tv") + 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") } } @@ -128,13 +156,13 @@ internal interface AppDaoInt : AppDao { } } - @Insert(onConflict = OnConflictStrategy.REPLACE) + @Insert(onConflict = REPLACE) fun insert(appMetadata: AppMetadata) - @Insert(onConflict = OnConflictStrategy.REPLACE) + @Insert(onConflict = REPLACE) fun insert(localizedFiles: List) - @Insert(onConflict = OnConflictStrategy.REPLACE) + @Insert(onConflict = REPLACE) fun insertLocalizedFileLists(localizedFiles: List) @Transaction @@ -229,23 +257,18 @@ internal interface AppDaoInt : AppDao { /** * 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 packageId = :packageId""") fun updatePreferredSigner(repoId: Long, packageId: String, preferredSigner: String?) - /** - * 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. - * Otherwise the compatibility is wrong. - */ @Query("""UPDATE AppMetadata SET isCompatible = ( SELECT TOTAL(isCompatible) > 0 FROM Version WHERE repoId = :repoId AND AppMetadata.packageId = Version.packageId ) WHERE repoId = :repoId""") - fun updateCompatibility(repoId: Long) + override fun updateCompatibility(repoId: Long) @Query("""UPDATE AppMetadata SET localizedName = :name, localizedSummary = :summary WHERE repoId = :repoId AND packageId = :packageId""") @@ -254,116 +277,93 @@ internal interface AppDaoInt : AppDao { @Update fun updateAppMetadata(appMetadata: AppMetadata): Int - override fun getApp(packageId: String): LiveData { - return getRepoIdForPackage(packageId).distinctUntilChanged().switchMap { repoId -> - if (repoId == null) MutableLiveData(null) - else getLiveApp(repoId, packageId) - } - } - - @Query("""SELECT repoId FROM RepositoryPreferences - JOIN AppMetadata AS app USING(repoId) - WHERE app.packageId = :packageId AND enabled = 1 ORDER BY weight DESC LIMIT 1""") - fun getRepoIdForPackage(packageId: String): LiveData - - fun getLiveApp(repoId: Long, packageId: String): LiveData = liveData(dispatcher) { - // TODO maybe observe those as well? - val localizedFiles = getLocalizedFiles(repoId, packageId) - val localizedFileList = getLocalizedFileLists(repoId, packageId) - val liveData: LiveData = - getLiveAppMetadata(repoId, packageId).distinctUntilChanged().map { - getApp(it, localizedFiles, localizedFileList) - } - emitSource(liveData) - } + @Transaction + @Query("""SELECT AppMetadata.* FROM AppMetadata + JOIN RepositoryPreferences AS pref USING (repoId) + WHERE packageId = :packageName + ORDER BY pref.weight DESC LIMIT 1""") + override fun getApp(packageName: String): LiveData @Transaction - override fun getApp(repoId: Long, packageId: String): App? { - val metadata = getAppMetadata(repoId, packageId) ?: return null - val localizedFiles = getLocalizedFiles(repoId, packageId) - val localizedFileList = getLocalizedFileLists(repoId, packageId) - return getApp(metadata, localizedFiles, localizedFileList) - } + @Query("""SELECT * FROM AppMetadata + WHERE repoId = :repoId AND packageId = :packageName""") + override fun getApp(repoId: Long, packageName: String): App? - private fun getApp( - metadata: AppMetadata, - localizedFiles: List?, - localizedFileList: List?, - ) = App( - metadata = metadata, - icon = localizedFiles?.toLocalizedFileV2("icon"), - featureGraphic = localizedFiles?.toLocalizedFileV2("featureGraphic"), - promoGraphic = localizedFiles?.toLocalizedFileV2("promoGraphic"), - tvBanner = localizedFiles?.toLocalizedFileV2("tvBanner"), - screenshots = if (localizedFileList.isNullOrEmpty()) null else Screenshots( - phone = localizedFileList.toLocalizedFileListV2("phone"), - sevenInch = localizedFileList.toLocalizedFileListV2("sevenInch"), - tenInch = localizedFileList.toLocalizedFileListV2("tenInch"), - wear = localizedFileList.toLocalizedFileListV2("wear"), - tv = localizedFileList.toLocalizedFileListV2("tv"), - ) - ) - - @Query("SELECT * FROM AppMetadata WHERE repoId = :repoId AND packageId = :packageId") - fun getLiveAppMetadata(repoId: Long, packageId: String): LiveData - - @Query("SELECT * FROM AppMetadata WHERE repoId = :repoId AND packageId = :packageId") - fun getAppMetadata(repoId: Long, packageId: String): AppMetadata? + /** + * Used for diffing. + */ + @Query("SELECT * FROM AppMetadata WHERE repoId = :repoId AND packageId = :packageName") + fun getAppMetadata(repoId: Long, packageName: String): AppMetadata? + /** + * Used for updating best locales. + */ @Query("SELECT * FROM AppMetadata") fun getAppMetadata(): List + /** + * used for diffing + */ @Query("SELECT * FROM LocalizedFile WHERE repoId = :repoId AND packageId = :packageId") fun getLocalizedFiles(repoId: Long, packageId: String): List - @Query("SELECT * FROM LocalizedFileList WHERE repoId = :repoId AND packageId = :packageId") - fun getLocalizedFileLists(repoId: Long, packageId: String): List - - @Query("SELECT * FROM LocalizedFile") - fun getLocalizedFiles(): List - - @Query("SELECT * FROM LocalizedFileList") - fun getLocalizedFileLists(): List - @Transaction @Query("""SELECT repoId, packageId, app.added, app.lastUpdated, localizedName, - localizedSummary, version.antiFeatures + localizedSummary, version.antiFeatures FROM AppMetadata AS app JOIN RepositoryPreferences AS pref USING (repoId) - LEFT JOIN Version AS version USING (repoId, packageId) + LEFT JOIN HighestVersion AS version USING (repoId, packageId) LEFT JOIN LocalizedIcon AS icon USING (repoId, packageId) WHERE pref.enabled = 1 GROUP BY packageId HAVING MAX(pref.weight) - AND MAX(COALESCE(version.manifest_versionCode, ${Long.MAX_VALUE})) ORDER BY localizedName IS NULL ASC, icon.packageId IS NULL ASC, localizedSummary IS NULL ASC, app.lastUpdated DESC LIMIT :limit""") override fun getAppOverviewItems(limit: Int): LiveData> @Transaction - // TODO maybe it makes sense to split categories into their own table for this? @Query("""SELECT repoId, packageId, app.added, app.lastUpdated, localizedName, localizedSummary, version.antiFeatures FROM AppMetadata AS app JOIN RepositoryPreferences AS pref USING (repoId) - LEFT JOIN Version AS version USING (repoId, packageId) + LEFT JOIN HighestVersion AS version USING (repoId, packageId) LEFT JOIN LocalizedIcon AS icon USING (repoId, packageId) WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' GROUP BY packageId HAVING MAX(pref.weight) - AND MAX(COALESCE(version.manifest_versionCode, ${Long.MAX_VALUE})) ORDER BY localizedName IS NULL ASC, icon.packageId IS NULL ASC, localizedSummary IS NULL ASC, app.lastUpdated DESC LIMIT :limit""") override fun getAppOverviewItems(category: String, limit: Int): LiveData> + /** + * Used by [DbUpdateChecker] to get specific apps with available updates. + */ + @Transaction + @SuppressWarnings(CURSOR_MISMATCH) // no anti-features needed here + @Query("""SELECT repoId, packageId, added, app.lastUpdated, localizedName, + localizedSummary + FROM AppMetadata AS app WHERE repoId = :repoId AND packageId = :packageId""") + fun getAppOverviewItem(repoId: Long, packageId: String): AppOverviewItem? + + // + // AppListItems + // + override fun getAppListItems( packageManager: PackageManager, sortOrder: AppListSortOrder, - ): LiveData> { - return when (sortOrder) { - AppListSortOrder.LAST_UPDATED -> getAppListItemsByLastUpdated().map(packageManager) - AppListSortOrder.NAME -> getAppListItemsByName().map(packageManager) - } + ): LiveData> = when (sortOrder) { + LAST_UPDATED -> getAppListItemsByLastUpdated().map(packageManager) + NAME -> getAppListItemsByName().map(packageManager) + } + + override fun getAppListItems( + packageManager: PackageManager, + category: String, + sortOrder: AppListSortOrder, + ): LiveData> = when (sortOrder) { + LAST_UPDATED -> getAppListItemsByLastUpdated(category).map(packageManager) + NAME -> getAppListItemsByName(category).map(packageManager) } private fun LiveData>.map( @@ -385,10 +385,10 @@ internal interface AppDaoInt : AppDao { SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures, app.isCompatible FROM AppMetadata AS app - JOIN Version AS version USING (repoId, packageId) + LEFT JOIN HighestVersion AS version USING (repoId, packageId) JOIN RepositoryPreferences AS pref USING (repoId) WHERE pref.enabled = 1 - GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) + GROUP BY packageId HAVING MAX(pref.weight) ORDER BY localizedName COLLATE NOCASE ASC""") fun getAppListItemsByName(): LiveData> @@ -397,49 +397,34 @@ internal interface AppDaoInt : AppDao { SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures, app.isCompatible FROM AppMetadata AS app - JOIN Version AS version USING (repoId, packageId) JOIN RepositoryPreferences AS pref USING (repoId) + LEFT JOIN HighestVersion AS version USING (repoId, packageId) WHERE pref.enabled = 1 - GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) + GROUP BY packageId HAVING MAX(pref.weight) ORDER BY app.lastUpdated DESC""") fun getAppListItemsByLastUpdated(): LiveData> - override fun getAppListItems( - packageManager: PackageManager, - category: String, - sortOrder: AppListSortOrder, - ): LiveData> { - return when (sortOrder) { - AppListSortOrder.LAST_UPDATED -> { - getAppListItemsByLastUpdated(category).map(packageManager) - } - AppListSortOrder.NAME -> getAppListItemsByName(category).map(packageManager) - } - } - - // TODO maybe it makes sense to split categories into their own table for this? @Transaction @Query(""" SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures, app.isCompatible FROM AppMetadata AS app - JOIN Version AS version USING (repoId, packageId) JOIN RepositoryPreferences AS pref USING (repoId) - WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' - GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) + LEFT JOIN HighestVersion AS version USING (repoId, packageId) + WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' + GROUP BY packageId HAVING MAX(pref.weight) ORDER BY app.lastUpdated DESC""") fun getAppListItemsByLastUpdated(category: String): LiveData> - // TODO maybe it makes sense to split categories into their own table for this? @Transaction @Query(""" SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures, app.isCompatible FROM AppMetadata AS app - JOIN Version AS version USING (repoId, packageId) JOIN RepositoryPreferences AS pref USING (repoId) - WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' - GROUP BY packageId HAVING MAX(pref.weight) AND MAX(version.manifest_versionCode) + LEFT JOIN HighestVersion AS version USING (repoId, packageId) + WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' + GROUP BY packageId HAVING MAX(pref.weight) ORDER BY localizedName COLLATE NOCASE ASC""") fun getAppListItemsByName(category: String): LiveData> @@ -467,16 +452,6 @@ internal interface AppDaoInt : AppDao { WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%'""") override fun getNumberOfAppsInCategory(category: String): Int - /** - * Used by [DbUpdateChecker] to get specific apps with available updates. - */ - @Transaction - @SuppressWarnings(CURSOR_MISMATCH) // no anti-features needed here - @Query("""SELECT repoId, packageId, added, app.lastUpdated, localizedName, - localizedSummary - FROM AppMetadata AS app WHERE repoId = :repoId AND packageId = :packageId""") - fun getAppOverviewItem(repoId: Long, packageId: String): AppOverviewItem? - @Query("DELETE FROM AppMetadata WHERE repoId = :repoId AND packageId = :packageId") fun deleteAppMetadata(repoId: Long, packageId: String) diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index 9ac207a7f..fcce3b066 100644 --- a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.res.Resources import android.util.Log import androidx.core.os.ConfigurationCompat.getLocales +import androidx.core.os.LocaleListCompat import androidx.room.AutoMigration import androidx.room.Database import androidx.room.Room @@ -16,7 +17,7 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @Database( - version = 6, // TODO set version to 1 before release and wipe old schemas + version = 8, // TODO set version to 1 before release and wipe old schemas entities = [ // repo CoreRepository::class, @@ -37,6 +38,7 @@ import kotlinx.coroutines.launch ], views = [ LocalizedIcon::class, + HighestVersion::class, ], exportSchema = true, autoMigrations = [ @@ -45,6 +47,8 @@ import kotlinx.coroutines.launch AutoMigration(from = 1, to = 3), AutoMigration(from = 2, to = 3), AutoMigration(from = 5, to = 6), + AutoMigration(from = 6, to = 7), + AutoMigration(from = 7, to = 8), ], ) @TypeConverters(Converters::class) @@ -63,9 +67,10 @@ public interface FDroidDatabase { public fun getAppDao(): AppDao public fun getVersionDao(): VersionDao public fun getAppPrefsDao(): AppPrefsDao - public fun afterLocalesChanged() { + public fun afterLocalesChanged( + locales: LocaleListCompat = getLocales(Resources.getSystem().configuration), + ) { val appDao = getAppDao() as AppDaoInt - val locales = getLocales(Resources.getSystem().configuration) runInTransaction { appDao.getAppMetadata().forEach { appMetadata -> appDao.updateAppMetadata( diff --git a/database/src/main/java/org/fdroid/database/Version.kt b/database/src/main/java/org/fdroid/database/Version.kt index 9b40bc545..d956cec52 100644 --- a/database/src/main/java/org/fdroid/database/Version.kt +++ b/database/src/main/java/org/fdroid/database/Version.kt @@ -1,6 +1,7 @@ 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 @@ -125,6 +126,14 @@ internal fun ManifestV2.toManifest() = AppManifest( features = features.map { it.name }, ) +@DatabaseView("""SELECT repoId, packageId, antiFeatures FROM Version + GROUP BY repoId, packageId HAVING MAX(manifest_versionCode)""") +internal class HighestVersion( + val repoId: Long, + val packageId: String, + val antiFeatures: Map? = null, +) + internal enum class VersionedStringType { PERMISSION, PERMISSION_SDK_23, From 7c5485f95855c33537e9dbd9dde2b7d2d64ac0f9 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 14 Jun 2022 13:26:12 -0300 Subject: [PATCH 32/42] [db] clean up RepositoryDao and add more tests --- .../java/org/fdroid/database/AppDaoTest.kt | 15 + .../org/fdroid/database/RepositoryDaoTest.kt | 269 ++++++++++++++++ .../org/fdroid/database/RepositoryTest.kt | 110 ------- .../java/org/fdroid/database/TestUtils.kt | 7 + .../org/fdroid/index/v2/IndexV2UpdaterTest.kt | 9 + .../main/java/org/fdroid/database/AppDao.kt | 5 + .../java/org/fdroid/database/Repository.kt | 3 +- .../java/org/fdroid/database/RepositoryDao.kt | 302 +++++++++++------- 8 files changed, 489 insertions(+), 231 deletions(-) create mode 100644 database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt delete mode 100644 database/src/dbTest/java/org/fdroid/database/RepositoryTest.kt diff --git a/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt b/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt index b33d0444f..56822bf45 100644 --- a/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt @@ -127,4 +127,19 @@ internal class AppDaoTest : AppTest() { assertEquals(0, appDao.getNumberOfAppsInCategory("C")) } + @Test + fun testGetNumberOfAppsInRepository() { + val repoId = repoDao.insertOrReplace(getRandomRepo()) + assertEquals(0, appDao.getNumberOfAppsInRepository(repoId)) + + appDao.insert(repoId, packageName1, app1, locales) + assertEquals(1, appDao.getNumberOfAppsInRepository(repoId)) + + appDao.insert(repoId, packageName2, app2, locales) + assertEquals(2, appDao.getNumberOfAppsInRepository(repoId)) + + appDao.insert(repoId, packageName3, app3, locales) + assertEquals(3, appDao.getNumberOfAppsInRepository(repoId)) + } + } diff --git a/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt b/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt new file mode 100644 index 000000000..7bca2cc02 --- /dev/null +++ b/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt @@ -0,0 +1,269 @@ +package org.fdroid.database + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fdroid.database.TestUtils.assertRepoEquals +import org.fdroid.database.TestUtils.getOrFail +import org.fdroid.test.TestAppUtils.getRandomMetadataV2 +import org.fdroid.test.TestRepoUtils.getRandomRepo +import org.fdroid.test.TestUtils.getRandomString +import org.fdroid.test.TestUtils.orNull +import org.fdroid.test.TestVersionUtils.getRandomPackageVersionV2 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.random.Random +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.test.fail + +@RunWith(AndroidJUnit4::class) +internal class RepositoryDaoTest : DbTest() { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @Test + fun testInsertInitialRepository() { + val repo = InitialRepository( + name = getRandomString(), + address = getRandomString(), + description = getRandomString(), + certificate = getRandomString(), + version = Random.nextLong(), + enabled = Random.nextBoolean(), + weight = Random.nextInt(), + ) + val repoId = repoDao.insert(repo) + + val actualRepo = repoDao.getRepository(repoId) ?: fail() + assertEquals(repo.name, actualRepo.getName(locales)) + assertEquals(repo.address, actualRepo.address) + assertEquals(repo.description, actualRepo.getDescription(locales)) + assertEquals(repo.certificate, actualRepo.certificate) + assertEquals(repo.version, actualRepo.version) + assertEquals(repo.enabled, actualRepo.enabled) + assertEquals(repo.weight, actualRepo.weight) + assertEquals(-1, actualRepo.timestamp) + assertEquals(emptyList(), actualRepo.mirrors) + assertEquals(emptyList(), actualRepo.userMirrors) + assertEquals(emptyList(), actualRepo.disabledMirrors) + assertEquals(emptyList(), actualRepo.getMirrors()) + assertEquals(emptyList(), actualRepo.antiFeatures) + assertEquals(emptyList(), actualRepo.categories) + assertEquals(emptyList(), actualRepo.releaseChannels) + assertNull(actualRepo.formatVersion) + assertNull(actualRepo.icon) + assertNull(actualRepo.lastUpdated) + assertNull(actualRepo.webBaseUrl) + } + + @Test + fun testInsertEmptyRepo() { + // insert empty repo + val address = getRandomString() + val username = getRandomString().orNull() + val password = getRandomString().orNull() + val repoId = repoDao.insertEmptyRepo(address, username, password) + + // check that repo got inserted as expected + val actualRepo = repoDao.getRepository(repoId) ?: fail() + assertEquals(address, actualRepo.address) + assertEquals(username, actualRepo.username) + assertEquals(password, actualRepo.password) + assertEquals(-1, actualRepo.timestamp) + assertEquals(emptyList(), actualRepo.getMirrors()) + assertEquals(emptyList(), actualRepo.antiFeatures) + assertEquals(emptyList(), actualRepo.categories) + assertEquals(emptyList(), actualRepo.releaseChannels) + assertNull(actualRepo.formatVersion) + assertNull(actualRepo.icon) + assertNull(actualRepo.lastUpdated) + assertNull(actualRepo.webBaseUrl) + } + + @Test + fun insertAndDeleteTwoRepos() { + // insert first repo + val repo1 = getRandomRepo() + val repoId1 = repoDao.insertOrReplace(repo1) + + // check that first repo got added and retrieved as expected + repoDao.getRepositories().let { repos -> + assertEquals(1, repos.size) + assertRepoEquals(repo1, repos[0]) + } + val repositoryPreferences1 = repoDao.getRepositoryPreferences(repoId1) + assertEquals(repoId1, repositoryPreferences1?.repoId) + + // insert second repo + val repo2 = getRandomRepo() + val repoId2 = repoDao.insertOrReplace(repo2) + + // check that both repos got added and retrieved as expected + listOf( + repoDao.getRepositories().sortedBy { it.repoId }, + repoDao.getLiveRepositories().getOrFail().sortedBy { it.repoId }, + ).forEach { repos -> + assertEquals(2, repos.size) + assertRepoEquals(repo1, repos[0]) + assertRepoEquals(repo2, repos[1]) + } + val repositoryPreferences2 = repoDao.getRepositoryPreferences(repoId2) + assertEquals(repoId2, repositoryPreferences2?.repoId) + // second repo has one weight point more than first repo + assertEquals(repositoryPreferences1?.weight?.plus(1), repositoryPreferences2?.weight) + + // remove first repo and check that the database only returns one + repoDao.deleteRepository(repoId1) + listOf( + repoDao.getRepositories(), + repoDao.getLiveRepositories().getOrFail(), + ).forEach { repos -> + assertEquals(1, repos.size) + assertRepoEquals(repo2, repos[0]) + } + assertNull(repoDao.getRepositoryPreferences(repoId1)) + + // remove second repo and check that all associated data got removed as well + repoDao.deleteRepository(repoId2) + assertEquals(0, repoDao.getRepositories().size) + assertEquals(0, repoDao.countMirrors()) + assertEquals(0, repoDao.countAntiFeatures()) + assertEquals(0, repoDao.countCategories()) + assertEquals(0, repoDao.countReleaseChannels()) + assertNull(repoDao.getRepositoryPreferences(repoId2)) + } + + @Test + fun insertTwoReposAndClearAll() { + val repo1 = getRandomRepo() + val repo2 = getRandomRepo() + repoDao.insertOrReplace(repo1) + repoDao.insertOrReplace(repo2) + assertEquals(2, repoDao.getRepositories().size) + assertEquals(2, repoDao.getLiveRepositories().getOrFail().size) + + repoDao.clearAll() + assertEquals(0, repoDao.getRepositories().size) + assertEquals(0, repoDao.getLiveRepositories().getOrFail().size) + } + + @Test + fun testSetRepositoryEnabled() { + // repo is enabled by default + val repoId = repoDao.insertOrReplace(getRandomRepo()) + assertTrue(repoDao.getRepository(repoId)?.enabled ?: fail()) + + // disabled repo is disabled + repoDao.setRepositoryEnabled(repoId, false) + assertFalse(repoDao.getRepository(repoId)?.enabled ?: fail()) + + // enabling again works + repoDao.setRepositoryEnabled(repoId, true) + assertTrue(repoDao.getRepository(repoId)?.enabled ?: fail()) + } + + @Test + fun testUpdateUserMirrors() { + // repo is enabled by default + val repoId = repoDao.insertOrReplace(getRandomRepo()) + assertEquals(emptyList(), repoDao.getRepository(repoId)?.userMirrors) + + // add user mirrors + val userMirrors = listOf(getRandomString(), getRandomString(), getRandomString()) + repoDao.updateUserMirrors(repoId, userMirrors) + val repo = repoDao.getRepository(repoId) ?: fail() + assertEquals(userMirrors, repo.userMirrors) + + // user mirrors are part of all mirrors + val userDownloadMirrors = userMirrors.map { org.fdroid.download.Mirror(it) } + assertTrue(repo.getMirrors().containsAll(userDownloadMirrors)) + + // remove user mirrors + repoDao.updateUserMirrors(repoId, emptyList()) + assertEquals(emptyList(), repoDao.getRepository(repoId)?.userMirrors) + } + + @Test + fun testUpdateUsernameAndPassword() { + // repo has no username or password initially + val repoId = repoDao.insertOrReplace(getRandomRepo()) + repoDao.getRepository(repoId)?.let { repo -> + assertEquals(null, repo.username) + assertEquals(null, repo.password) + } ?: fail() + + // add user name and password + val username = getRandomString().orNull() + val password = getRandomString().orNull() + repoDao.updateUsernameAndPassword(repoId, username, password) + repoDao.getRepository(repoId)?.let { repo -> + assertEquals(username, repo.username) + assertEquals(password, repo.password) + } ?: fail() + } + + @Test + fun testUpdateDisabledMirrors() { + // repo has no username or password initially + val repoId = repoDao.insertOrReplace(getRandomRepo()) + repoDao.getRepository(repoId)?.let { repo -> + assertEquals(null, repo.username) + assertEquals(null, repo.password) + } ?: fail() + + // add user name and password + val username = getRandomString().orNull() + val password = getRandomString().orNull() + repoDao.updateUsernameAndPassword(repoId, username, password) + repoDao.getRepository(repoId)?.let { repo -> + assertEquals(username, repo.username) + assertEquals(password, repo.password) + } ?: fail() + } + + @Test + fun clearingRepoRemovesAllAssociatedData() { + // insert one repo with one app with one version + val repoId = repoDao.insertOrReplace(getRandomRepo()) + val repositoryPreferences = repoDao.getRepositoryPreferences(repoId) + val packageId = getRandomString() + val versionId = getRandomString() + appDao.insert(repoId, packageId, getRandomMetadataV2()) + val packageVersion = getRandomPackageVersionV2() + versionDao.insert(repoId, packageId, versionId, packageVersion, Random.nextBoolean()) + + // data is there as expected + assertEquals(1, repoDao.getRepositories().size) + assertEquals(1, appDao.getAppMetadata().size) + assertEquals(1, versionDao.getAppVersions(repoId, packageId).size) + assertTrue(versionDao.getVersionedStrings(repoId, packageId).isNotEmpty()) + + // clearing the repo removes apps and versions + repoDao.clear(repoId) + assertEquals(1, repoDao.getRepositories().size) + assertEquals(0, appDao.countApps()) + assertEquals(0, appDao.countLocalizedFiles()) + assertEquals(0, appDao.countLocalizedFileLists()) + assertEquals(0, versionDao.getAppVersions(repoId, packageId).size) + assertEquals(0, versionDao.getVersionedStrings(repoId, packageId).size) + // preferences are not touched by clearing + assertEquals(repositoryPreferences, repoDao.getRepositoryPreferences(repoId)) + } + + @Test + fun certGetsUpdated() { + val repoId = repoDao.insertOrReplace(getRandomRepo()) + assertEquals(1, repoDao.getRepositories().size) + assertEquals(null, repoDao.getRepositories()[0].certificate) + + val cert = getRandomString() + repoDao.updateRepository(repoId, cert) + + assertEquals(1, repoDao.getRepositories().size) + assertEquals(cert, repoDao.getRepositories()[0].certificate) + } +} diff --git a/database/src/dbTest/java/org/fdroid/database/RepositoryTest.kt b/database/src/dbTest/java/org/fdroid/database/RepositoryTest.kt deleted file mode 100644 index d2a3e8879..000000000 --- a/database/src/dbTest/java/org/fdroid/database/RepositoryTest.kt +++ /dev/null @@ -1,110 +0,0 @@ -package org.fdroid.database - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.fdroid.database.TestUtils.assertRepoEquals -import org.fdroid.test.TestAppUtils.getRandomMetadataV2 -import org.fdroid.test.TestRepoUtils.getRandomRepo -import org.fdroid.test.TestUtils.getRandomString -import org.fdroid.test.TestVersionUtils.getRandomPackageVersionV2 -import org.junit.Test -import org.junit.runner.RunWith -import kotlin.random.Random -import kotlin.test.assertEquals -import kotlin.test.assertNull -import kotlin.test.assertTrue - -@RunWith(AndroidJUnit4::class) -internal class RepositoryTest : DbTest() { - - @Test - fun insertAndDeleteTwoRepos() { - // insert first repo - val repo1 = getRandomRepo() - val repoId1 = repoDao.insertOrReplace(repo1) - - // check that first repo got added and retrieved as expected - var repos = repoDao.getRepositories() - assertEquals(1, repos.size) - assertRepoEquals(repo1, repos[0]) - val repositoryPreferences1 = repoDao.getRepositoryPreferences(repoId1) - assertEquals(repoId1, repositoryPreferences1?.repoId) - - // insert second repo - val repo2 = getRandomRepo() - val repoId2 = repoDao.insertOrReplace(repo2) - - // check that both repos got added and retrieved as expected - repos = repoDao.getRepositories().sortedBy { it.repoId } - assertEquals(2, repos.size) - assertRepoEquals(repo1, repos[0]) - assertRepoEquals(repo2, repos[1]) - val repositoryPreferences2 = repoDao.getRepositoryPreferences(repoId2) - assertEquals(repoId2, repositoryPreferences2?.repoId) - assertEquals(repositoryPreferences1?.weight?.plus(1), repositoryPreferences2?.weight) - - // remove first repo and check that the database only returns one - repoDao.deleteRepository(repos[0].repository.repoId) - assertEquals(1, repoDao.getRepositories().size) - - // remove second repo as well and check that all associated data got removed as well - repoDao.deleteRepository(repos[1].repository.repoId) - assertEquals(0, repoDao.getRepositories().size) - assertEquals(0, repoDao.getMirrors().size) - assertEquals(0, repoDao.getAntiFeatures().size) - assertEquals(0, repoDao.getCategories().size) - assertEquals(0, repoDao.getReleaseChannels().size) - assertNull(repoDao.getRepositoryPreferences(repoId1)) - assertNull(repoDao.getRepositoryPreferences(repoId2)) - } - - @Test - fun insertTwoReposAndClearAll() { - val repo1 = getRandomRepo() - val repo2 = getRandomRepo() - repoDao.insertOrReplace(repo1) - repoDao.insertOrReplace(repo2) - assertEquals(2, repoDao.getRepositories().size) - - repoDao.clearAll() - assertEquals(0, repoDao.getRepositories().size) - } - - @Test - fun clearingRepoRemovesAllAssociatedData() { - val repoId = repoDao.insertOrReplace(getRandomRepo()) - val repositoryPreferences = repoDao.getRepositoryPreferences(repoId) - val packageId = getRandomString() - val versionId = getRandomString() - appDao.insert(repoId, packageId, getRandomMetadataV2()) - val packageVersion = getRandomPackageVersionV2() - versionDao.insert(repoId, packageId, versionId, packageVersion, Random.nextBoolean()) - - assertEquals(1, repoDao.getRepositories().size) - assertEquals(1, appDao.getAppMetadata().size) - assertEquals(1, versionDao.getAppVersions(repoId, packageId).size) - assertTrue(versionDao.getVersionedStrings(repoId, packageId).isNotEmpty()) - - repoDao.clear(repoId) - assertEquals(1, repoDao.getRepositories().size) - assertEquals(0, appDao.countApps()) - assertEquals(0, appDao.countLocalizedFiles()) - assertEquals(0, appDao.countLocalizedFileLists()) - assertEquals(0, versionDao.getAppVersions(repoId, packageId).size) - assertEquals(0, versionDao.getVersionedStrings(repoId, packageId).size) - // preferences are not touched by clearing - assertEquals(repositoryPreferences, repoDao.getRepositoryPreferences(repoId)) - } - - @Test - fun certGetsUpdated() { - val repoId = repoDao.insertOrReplace(getRandomRepo()) - assertEquals(1, repoDao.getRepositories().size) - assertEquals(null, repoDao.getRepositories()[0].certificate) - - val cert = getRandomString() - repoDao.updateRepository(repoId, cert) - - assertEquals(1, repoDao.getRepositories().size) - assertEquals(cert, repoDao.getRepositories()[0].certificate) - } -} diff --git a/database/src/dbTest/java/org/fdroid/database/TestUtils.kt b/database/src/dbTest/java/org/fdroid/database/TestUtils.kt index 2ce212fac..ebc90873c 100644 --- a/database/src/dbTest/java/org/fdroid/database/TestUtils.kt +++ b/database/src/dbTest/java/org/fdroid/database/TestUtils.kt @@ -11,10 +11,17 @@ import org.junit.Assert import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue import kotlin.test.fail internal object TestUtils { + fun assertTimestampRecent(timestamp: Long?) { + assertNotNull(timestamp) + assertTrue(System.currentTimeMillis() - timestamp < 2000) + } + fun assertRepoEquals(repoV2: RepoV2, repo: Repository) { val repoId = repo.repoId // mirrors diff --git a/database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt b/database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt index 03194ed6a..bcc11b7a2 100644 --- a/database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt +++ b/database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt @@ -10,6 +10,7 @@ import org.fdroid.CompatibilityChecker import org.fdroid.database.DbTest import org.fdroid.database.IndexFormatVersion.TWO import org.fdroid.database.Repository +import org.fdroid.database.TestUtils.assertTimestampRecent import org.fdroid.download.Downloader import org.fdroid.download.DownloaderFactory import org.fdroid.index.IndexUpdateResult @@ -27,6 +28,7 @@ import org.junit.Test import org.junit.rules.TemporaryFolder import org.junit.runner.RunWith import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue import kotlin.test.fail @@ -65,6 +67,7 @@ internal class IndexV2UpdaterTest : DbTest() { val updatedRepo = repoDao.getRepository(repoId) ?: fail() assertEquals(TWO, updatedRepo.formatVersion) assertEquals(CERTIFICATE, updatedRepo.certificate) + assertTimestampRecent(repoDao.getRepository(repoId)?.lastUpdated) } @Test @@ -79,6 +82,7 @@ internal class IndexV2UpdaterTest : DbTest() { val result = indexUpdater.updateNewRepo(repo, FINGERPRINT).noError() assertEquals(IndexUpdateResult.Processed, result) assertDbEquals(repoId, TestDataMidV2.index) + assertTimestampRecent(repoDao.getRepository(repoId)?.lastUpdated) } @Test @@ -93,6 +97,7 @@ internal class IndexV2UpdaterTest : DbTest() { val result = indexUpdater.updateNewRepo(repo, FINGERPRINT).noError() assertEquals(IndexUpdateResult.Processed, result) assertDbEquals(repoId, TestDataMaxV2.index) + assertTimestampRecent(repoDao.getRepository(repoId)?.lastUpdated) } @Test @@ -107,6 +112,7 @@ internal class IndexV2UpdaterTest : DbTest() { val result = indexUpdater.update(repo).noError() assertEquals(IndexUpdateResult.Processed, result) assertDbEquals(repoId, TestDataMidV2.index) + assertTimestampRecent(repoDao.getRepository(repoId)?.lastUpdated) } @Test @@ -122,6 +128,7 @@ internal class IndexV2UpdaterTest : DbTest() { val result = indexUpdater.update(repo).noError() assertEquals(IndexUpdateResult.Processed, result) assertDbEquals(repoId, TestDataMinV2.index) + assertTimestampRecent(repoDao.getRepository(repoId)?.lastUpdated) } @Test @@ -137,6 +144,7 @@ internal class IndexV2UpdaterTest : DbTest() { val result = indexUpdater.update(repo).noError() assertEquals(IndexUpdateResult.Processed, result) assertDbEquals(repoId, TestDataMaxV2.index) + assertTimestampRecent(repoDao.getRepository(repoId)?.lastUpdated) } @Test @@ -152,6 +160,7 @@ internal class IndexV2UpdaterTest : DbTest() { val result = indexUpdater.update(repo).noError() assertEquals(IndexUpdateResult.Unchanged, result) assertDbEquals(repoId, TestDataMinV2.index) + assertNull(repoDao.getRepository(repoId)?.lastUpdated) } @Test diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index b174c45f9..1e85902f8 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -96,6 +96,8 @@ public interface AppDao { public fun getInstalledAppListItems(packageManager: PackageManager): LiveData> public fun getNumberOfAppsInCategory(category: String): Int + + public fun getNumberOfAppsInRepository(repoId: Long): Int } public enum class AppListSortOrder { @@ -452,6 +454,9 @@ internal interface AppDaoInt : AppDao { 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 packageId = :packageId") fun deleteAppMetadata(repoId: Long, packageId: String) diff --git a/database/src/main/java/org/fdroid/database/Repository.kt b/database/src/main/java/org/fdroid/database/Repository.kt index e3faadf23..c627db3fe 100644 --- a/database/src/main/java/org/fdroid/database/Repository.kt +++ b/database/src/main/java/org/fdroid/database/Repository.kt @@ -121,8 +121,7 @@ public data class Repository( */ @JvmOverloads public fun getAllMirrors(includeUserMirrors: Boolean = true): List { - // FIXME decide whether we need to add our own address here - return listOf(org.fdroid.download.Mirror(address)) + mirrors.map { + return mirrors.map { it.toDownloadMirror() } + if (includeUserMirrors) userMirrors.map { org.fdroid.download.Mirror(it) diff --git a/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/database/src/main/java/org/fdroid/database/RepositoryDao.kt index 66520fd51..feec65181 100644 --- a/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -21,30 +21,76 @@ 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) + public fun insert(initialRepo: InitialRepository): Long /** - * Removes all repos and their preferences. + * Inserts an empty [Repository] for an initial update. + * + * @return the [Repository.repoId] of the inserted repo. */ - public fun clearAll() - - public fun getRepository(repoId: Long): Repository? public fun insertEmptyRepo( address: String, username: String? = null, password: String? = null, ): Long - public fun deleteRepository(repoId: 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 + + /** + * Same as [getRepositories], but does return a [LiveData]. + */ public fun getLiveRepositories(): LiveData> - public fun countAppsPerRepository(repoId: Long): Int - public fun setRepositoryEnabled(repoId: Long, enabled: Boolean) - public fun updateUserMirrors(repoId: Long, mirrors: List) - public fun updateUsernameAndPassword(repoId: Long, username: String?, password: String?) - public fun updateDisabledMirrors(repoId: Long, disabledMirrors: List) + + /** + * Returns a live data of all categories declared by all [Repository]s. + */ public fun getLiveCategories(): LiveData> + + /** + * 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) + + /** + * 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) + + /** + * 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 @@ -72,7 +118,7 @@ internal interface RepositoryDaoInt : RepositoryDao { fun insert(repositoryPreferences: RepositoryPreferences) @Transaction - override fun insert(initialRepo: InitialRepository) { + override fun insert(initialRepo: InitialRepository): Long { val repo = CoreRepository( name = mapOf("en-US" to initialRepo.name), address = initialRepo.address, @@ -92,6 +138,7 @@ internal interface RepositoryDaoInt : RepositoryDao { enabled = initialRepo.enabled, ) insert(repositoryPreferences) + return repoId } @Transaction @@ -125,37 +172,38 @@ internal interface RepositoryDaoInt : RepositoryDao { @Transaction @VisibleForTesting - fun insertOrReplace(repository: RepoV2): Long { - val repoId = insertOrReplace(repository.toCoreRepository(version = 0)) - insertRepositoryPreferences(repoId) + 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 } - private fun insertRepositoryPreferences(repoId: Long) { - val currentMaxWeight = getMaxRepositoryWeight() - val repositoryPreferences = RepositoryPreferences(repoId, currentMaxWeight + 1) - insert(repositoryPreferences) - } - - /** - * 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) - } + @Query("SELECT MAX(weight) FROM RepositoryPreferences") + fun getMaxRepositoryWeight(): Int @Transaction - override fun clearAll() { - deleteAllCoreRepositories() - deleteAllRepositoryPreferences() - } + @Query("SELECT * FROM CoreRepository WHERE repoId = :repoId") + override fun getRepository(repoId: Long): Repository? + + @Transaction + @Query("SELECT * FROM CoreRepository") + override fun getRepositories(): List + + @Transaction + @Query("SELECT * FROM CoreRepository") + override fun getLiveRepositories(): LiveData> + + @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> /** * Updates an existing repo with new data from a full index update. @@ -180,10 +228,25 @@ internal interface RepositoryDaoInt : RepositoryDao { insertReleaseChannels(repository.releaseChannels.toRepoReleaseChannel(repoId)) } - @Transaction - @Query("SELECT * FROM CoreRepository WHERE repoId = :repoId") - override fun getRepository(repoId: Long): Repository? + @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 @@ -237,15 +300,6 @@ internal interface RepositoryDaoInt : RepositoryDao { ) } - @Update - fun updateRepository(repo: CoreRepository): Int - - @Query("UPDATE CoreRepository SET certificate = :certificate WHERE repoId = :repoId") - fun updateRepository(repoId: Long, certificate: String) - - @Update - fun updateRepositoryPreferences(preferences: RepositoryPreferences) - @Query("UPDATE RepositoryPreferences SET enabled = :enabled WHERE repoId = :repoId") override fun setRepositoryEnabled(repoId: Long, enabled: Boolean) @@ -260,73 +314,6 @@ internal interface RepositoryDaoInt : RepositoryDao { WHERE repoId = :repoId""") override fun updateDisabledMirrors(repoId: Long, disabledMirrors: List) - @Transaction - @Query("SELECT * FROM CoreRepository") - override fun getRepositories(): List - - @Transaction - @Query("SELECT * FROM CoreRepository") - override fun getLiveRepositories(): LiveData> - - @VisibleForTesting - @Query("SELECT * FROM Mirror") - fun getMirrors(): List - - @VisibleForTesting - @Query("DELETE FROM Mirror WHERE repoId = :repoId") - fun deleteMirrors(repoId: Long) - - @VisibleForTesting - @Query("SELECT * FROM AntiFeature") - fun getAntiFeatures(): List - - @Query("SELECT * FROM RepositoryPreferences WHERE repoId = :repoId") - fun getRepositoryPreferences(repoId: Long): RepositoryPreferences? - - @Query("SELECT MAX(weight) FROM RepositoryPreferences") - fun getMaxRepositoryWeight(): Int - - @VisibleForTesting - @Query("DELETE FROM AntiFeature WHERE repoId = :repoId") - fun deleteAntiFeatures(repoId: Long) - - @VisibleForTesting - @Query("DELETE FROM AntiFeature WHERE repoId = :repoId AND id = :id") - fun deleteAntiFeature(repoId: Long, id: String) - - @VisibleForTesting - @Query("SELECT * FROM Category") - fun getCategories(): List - - @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> - - @Query("SELECT COUNT(*) FROM AppMetadata WHERE repoId = :repoId") - override fun countAppsPerRepository(repoId: Long): Int - - @VisibleForTesting - @Query("DELETE FROM Category WHERE repoId = :repoId") - fun deleteCategories(repoId: Long) - - @VisibleForTesting - @Query("DELETE FROM Category WHERE repoId = :repoId AND id = :id") - fun deleteCategory(repoId: Long, id: String) - - @VisibleForTesting - @Query("SELECT * FROM ReleaseChannel") - fun getReleaseChannels(): List - - @VisibleForTesting - @Query("DELETE FROM ReleaseChannel WHERE repoId = :repoId") - fun deleteReleaseChannels(repoId: Long) - - @VisibleForTesting - @Query("DELETE FROM ReleaseChannel WHERE repoId = :repoId AND id = :id") - fun deleteReleaseChannel(repoId: Long, id: String) - @Transaction override fun deleteRepository(repoId: Long) { deleteCoreRepository(repoId) @@ -338,13 +325,90 @@ internal interface RepositoryDaoInt : RepositoryDao { @Query("DELETE FROM CoreRepository WHERE repoId = :repoId") fun deleteCoreRepository(repoId: Long) - @Query("DELETE FROM CoreRepository") - fun deleteAllCoreRepositories() - @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 + } From 61b9ffe4f5e96335ffe786481620bdf2bff47a1c Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Thu, 16 Jun 2022 17:48:54 -0300 Subject: [PATCH 33/42] [db] Add a RepoUriBuilder for custom Uris for downloading a file from a repo. Allowing different implementations for this is useful for exotic repository locations that do not allow for simple concatenation such as content:// repos. --- .../org/fdroid/database/RepositoryDaoTest.kt | 4 ++-- .../org/fdroid/index/v1/IndexV1UpdaterTest.kt | 7 ++++++- .../org/fdroid/index/v2/IndexV2UpdaterTest.kt | 7 ++++++- .../java/org/fdroid/database/Repository.kt | 8 +++++++- .../java/org/fdroid/index/IndexUpdater.kt | 16 ++++++++++++++++ .../main/java/org/fdroid/index/RepoUpdater.kt | 19 +++++++++++++++++-- .../org/fdroid/index/v1/IndexV1Updater.kt | 6 ++++-- .../org/fdroid/index/v2/IndexV2Updater.kt | 12 +++++------- 8 files changed, 63 insertions(+), 16 deletions(-) diff --git a/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt b/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt index 7bca2cc02..e1c2455b5 100644 --- a/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt @@ -50,7 +50,7 @@ internal class RepositoryDaoTest : DbTest() { assertEquals(emptyList(), actualRepo.mirrors) assertEquals(emptyList(), actualRepo.userMirrors) assertEquals(emptyList(), actualRepo.disabledMirrors) - assertEquals(emptyList(), actualRepo.getMirrors()) + assertEquals(listOf(org.fdroid.download.Mirror(repo.address)), actualRepo.getMirrors()) assertEquals(emptyList(), actualRepo.antiFeatures) assertEquals(emptyList(), actualRepo.categories) assertEquals(emptyList(), actualRepo.releaseChannels) @@ -74,7 +74,7 @@ internal class RepositoryDaoTest : DbTest() { assertEquals(username, actualRepo.username) assertEquals(password, actualRepo.password) assertEquals(-1, actualRepo.timestamp) - assertEquals(emptyList(), actualRepo.getMirrors()) + assertEquals(listOf(org.fdroid.download.Mirror(address)), actualRepo.getMirrors()) assertEquals(emptyList(), actualRepo.antiFeatures) assertEquals(emptyList(), actualRepo.categories) assertEquals(emptyList(), actualRepo.releaseChannels) diff --git a/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt b/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt index 2d4562d6a..7ea12a620 100644 --- a/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt +++ b/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt @@ -50,7 +50,12 @@ internal class IndexV1UpdaterTest : DbTest() { @Before override fun createDb() { super.createDb() - indexUpdater = IndexV1Updater(db, tempFileProvider, downloaderFactory, compatibilityChecker) + indexUpdater = IndexV1Updater( + database = db, + tempFileProvider = tempFileProvider, + downloaderFactory = downloaderFactory, + compatibilityChecker = compatibilityChecker, + ) } @Test diff --git a/database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt b/database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt index bcc11b7a2..5b2cb9a1d 100644 --- a/database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt +++ b/database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt @@ -47,7 +47,12 @@ internal class IndexV2UpdaterTest : DbTest() { @Before override fun createDb() { super.createDb() - indexUpdater = IndexV2Updater(db, tempFileProvider, downloaderFactory, compatibilityChecker) + indexUpdater = IndexV2Updater( + database = db, + tempFileProvider = tempFileProvider, + downloaderFactory = downloaderFactory, + compatibilityChecker = compatibilityChecker, + ) } @Test diff --git a/database/src/main/java/org/fdroid/database/Repository.kt b/database/src/main/java/org/fdroid/database/Repository.kt index c627db3fe..b34c2c620 100644 --- a/database/src/main/java/org/fdroid/database/Repository.kt +++ b/database/src/main/java/org/fdroid/database/Repository.kt @@ -121,11 +121,17 @@ public data class Repository( */ @JvmOverloads public fun getAllMirrors(includeUserMirrors: Boolean = true): List { - return mirrors.map { + 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)) + } } public fun getName(localeList: LocaleListCompat): String? = name.getBestLocale(localeList) diff --git a/database/src/main/java/org/fdroid/index/IndexUpdater.kt b/database/src/main/java/org/fdroid/index/IndexUpdater.kt index bc2fd37b8..9d1028497 100644 --- a/database/src/main/java/org/fdroid/index/IndexUpdater.kt +++ b/database/src/main/java/org/fdroid/index/IndexUpdater.kt @@ -1,5 +1,6 @@ package org.fdroid.index +import android.net.Uri import org.fdroid.database.IndexFormatVersion import org.fdroid.database.Repository import org.fdroid.download.Downloader @@ -19,6 +20,21 @@ public interface IndexUpdateListener { 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 diff --git a/database/src/main/java/org/fdroid/index/RepoUpdater.kt b/database/src/main/java/org/fdroid/index/RepoUpdater.kt index 954488d15..169d5a037 100644 --- a/database/src/main/java/org/fdroid/index/RepoUpdater.kt +++ b/database/src/main/java/org/fdroid/index/RepoUpdater.kt @@ -14,6 +14,7 @@ public class RepoUpdater( tempDir: File, db: FDroidDatabase, downloaderFactory: DownloaderFactory, + repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder, compatibilityChecker: CompatibilityChecker, listener: IndexUpdateListener, ) { @@ -26,8 +27,22 @@ public class RepoUpdater( * A list of [IndexUpdater]s to try, sorted by newest first. */ private val indexUpdater = listOf( - IndexV2Updater(db, tempFileProvider, downloaderFactory, compatibilityChecker, listener), - IndexV1Updater(db, tempFileProvider, downloaderFactory, compatibilityChecker, listener), + 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, + ), ) public fun update( diff --git a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt index 37083829a..c64347a40 100644 --- a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt +++ b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt @@ -1,6 +1,5 @@ package org.fdroid.index.v1 -import android.net.Uri import org.fdroid.CompatibilityChecker import org.fdroid.database.DbV1StreamReceiver import org.fdroid.database.FDroidDatabase @@ -12,7 +11,9 @@ import org.fdroid.download.DownloaderFactory 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" @@ -22,6 +23,7 @@ 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() { @@ -39,7 +41,7 @@ public class IndexV1Updater( require(formatVersion == null || formatVersion == ONE) { "Format downgrade not allowed for ${repo.address}" } - val uri = Uri.parse(repo.address).buildUpon().appendPath(SIGNED_FILE_NAME).build() + val uri = repoUriBuilder.getUri(repo, SIGNED_FILE_NAME) val file = tempFileProvider.createTempFile() val downloader = downloaderFactory.createWithTryFirstMirror(repo, uri, file).apply { cacheTag = repo.lastETag diff --git a/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt b/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt index 5141d508f..6971271bb 100644 --- a/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt +++ b/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt @@ -1,6 +1,5 @@ package org.fdroid.index.v2 -import android.net.Uri import org.fdroid.CompatibilityChecker import org.fdroid.database.DbV2DiffStreamReceiver import org.fdroid.database.DbV2StreamReceiver @@ -15,20 +14,19 @@ 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" -private fun Repository.getUri(fileName: String): Uri = Uri.parse(address).buildUpon() - .appendEncodedPath(fileName.trimStart('/')) - .build() - 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() { @@ -64,7 +62,7 @@ public class IndexV2Updater( certificate: String?, fingerprint: String?, ): Pair { - val uri = repo.getUri(SIGNED_FILE_NAME) + val uri = repoUriBuilder.getUri(repo, SIGNED_FILE_NAME) val file = tempFileProvider.createTempFile() val downloader = downloaderFactory.createWithTryFirstMirror(repo, uri, file).apply { setIndexUpdateListener(listener, repo) @@ -86,7 +84,7 @@ public class IndexV2Updater( repoVersion: Long, streamProcessor: IndexV2StreamProcessor, ): IndexUpdateResult { - val uri = repo.getUri(entryFile.name) + val uri = repoUriBuilder.getUri(repo, entryFile.name.trimStart('/')) val file = tempFileProvider.createTempFile() val downloader = downloaderFactory.createWithTryFirstMirror(repo, uri, file).apply { setIndexUpdateListener(listener, repo) From 01381c268de723cb3b2e5107b0d6819f63438e48 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 27 Jun 2022 18:07:13 -0300 Subject: [PATCH 34/42] [db] remove getBestLocale() as it was moved to index lib --- .../org/fdroid/database/AppListItemsTest.kt | 1 + .../fdroid/database/AppOverviewItemsTest.kt | 1 + .../src/main/java/org/fdroid/database/App.kt | 33 +------------------ .../main/java/org/fdroid/database/AppDao.kt | 1 + .../org/fdroid/database/FDroidDatabase.kt | 1 + .../java/org/fdroid/database/Repository.kt | 1 + .../main/java/org/fdroid/database/Version.kt | 1 + 7 files changed, 7 insertions(+), 32 deletions(-) diff --git a/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt b/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt index 6ad0aa7d4..295ea26a5 100644 --- a/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt @@ -5,6 +5,7 @@ import android.content.pm.PackageManager import androidx.test.ext.junit.runners.AndroidJUnit4 import io.mockk.every import io.mockk.mockk +import org.fdroid.LocaleChooser.getBestLocale import org.fdroid.database.AppListSortOrder.LAST_UPDATED import org.fdroid.database.AppListSortOrder.NAME import org.fdroid.database.TestUtils.getOrFail diff --git a/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt b/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt index f0b319728..58acfd2af 100644 --- a/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt @@ -1,6 +1,7 @@ package org.fdroid.database import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.fdroid.LocaleChooser.getBestLocale import org.fdroid.database.TestUtils.getOrAwaitValue import org.fdroid.database.TestUtils.getOrFail import org.fdroid.index.v2.MetadataV2 diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt index 11b0390d0..bd3e838c0 100644 --- a/database/src/main/java/org/fdroid/database/App.kt +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -10,6 +10,7 @@ import androidx.room.Entity import androidx.room.ForeignKey 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 @@ -260,38 +261,6 @@ public data class UpdatableApp( }?.toLocalizedFileV2().getBestLocale(localeList) } -internal fun Map?.getBestLocale(localeList: LocaleListCompat): T? { - if (isNullOrEmpty()) return null - val firstMatch = localeList.getFirstMatch(keys.toTypedArray()) ?: return null - val tag = firstMatch.toLanguageTag() - // try first matched tag first (usually has region tag, e.g. de-DE) - return get(tag) ?: run { - // split away stuff like script and try language and region only - val langCountryTag = "${firstMatch.language}-${firstMatch.country}" - getOrStartsWith(langCountryTag) ?: run { - // split away region tag and try language only - val langTag = firstMatch.language - // try language, then English and then just take the first of the list - getOrStartsWith(langTag) ?: get("en-US") ?: get("en") ?: values.first() - } - } -} - -/** - * Returns the value from the map with the given key or if that key is not contained in the map, - * tries the first map key that starts with the given key. - * If nothing matches, null is returned. - * - * This is useful when looking for a language tag like `fr_CH` and falling back to `fr` - * in a map that has `fr_FR` as a key. - */ -private fun Map.getOrStartsWith(s: String): T? = get(s) ?: run { - entries.forEach { (key, value) -> - if (key.startsWith(s)) return value - } - return null -} - internal interface IFile { val type: String val locale: String diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index 1e85902f8..cc5372a7e 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -19,6 +19,7 @@ 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 diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index fcce3b066..002972eb0 100644 --- a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import org.fdroid.LocaleChooser.getBestLocale @Database( version = 8, // TODO set version to 1 before release and wipe old schemas diff --git a/database/src/main/java/org/fdroid/database/Repository.kt b/database/src/main/java/org/fdroid/database/Repository.kt index b34c2c620..8b6e93627 100644 --- a/database/src/main/java/org/fdroid/database/Repository.kt +++ b/database/src/main/java/org/fdroid/database/Repository.kt @@ -7,6 +7,7 @@ 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.IndexUtils.getFingerprint import org.fdroid.index.v2.AntiFeatureV2 import org.fdroid.index.v2.CategoryV2 diff --git a/database/src/main/java/org/fdroid/database/Version.kt b/database/src/main/java/org/fdroid/database/Version.kt index d956cec52..e209b6706 100644 --- a/database/src/main/java/org/fdroid/database/Version.kt +++ b/database/src/main/java/org/fdroid/database/Version.kt @@ -6,6 +6,7 @@ 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 From 28fa5fd46e7da1165189806cda2f2647fecd1d80 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 29 Jun 2022 14:57:22 -0300 Subject: [PATCH 35/42] [db] add search support with FTS4 FTS5 isn't supported by Room because old Android devices ship only with sqlite with FTS4 --- .../org/fdroid/database/AppListItemsTest.kt | 109 +++++++++++++++++- .../src/main/java/org/fdroid/database/App.kt | 12 ++ .../main/java/org/fdroid/database/AppDao.kt | 53 ++++++++- .../org/fdroid/database/FDroidDatabase.kt | 4 +- 4 files changed, 167 insertions(+), 11 deletions(-) diff --git a/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt b/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt index 295ea26a5..e7888121b 100644 --- a/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt @@ -34,6 +34,107 @@ internal class AppListItemsTest : AppTest() { Pair(packageName3, app3), ) + @Test + fun testSearchQuery() { + val app1 = app1.copy(name = mapOf("en-US" to "One"), summary = mapOf("en-US" to "Onearry")) + val app2 = app2.copy(name = mapOf("en-US" to "Two"), summary = mapOf("de" to "Zfassung")) + val app3 = app3.copy(name = mapOf("de-DE" to "Drei"), summary = mapOf("de" to "Zfassung")) + // insert three apps in a random order + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName1, app1, locales) + appDao.insert(repoId, packageName3, app3, locales) + appDao.insert(repoId, packageName2, app2, locales) + + // nothing is installed + every { pm.getInstalledPackages(0) } returns emptyList() + + // get first app by search, sort order doesn't matter + appDao.getAppListItems(pm, "One", LAST_UPDATED).getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app1, apps[0]) + } + + // get second app by search, sort order doesn't matter + appDao.getAppListItems(pm, "Two", NAME).getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app2, apps[0]) + } + + // get second and third app by searching for summary + appDao.getAppListItems(pm, "Zfassung", LAST_UPDATED).getOrFail().let { apps -> + assertEquals(2, apps.size) + // sort-order isn't fixes, yet + if (apps[0].packageId == packageName2) { + assertEquals(app2, apps[0]) + assertEquals(app3, apps[1]) + } else { + assertEquals(app3, apps[0]) + assertEquals(app2, apps[1]) + } + } + + // empty search for unknown search term + appDao.getAppListItems(pm, "foo bar", LAST_UPDATED).getOrFail().let { apps -> + assertEquals(0, apps.size) + } + } + + @Test + fun testSearchQueryInCategory() { + val app1 = app1.copy(name = mapOf("en-US" to "One"), summary = mapOf("en-US" to "Onearry")) + val app2 = app2.copy(name = mapOf("en-US" to "Two"), summary = mapOf("de" to "Zfassung")) + val app3 = app3.copy(name = mapOf("de-DE" to "Drei"), summary = mapOf("de" to "Zfassung")) + // insert three apps in a random order + val repoId = repoDao.insertOrReplace(getRandomRepo()) + appDao.insert(repoId, packageName1, app1, locales) + appDao.insert(repoId, packageName3, app3, locales) + appDao.insert(repoId, packageName2, app2, locales) + + // nothing is installed + every { pm.getInstalledPackages(0) } returns emptyList() + + // get first app by search, sort order doesn't matter + appDao.getAppListItems(pm, "A", "One", LAST_UPDATED).getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app1, apps[0]) + } + + // get second app by search, sort order doesn't matter + appDao.getAppListItems(pm, "A", "Two", NAME).getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app2, apps[0]) + } + + // get second and third app by searching for summary + appDao.getAppListItems(pm, "A", "Zfassung", LAST_UPDATED).getOrFail().let { apps -> + assertEquals(2, apps.size) + // sort-order isn't fixes, yet + if (apps[0].packageId == packageName2) { + assertEquals(app2, apps[0]) + assertEquals(app3, apps[1]) + } else { + assertEquals(app3, apps[0]) + assertEquals(app2, apps[1]) + } + } + + // get third app by searching for summary in category B only + appDao.getAppListItems(pm, "B", "Zfassung", LAST_UPDATED).getOrFail().let { apps -> + assertEquals(1, apps.size) + assertEquals(app3, apps[0]) + } + + // empty search for unknown category + appDao.getAppListItems(pm, "C", "Zfassung", LAST_UPDATED).getOrFail().let { apps -> + assertEquals(0, apps.size) + } + + // empty search for unknown search term + appDao.getAppListItems(pm, "A", "foo bar", LAST_UPDATED).getOrFail().let { apps -> + assertEquals(0, apps.size) + } + } + @Test fun testSortOrderByLastUpdated() { // insert three apps in a random order @@ -46,7 +147,7 @@ internal class AppListItemsTest : AppTest() { every { pm.getInstalledPackages(0) } returns emptyList() // get apps sorted by last updated - appDao.getAppListItems(pm, LAST_UPDATED).getOrFail().let { apps -> + appDao.getAppListItems(pm, "", LAST_UPDATED).getOrFail().let { apps -> assertEquals(3, apps.size) // we expect apps to be sorted by last updated descending appPairs.sortedByDescending { (_, metadataV2) -> @@ -70,7 +171,7 @@ internal class AppListItemsTest : AppTest() { every { pm.getInstalledPackages(0) } returns emptyList() // get apps sorted by name ascending - appDao.getAppListItems(pm, NAME).getOrFail().let { apps -> + appDao.getAppListItems(pm, null, NAME).getOrFail().let { apps -> assertEquals(3, apps.size) // we expect apps to be sorted by last updated descending appPairs.sortedBy { (_, metadataV2) -> @@ -100,8 +201,8 @@ internal class AppListItemsTest : AppTest() { // get apps sorted by name and last update, test on both lists listOf( - appDao.getAppListItems(pm, NAME).getOrFail(), - appDao.getAppListItems(pm, LAST_UPDATED).getOrFail(), + appDao.getAppListItems(pm, "", NAME).getOrFail(), + appDao.getAppListItems(pm, null, LAST_UPDATED).getOrFail(), ).forEach { apps -> assertEquals(2, apps.size) // the installed app should have app data diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt index bd3e838c0..166790439 100644 --- a/database/src/main/java/org/fdroid/database/App.kt +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -8,6 +8,7 @@ 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 @@ -105,6 +106,17 @@ internal fun MetadataV2.toAppMetadata( isCompatible = isCompatible, ) +@Entity +@Fts4(contentEntity = AppMetadata::class) +internal data class AppMetadataFts( + val repoId: Long, + val packageId: String, + @ColumnInfo(name = "localizedName") + val name: String? = null, + @ColumnInfo(name = "localizedSummary") + val summary: String? = null, +) + public data class App internal constructor( @Embedded val metadata: AppMetadata, @Relation( diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index cc5372a7e..ad9a560b5 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -83,14 +83,24 @@ public interface AppDao { limit: Int = 50, ): LiveData> + /** + * 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> + /** + * Like [getAppListItems], but further filter items by the given [category]. + */ public fun getAppListItems( packageManager: PackageManager, category: String, + searchQuery: String?, sortOrder: AppListSortOrder, ): LiveData> @@ -354,19 +364,25 @@ internal interface AppDaoInt : AppDao { override fun getAppListItems( packageManager: PackageManager, + searchQuery: String?, sortOrder: AppListSortOrder, - ): LiveData> = when (sortOrder) { - LAST_UPDATED -> getAppListItemsByLastUpdated().map(packageManager) - NAME -> getAppListItemsByName().map(packageManager) + ): LiveData> { + 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> = when (sortOrder) { - LAST_UPDATED -> getAppListItemsByLastUpdated(category).map(packageManager) - NAME -> getAppListItemsByName(category).map(packageManager) + ): LiveData> { + return if (searchQuery.isNullOrEmpty()) when (sortOrder) { + LAST_UPDATED -> getAppListItemsByLastUpdated(category).map(packageManager) + NAME -> getAppListItemsByName(category).map(packageManager) + } else getAppListItems(category, searchQuery) } private fun LiveData>.map( @@ -383,6 +399,31 @@ internal interface AppDaoInt : AppDao { } } + @Transaction + @Query(""" + SELECT repoId, packageId, app.localizedName, app.localizedSummary, version.antiFeatures, + app.isCompatible + FROM AppMetadata AS app + JOIN AppMetadataFts USING (repoId, packageId) + LEFT JOIN HighestVersion AS version USING (repoId, packageId) + JOIN RepositoryPreferences AS pref USING (repoId) + WHERE pref.enabled = 1 AND AppMetadataFts MATCH '"*' || :searchQuery || '*"' + GROUP BY packageId HAVING MAX(pref.weight)""") + fun getAppListItems(searchQuery: String): LiveData> + + @Transaction + @Query(""" + SELECT repoId, packageId, app.localizedName, app.localizedSummary, version.antiFeatures, + app.isCompatible + FROM AppMetadata AS app + JOIN AppMetadataFts USING (repoId, packageId) + LEFT JOIN HighestVersion AS version USING (repoId, packageId) + JOIN RepositoryPreferences AS pref USING (repoId) + WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' AND + AppMetadataFts MATCH '"*' || :searchQuery || '*"' + GROUP BY packageId HAVING MAX(pref.weight)""") + fun getAppListItems(category: String, searchQuery: String): LiveData> + @Transaction @Query(""" SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures, diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index 002972eb0..9baf02486 100644 --- a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -18,7 +18,7 @@ import kotlinx.coroutines.launch import org.fdroid.LocaleChooser.getBestLocale @Database( - version = 8, // TODO set version to 1 before release and wipe old schemas + version = 9, // TODO set version to 1 before release and wipe old schemas entities = [ // repo CoreRepository::class, @@ -29,6 +29,7 @@ import org.fdroid.LocaleChooser.getBestLocale RepositoryPreferences::class, // packages AppMetadata::class, + AppMetadataFts::class, LocalizedFile::class, LocalizedFileList::class, // versions @@ -50,6 +51,7 @@ import org.fdroid.LocaleChooser.getBestLocale AutoMigration(from = 5, to = 6), AutoMigration(from = 6, to = 7), AutoMigration(from = 7, to = 8), + AutoMigration(from = 8, to = 9), ], ) @TypeConverters(Converters::class) From 517e64649796ac7c322476481f6803a39fe68cd8 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 19 Jul 2022 14:22:03 -0300 Subject: [PATCH 36/42] [db] add dokka for generation of docs --- .gitlab-ci.yml | 3 ++- database/build.gradle | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2401db471..21ee23063 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -148,11 +148,12 @@ pages: only: - master script: - - ./gradlew :download:dokkaHtml :index:dokkaHtml + - ./gradlew :download:dokkaHtml :index:dokkaHtml :database:dokkaHtml - mkdir public - touch public/index.html - cp -r download/build/dokka/html public/download - cp -r index/build/dokka/html public/index + - cp -r database/build/dokka/html public/database artifacts: paths: - public diff --git a/database/build.gradle b/database/build.gradle index 695cb22ca..5ecc75239 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -2,6 +2,7 @@ plugins { id 'kotlin-android' id 'com.android.library' id 'kotlin-kapt' + id 'org.jetbrains.dokka' id "org.jlleitschuh.gradle.ktlint" version "10.2.1" } @@ -97,4 +98,14 @@ dependencies { androidTestImplementation 'commons-io:commons-io:2.6' } +import org.jetbrains.dokka.gradle.DokkaTask +tasks.withType(DokkaTask).configureEach { + pluginsMapConfiguration.set( + ["org.jetbrains.dokka.base.DokkaBase": """{ + "customAssets": ["${file("${rootProject.rootDir}/logo-icon.svg")}"], + "footerMessage": "© 2010-2022 F-Droid Limited and Contributors" + }"""] + ) +} + apply from: "${rootProject.rootDir}/gradle/ktlint.gradle" From a6bce15116a4b45495404776c53c106ad36164cd Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Tue, 19 Jul 2022 14:52:05 -0300 Subject: [PATCH 37/42] [db] clean up public API and docs --- .../1.json | 343 +++++++++++------- .../org/fdroid/database/AppListItemsTest.kt | 26 +- .../fdroid/database/AppOverviewItemsTest.kt | 36 +- .../fdroid/database/DbUpdateCheckerTest.kt | 6 +- .../org/fdroid/database/IndexV1InsertTest.kt | 12 +- .../org/fdroid/database/IndexV2DiffTest.kt | 8 +- .../org/fdroid/database/RepositoryDaoTest.kt | 18 +- .../org/fdroid/database/RepositoryDiffTest.kt | 41 ++- .../java/org/fdroid/database/TestUtils.kt | 4 +- .../java/org/fdroid/database/VersionTest.kt | 12 +- .../org/fdroid/index/v1/IndexV1UpdaterTest.kt | 10 +- .../org/fdroid/index/v2/IndexV2UpdaterTest.kt | 2 +- .../src/main/java/org/fdroid/database/App.kt | 276 ++++++++------ .../main/java/org/fdroid/database/AppDao.kt | 180 ++++----- .../main/java/org/fdroid/database/AppPrefs.kt | 17 +- .../java/org/fdroid/database/AppPrefsDao.kt | 4 +- .../org/fdroid/database/DbUpdateChecker.kt | 13 +- .../org/fdroid/database/DbV1StreamReceiver.kt | 14 +- .../fdroid/database/DbV2DiffStreamReceiver.kt | 8 +- .../org/fdroid/database/DbV2StreamReceiver.kt | 14 +- .../org/fdroid/database/FDroidDatabase.kt | 131 ++----- .../fdroid/database/FDroidDatabaseHolder.kt | 93 +++++ .../java/org/fdroid/database/Repository.kt | 197 +++++++--- .../java/org/fdroid/database/RepositoryDao.kt | 1 + .../main/java/org/fdroid/database/Version.kt | 80 ++-- .../java/org/fdroid/database/VersionDao.kt | 29 +- .../java/org/fdroid/index/IndexUpdater.kt | 20 +- .../main/java/org/fdroid/index/RepoUpdater.kt | 9 + .../org/fdroid/index/v1/IndexV1Updater.kt | 4 +- .../org/fdroid/index/v2/IndexV2Updater.kt | 6 +- 30 files changed, 984 insertions(+), 630 deletions(-) create mode 100644 database/src/main/java/org/fdroid/database/FDroidDatabaseHolder.kt diff --git a/database/schemas/org.fdroid.database.FDroidDatabaseInt/1.json b/database/schemas/org.fdroid.database.FDroidDatabaseInt/1.json index 901550bae..497371163 100644 --- a/database/schemas/org.fdroid.database.FDroidDatabaseInt/1.json +++ b/database/schemas/org.fdroid.database.FDroidDatabaseInt/1.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "bf86c814bcbc98d81f1530ea479c6340", + "identityHash": "beaebd71355e07ce5f0500c19d9045fd", "entities": [ { "tableName": "CoreRepository", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `address` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `version` INTEGER, `description` TEXT NOT NULL, `certificate` TEXT, `icon_name` TEXT, `icon_sha256` TEXT, `icon_size` INTEGER)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT, `address` TEXT NOT NULL, `webBaseUrl` TEXT, `timestamp` INTEGER NOT NULL, `version` INTEGER, `formatVersion` TEXT, `maxAge` INTEGER, `description` TEXT NOT NULL, `certificate` TEXT)", "fields": [ { "fieldPath": "repoId", @@ -20,12 +20,24 @@ "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, { "fieldPath": "address", "columnName": "address", "affinity": "TEXT", "notNull": true }, + { + "fieldPath": "webBaseUrl", + "columnName": "webBaseUrl", + "affinity": "TEXT", + "notNull": false + }, { "fieldPath": "timestamp", "columnName": "timestamp", @@ -38,6 +50,18 @@ "affinity": "INTEGER", "notNull": false }, + { + "fieldPath": "formatVersion", + "columnName": "formatVersion", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "maxAge", + "columnName": "maxAge", + "affinity": "INTEGER", + "notNull": false + }, { "fieldPath": "description", "columnName": "description", @@ -49,24 +73,6 @@ "columnName": "certificate", "affinity": "TEXT", "notNull": false - }, - { - "fieldPath": "icon.name", - "columnName": "icon_name", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "icon.sha256", - "columnName": "icon_sha256", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "icon.size", - "columnName": "icon_size", - "affinity": "INTEGER", - "notNull": false } ], "primaryKey": { @@ -332,7 +338,7 @@ }, { "tableName": "RepositoryPreferences", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `lastUpdated` INTEGER, `lastETag` TEXT, `userMirrors` TEXT, `disabledMirrors` TEXT, `username` TEXT, `password` TEXT, `isSwap` INTEGER NOT NULL, PRIMARY KEY(`repoId`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `lastUpdated` INTEGER, `lastETag` TEXT, `userMirrors` TEXT, `disabledMirrors` TEXT, `username` TEXT, `password` TEXT, PRIMARY KEY(`repoId`))", "fields": [ { "fieldPath": "repoId", @@ -387,12 +393,6 @@ "columnName": "password", "affinity": "TEXT", "notNull": false - }, - { - "fieldPath": "isSwap", - "columnName": "isSwap", - "affinity": "INTEGER", - "notNull": true } ], "primaryKey": { @@ -406,7 +406,7 @@ }, { "tableName": "AppMetadata", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageId` TEXT NOT NULL, `added` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `name` TEXT, `summary` TEXT, `description` TEXT, `localizedName` TEXT, `localizedSummary` TEXT, `webSite` TEXT, `changelog` TEXT, `license` TEXT, `sourceCode` TEXT, `issueTracker` TEXT, `translation` TEXT, `preferredSigner` TEXT, `video` TEXT, `categories` TEXT, `isCompatible` INTEGER NOT NULL, `author_name` TEXT, `author_email` TEXT, `author_website` TEXT, `author_phone` TEXT, `donation_url` TEXT, `donation_liberapayID` TEXT, `donation_liberapay` TEXT, `donation_openCollective` TEXT, `donation_bitcoin` TEXT, `donation_litecoin` TEXT, `donation_flattrID` TEXT, PRIMARY KEY(`repoId`, `packageId`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `added` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `name` TEXT, `summary` TEXT, `description` TEXT, `localizedName` TEXT, `localizedSummary` TEXT, `webSite` TEXT, `changelog` TEXT, `license` TEXT, `sourceCode` TEXT, `issueTracker` TEXT, `translation` TEXT, `preferredSigner` TEXT, `video` TEXT, `authorName` TEXT, `authorEmail` TEXT, `authorWebSite` TEXT, `authorPhone` TEXT, `donate` TEXT, `liberapayID` TEXT, `liberapay` TEXT, `openCollective` TEXT, `bitcoin` TEXT, `litecoin` TEXT, `flattrID` TEXT, `categories` TEXT, `isCompatible` INTEGER NOT NULL, PRIMARY KEY(`repoId`, `packageName`), FOREIGN KEY(`repoId`) REFERENCES `CoreRepository`(`repoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "repoId", @@ -415,8 +415,8 @@ "notNull": true }, { - "fieldPath": "packageId", - "columnName": "packageId", + "fieldPath": "packageName", + "columnName": "packageName", "affinity": "TEXT", "notNull": true }, @@ -510,6 +510,72 @@ "affinity": "TEXT", "notNull": false }, + { + "fieldPath": "authorName", + "columnName": "authorName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorEmail", + "columnName": "authorEmail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorWebSite", + "columnName": "authorWebSite", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorPhone", + "columnName": "authorPhone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "donate", + "columnName": "donate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liberapayID", + "columnName": "liberapayID", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liberapay", + "columnName": "liberapay", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "openCollective", + "columnName": "openCollective", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitcoin", + "columnName": "bitcoin", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "litecoin", + "columnName": "litecoin", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "flattrID", + "columnName": "flattrID", + "affinity": "TEXT", + "notNull": false + }, { "fieldPath": "categories", "columnName": "categories", @@ -521,78 +587,12 @@ "columnName": "isCompatible", "affinity": "INTEGER", "notNull": true - }, - { - "fieldPath": "author.name", - "columnName": "author_name", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "author.email", - "columnName": "author_email", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "author.website", - "columnName": "author_website", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "author.phone", - "columnName": "author_phone", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "donation.url", - "columnName": "donation_url", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "donation.liberapayID", - "columnName": "donation_liberapayID", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "donation.liberapay", - "columnName": "donation_liberapay", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "donation.openCollective", - "columnName": "donation_openCollective", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "donation.bitcoin", - "columnName": "donation_bitcoin", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "donation.litecoin", - "columnName": "donation_litecoin", - "affinity": "TEXT", - "notNull": false - }, - { - "fieldPath": "donation.flattrID", - "columnName": "donation_flattrID", - "affinity": "TEXT", - "notNull": false } ], "primaryKey": { "columnNames": [ "repoId", - "packageId" + "packageName" ], "autoGenerate": false }, @@ -612,8 +612,25 @@ ] }, { - "tableName": "LocalizedFile", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageId` TEXT NOT NULL, `type` TEXT NOT NULL, `locale` TEXT NOT NULL, `name` TEXT NOT NULL, `sha256` TEXT, `size` INTEGER, PRIMARY KEY(`repoId`, `packageId`, `type`, `locale`), FOREIGN KEY(`repoId`, `packageId`) REFERENCES `AppMetadata`(`repoId`, `packageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "AppMetadata", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_BEFORE_UPDATE BEFORE UPDATE ON `AppMetadata` BEGIN DELETE FROM `AppMetadataFts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_BEFORE_DELETE BEFORE DELETE ON `AppMetadata` BEGIN DELETE FROM `AppMetadataFts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_AFTER_UPDATE AFTER UPDATE ON `AppMetadata` BEGIN INSERT INTO `AppMetadataFts`(`docid`, `repoId`, `packageName`, `localizedName`, `localizedSummary`) VALUES (NEW.`rowid`, NEW.`repoId`, NEW.`packageName`, NEW.`localizedName`, NEW.`localizedSummary`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_AppMetadataFts_AFTER_INSERT AFTER INSERT ON `AppMetadata` BEGIN INSERT INTO `AppMetadataFts`(`docid`, `repoId`, `packageName`, `localizedName`, `localizedSummary`) VALUES (NEW.`rowid`, NEW.`repoId`, NEW.`packageName`, NEW.`localizedName`, NEW.`localizedSummary`); END" + ], + "tableName": "AppMetadataFts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `localizedName` TEXT, `localizedSummary` TEXT, content=`AppMetadata`)", "fields": [ { "fieldPath": "repoId", @@ -622,8 +639,44 @@ "notNull": true }, { - "fieldPath": "packageId", - "columnName": "packageId", + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "localizedName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "summary", + "columnName": "localizedSummary", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "LocalizedFile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `type` TEXT NOT NULL, `locale` TEXT NOT NULL, `name` TEXT NOT NULL, `sha256` TEXT, `size` INTEGER, PRIMARY KEY(`repoId`, `packageName`, `type`, `locale`), FOREIGN KEY(`repoId`, `packageName`) REFERENCES `AppMetadata`(`repoId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "repoId", + "columnName": "repoId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "packageName", "affinity": "TEXT", "notNull": true }, @@ -661,7 +714,7 @@ "primaryKey": { "columnNames": [ "repoId", - "packageId", + "packageName", "type", "locale" ], @@ -675,18 +728,18 @@ "onUpdate": "NO ACTION", "columns": [ "repoId", - "packageId" + "packageName" ], "referencedColumns": [ "repoId", - "packageId" + "packageName" ] } ] }, { "tableName": "LocalizedFileList", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageId` TEXT NOT NULL, `type` TEXT NOT NULL, `locale` TEXT NOT NULL, `name` TEXT NOT NULL, `sha256` TEXT, `size` INTEGER, PRIMARY KEY(`repoId`, `packageId`, `type`, `locale`, `name`), FOREIGN KEY(`repoId`, `packageId`) REFERENCES `AppMetadata`(`repoId`, `packageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `type` TEXT NOT NULL, `locale` TEXT NOT NULL, `name` TEXT NOT NULL, `sha256` TEXT, `size` INTEGER, PRIMARY KEY(`repoId`, `packageName`, `type`, `locale`, `name`), FOREIGN KEY(`repoId`, `packageName`) REFERENCES `AppMetadata`(`repoId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "repoId", @@ -695,8 +748,8 @@ "notNull": true }, { - "fieldPath": "packageId", - "columnName": "packageId", + "fieldPath": "packageName", + "columnName": "packageName", "affinity": "TEXT", "notNull": true }, @@ -734,7 +787,7 @@ "primaryKey": { "columnNames": [ "repoId", - "packageId", + "packageName", "type", "locale", "name" @@ -749,18 +802,18 @@ "onUpdate": "NO ACTION", "columns": [ "repoId", - "packageId" + "packageName" ], "referencedColumns": [ "repoId", - "packageId" + "packageName" ] } ] }, { "tableName": "Version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageId` TEXT NOT NULL, `versionId` TEXT NOT NULL, `added` INTEGER NOT NULL, `releaseChannels` TEXT, `antiFeatures` TEXT, `whatsNew` TEXT, `isCompatible` INTEGER NOT NULL, `file_name` TEXT NOT NULL, `file_sha256` TEXT NOT NULL, `file_size` INTEGER, `src_name` TEXT, `src_sha256` TEXT, `src_size` INTEGER, `manifest_versionName` TEXT NOT NULL, `manifest_versionCode` INTEGER NOT NULL, `manifest_maxSdkVersion` INTEGER, `manifest_nativecode` TEXT, `manifest_features` TEXT, `manifest_usesSdk_minSdkVersion` INTEGER, `manifest_usesSdk_targetSdkVersion` INTEGER, `manifest_signer_sha256` TEXT, PRIMARY KEY(`repoId`, `packageId`, `versionId`), FOREIGN KEY(`repoId`, `packageId`) REFERENCES `AppMetadata`(`repoId`, `packageId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `versionId` TEXT NOT NULL, `added` INTEGER NOT NULL, `releaseChannels` TEXT, `antiFeatures` TEXT, `whatsNew` TEXT, `isCompatible` INTEGER NOT NULL, `file_name` TEXT NOT NULL, `file_sha256` TEXT NOT NULL, `file_size` INTEGER, `src_name` TEXT, `src_sha256` TEXT, `src_size` INTEGER, `manifest_versionName` TEXT NOT NULL, `manifest_versionCode` INTEGER NOT NULL, `manifest_maxSdkVersion` INTEGER, `manifest_nativecode` TEXT, `manifest_features` TEXT, `manifest_usesSdk_minSdkVersion` INTEGER, `manifest_usesSdk_targetSdkVersion` INTEGER, `manifest_signer_sha256` TEXT, `manifest_signer_hasMultipleSigners` INTEGER, PRIMARY KEY(`repoId`, `packageName`, `versionId`), FOREIGN KEY(`repoId`, `packageName`) REFERENCES `AppMetadata`(`repoId`, `packageName`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "repoId", @@ -769,8 +822,8 @@ "notNull": true }, { - "fieldPath": "packageId", - "columnName": "packageId", + "fieldPath": "packageName", + "columnName": "packageName", "affinity": "TEXT", "notNull": true }, @@ -893,12 +946,18 @@ "columnName": "manifest_signer_sha256", "affinity": "TEXT", "notNull": false + }, + { + "fieldPath": "manifest.signer.hasMultipleSigners", + "columnName": "manifest_signer_hasMultipleSigners", + "affinity": "INTEGER", + "notNull": false } ], "primaryKey": { "columnNames": [ "repoId", - "packageId", + "packageName", "versionId" ], "autoGenerate": false @@ -911,18 +970,18 @@ "onUpdate": "NO ACTION", "columns": [ "repoId", - "packageId" + "packageName" ], "referencedColumns": [ "repoId", - "packageId" + "packageName" ] } ] }, { "tableName": "VersionedString", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageId` TEXT NOT NULL, `versionId` TEXT NOT NULL, `type` TEXT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER, PRIMARY KEY(`repoId`, `packageId`, `versionId`, `type`, `name`), FOREIGN KEY(`repoId`, `packageId`, `versionId`) REFERENCES `Version`(`repoId`, `packageId`, `versionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`repoId` INTEGER NOT NULL, `packageName` TEXT NOT NULL, `versionId` TEXT NOT NULL, `type` TEXT NOT NULL, `name` TEXT NOT NULL, `version` INTEGER, PRIMARY KEY(`repoId`, `packageName`, `versionId`, `type`, `name`), FOREIGN KEY(`repoId`, `packageName`, `versionId`) REFERENCES `Version`(`repoId`, `packageName`, `versionId`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "repoId", @@ -931,8 +990,8 @@ "notNull": true }, { - "fieldPath": "packageId", - "columnName": "packageId", + "fieldPath": "packageName", + "columnName": "packageName", "affinity": "TEXT", "notNull": true }, @@ -964,7 +1023,7 @@ "primaryKey": { "columnNames": [ "repoId", - "packageId", + "packageName", "versionId", "type", "name" @@ -979,27 +1038,63 @@ "onUpdate": "NO ACTION", "columns": [ "repoId", - "packageId", + "packageName", "versionId" ], "referencedColumns": [ "repoId", - "packageId", + "packageName", "versionId" ] } ] + }, + { + "tableName": "AppPrefs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`packageName` TEXT NOT NULL, `ignoreVersionCodeUpdate` INTEGER NOT NULL, `appPrefReleaseChannels` TEXT, PRIMARY KEY(`packageName`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "packageName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ignoreVersionCodeUpdate", + "columnName": "ignoreVersionCodeUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appPrefReleaseChannels", + "columnName": "appPrefReleaseChannels", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "packageName" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] } ], "views": [ { "viewName": "LocalizedIcon", - "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM LocalizedFile\n JOIN RepositoryPreferences AS prefs USING (repoId)\n WHERE type='icon' GROUP BY repoId, packageId, locale HAVING MAX(prefs.weight)" + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM LocalizedFile WHERE type='icon'" + }, + { + "viewName": "HighestVersion", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT repoId, packageName, antiFeatures FROM Version\n GROUP BY repoId, packageName HAVING MAX(manifest_versionCode)" } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bf86c814bcbc98d81f1530ea479c6340')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'beaebd71355e07ce5f0500c19d9045fd')" ] } } \ No newline at end of file diff --git a/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt b/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt index e7888121b..a41f1dbc6 100644 --- a/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt @@ -64,7 +64,7 @@ internal class AppListItemsTest : AppTest() { appDao.getAppListItems(pm, "Zfassung", LAST_UPDATED).getOrFail().let { apps -> assertEquals(2, apps.size) // sort-order isn't fixes, yet - if (apps[0].packageId == packageName2) { + if (apps[0].packageName == packageName2) { assertEquals(app2, apps[0]) assertEquals(app3, apps[1]) } else { @@ -109,7 +109,7 @@ internal class AppListItemsTest : AppTest() { appDao.getAppListItems(pm, "A", "Zfassung", LAST_UPDATED).getOrFail().let { apps -> assertEquals(2, apps.size) // sort-order isn't fixes, yet - if (apps[0].packageId == packageName2) { + if (apps[0].packageName == packageName2) { assertEquals(app2, apps[0]) assertEquals(app3, apps[1]) } else { @@ -153,7 +153,7 @@ internal class AppListItemsTest : AppTest() { appPairs.sortedByDescending { (_, metadataV2) -> metadataV2.lastUpdated }.forEachIndexed { i, pair -> - assertEquals(pair.first, apps[i].packageId) + assertEquals(pair.first, apps[i].packageName) assertEquals(pair.second, apps[i]) } } @@ -177,7 +177,7 @@ internal class AppListItemsTest : AppTest() { appPairs.sortedBy { (_, metadataV2) -> metadataV2.name.getBestLocale(locales) }.forEachIndexed { i, pair -> - assertEquals(pair.first, apps[i].packageId) + assertEquals(pair.first, apps[i].packageName) assertEquals(pair.second, apps[i]) } } @@ -206,8 +206,8 @@ internal class AppListItemsTest : AppTest() { ).forEach { apps -> assertEquals(2, apps.size) // the installed app should have app data - val installed = if (apps[0].packageId == packageName1) apps[1] else apps[0] - val other = if (apps[0].packageId == packageName1) apps[0] else apps[1] + val installed = if (apps[0].packageName == packageName1) apps[1] else apps[0] + val other = if (apps[0].packageName == packageName1) apps[0] else apps[1] assertEquals(packageInfo2.versionName, installed.installedVersionName) assertEquals(packageInfo2.getVersionCode(), installed.installedVersionCode) assertNull(other.installedVersionName) @@ -239,7 +239,7 @@ internal class AppListItemsTest : AppTest() { // now only one is not compatible getItems { apps -> assertEquals(2, apps.size) - if (apps[0].packageId == packageName1) { + if (apps[0].packageName == packageName1) { assertTrue(apps[0].isCompatible) assertFalse(apps[1].isCompatible) } else { @@ -259,7 +259,7 @@ internal class AppListItemsTest : AppTest() { getItems { apps -> assertEquals(1, apps.size) assertNull(apps[0].antiFeatures) - assertEquals(emptyList(), apps[0].getAntiFeatureNames()) + assertEquals(emptyList(), apps[0].antiFeatureKeys) } // app gets a version @@ -270,7 +270,7 @@ internal class AppListItemsTest : AppTest() { // note that installed versions don't contain anti-features, so they are ignored getItems(alsoInstalled = false) { apps -> assertEquals(1, apps.size) - assertEquals(version1.antiFeatures.map { it.key }, apps[0].getAntiFeatureNames()) + assertEquals(version1.antiFeatures.map { it.key }, apps[0].antiFeatureKeys) } // app gets another version @@ -281,7 +281,7 @@ internal class AppListItemsTest : AppTest() { // note that installed versions don't contain anti-features, so they are ignored getItems(alsoInstalled = false) { apps -> assertEquals(1, apps.size) - assertEquals(version1.antiFeatures.map { it.key }, apps[0].getAntiFeatureNames()) + assertEquals(version1.antiFeatures.map { it.key }, apps[0].antiFeatureKeys) } } @@ -328,7 +328,7 @@ internal class AppListItemsTest : AppTest() { // app from repo with highest weight is returned (app1) getItems { apps -> assertEquals(1, apps.size) - assertEquals(packageName, apps[0].packageId) + assertEquals(packageName, apps[0].packageName) assertEquals(app1, apps[0]) } } @@ -347,8 +347,8 @@ internal class AppListItemsTest : AppTest() { appDao.getAppListItemsByLastUpdated("B").getOrFail(), ).forEach { apps -> assertEquals(2, apps.size) - assertNotEquals(packageName2, apps[0].packageId) - assertNotEquals(packageName2, apps[1].packageId) + assertNotEquals(packageName2, apps[0].packageName) + assertNotEquals(packageName2, apps[1].packageName) } // no app is in category C diff --git a/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt b/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt index 58acfd2af..a13a005d3 100644 --- a/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt @@ -126,10 +126,10 @@ internal class AppOverviewItemsTest : AppTest() { appDao.getAppOverviewItems().getOrFail().let { apps -> assertEquals(2, apps.size) // app 2 is first, because has icon and summary - assertEquals(packageName2, apps[0].packageId) + assertEquals(packageName2, apps[0].packageName) assertEquals(icons2, apps[0].localizedIcon?.toLocalizedFileV2()) // app 1 is next, because has icon - assertEquals(packageName1, apps[1].packageId) + assertEquals(packageName1, apps[1].packageName) assertEquals(icons1, apps[1].localizedIcon?.toLocalizedFileV2()) } @@ -138,9 +138,9 @@ internal class AppOverviewItemsTest : AppTest() { versionDao.insert(repoId, packageName3, "3", getRandomPackageVersionV2(), true) appDao.getAppOverviewItems().getOrFail().let { apps -> assertEquals(3, apps.size) - assertEquals(packageName2, apps[0].packageId) - assertEquals(packageName1, apps[1].packageId) - assertEquals(packageName3, apps[2].packageId) + assertEquals(packageName2, apps[0].packageName) + assertEquals(packageName1, apps[1].packageName) + assertEquals(packageName3, apps[2].packageName) assertEquals(emptyList(), apps[2].localizedIcon) } @@ -157,10 +157,10 @@ internal class AppOverviewItemsTest : AppTest() { // note that we don't insert a version here appDao.getAppOverviewItems().getOrFail().let { apps -> assertEquals(3, apps.size) - assertEquals(packageName3, apps[0].packageId) - assertEquals(emptyList(), apps[0].antiFeatureNames) - assertEquals(packageName2, apps[1].packageId) - assertEquals(packageName1, apps[2].packageId) + assertEquals(packageName3, apps[0].packageName) + assertEquals(emptyList(), apps[0].antiFeatureKeys) + assertEquals(packageName2, apps[1].packageName) + assertEquals(packageName1, apps[2].packageName) } } @@ -176,10 +176,10 @@ internal class AppOverviewItemsTest : AppTest() { appDao.getAppOverviewItems("A").getOrFail().let { apps -> assertEquals(2, apps.size) // app 2 is first, because has icon and summary - assertEquals(packageName2, apps[0].packageId) + assertEquals(packageName2, apps[0].packageName) assertEquals(icons2, apps[0].localizedIcon?.toLocalizedFileV2()) // app 1 is next, because has icon - assertEquals(packageName1, apps[1].packageId) + assertEquals(packageName1, apps[1].packageName) assertEquals(icons1, apps[1].localizedIcon?.toLocalizedFileV2()) } @@ -191,9 +191,9 @@ internal class AppOverviewItemsTest : AppTest() { versionDao.insert(repoId, packageName3, "3", getRandomPackageVersionV2(), true) appDao.getAppOverviewItems("A").getOrFail().let { apps -> assertEquals(3, apps.size) - assertEquals(packageName2, apps[0].packageId) - assertEquals(packageName1, apps[1].packageId) - assertEquals(packageName3, apps[2].packageId) + assertEquals(packageName2, apps[0].packageName) + assertEquals(packageName1, apps[1].packageName) + assertEquals(packageName3, apps[2].packageName) assertEquals(emptyList(), apps[2].localizedIcon) } @@ -210,10 +210,10 @@ internal class AppOverviewItemsTest : AppTest() { // note that we don't insert a version here appDao.getAppOverviewItems("A").getOrFail().let { apps -> assertEquals(3, apps.size) - assertEquals(packageName3, apps[0].packageId) - assertEquals(emptyList(), apps[0].antiFeatureNames) - assertEquals(packageName2, apps[1].packageId) - assertEquals(packageName1, apps[2].packageId) + assertEquals(packageName3, apps[0].packageName) + assertEquals(emptyList(), apps[0].antiFeatureKeys) + assertEquals(packageName2, apps[1].packageName) + assertEquals(packageName1, apps[2].packageName) } // only two apps are returned for category B diff --git a/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt b/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt index 7d2d59965..5ceb7c3b7 100644 --- a/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt @@ -42,7 +42,7 @@ internal class DbUpdateCheckerTest : DbTest() { val appVersion = updateChecker.getSuggestedVersion(packageInfo.packageName) val expectedVersion = TestDataMinV2.version.toVersion( repoId = repoId, - packageId = packageInfo.packageName, + packageName = packageInfo.packageName, versionId = TestDataMinV2.version.file.sha256, isCompatible = true, ) @@ -75,8 +75,8 @@ internal class DbUpdateCheckerTest : DbTest() { val appVersions = updateChecker.getUpdatableApps() assertEquals(1, appVersions.size) assertEquals(0, appVersions[0].installedVersionCode) - assertEquals(TestDataMinV2.packageName, appVersions[0].packageId) - assertEquals(TestDataMinV2.version.file.sha256, appVersions[0].upgrade.version.versionId) + assertEquals(TestDataMinV2.packageName, appVersions[0].packageName) + assertEquals(TestDataMinV2.version.file.sha256, appVersions[0].update.version.versionId) } } diff --git a/database/src/dbTest/java/org/fdroid/database/IndexV1InsertTest.kt b/database/src/dbTest/java/org/fdroid/database/IndexV1InsertTest.kt index 2124e983a..0eb217adf 100644 --- a/database/src/dbTest/java/org/fdroid/database/IndexV1InsertTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/IndexV1InsertTest.kt @@ -105,13 +105,13 @@ internal class IndexV1InsertTest : DbTest() { callback() } - override fun receive(packageId: String, m: MetadataV2) { - streamReceiver.receive(packageId, m) + override fun receive(packageName: String, m: MetadataV2) { + streamReceiver.receive(packageName, m) callback() } - override fun receive(packageId: String, v: Map) { - streamReceiver.receive(packageId, v) + override fun receive(packageName: String, v: Map) { + streamReceiver.receive(packageName, v) callback() } @@ -124,8 +124,8 @@ internal class IndexV1InsertTest : DbTest() { callback() } - override fun updateAppMetadata(packageId: String, preferredSigner: String?) { - streamReceiver.updateAppMetadata(packageId, preferredSigner) + override fun updateAppMetadata(packageName: String, preferredSigner: String?) { + streamReceiver.updateAppMetadata(packageName, preferredSigner) callback() } diff --git a/database/src/dbTest/java/org/fdroid/database/IndexV2DiffTest.kt b/database/src/dbTest/java/org/fdroid/database/IndexV2DiffTest.kt index 898c5483a..397a0fcf5 100644 --- a/database/src/dbTest/java/org/fdroid/database/IndexV2DiffTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/IndexV2DiffTest.kt @@ -207,11 +207,11 @@ internal class IndexV2DiffTest : DbTest() { endIndex = TestDataMinV2.index, ) } - val diffPackageIdJson = """{ + val diffPackageNameJson = """{ "packages": { "org.fdroid.min1": { "metadata": { - "packageId": "foo" + "packageName": "foo" } } } @@ -219,7 +219,7 @@ internal class IndexV2DiffTest : DbTest() { assertFailsWith { testJsonDiff( startPath = "index-min-v2.json", - diff = diffPackageIdJson, + diff = diffPackageNameJson, endIndex = TestDataMinV2.index, ) } @@ -230,7 +230,7 @@ internal class IndexV2DiffTest : DbTest() { assertFailsWith { testJsonDiff( startPath = "index-min-v2.json", - diff = getMinVersionJson(""""packageId": "foo""""), + diff = getMinVersionJson(""""packageName": "foo""""), endIndex = TestDataMinV2.index, ) } diff --git a/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt b/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt index e1c2455b5..66338059c 100644 --- a/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt @@ -55,7 +55,7 @@ internal class RepositoryDaoTest : DbTest() { assertEquals(emptyList(), actualRepo.categories) assertEquals(emptyList(), actualRepo.releaseChannels) assertNull(actualRepo.formatVersion) - assertNull(actualRepo.icon) + assertNull(actualRepo.repository.icon) assertNull(actualRepo.lastUpdated) assertNull(actualRepo.webBaseUrl) } @@ -79,7 +79,7 @@ internal class RepositoryDaoTest : DbTest() { assertEquals(emptyList(), actualRepo.categories) assertEquals(emptyList(), actualRepo.releaseChannels) assertNull(actualRepo.formatVersion) - assertNull(actualRepo.icon) + assertNull(actualRepo.repository.icon) assertNull(actualRepo.lastUpdated) assertNull(actualRepo.webBaseUrl) } @@ -230,17 +230,17 @@ internal class RepositoryDaoTest : DbTest() { // insert one repo with one app with one version val repoId = repoDao.insertOrReplace(getRandomRepo()) val repositoryPreferences = repoDao.getRepositoryPreferences(repoId) - val packageId = getRandomString() + val packageName = getRandomString() val versionId = getRandomString() - appDao.insert(repoId, packageId, getRandomMetadataV2()) + appDao.insert(repoId, packageName, getRandomMetadataV2()) val packageVersion = getRandomPackageVersionV2() - versionDao.insert(repoId, packageId, versionId, packageVersion, Random.nextBoolean()) + versionDao.insert(repoId, packageName, versionId, packageVersion, Random.nextBoolean()) // data is there as expected assertEquals(1, repoDao.getRepositories().size) assertEquals(1, appDao.getAppMetadata().size) - assertEquals(1, versionDao.getAppVersions(repoId, packageId).size) - assertTrue(versionDao.getVersionedStrings(repoId, packageId).isNotEmpty()) + assertEquals(1, versionDao.getAppVersions(repoId, packageName).size) + assertTrue(versionDao.getVersionedStrings(repoId, packageName).isNotEmpty()) // clearing the repo removes apps and versions repoDao.clear(repoId) @@ -248,8 +248,8 @@ internal class RepositoryDaoTest : DbTest() { assertEquals(0, appDao.countApps()) assertEquals(0, appDao.countLocalizedFiles()) assertEquals(0, appDao.countLocalizedFileLists()) - assertEquals(0, versionDao.getAppVersions(repoId, packageId).size) - assertEquals(0, versionDao.getVersionedStrings(repoId, packageId).size) + assertEquals(0, versionDao.getAppVersions(repoId, packageName).size) + assertEquals(0, versionDao.getVersionedStrings(repoId, packageName).size) // preferences are not touched by clearing assertEquals(repositoryPreferences, repoDao.getRepositoryPreferences(repoId)) } diff --git a/database/src/dbTest/java/org/fdroid/database/RepositoryDiffTest.kt b/database/src/dbTest/java/org/fdroid/database/RepositoryDiffTest.kt index 433964e44..0aa71f618 100644 --- a/database/src/dbTest/java/org/fdroid/database/RepositoryDiffTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/RepositoryDiffTest.kt @@ -107,7 +107,7 @@ internal class RepositoryDiffTest : DbTest() { }""".trimIndent() val expectedText = if (updateText == null) emptyMap() else mapOf("en" to "foo") testDiff(repo, json) { repos -> - assertEquals(expectedText, repos[0].description) + assertEquals(expectedText, repos[0].repository.description) assertRepoEquals(repo.copy(description = expectedText), repos[0]) } } @@ -139,7 +139,34 @@ internal class RepositoryDiffTest : DbTest() { @Test fun antiFeatureKeyChangeDiff() { - // TODO test with changing keys + val antiFeatureKey = getRandomString() + val antiFeature = AntiFeatureV2( + icon = getRandomFileV2(), + name = getRandomLocalizedTextV2(), + description = getRandomLocalizedTextV2(), + ) + val antiFeatures = mapOf(antiFeatureKey to antiFeature) + val repo = getRandomRepo().copy(antiFeatures = antiFeatures) + + @Suppress("UNCHECKED_CAST") + val newAntiFeatures = mapOf(antiFeatureKey to antiFeature.copy( + icon = null, + name = getRandomLocalizedTextV2(), + description = getRandomLocalizedTextV2(), + )) + val json = """ + { + "antiFeatures": { + "$antiFeatureKey": ${Json.encodeToString(newAntiFeatures)} + } + }""".trimIndent() + testDiff(repo, json) { repos -> + val expectedFeatures = repo.antiFeatures.applyDiff(antiFeatures) + val expectedRepoAntiFeatures = + expectedFeatures.toRepoAntiFeatures(repos[0].repoId) + assertEquals(expectedRepoAntiFeatures.toSet(), repos[0].antiFeatures.toSet()) + assertRepoEquals(repo.copy(antiFeatures = expectedFeatures), repos[0]) + } } @Test @@ -167,11 +194,6 @@ internal class RepositoryDiffTest : DbTest() { } } - @Test - fun categoriesKeyChangeDiff() { - // TODO test with changing keys - } - @Test fun releaseChannelsDiff() { val repo = getRandomRepo().copy(releaseChannels = getRandomMap { @@ -196,11 +218,6 @@ internal class RepositoryDiffTest : DbTest() { } } - @Test - fun releaseChannelKeyChangeDiff() { - // TODO test with changing keys - } - private fun testDiff(repo: RepoV2, json: String, repoChecker: (List) -> Unit) { // insert repo repoDao.insertOrReplace(repo) diff --git a/database/src/dbTest/java/org/fdroid/database/TestUtils.kt b/database/src/dbTest/java/org/fdroid/database/TestUtils.kt index ebc90873c..b4ea0c63f 100644 --- a/database/src/dbTest/java/org/fdroid/database/TestUtils.kt +++ b/database/src/dbTest/java/org/fdroid/database/TestUtils.kt @@ -88,8 +88,8 @@ internal object TestUtils { usesSdk = manifest.usesSdk, maxSdkVersion = manifest.maxSdkVersion, signer = manifest.signer, - usesPermission = usesPermission?.sortedBy { it.name } ?: emptyList(), - usesPermissionSdk23 = usesPermissionSdk23?.sortedBy { it.name } ?: emptyList(), + usesPermission = usesPermission.sortedBy { it.name }, + usesPermissionSdk23 = usesPermissionSdk23.sortedBy { it.name }, nativecode = manifest.nativecode?.sorted() ?: emptyList(), features = manifest.features?.map { FeatureV2(it) } ?: emptyList(), ), diff --git a/database/src/dbTest/java/org/fdroid/database/VersionTest.kt b/database/src/dbTest/java/org/fdroid/database/VersionTest.kt index 5fe727c49..20c81a458 100644 --- a/database/src/dbTest/java/org/fdroid/database/VersionTest.kt +++ b/database/src/dbTest/java/org/fdroid/database/VersionTest.kt @@ -61,8 +61,8 @@ internal class VersionTest : DbTest() { assertEquals(versionId1, appVersion.version.versionId) assertEquals(getVersion1(repoId), appVersion.version) val manifest = packageVersion1.manifest - assertEquals(manifest.usesPermission.toSet(), appVersion.usesPermission?.toSet()) - assertEquals(manifest.usesPermissionSdk23.toSet(), appVersion.usesPermissionSdk23?.toSet()) + assertEquals(manifest.usesPermission.toSet(), appVersion.usesPermission.toSet()) + assertEquals(manifest.usesPermissionSdk23.toSet(), appVersion.usesPermissionSdk23.toSet()) assertEquals( manifest.features.map { it.name }.toSet(), appVersion.version.manifest.features?.toSet() @@ -98,8 +98,8 @@ internal class VersionTest : DbTest() { // check first version matches assertEquals(getVersion1(repoId), appVersion.version) val manifest = packageVersion1.manifest - assertEquals(manifest.usesPermission.toSet(), appVersion.usesPermission?.toSet()) - assertEquals(manifest.usesPermissionSdk23.toSet(), appVersion.usesPermissionSdk23?.toSet()) + assertEquals(manifest.usesPermission.toSet(), appVersion.usesPermission.toSet()) + assertEquals(manifest.usesPermissionSdk23.toSet(), appVersion.usesPermissionSdk23.toSet()) assertEquals( manifest.features.map { it.name }.toSet(), appVersion.version.manifest.features?.toSet() @@ -108,9 +108,9 @@ internal class VersionTest : DbTest() { // check second version matches assertEquals(getVersion2(repoId), appVersion2.version) val manifest2 = packageVersion2.manifest - assertEquals(manifest2.usesPermission.toSet(), appVersion2.usesPermission?.toSet()) + assertEquals(manifest2.usesPermission.toSet(), appVersion2.usesPermission.toSet()) assertEquals(manifest2.usesPermissionSdk23.toSet(), - appVersion2.usesPermissionSdk23?.toSet()) + appVersion2.usesPermissionSdk23.toSet()) assertEquals( manifest.features.map { it.name }.toSet(), appVersion.version.manifest.features?.toSet() diff --git a/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt b/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt index 7ea12a620..c39cbc96d 100644 --- a/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt +++ b/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt @@ -78,20 +78,20 @@ internal class IndexV1UpdaterTest : DbTest() { assertNull(appDao.getApp(packageName).getOrAwaitValue()) } appDao.getAppMetadata().forEach { app -> - val numVersions = versionDao.getVersions(listOf(app.packageId)).size + val numVersions = versionDao.getVersions(listOf(app.packageName)).size assertTrue(numVersions > 0) } assertEquals(1497639511824, updatedRepo.timestamp) assertEquals(TESTY_CANONICAL_URL, updatedRepo.address) - assertEquals("non-public test repo", updatedRepo.name.values.first()) + assertEquals("non-public test repo", updatedRepo.repository.name.values.first()) assertEquals(18, updatedRepo.version) - assertEquals("/icons/fdroid-icon.png", updatedRepo.icon?.values?.first()?.name) + assertEquals("/icons/fdroid-icon.png", updatedRepo.repository.icon?.values?.first()?.name) val description = "This is a repository of apps to be used with F-Droid. " + "Applications in this repository are either official binaries built " + "by the original application developers, or are binaries built " + "from source by the admin of f-droid.org using the tools on " + "https://gitlab.com/u/fdroid. " - assertEquals(description, updatedRepo.description.values.first()) + assertEquals(description, updatedRepo.repository.description.values.first()) assertEquals( setOf(TESTY_CANONICAL_URL, "http://frkcchxlcvnb4m5a.onion/fdroid/repo"), updatedRepo.mirrors.map { it.url }.toSet(), @@ -109,7 +109,7 @@ internal class IndexV1UpdaterTest : DbTest() { } assertNotNull(protoVersion) assertEquals("/io.proto.player-1.apk", protoVersion.version.file.name) - val perms = protoVersion.usesPermission?.map { it.name } ?: fail() + val perms = protoVersion.usesPermission.map { it.name } assertTrue(perms.contains(Manifest.permission.READ_EXTERNAL_STORAGE)) assertTrue(perms.contains(Manifest.permission.WRITE_EXTERNAL_STORAGE)) assertFalse(perms.contains(Manifest.permission.READ_CALENDAR)) diff --git a/database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt b/database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt index 5b2cb9a1d..d24a5d6c8 100644 --- a/database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt +++ b/database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt @@ -8,11 +8,11 @@ import io.mockk.just import io.mockk.mockk import org.fdroid.CompatibilityChecker import org.fdroid.database.DbTest -import org.fdroid.database.IndexFormatVersion.TWO import org.fdroid.database.Repository import org.fdroid.database.TestUtils.assertTimestampRecent import org.fdroid.download.Downloader import org.fdroid.download.DownloaderFactory +import org.fdroid.index.IndexFormatVersion.TWO import org.fdroid.index.IndexUpdateResult import org.fdroid.index.SigningException import org.fdroid.index.TempFileProvider diff --git a/database/src/main/java/org/fdroid/database/App.kt b/database/src/main/java/org/fdroid/database/App.kt index 166790439..127d50eec 100644 --- a/database/src/main/java/org/fdroid/database/App.kt +++ b/database/src/main/java/org/fdroid/database/App.kt @@ -20,8 +20,21 @@ 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", "packageId"], + primaryKeys = ["repoId", "packageName"], foreignKeys = [ForeignKey( entity = CoreRepository::class, parentColumns = ["repoId"], @@ -30,52 +43,52 @@ import org.fdroid.index.v2.Screenshots )], ) public data class AppMetadata( - val repoId: Long, - val packageId: String, - val added: Long, - val lastUpdated: Long, - val name: LocalizedTextV2? = null, - val summary: LocalizedTextV2? = null, - val description: LocalizedTextV2? = null, - val localizedName: String? = null, - val localizedSummary: String? = null, - val webSite: String? = null, - val changelog: String? = null, - val license: String? = null, - val sourceCode: String? = null, - val issueTracker: String? = null, - val translation: String? = null, - val preferredSigner: String? = null, // TODO use platformSig if an APK matches it - val video: LocalizedTextV2? = null, - val authorName: String? = null, - val authorEmail: String? = null, - val authorWebSite: String? = null, - val authorPhone: String? = null, - val donate: List? = null, - val liberapayID: String? = null, - val liberapay: String? = null, - val openCollective: String? = null, - val bitcoin: String? = null, - val litecoin: String? = null, - val flattrID: String? = null, - val categories: List? = null, + 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? = 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? = 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]. */ - val isCompatible: Boolean, + public val isCompatible: Boolean, ) internal fun MetadataV2.toAppMetadata( repoId: Long, - packageId: String, + packageName: String, isCompatible: Boolean = false, locales: LocaleListCompat = getLocales(Resources.getSystem().configuration), ) = AppMetadata( repoId = repoId, - packageId = packageId, + packageName = packageName, added = added, lastUpdated = lastUpdated, name = name, @@ -110,31 +123,37 @@ internal fun MetadataV2.toAppMetadata( @Fts4(contentEntity = AppMetadata::class) internal data class AppMetadataFts( val repoId: Long, - val packageId: String, + 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 val metadata: AppMetadata, + @Embedded public val metadata: AppMetadata, @Relation( - parentColumn = "packageId", - entityColumn = "packageId", + parentColumn = "packageName", + entityColumn = "packageName", ) private val localizedFiles: List? = null, @Relation( - parentColumn = "packageId", - entityColumn = "packageId", + parentColumn = "packageName", + entityColumn = "packageName", ) private val localizedFileLists: List? = null, -) { - val icon: LocalizedFileV2? get() = getLocalizedFile("icon") - val featureGraphic: LocalizedFileV2? get() = getLocalizedFile("featureGraphic") - val promoGraphic: LocalizedFileV2? get() = getLocalizedFile("promoGraphic") - val tvBanner: LocalizedFileV2? get() = getLocalizedFile("tvBanner") - val screenshots: Screenshots? +) : 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"), @@ -163,15 +182,17 @@ public data class App internal constructor( return map.ifEmpty { null } } - public val name: String? get() = metadata.localizedName - public val summary: String? get() = metadata.localizedSummary + 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 fun getIcon(localeList: LocaleListCompat): FileV2? = icon.getBestLocale(localeList) + public override fun getIcon(localeList: LocaleListCompat): FileV2? = + icon.getBestLocale(localeList) + public fun getFeatureGraphic(localeList: LocaleListCompat): FileV2? = featureGraphic.getBestLocale(localeList) @@ -181,57 +202,74 @@ public data class App internal constructor( public fun getTvBanner(localeList: LocaleListCompat): FileV2? = tvBanner.getBestLocale(localeList) - // TODO remove ?.map { it.name } when client can handle FileV2 - public fun getPhoneScreenshots(localeList: LocaleListCompat): List = - screenshots?.phone.getBestLocale(localeList)?.map { it.name } ?: emptyList() + public fun getPhoneScreenshots(localeList: LocaleListCompat): List = + screenshots?.phone.getBestLocale(localeList) ?: emptyList() - public fun getSevenInchScreenshots(localeList: LocaleListCompat): List = - screenshots?.sevenInch.getBestLocale(localeList)?.map { it.name } ?: emptyList() + public fun getSevenInchScreenshots(localeList: LocaleListCompat): List = + screenshots?.sevenInch.getBestLocale(localeList) ?: emptyList() - public fun getTenInchScreenshots(localeList: LocaleListCompat): List = - screenshots?.tenInch.getBestLocale(localeList)?.map { it.name } ?: emptyList() + public fun getTenInchScreenshots(localeList: LocaleListCompat): List = + screenshots?.tenInch.getBestLocale(localeList) ?: emptyList() - public fun getTvScreenshots(localeList: LocaleListCompat): List = - screenshots?.tv.getBestLocale(localeList)?.map { it.name } ?: emptyList() + public fun getTvScreenshots(localeList: LocaleListCompat): List = + screenshots?.tv.getBestLocale(localeList) ?: emptyList() - public fun getWearScreenshots(localeList: LocaleListCompat): List = - screenshots?.wear.getBestLocale(localeList)?.map { it.name } ?: emptyList() + public fun getWearScreenshots(localeList: LocaleListCompat): List = + screenshots?.wear.getBestLocale(localeList) ?: emptyList() } -public data class AppOverviewItem( - public val repoId: Long, - public val packageId: String, +/** + * 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 val name: String? = null, + public override val name: String? = null, @ColumnInfo(name = "localizedSummary") - public val summary: String? = null, + public override val summary: String? = null, internal val antiFeatures: Map? = null, @Relation( - parentColumn = "packageId", - entityColumn = "packageId", + parentColumn = "packageName", + entityColumn = "packageName", ) internal val localizedIcon: List? = null, -) { - public fun getIcon(localeList: LocaleListCompat): FileV2? = localizedIcon?.filter { icon -> - icon.repoId == repoId - }?.toLocalizedFileV2().getBestLocale(localeList) +) : MinimalApp { + public override fun getIcon(localeList: LocaleListCompat): FileV2? { + return localizedIcon?.filter { icon -> + icon.repoId == repoId + }?.toLocalizedFileV2().getBestLocale(localeList) + } - val antiFeatureNames: List get() = antiFeatures?.map { it.key } ?: emptyList() + public val antiFeatureKeys: List get() = antiFeatures?.map { it.key } ?: emptyList() } -public data class AppListItem constructor( - public val repoId: Long, - public val packageId: String, +/** + * 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 val name: String? = null, + public override val name: String? = null, @ColumnInfo(name = "localizedSummary") - public val summary: String? = null, + public override val summary: String? = null, internal val antiFeatures: String?, @Relation( - parentColumn = "packageId", - entityColumn = "packageId", + parentColumn = "packageName", + entityColumn = "packageName", ) internal val localizedIcon: List?, /** @@ -243,34 +281,54 @@ public data class AppListItem constructor( */ @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, -) { - public fun getAntiFeatureNames(): List { - return fromStringToMapOfLocalizedTextV2(antiFeatures)?.map { it.key } ?: emptyList() +) : MinimalApp { + @delegate:Ignore + private val antiFeaturesDecoded by lazy { + fromStringToMapOfLocalizedTextV2(antiFeatures) } - public fun getIcon(localeList: LocaleListCompat): FileV2? = localizedIcon?.filter { icon -> - icon.repoId == repoId - }?.toLocalizedFileV2().getBestLocale(localeList) + public override fun getIcon(localeList: LocaleListCompat): FileV2? { + return localizedIcon?.filter { icon -> + icon.repoId == repoId + }?.toLocalizedFileV2().getBestLocale(localeList) + } + + public val antiFeatureKeys: List + get() = antiFeaturesDecoded?.map { it.key } ?: emptyList() + + public fun getAntiFeatureReason(antiFeatureKey: String, localeList: LocaleListCompat): String? { + return antiFeaturesDecoded?.get(antiFeatureKey)?.getBestLocale(localeList) + } } -public data class UpdatableApp( - public val packageId: String, +/** + * 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 upgrade: AppVersion, + 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 val name: String? = null, - public val summary: String? = null, + public override val name: String? = null, + public override val summary: String? = null, internal val localizedIcon: List? = null, -) { - public fun getIcon(localeList: LocaleListCompat): FileV2? = localizedIcon?.filter { icon -> - icon.repoId == upgrade.repoId - }?.toLocalizedFileV2().getBestLocale(localeList) +) : MinimalApp { + public override fun getIcon(localeList: LocaleListCompat): FileV2? { + return localizedIcon?.filter { icon -> + icon.repoId == update.repoId + }?.toLocalizedFileV2().getBestLocale(localeList) + } } internal interface IFile { @@ -282,17 +340,17 @@ internal interface IFile { } @Entity( - primaryKeys = ["repoId", "packageId", "type", "locale"], + primaryKeys = ["repoId", "packageName", "type", "locale"], foreignKeys = [ForeignKey( entity = AppMetadata::class, - parentColumns = ["repoId", "packageId"], - childColumns = ["repoId", "packageId"], + parentColumns = ["repoId", "packageName"], + childColumns = ["repoId", "packageName"], onDelete = ForeignKey.CASCADE, )], ) internal data class LocalizedFile( val repoId: Long, - val packageId: String, + val packageName: String, override val type: String, override val locale: String, override val name: String, @@ -302,12 +360,12 @@ internal data class LocalizedFile( internal fun LocalizedFileV2.toLocalizedFile( repoId: Long, - packageId: String, + packageName: String, type: String, ): List = map { (locale, file) -> LocalizedFile( repoId = repoId, - packageId = packageId, + packageName = packageName, type = type, locale = locale, name = file.name, @@ -328,9 +386,9 @@ internal fun List.toLocalizedFileV2(): LocalizedFileV2? = associate { fil // 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'") -public data class LocalizedIcon( +internal data class LocalizedIcon( val repoId: Long, - val packageId: String, + val packageName: String, override val type: String, override val locale: String, override val name: String, @@ -339,17 +397,17 @@ public data class LocalizedIcon( ) : IFile @Entity( - primaryKeys = ["repoId", "packageId", "type", "locale", "name"], + primaryKeys = ["repoId", "packageName", "type", "locale", "name"], foreignKeys = [ForeignKey( entity = AppMetadata::class, - parentColumns = ["repoId", "packageId"], - childColumns = ["repoId", "packageId"], + parentColumns = ["repoId", "packageName"], + childColumns = ["repoId", "packageName"], onDelete = ForeignKey.CASCADE, )], ) internal data class LocalizedFileList( val repoId: Long, - val packageId: String, + val packageName: String, val type: String, val locale: String, val name: String, @@ -359,20 +417,20 @@ internal data class LocalizedFileList( internal fun LocalizedFileListV2.toLocalizedFileList( repoId: Long, - packageId: String, + packageName: String, type: String, ): List = flatMap { (locale, files) -> - files.map { file -> file.toLocalizedFileList(repoId, packageId, type, locale) } + files.map { file -> file.toLocalizedFileList(repoId, packageName, type, locale) } } internal fun FileV2.toLocalizedFileList( repoId: Long, - packageId: String, + packageName: String, type: String, locale: String, ) = LocalizedFileList( repoId = repoId, - packageId = packageId, + packageName = packageName, type = type, locale = locale, name = name, diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/database/src/main/java/org/fdroid/database/AppDao.kt index ad9a560b5..7cefc0632 100644 --- a/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/database/src/main/java/org/fdroid/database/AppDao.kt @@ -122,7 +122,7 @@ public enum class AppListSortOrder { * 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("packageId", "repoId") +private val DENY_LIST = listOf("packageName", "repoId") /** * A list of unknown fields in [LocalizedFileV2] or [LocalizedFileListV2] @@ -130,7 +130,7 @@ private val DENY_LIST = listOf("packageId", "repoId") * * Similar to [DENY_LIST]. */ -private val DENY_FILE_LIST = listOf("packageId", "repoId", "type") +private val DENY_FILE_LIST = listOf("packageName", "repoId", "type") @Dao internal interface AppDaoInt : AppDao { @@ -156,15 +156,15 @@ internal interface AppDaoInt : AppDao { } } - private fun LocalizedFileV2?.insert(repoId: Long, packageId: String, type: String) { - this?.toLocalizedFile(repoId, packageId, type)?.let { files -> + 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, packageId: String, type: String) { - this?.toLocalizedFileList(repoId, packageId, type)?.let { files -> + private fun LocalizedFileListV2?.insert(repoId: Long, packageName: String, type: String) { + this?.toLocalizedFileList(repoId, packageName, type)?.let { files -> insertLocalizedFileLists(files) } } @@ -181,19 +181,19 @@ internal interface AppDaoInt : AppDao { @Transaction fun updateApp( repoId: Long, - packageId: String, + packageName: String, jsonObject: JsonObject?, locales: LocaleListCompat, ) { if (jsonObject == null) { // this app is gone, we need to delete it - deleteAppMetadata(repoId, packageId) + deleteAppMetadata(repoId, packageName) return } - val metadata = getAppMetadata(repoId, packageId) + val metadata = getAppMetadata(repoId, packageName) if (metadata == null) { // new app val metadataV2: MetadataV2 = json.decodeFromJsonElement(jsonObject) - insert(repoId, packageId, metadataV2) + insert(repoId, packageName, metadataV2) } else { // diff against existing app // ensure that diff does not include internal keys DENY_LIST.forEach { forbiddenKey -> @@ -210,28 +210,28 @@ internal interface AppDaoInt : AppDao { } else diffedApp updateAppMetadata(updatedApp) // diff localizedFiles - val localizedFiles = getLocalizedFiles(repoId, packageId) - localizedFiles.diffAndUpdate(repoId, packageId, "icon", jsonObject) - localizedFiles.diffAndUpdate(repoId, packageId, "featureGraphic", jsonObject) - localizedFiles.diffAndUpdate(repoId, packageId, "promoGraphic", jsonObject) - localizedFiles.diffAndUpdate(repoId, packageId, "tvBanner", jsonObject) + 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, packageId) + deleteLocalizedFileLists(repoId, packageName) } else if (screenshots is JsonObject) { - diffAndUpdateLocalizedFileList(repoId, packageId, "phone", screenshots) - diffAndUpdateLocalizedFileList(repoId, packageId, "sevenInch", screenshots) - diffAndUpdateLocalizedFileList(repoId, packageId, "tenInch", screenshots) - diffAndUpdateLocalizedFileList(repoId, packageId, "wear", screenshots) - diffAndUpdateLocalizedFileList(repoId, packageId, "tv", screenshots) + 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.diffAndUpdate( repoId: Long, - packageId: String, + packageName: String, type: String, jsonObject: JsonObject, ) = diffAndUpdateTable( @@ -239,9 +239,9 @@ internal interface AppDaoInt : AppDao { jsonObjectKey = type, itemList = filter { it.type == type }, itemFinder = { locale, item -> item.locale == locale }, - newItem = { locale -> LocalizedFile(repoId, packageId, type, locale, "") }, - deleteAll = { deleteLocalizedFiles(repoId, packageId, type) }, - deleteOne = { locale -> deleteLocalizedFile(repoId, packageId, type, 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, @@ -249,7 +249,7 @@ internal interface AppDaoInt : AppDao { private fun diffAndUpdateLocalizedFileList( repoId: Long, - packageId: String, + packageName: String, type: String, jsonObject: JsonObject, ) { @@ -258,11 +258,11 @@ internal interface AppDaoInt : AppDao { jsonObjectKey = type, listParser = { locale, jsonArray -> json.decodeFromJsonElement>(jsonArray).map { - it.toLocalizedFileList(repoId, packageId, type, locale) + it.toLocalizedFileList(repoId, packageName, type, locale) } }, - deleteAll = { deleteLocalizedFileLists(repoId, packageId, type) }, - deleteList = { locale -> deleteLocalizedFileList(repoId, packageId, type, locale) }, + deleteAll = { deleteLocalizedFileLists(repoId, packageName, type) }, + deleteList = { locale -> deleteLocalizedFileList(repoId, packageName, type, locale) }, insertNewList = { _, fileLists -> insertLocalizedFileLists(fileLists) }, ) } @@ -272,20 +272,20 @@ internal interface AppDaoInt : AppDao { */ @Deprecated("Only for v1 index") @Query("""UPDATE AppMetadata SET preferredSigner = :preferredSigner - WHERE repoId = :repoId AND packageId = :packageId""") - fun updatePreferredSigner(repoId: Long, packageId: String, preferredSigner: String?) + 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.packageId = Version.packageId + 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 packageId = :packageId""") - fun updateAppMetadata(repoId: Long, packageId: String, name: String?, summary: String?) + WHERE repoId = :repoId AND packageName = :packageName""") + fun updateAppMetadata(repoId: Long, packageName: String, name: String?, summary: String?) @Update fun updateAppMetadata(appMetadata: AppMetadata): Int @@ -293,19 +293,19 @@ internal interface AppDaoInt : AppDao { @Transaction @Query("""SELECT AppMetadata.* FROM AppMetadata JOIN RepositoryPreferences AS pref USING (repoId) - WHERE packageId = :packageName + WHERE packageName = :packageName ORDER BY pref.weight DESC LIMIT 1""") override fun getApp(packageName: String): LiveData @Transaction @Query("""SELECT * FROM AppMetadata - WHERE repoId = :repoId AND packageId = :packageName""") + 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 packageId = :packageName") + @Query("SELECT * FROM AppMetadata WHERE repoId = :repoId AND packageName = :packageName") fun getAppMetadata(repoId: Long, packageName: String): AppMetadata? /** @@ -317,33 +317,33 @@ internal interface AppDaoInt : AppDao { /** * used for diffing */ - @Query("SELECT * FROM LocalizedFile WHERE repoId = :repoId AND packageId = :packageId") - fun getLocalizedFiles(repoId: Long, packageId: String): List + @Query("SELECT * FROM LocalizedFile WHERE repoId = :repoId AND packageName = :packageName") + fun getLocalizedFiles(repoId: Long, packageName: String): List @Transaction - @Query("""SELECT repoId, packageId, app.added, app.lastUpdated, localizedName, + @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, packageId) - LEFT JOIN LocalizedIcon AS icon USING (repoId, packageId) + LEFT JOIN HighestVersion AS version USING (repoId, packageName) + LEFT JOIN LocalizedIcon AS icon USING (repoId, packageName) WHERE pref.enabled = 1 - GROUP BY packageId HAVING MAX(pref.weight) - ORDER BY localizedName IS NULL ASC, icon.packageId IS NULL ASC, + 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> @Transaction - @Query("""SELECT repoId, packageId, app.added, app.lastUpdated, localizedName, + @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, packageId) - LEFT JOIN LocalizedIcon AS icon USING (repoId, packageId) + 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 packageId HAVING MAX(pref.weight) - ORDER BY localizedName IS NULL ASC, icon.packageId IS NULL ASC, + 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> @@ -353,10 +353,10 @@ internal interface AppDaoInt : AppDao { */ @Transaction @SuppressWarnings(CURSOR_MISMATCH) // no anti-features needed here - @Query("""SELECT repoId, packageId, added, app.lastUpdated, localizedName, + @Query("""SELECT repoId, packageName, added, app.lastUpdated, localizedName, localizedSummary - FROM AppMetadata AS app WHERE repoId = :repoId AND packageId = :packageId""") - fun getAppOverviewItem(repoId: Long, packageId: String): AppOverviewItem? + FROM AppMetadata AS app WHERE repoId = :repoId AND packageName = :packageName""") + fun getAppOverviewItem(repoId: Long, packageName: String): AppOverviewItem? // // AppListItems @@ -391,7 +391,7 @@ internal interface AppDaoInt : AppDao { .associateBy { packageInfo -> packageInfo.packageName }, ) = map { items -> items.map { item -> - val packageInfo = installedPackages[item.packageId] + val packageInfo = installedPackages[item.packageName] if (packageInfo == null) item else item.copy( installedVersionName = packageInfo.versionName, installedVersionCode = packageInfo.getVersionCode(), @@ -401,84 +401,84 @@ internal interface AppDaoInt : AppDao { @Transaction @Query(""" - SELECT repoId, packageId, app.localizedName, app.localizedSummary, version.antiFeatures, + SELECT repoId, packageName, app.localizedName, app.localizedSummary, version.antiFeatures, app.isCompatible FROM AppMetadata AS app - JOIN AppMetadataFts USING (repoId, packageId) - LEFT JOIN HighestVersion AS version USING (repoId, packageId) + 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 packageId HAVING MAX(pref.weight)""") + GROUP BY packageName HAVING MAX(pref.weight)""") fun getAppListItems(searchQuery: String): LiveData> @Transaction @Query(""" - SELECT repoId, packageId, app.localizedName, app.localizedSummary, version.antiFeatures, + SELECT repoId, packageName, app.localizedName, app.localizedSummary, version.antiFeatures, app.isCompatible FROM AppMetadata AS app - JOIN AppMetadataFts USING (repoId, packageId) - LEFT JOIN HighestVersion AS version USING (repoId, packageId) + 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 packageId HAVING MAX(pref.weight)""") + GROUP BY packageName HAVING MAX(pref.weight)""") fun getAppListItems(category: String, searchQuery: String): LiveData> @Transaction @Query(""" - SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures, + SELECT repoId, packageName, localizedName, localizedSummary, version.antiFeatures, app.isCompatible FROM AppMetadata AS app - LEFT JOIN HighestVersion AS version USING (repoId, packageId) + LEFT JOIN HighestVersion AS version USING (repoId, packageName) JOIN RepositoryPreferences AS pref USING (repoId) WHERE pref.enabled = 1 - GROUP BY packageId HAVING MAX(pref.weight) + GROUP BY packageName HAVING MAX(pref.weight) ORDER BY localizedName COLLATE NOCASE ASC""") fun getAppListItemsByName(): LiveData> @Transaction @Query(""" - SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures, + 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, packageId) + LEFT JOIN HighestVersion AS version USING (repoId, packageName) WHERE pref.enabled = 1 - GROUP BY packageId HAVING MAX(pref.weight) + GROUP BY packageName HAVING MAX(pref.weight) ORDER BY app.lastUpdated DESC""") fun getAppListItemsByLastUpdated(): LiveData> @Transaction @Query(""" - SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures, + 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, packageId) + LEFT JOIN HighestVersion AS version USING (repoId, packageName) WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' - GROUP BY packageId HAVING MAX(pref.weight) + GROUP BY packageName HAVING MAX(pref.weight) ORDER BY app.lastUpdated DESC""") fun getAppListItemsByLastUpdated(category: String): LiveData> @Transaction @Query(""" - SELECT repoId, packageId, localizedName, localizedSummary, version.antiFeatures, + 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, packageId) + LEFT JOIN HighestVersion AS version USING (repoId, packageName) WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' - GROUP BY packageId HAVING MAX(pref.weight) + GROUP BY packageName HAVING MAX(pref.weight) ORDER BY localizedName COLLATE NOCASE ASC""") fun getAppListItemsByName(category: String): LiveData> @Transaction @SuppressWarnings(CURSOR_MISMATCH) // no anti-features needed here - @Query("""SELECT repoId, packageId, localizedName, localizedSummary, app.isCompatible + @Query("""SELECT repoId, packageName, localizedName, localizedSummary, app.isCompatible FROM AppMetadata AS app JOIN RepositoryPreferences AS pref USING (repoId) - WHERE pref.enabled = 1 AND packageId IN (:packageNames) - GROUP BY packageId HAVING MAX(pref.weight) + 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): LiveData> @@ -491,7 +491,7 @@ internal interface AppDaoInt : AppDao { return getAppListItems(packageNames).map(packageManager, installedPackages) } - @Query("""SELECT COUNT(DISTINCT packageId) FROM AppMetadata + @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 @@ -499,28 +499,30 @@ internal interface AppDaoInt : AppDao { @Query("SELECT COUNT(*) FROM AppMetadata WHERE repoId = :repoId") override fun getNumberOfAppsInRepository(repoId: Long): Int - @Query("DELETE FROM AppMetadata WHERE repoId = :repoId AND packageId = :packageId") - fun deleteAppMetadata(repoId: Long, packageId: String) + @Query("DELETE FROM AppMetadata WHERE repoId = :repoId AND packageName = :packageName") + fun deleteAppMetadata(repoId: Long, packageName: String) @Query("""DELETE FROM LocalizedFile - WHERE repoId = :repoId AND packageId = :packageId AND type = :type""") - fun deleteLocalizedFiles(repoId: Long, packageId: String, type: String) + 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 packageId = :packageId AND type = :type AND locale = :locale""") - fun deleteLocalizedFile(repoId: Long, packageId: String, type: String, locale: String) + 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 packageId = :packageId""") - fun deleteLocalizedFileLists(repoId: Long, packageId: String) + WHERE repoId = :repoId AND packageName = :packageName""") + fun deleteLocalizedFileLists(repoId: Long, packageName: String) @Query("""DELETE FROM LocalizedFileList - WHERE repoId = :repoId AND packageId = :packageId AND type = :type""") - fun deleteLocalizedFileLists(repoId: Long, packageId: String, type: String) + 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 packageId = :packageId AND type = :type AND locale = :locale""") - fun deleteLocalizedFileList(repoId: Long, packageId: String, type: String, locale: String) + 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") diff --git a/database/src/main/java/org/fdroid/database/AppPrefs.kt b/database/src/main/java/org/fdroid/database/AppPrefs.kt index 11593c5a1..ba54de720 100644 --- a/database/src/main/java/org/fdroid/database/AppPrefs.kt +++ b/database/src/main/java/org/fdroid/database/AppPrefs.kt @@ -4,10 +4,14 @@ 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 packageId: String, + 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. @@ -18,14 +22,25 @@ public data class AppPrefs( 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) } diff --git a/database/src/main/java/org/fdroid/database/AppPrefsDao.kt b/database/src/main/java/org/fdroid/database/AppPrefsDao.kt index 1710329e6..aa8fe0b4b 100644 --- a/database/src/main/java/org/fdroid/database/AppPrefsDao.kt +++ b/database/src/main/java/org/fdroid/database/AppPrefsDao.kt @@ -22,10 +22,10 @@ internal interface AppPrefsDaoInt : AppPrefsDao { } } - @Query("SELECT * FROM AppPrefs WHERE packageId = :packageName") + @Query("SELECT * FROM AppPrefs WHERE packageName = :packageName") fun getLiveAppPrefs(packageName: String): LiveData - @Query("SELECT * FROM AppPrefs WHERE packageId = :packageName") + @Query("SELECT * FROM AppPrefs WHERE packageName = :packageName") fun getAppPrefsOrNull(packageName: String): AppPrefs? @Insert(onConflict = REPLACE) diff --git a/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt b/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt index f9ff28197..05bf16c2e 100644 --- a/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt +++ b/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt @@ -33,7 +33,7 @@ public class DbUpdateChecker( val packageNames = installedPackages.map { it.packageName } val versionsByPackage = HashMap>(packageNames.size) versionDao.getVersions(packageNames).forEach { version -> - val list = versionsByPackage.getOrPut(version.packageId) { ArrayList() } + val list = versionsByPackage.getOrPut(version.packageName) { ArrayList() } list.add(version) } installedPackages.iterator().forEach { packageInfo -> @@ -73,7 +73,7 @@ public class DbUpdateChecker( releaseChannels) ?: return null val versionedStrings = versionDao.getVersionedStrings( repoId = version.repoId, - packageName = version.packageId, + packageName = version.packageName, versionId = version.versionId, ) return version.toAppVersion(versionedStrings) @@ -109,15 +109,16 @@ public class DbUpdateChecker( private fun getUpdatableApp(version: Version, installedVersionCode: Long): UpdatableApp? { val versionedStrings = versionDao.getVersionedStrings( repoId = version.repoId, - packageName = version.packageId, + packageName = version.packageName, versionId = version.versionId, ) val appOverviewItem = - appDao.getAppOverviewItem(version.repoId, version.packageId) ?: return null + appDao.getAppOverviewItem(version.repoId, version.packageName) ?: return null return UpdatableApp( - packageId = version.packageId, + repoId = version.repoId, + packageName = version.packageName, installedVersionCode = installedVersionCode, - upgrade = version.toAppVersion(versionedStrings), + update = version.toAppVersion(versionedStrings), hasKnownVulnerability = version.hasKnownVulnerability, name = appOverviewItem.name, summary = appOverviewItem.summary, diff --git a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt index 9519130ce..5fc39bd59 100644 --- a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt @@ -4,7 +4,7 @@ import android.content.res.Resources import androidx.core.os.ConfigurationCompat.getLocales import androidx.core.os.LocaleListCompat import org.fdroid.CompatibilityChecker -import org.fdroid.database.IndexFormatVersion.ONE +import org.fdroid.index.IndexFormatVersion.ONE import org.fdroid.index.v1.IndexV1StreamReceiver import org.fdroid.index.v2.AntiFeatureV2 import org.fdroid.index.v2.CategoryV2 @@ -31,12 +31,12 @@ internal class DbV1StreamReceiver( db.getRepositoryDao().update(repoId, repo, version, ONE, certificate) } - override fun receive(packageId: String, m: MetadataV2) { - db.getAppDao().insert(repoId, packageId, m, locales) + override fun receive(packageName: String, m: MetadataV2) { + db.getAppDao().insert(repoId, packageName, m, locales) } - override fun receive(packageId: String, v: Map) { - db.getVersionDao().insert(repoId, packageId, v) { + override fun receive(packageName: String, v: Map) { + db.getVersionDao().insert(repoId, packageName, v) { compatibilityChecker.isCompatible(it.manifest) } } @@ -54,8 +54,8 @@ internal class DbV1StreamReceiver( db.afterUpdatingRepo(repoId) } - override fun updateAppMetadata(packageId: String, preferredSigner: String?) { - db.getAppDao().updatePreferredSigner(repoId, packageId, preferredSigner) + override fun updateAppMetadata(packageName: String, preferredSigner: String?) { + db.getAppDao().updatePreferredSigner(repoId, packageName, preferredSigner) } } diff --git a/database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt index 689d33c69..ed24dede2 100644 --- a/database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt @@ -19,15 +19,15 @@ internal class DbV2DiffStreamReceiver( db.getRepositoryDao().updateRepository(repoId, version, repoJsonObject) } - override fun receivePackageMetadataDiff(packageId: String, packageJsonObject: JsonObject?) { - db.getAppDao().updateApp(repoId, packageId, packageJsonObject, locales) + override fun receivePackageMetadataDiff(packageName: String, packageJsonObject: JsonObject?) { + db.getAppDao().updateApp(repoId, packageName, packageJsonObject, locales) } override fun receiveVersionsDiff( - packageId: String, + packageName: String, versionsDiffMap: Map?, ) { - db.getVersionDao().update(repoId, packageId, versionsDiffMap) { + db.getVersionDao().update(repoId, packageName, versionsDiffMap) { compatibilityChecker.isCompatible(it) } } diff --git a/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt b/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt index 0e278895d..51c1781bf 100644 --- a/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt +++ b/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt @@ -5,12 +5,18 @@ import androidx.core.os.ConfigurationCompat.getLocales import androidx.core.os.LocaleListCompat import kotlinx.serialization.SerializationException import org.fdroid.CompatibilityChecker -import org.fdroid.database.IndexFormatVersion.TWO +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, @@ -34,11 +40,11 @@ internal class DbV2StreamReceiver( } @Synchronized - override fun receive(packageId: String, p: PackageV2) { + override fun receive(packageName: String, p: PackageV2) { p.walkFiles(nonNullFileV2) clearRepoDataIfNeeded() - db.getAppDao().insert(repoId, packageId, p.metadata, locales) - db.getVersionDao().insert(repoId, packageId, p.versions) { + db.getAppDao().insert(repoId, packageName, p.metadata, locales) + db.getVersionDao().insert(repoId, packageName, p.versions) { compatibilityChecker.isCompatible(it.manifest) } } diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt index 9baf02486..7d32c8d2c 100644 --- a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt +++ b/database/src/main/java/org/fdroid/database/FDroidDatabase.kt @@ -1,24 +1,19 @@ package org.fdroid.database -import android.content.Context import android.content.res.Resources -import android.util.Log import androidx.core.os.ConfigurationCompat.getLocales import androidx.core.os.LocaleListCompat -import androidx.room.AutoMigration import androidx.room.Database -import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters -import androidx.sqlite.db.SupportSQLiteDatabase -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch import org.fdroid.LocaleChooser.getBestLocale +import java.util.Locale @Database( - version = 9, // TODO set version to 1 before release and wipe old schemas + // 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, @@ -44,14 +39,7 @@ import org.fdroid.LocaleChooser.getBestLocale ], exportSchema = true, autoMigrations = [ - // TODO remove auto-migrations - AutoMigration(from = 1, to = 2), - AutoMigration(from = 1, to = 3), - AutoMigration(from = 2, to = 3), - AutoMigration(from = 5, to = 6), - AutoMigration(from = 6, to = 7), - AutoMigration(from = 7, to = 8), - AutoMigration(from = 8, to = 9), + // add future migrations here (if they are easy enough to be done automatically) ], ) @TypeConverters(Converters::class) @@ -60,25 +48,13 @@ internal abstract class FDroidDatabaseInt internal constructor() : RoomDatabase( abstract override fun getAppDao(): AppDaoInt abstract override fun getVersionDao(): VersionDaoInt abstract override fun getAppPrefsDao(): AppPrefsDaoInt - fun afterUpdatingRepo(repoId: Long) { - getAppDao().updateCompatibility(repoId) - } -} - -public interface FDroidDatabase { - public fun getRepositoryDao(): RepositoryDao - public fun getAppDao(): AppDao - public fun getVersionDao(): VersionDao - public fun getAppPrefsDao(): AppPrefsDao - public fun afterLocalesChanged( - locales: LocaleListCompat = getLocales(Resources.getSystem().configuration), - ) { - val appDao = getAppDao() as AppDaoInt + override fun afterLocalesChanged(locales: LocaleListCompat) { + val appDao = getAppDao() runInTransaction { appDao.getAppMetadata().forEach { appMetadata -> appDao.updateAppMetadata( repoId = appMetadata.repoId, - packageId = appMetadata.packageId, + packageName = appMetadata.packageName, name = appMetadata.name.getBestLocale(locales), summary = appMetadata.summary.getBestLocale(locales), ) @@ -86,64 +62,35 @@ public interface FDroidDatabase { } } + /** + * 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) } - -public fun interface FDroidFixture { - public fun prePopulateDb(db: FDroidDatabase) -} - -public object FDroidDatabaseHolder { - // Singleton prevents multiple instances of database opening at the same time. - @Volatile - private var INSTANCE: FDroidDatabaseInt? = null - - internal val TAG = FDroidDatabase::class.simpleName - internal val dispatcher get() = Dispatchers.IO - - @JvmStatic - public fun getDb(context: Context, fixture: FDroidFixture?): FDroidDatabase { - return getDb(context, "test", fixture) - } - - internal 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(this) { - val builder = Room.databaseBuilder( - context.applicationContext, - FDroidDatabaseInt::class.java, - name, - ).fallbackToDestructiveMigration() // TODO remove before release - if (fixture != null) builder.addCallback(FixtureCallback(fixture)) - val instance = builder.build() - INSTANCE = instance - // return instance - instance - } - } - - @OptIn(DelicateCoroutinesApi::class) - private class FixtureCallback(private val fixture: FDroidFixture) : RoomDatabase.Callback() { - override fun onCreate(db: SupportSQLiteDatabase) { - super.onCreate(db) - GlobalScope.launch(dispatcher) { - synchronized(this) { - val database = INSTANCE ?: error("DB not yet initialized") - fixture.prePopulateDb(database) - Log.d(TAG, "Loaded fixtures") - } - } - } - - // TODO remove before release - override fun onDestructiveMigration(db: SupportSQLiteDatabase) { - onCreate(db) - } - } - -} diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabaseHolder.kt b/database/src/main/java/org/fdroid/database/FDroidDatabaseHolder.kt new file mode 100644 index 000000000..620bd1a82 --- /dev/null +++ b/database/src/main/java/org/fdroid/database/FDroidDatabaseHolder.kt @@ -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) + } + } + +} diff --git a/database/src/main/java/org/fdroid/database/Repository.kt b/database/src/main/java/org/fdroid/database/Repository.kt index 8b6e93627..586466a2f 100644 --- a/database/src/main/java/org/fdroid/database/Repository.kt +++ b/database/src/main/java/org/fdroid/database/Repository.kt @@ -8,6 +8,7 @@ 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 @@ -18,10 +19,8 @@ import org.fdroid.index.v2.MirrorV2 import org.fdroid.index.v2.ReleaseChannelV2 import org.fdroid.index.v2.RepoV2 -public enum class IndexFormatVersion { ONE, TWO } - @Entity -public data class CoreRepository( +internal data class CoreRepository( @PrimaryKey(autoGenerate = true) val repoId: Long = 0, val name: LocalizedTextV2 = emptyMap(), val icon: LocalizedFileV2?, @@ -54,7 +53,7 @@ internal fun RepoV2.toCoreRepository( certificate = certificate, ) -public data class Repository( +public data class Repository internal constructor( @Embedded internal val repository: CoreRepository, @Relation( parentColumn = "repoId", @@ -65,46 +64,106 @@ public data class Repository( parentColumn = "repoId", entityColumn = "repoId", ) - val antiFeatures: List, + internal val antiFeatures: List, @Relation( parentColumn = "repoId", entityColumn = "repoId", ) - val categories: List, + internal val categories: List, @Relation( parentColumn = "repoId", entityColumn = "repoId", ) - val releaseChannels: List, + internal val releaseChannels: List, @Relation( parentColumn = "repoId", entityColumn = "repoId", ) internal val preferences: RepositoryPreferences, ) { - val repoId: Long get() = repository.repoId - internal val name: LocalizedTextV2 get() = repository.name - internal val icon: LocalizedFileV2? get() = repository.icon - val address: String get() = repository.address - val webBaseUrl: String? get() = repository.webBaseUrl - val timestamp: Long get() = repository.timestamp - val version: Long get() = repository.version ?: 0 - val formatVersion: IndexFormatVersion? get() = repository.formatVersion - internal val description: LocalizedTextV2 get() = repository.description - val certificate: String? get() = repository.certificate + /** + * 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, + ) + ) - val weight: Int get() = preferences.weight - val enabled: Boolean get() = preferences.enabled - val lastUpdated: Long? get() = preferences.lastUpdated - val lastETag: String? get() = preferences.lastETag - val userMirrors: List get() = preferences.userMirrors ?: emptyList() - val disabledMirrors: List get() = preferences.disabledMirrors ?: emptyList() - val username: String? get() = preferences.username - val password: String? get() = preferences.password - val isSwap: Boolean get() = preferences.isSwap + 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 { + return antiFeatures.associateBy { antiFeature -> antiFeature.id } + } + + public fun getCategories(): Map { + return categories.associateBy { category -> category.id } + } + + public fun getReleaseChannels(): Map { + 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 get() = preferences.userMirrors ?: emptyList() + public val disabledMirrors: List 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 - val fingerprint: String? by lazy { + public val fingerprint: String? by lazy { certificate?.let { getFingerprint(it) } } @@ -134,14 +193,11 @@ public data class Repository( add(0, org.fdroid.download.Mirror(address)) } } - - public fun getName(localeList: LocaleListCompat): String? = name.getBestLocale(localeList) - public fun getDescription(localeList: LocaleListCompat): String? = - description.getBestLocale(localeList) - - public fun getIcon(localeList: LocaleListCompat): FileV2? = icon.getBestLocale(localeList) } +/** + * A database table to store repository mirror information. + */ @Entity( primaryKeys = ["repoId", "url"], foreignKeys = [ForeignKey( @@ -151,12 +207,12 @@ public data class Repository( onDelete = ForeignKey.CASCADE, )], ) -public data class Mirror( +internal data class Mirror( val repoId: Long, val url: String, val location: String? = null, ) { - public fun toDownloadMirror(): org.fdroid.download.Mirror = org.fdroid.download.Mirror( + fun toDownloadMirror(): org.fdroid.download.Mirror = org.fdroid.download.Mirror( baseUrl = url, location = location, ) @@ -168,6 +224,24 @@ internal fun MirrorV2.toMirror(repoId: Long) = Mirror( 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( @@ -177,13 +251,13 @@ internal fun MirrorV2.toMirror(repoId: Long) = Mirror( onDelete = ForeignKey.CASCADE, )], ) -public data class AntiFeature( - val repoId: Long, - val id: String, - @Embedded(prefix = "icon_") val icon: FileV2? = null, - val name: LocalizedTextV2, - val description: LocalizedTextV2, -) +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.toRepoAntiFeatures(repoId: Long) = map { AntiFeature( @@ -195,6 +269,9 @@ internal fun Map.toRepoAntiFeatures(repoId: Long) = map { ) } +/** + * A category of apps belonging to a [Repository]. + */ @Entity( primaryKeys = ["repoId", "id"], foreignKeys = [ForeignKey( @@ -204,13 +281,13 @@ internal fun Map.toRepoAntiFeatures(repoId: Long) = map { onDelete = ForeignKey.CASCADE, )], ) -public data class Category( - val repoId: Long, - val id: String, - @Embedded(prefix = "icon_") val icon: FileV2? = null, - val name: LocalizedTextV2, - val description: LocalizedTextV2, -) +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.toRepoCategories(repoId: Long) = map { Category( @@ -222,6 +299,9 @@ internal fun Map.toRepoCategories(repoId: Long) = map { ) } +/** + * A release-channel for apps belonging to a [Repository]. + */ @Entity( primaryKeys = ["repoId", "id"], foreignKeys = [ForeignKey( @@ -232,12 +312,12 @@ internal fun Map.toRepoCategories(repoId: Long) = map { )], ) public data class ReleaseChannel( - val repoId: Long, - val id: String, - @Embedded(prefix = "icon_") val icon: FileV2? = null, - val name: LocalizedTextV2, - val description: LocalizedTextV2, -) + 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.toRepoReleaseChannel(repoId: Long) = map { ReleaseChannel( @@ -249,21 +329,20 @@ internal fun Map.toRepoReleaseChannel(repoId: Long) = } @Entity -public data class RepositoryPreferences( +internal data class RepositoryPreferences( @PrimaryKey internal val repoId: Long, val weight: Int, val enabled: Boolean = true, val lastUpdated: Long? = System.currentTimeMillis(), - val lastETag: String? = null, + @Deprecated("Only used for indexV1") val lastETag: String? = null, val userMirrors: List? = null, val disabledMirrors: List? = null, val username: String? = null, val password: String? = null, - val isSwap: Boolean = false, // TODO remove ) /** - * A [Repository] which the [FDroidDatabase] gets pre-populated with. + * A reduced version of [Repository] used to pre-populate the [FDroidDatabase]. */ public data class InitialRepository( val name: String, diff --git a/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/database/src/main/java/org/fdroid/database/RepositoryDao.kt index feec65181..a6e080803 100644 --- a/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -13,6 +13,7 @@ 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 diff --git a/database/src/main/java/org/fdroid/database/Version.kt b/database/src/main/java/org/fdroid/database/Version.kt index e209b6706..3e2ad9d11 100644 --- a/database/src/main/java/org/fdroid/database/Version.kt +++ b/database/src/main/java/org/fdroid/database/Version.kt @@ -21,18 +21,23 @@ 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", "packageId", "versionId"], + primaryKeys = ["repoId", "packageName", "versionId"], foreignKeys = [ForeignKey( entity = AppMetadata::class, - parentColumns = ["repoId", "packageId"], - childColumns = ["repoId", "packageId"], + parentColumns = ["repoId", "packageName"], + childColumns = ["repoId", "packageName"], onDelete = ForeignKey.CASCADE, )], ) -public data class Version( +internal data class Version( val repoId: Long, - val packageId: String, + val packageName: String, val versionId: String, val added: Long, @Embedded(prefix = "file_") val file: FileV1, @@ -57,12 +62,12 @@ public data class Version( internal fun PackageVersionV2.toVersion( repoId: Long, - packageId: String, + packageName: String, versionId: String, isCompatible: Boolean, ) = Version( repoId = repoId, - packageId = packageId, + packageName = packageName, versionId = versionId, added = added, file = file, @@ -74,6 +79,9 @@ internal fun PackageVersionV2.toVersion( 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( @@ -83,38 +91,44 @@ public data class AppVersion internal constructor( internal val versionedStrings: List?, ) { public val repoId: Long get() = version.repoId - public val packageId: String get() = version.packageId + 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? get() = versionedStrings?.getPermissions(version) - public val usesPermissionSdk23: List? - get() = versionedStrings?.getPermissionsSdk23(version) + public val usesPermission: List + get() = versionedStrings?.getPermissions(version) ?: emptyList() + public val usesPermissionSdk23: List + get() = versionedStrings?.getPermissionsSdk23(version) ?: emptyList() public val featureNames: List get() = version.manifest.features ?: emptyList() public val nativeCode: List get() = version.manifest.nativecode ?: emptyList() public val releaseChannels: List get() = version.releaseChannels ?: emptyList() - val antiFeatureNames: List - get() { - return version.antiFeatures?.map { it.key } ?: emptyList() - } + public val antiFeatureKeys: List + 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( - val versionName: String, - val versionCode: Long, - @Embedded(prefix = "usesSdk_") val usesSdk: UsesSdkV2? = null, - override val maxSdkVersion: Int? = null, - @Embedded(prefix = "signer_") val signer: SignerV2? = null, - override val nativecode: List? = emptyList(), - val features: List? = emptyList(), + 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? = emptyList(), + public val features: List? = emptyList(), ) : PackageManifest { - override val minSdkVersion: Int? get() = usesSdk?.minSdkVersion - override val featureNames: List? get() = features + public override val minSdkVersion: Int? get() = usesSdk?.minSdkVersion + public override val featureNames: List? get() = features } internal fun ManifestV2.toManifest() = AppManifest( @@ -127,11 +141,11 @@ internal fun ManifestV2.toManifest() = AppManifest( features = features.map { it.name }, ) -@DatabaseView("""SELECT repoId, packageId, antiFeatures FROM Version - GROUP BY repoId, packageId HAVING MAX(manifest_versionCode)""") +@DatabaseView("""SELECT repoId, packageName, antiFeatures FROM Version + GROUP BY repoId, packageName HAVING MAX(manifest_versionCode)""") internal class HighestVersion( val repoId: Long, - val packageId: String, + val packageName: String, val antiFeatures: Map? = null, ) @@ -141,17 +155,17 @@ internal enum class VersionedStringType { } @Entity( - primaryKeys = ["repoId", "packageId", "versionId", "type", "name"], + primaryKeys = ["repoId", "packageName", "versionId", "type", "name"], foreignKeys = [ForeignKey( entity = Version::class, - parentColumns = ["repoId", "packageId", "versionId"], - childColumns = ["repoId", "packageId", "versionId"], + parentColumns = ["repoId", "packageName", "versionId"], + childColumns = ["repoId", "packageName", "versionId"], onDelete = ForeignKey.CASCADE, )], ) internal data class VersionedString( val repoId: Long, - val packageId: String, + val packageName: String, val versionId: String, val type: VersionedStringType, val name: String, @@ -164,7 +178,7 @@ internal fun List.toVersionedString( ) = map { permission -> VersionedString( repoId = version.repoId, - packageId = version.packageId, + packageName = version.packageName, versionId = version.versionId, type = type, name = permission.name, @@ -200,7 +214,7 @@ private fun VersionedString.map( wantedType: VersionedStringType, factory: () -> T, ): T? { - return if (repoId != v.repoId || packageId != v.packageId || versionId != v.versionId || + return if (repoId != v.repoId || packageName != v.packageName || versionId != v.versionId || type != wantedType ) null else factory() diff --git a/database/src/main/java/org/fdroid/database/VersionDao.kt b/database/src/main/java/org/fdroid/database/VersionDao.kt index 80d5162b0..4890ddfa9 100644 --- a/database/src/main/java/org/fdroid/database/VersionDao.kt +++ b/database/src/main/java/org/fdroid/database/VersionDao.kt @@ -44,7 +44,7 @@ public interface VersionDao { * 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("packageId", "repoId", "versionId") +private val DENY_LIST = listOf("packageName", "repoId", "versionId") @Dao internal interface VersionDaoInt : VersionDao { @@ -56,7 +56,6 @@ internal interface VersionDaoInt : VersionDao { packageVersions: Map, checkIfCompatible: (PackageVersionV2) -> Boolean, ) { - // TODO maybe the number of queries here can be reduced packageVersions.entries.iterator().forEach { (versionId, packageVersion) -> val isCompatible = checkIfCompatible(packageVersion) insert(repoId, packageName, versionId, packageVersion, isCompatible) @@ -128,7 +127,7 @@ internal interface VersionDaoInt : VersionDao { // diff versioned strings val manifest = jsonObject["manifest"] if (manifest is JsonNull) { // no more manifest, delete all versionedStrings - deleteVersionedStrings(version.repoId, version.packageId, version.versionId) + deleteVersionedStrings(version.repoId, version.packageName, version.versionId) } else if (manifest is JsonObject) { diffVersionedStrings(version, manifest, "usesPermission", PERMISSION) diffVersionedStrings(version, manifest, "usesPermissionSdk23", @@ -149,7 +148,7 @@ internal interface VersionDaoInt : VersionDao { list.toVersionedString(version, type) }, deleteList = { - deleteVersionedStrings(version.repoId, version.packageId, version.versionId, type) + deleteVersionedStrings(version.repoId, version.packageName, version.versionId, type) }, insertNewList = { versionedStrings -> insert(versionedStrings) }, ) @@ -158,7 +157,7 @@ internal interface VersionDaoInt : VersionDao { @RewriteQueriesToDropUnusedColumns @Query("""SELECT * FROM Version JOIN RepositoryPreferences AS pref USING (repoId) - WHERE pref.enabled = 1 AND packageId = :packageName + WHERE pref.enabled = 1 AND packageName = :packageName ORDER BY manifest_versionCode DESC, pref.weight DESC""") override fun getAppVersions(packageName: String): LiveData> @@ -167,11 +166,11 @@ internal interface VersionDaoInt : VersionDao { */ @Transaction @Query("""SELECT * FROM Version - WHERE repoId = :repoId AND packageId = :packageName""") + WHERE repoId = :repoId AND packageName = :packageName""") fun getAppVersions(repoId: Long, packageName: String): List @Query("""SELECT * FROM Version - WHERE repoId = :repoId AND packageId = :packageName AND versionId = :versionId""") + WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""") fun getVersion(repoId: Long, packageName: String, versionId: String): Version? /** @@ -181,37 +180,37 @@ internal interface VersionDaoInt : VersionDao { @RewriteQueriesToDropUnusedColumns @Query("""SELECT * FROM Version JOIN RepositoryPreferences AS pref USING (repoId) - LEFT JOIN AppPrefs USING (packageId) + LEFT JOIN AppPrefs USING (packageName) WHERE pref.enabled = 1 AND manifest_versionCode > COALESCE(AppPrefs.ignoreVersionCodeUpdate, 0) AND - packageId IN (:packageNames) + packageName IN (:packageNames) ORDER BY manifest_versionCode DESC, pref.weight DESC""") fun getVersions(packageNames: List): List - @Query("SELECT * FROM VersionedString WHERE repoId = :repoId AND packageId = :packageName") + @Query("SELECT * FROM VersionedString WHERE repoId = :repoId AND packageName = :packageName") fun getVersionedStrings(repoId: Long, packageName: String): List @Query("""SELECT * FROM VersionedString - WHERE repoId = :repoId AND packageId = :packageName AND versionId = :versionId""") + WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""") fun getVersionedStrings( repoId: Long, packageName: String, versionId: String, ): List - @Query("""DELETE FROM Version WHERE repoId = :repoId AND packageId = :packageName""") + @Query("""DELETE FROM Version WHERE repoId = :repoId AND packageName = :packageName""") fun deleteAppVersion(repoId: Long, packageName: String) @Query("""DELETE FROM Version - WHERE repoId = :repoId AND packageId = :packageName AND versionId = :versionId""") + 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 packageId = :packageName AND versionId = :versionId""") + 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 packageId = :packageName AND versionId = :versionId AND type = :type""") + AND packageName = :packageName AND versionId = :versionId AND type = :type""") fun deleteVersionedStrings( repoId: Long, packageName: String, diff --git a/database/src/main/java/org/fdroid/index/IndexUpdater.kt b/database/src/main/java/org/fdroid/index/IndexUpdater.kt index 9d1028497..f2eaf7286 100644 --- a/database/src/main/java/org/fdroid/index/IndexUpdater.kt +++ b/database/src/main/java/org/fdroid/index/IndexUpdater.kt @@ -1,13 +1,17 @@ package org.fdroid.index import android.net.Uri -import org.fdroid.database.IndexFormatVersion 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() @@ -40,10 +44,21 @@ public fun interface TempFileProvider { 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?, @@ -51,6 +66,9 @@ public abstract class IndexUpdater { update(repo, null, expectedSigningFingerprint) } + /** + * Updates an existing [repo] with a known [Repository.certificate]. + */ public fun update( repo: Repository, ): IndexUpdateResult = catchExceptions { diff --git a/database/src/main/java/org/fdroid/index/RepoUpdater.kt b/database/src/main/java/org/fdroid/index/RepoUpdater.kt index 169d5a037..fce2a125a 100644 --- a/database/src/main/java/org/fdroid/index/RepoUpdater.kt +++ b/database/src/main/java/org/fdroid/index/RepoUpdater.kt @@ -10,6 +10,10 @@ 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, @@ -45,6 +49,11 @@ public class RepoUpdater( ), ) + /** + * 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, diff --git a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt index c64347a40..5cc29d745 100644 --- a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt +++ b/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt @@ -4,10 +4,10 @@ import org.fdroid.CompatibilityChecker import org.fdroid.database.DbV1StreamReceiver import org.fdroid.database.FDroidDatabase import org.fdroid.database.FDroidDatabaseInt -import org.fdroid.database.IndexFormatVersion -import org.fdroid.database.IndexFormatVersion.ONE 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 diff --git a/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt b/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt index 6971271bb..c4482038e 100644 --- a/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt +++ b/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt @@ -5,11 +5,11 @@ import org.fdroid.database.DbV2DiffStreamReceiver import org.fdroid.database.DbV2StreamReceiver import org.fdroid.database.FDroidDatabase import org.fdroid.database.FDroidDatabaseInt -import org.fdroid.database.IndexFormatVersion -import org.fdroid.database.IndexFormatVersion.ONE -import org.fdroid.database.IndexFormatVersion.TWO 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 From f6075848e7da031f0d7021c07eee9150bc644d6f Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Wed, 20 Jul 2022 16:06:50 -0300 Subject: [PATCH 38/42] 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. --- .gitlab-ci.yml | 14 +- README.md | 5 +- app/build.gradle | 4 +- download/README.md | 40 ---- index/LICENSE | 202 ------------------ index/README.md | 41 ---- {download => libs}/LICENSE | 0 libs/README.md | 98 +++++++++ {database => libs/database}/.gitignore | 0 {database => libs/database}/build.gradle | 12 +- .../database}/consumer-rules.pro | 0 .../database}/proguard-rules.pro | 0 .../1.json | 0 .../java/org/fdroid/database/AppDaoTest.kt | 0 .../org/fdroid/database/AppListItemsTest.kt | 0 .../fdroid/database/AppOverviewItemsTest.kt | 0 .../java/org/fdroid/database/AppTest.kt | 0 .../dbTest/java/org/fdroid/database/DbTest.kt | 0 .../fdroid/database/DbUpdateCheckerTest.kt | 0 .../org/fdroid/database/IndexV1InsertTest.kt | 0 .../org/fdroid/database/IndexV2DiffTest.kt | 0 .../org/fdroid/database/IndexV2InsertTest.kt | 0 .../org/fdroid/database/RepositoryDaoTest.kt | 0 .../org/fdroid/database/RepositoryDiffTest.kt | 0 .../java/org/fdroid/database/TestUtils.kt | 0 .../java/org/fdroid/database/VersionTest.kt | 0 .../org/fdroid/index/v1/IndexV1UpdaterTest.kt | 0 .../org/fdroid/index/v2/IndexV2UpdaterTest.kt | 0 .../database}/src/main/AndroidManifest.xml | 0 .../src/main/java/org/fdroid/database/App.kt | 0 .../main/java/org/fdroid/database/AppDao.kt | 0 .../main/java/org/fdroid/database/AppPrefs.kt | 0 .../java/org/fdroid/database/AppPrefsDao.kt | 0 .../java/org/fdroid/database/Converters.kt | 0 .../java/org/fdroid/database/DbDiffUtils.kt | 0 .../org/fdroid/database/DbUpdateChecker.kt | 0 .../org/fdroid/database/DbV1StreamReceiver.kt | 0 .../fdroid/database/DbV2DiffStreamReceiver.kt | 0 .../org/fdroid/database/DbV2StreamReceiver.kt | 0 .../org/fdroid/database/FDroidDatabase.kt | 0 .../fdroid/database/FDroidDatabaseHolder.kt | 0 .../java/org/fdroid/database/Repository.kt | 0 .../java/org/fdroid/database/RepositoryDao.kt | 0 .../main/java/org/fdroid/database/Version.kt | 0 .../java/org/fdroid/database/VersionDao.kt | 0 .../org/fdroid/download/DownloaderFactory.kt | 0 .../java/org/fdroid/index/IndexUpdater.kt | 0 .../main/java/org/fdroid/index/RepoUpdater.kt | 0 .../org/fdroid/index/v1/IndexV1Updater.kt | 0 .../org/fdroid/index/v2/IndexV2Updater.kt | 0 {database => libs/database}/src/sharedTest | 0 .../java/org/fdroid/database/AppPrefsTest.kt | 0 .../org/fdroid/database/ConvertersTest.kt | 0 {download => libs/download}/.gitignore | 0 {download => libs/download}/build.gradle | 1 - {download => libs/download}/gradle.properties | 0 {download => libs/download}/lint.xml | 2 - .../HttpManagerInstrumentationTest.kt | 0 .../src/androidMain/AndroidManifest.xml | 0 .../kotlin/org/fdroid/download/Downloader.kt | 0 .../org/fdroid/download/HttpDownloader.kt | 0 .../kotlin/org/fdroid/download/HttpManager.kt | 0 .../kotlin/org/fdroid/download/HttpPoster.kt | 0 .../download/glide/DownloadRequestLoader.kt | 0 .../org/fdroid/download/glide/HttpFetcher.kt | 0 .../download/glide/HttpGlideUrlLoader.kt | 0 .../kotlin/org/fdroid/fdroid/HashUtils.kt | 0 .../org/fdroid/download/HttpDownloaderTest.kt | 0 .../org/fdroid/download/HttpPosterTest.kt | 0 .../org/fdroid/download/DownloadRequest.kt | 0 .../kotlin/org/fdroid/download/HeadInfo.kt | 0 .../kotlin/org/fdroid/download/HttpManager.kt | 0 .../kotlin/org/fdroid/download/Mirror.kt | 0 .../org/fdroid/download/MirrorChooser.kt | 0 .../kotlin/org/fdroid/download/Proxy.kt | 0 .../org/fdroid/fdroid/ProgressListener.kt | 0 .../commonTest/kotlin/org/fdroid/TestUtils.kt | 0 .../download/HttpManagerIntegrationTest.kt | 0 .../org/fdroid/download/HttpManagerTest.kt | 0 .../org/fdroid/download/MirrorChooserTest.kt | 0 .../kotlin/org/fdroid/download/MirrorTest.kt | 0 .../kotlin/org/fdroid/download/HttpManager.kt | 0 .../kotlin/org/fdroid/download/HttpManager.kt | 0 {index => libs/index}/.gitignore | 0 {index => libs/index}/build.gradle | 8 +- {index => libs/index}/consumer-rules.pro | 0 {index => libs/index}/gradle.properties | 0 .../kotlin/org/fdroid/BestLocaleTest.kt | 0 .../org/fdroid/index/v1/IndexV1CreatorTest.kt | 0 .../src/androidMain/AndroidManifest.xml | 0 .../kotlin/org/fdroid/CompatibilityChecker.kt | 0 .../kotlin/org/fdroid/LocaleChooser.kt | 0 .../kotlin/org/fdroid/UpdateChecker.kt | 0 .../kotlin/org/fdroid/index/IndexCreator.kt | 0 .../kotlin/org/fdroid/index/IndexParser.kt | 0 .../kotlin/org/fdroid/index/IndexUtils.kt | 0 .../org/fdroid/index/JarIndexVerifier.kt | 0 .../org/fdroid/index/v1/IndexV1Creator.kt | 0 .../fdroid/index/v1/IndexV1StreamProcessor.kt | 0 .../org/fdroid/index/v1/IndexV1Verifier.kt | 0 .../org/fdroid/index/v2/EntryVerifier.kt | 0 .../index/v2/IndexV2DiffStreamProcessor.kt | 0 .../index/v2/IndexV2FullStreamProcessor.kt | 0 .../fdroid/index/v2/IndexV2StreamProcessor.kt | 0 .../org/fdroid/index/v2/ReflectionDiffer.kt | 0 .../org/fdroid/CompatibilityCheckerTest.kt | 0 .../kotlin/org/fdroid/UpdateCheckerTest.kt | 0 .../index/v1/IndexV1StreamProcessorTest.kt | 17 +- .../fdroid/index/v1/IndexV1VerifierTest.kt | 0 .../org/fdroid/index/v2/EntryVerifierTest.kt | 0 .../v2/IndexV2FullStreamProcessorTest.kt | 13 +- .../fdroid/index/v2/ReflectionDifferTest.kt | 37 ++-- .../kotlin/org/fdroid/index/IndexConverter.kt | 0 .../kotlin/org/fdroid/index/IndexParser.kt | 0 .../kotlin/org/fdroid/index/v1/AppV1.kt | 0 .../kotlin/org/fdroid/index/v1/IndexV1.kt | 0 .../fdroid/index/v1/IndexV1StreamReceiver.kt | 0 .../kotlin/org/fdroid/index/v1/PackageV1.kt | 0 .../kotlin/org/fdroid/index/v2/IndexV2.kt | 0 .../index/v2/IndexV2DiffStreamReceiver.kt | 0 .../fdroid/index/v2/IndexV2StreamReceiver.kt | 0 .../kotlin/org/fdroid/index/v2/PackageV2.kt | 0 .../org/fdroid/index/IndexConverterTest.kt | 14 +- .../kotlin/org/fdroid/index/v1/IndexV1Test.kt | 9 +- .../kotlin/org/fdroid/index/v2/EntryTest.kt | 13 +- .../v2/IndexV2DiffStreamProcessorTest.kt | 13 +- .../kotlin/org/fdroid/index/v2/IndexV2Test.kt | 12 +- .../invalid-MD5-MD5withRSA-v1.jar | Bin .../invalid-MD5-MD5withRSA-v2.jar | Bin .../invalid-MD5-SHA1withRSA-v1.jar | Bin .../invalid-MD5-SHA1withRSA-v2.jar | Bin .../invalid-SHA1-SHA1withRSA-v2.jar | Bin .../resources/verification/invalid-v1.jar | Bin .../resources/verification/invalid-v2.jar | Bin .../verification/invalid-wrong-entry-v1.jar | Bin .../resources/verification/unsigned.jar | Bin .../verification/valid-apksigner-v2.jar | Bin .../resources/verification/valid-v1.jar | Bin .../resources/verification/valid-v2.jar | Bin libs/sharedTest/.gitignore | 1 + libs/sharedTest/build.gradle | 27 +++ libs/sharedTest/src/main/AndroidManifest.xml | 2 + .../src/main/assets}/diff-empty-max/1337.json | 0 .../src/main/assets}/diff-empty-max/23.json | 0 .../src/main/assets}/diff-empty-max/42.json | 0 .../src/main/assets}/diff-empty-max/entry.jar | Bin .../main/assets}/diff-empty-max/entry.json | 0 .../src/main/assets}/diff-empty-mid/23.json | 0 .../src/main/assets}/diff-empty-mid/42.json | 0 .../src/main/assets}/diff-empty-mid/entry.jar | Bin .../main/assets}/diff-empty-mid/entry.json | 0 .../src/main/assets}/diff-empty-min/23.json | 0 .../src/main/assets}/diff-empty-min/entry.jar | Bin .../main/assets}/diff-empty-min/entry.json | 0 .../src/main/assets}/entry-empty-v2.json | 0 .../src/main/assets}/index-empty-v1.json | 0 .../src/main/assets}/index-empty-v2.json | 0 .../src/main/assets}/index-max-v1.json | 0 .../src/main/assets}/index-max-v2.json | 0 .../src/main/assets}/index-mid-v1.json | 0 .../src/main/assets}/index-mid-v2.json | 0 .../main/assets}/index-min-reordered-v2.json | 0 .../src/main/assets}/index-min-v1.json | 0 .../src/main/assets}/index-min-v2.json | 0 ...r.at_corrupt_app_package_name_index-v1.jar | Bin ...at.or.at_corrupt_package_name_index-v1.jar | Bin .../main/assets}/testy.at.or.at_index-v1.jar | Bin .../testy.at.or.at_no-.RSA_index-v1.jar | Bin .../testy.at.or.at_no-.SF_index-v1.jar | Bin ...testy.at.or.at_no-MANIFEST.MF_index-v1.jar | Bin .../testy.at.or.at_no-signature_index-v1.jar | Bin .../main}/kotlin/org/fdroid/test/DiffUtils.kt | 18 +- .../kotlin/org/fdroid/test/TestAppUtils.kt | 10 +- .../kotlin/org/fdroid/test/TestDataEntryV2.kt | 10 +- .../kotlin/org/fdroid/test/TestDataV1.kt | 8 +- .../kotlin/org/fdroid/test/TestDataV2.kt | 20 +- .../kotlin/org/fdroid/test/TestRepoUtils.kt | 12 +- .../main}/kotlin/org/fdroid/test/TestUtils.kt | 18 +- .../org/fdroid/test/TestVersionUtils.kt | 2 +- .../org/fdroid/test/VerifierConstants.kt | 8 +- settings.gradle | 7 +- 181 files changed, 265 insertions(+), 433 deletions(-) delete mode 100644 download/README.md delete mode 100644 index/LICENSE delete mode 100644 index/README.md rename {download => libs}/LICENSE (100%) create mode 100644 libs/README.md rename {database => libs/database}/.gitignore (100%) rename {database => libs/database}/build.gradle (91%) rename {database => libs/database}/consumer-rules.pro (100%) rename {database => libs/database}/proguard-rules.pro (100%) rename {database => libs/database}/schemas/org.fdroid.database.FDroidDatabaseInt/1.json (100%) rename {database => libs/database}/src/dbTest/java/org/fdroid/database/AppDaoTest.kt (100%) rename {database => libs/database}/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt (100%) rename {database => libs/database}/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt (100%) rename {database => libs/database}/src/dbTest/java/org/fdroid/database/AppTest.kt (100%) rename {database => libs/database}/src/dbTest/java/org/fdroid/database/DbTest.kt (100%) rename {database => libs/database}/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt (100%) rename {database => libs/database}/src/dbTest/java/org/fdroid/database/IndexV1InsertTest.kt (100%) rename {database => libs/database}/src/dbTest/java/org/fdroid/database/IndexV2DiffTest.kt (100%) rename {database => libs/database}/src/dbTest/java/org/fdroid/database/IndexV2InsertTest.kt (100%) rename {database => libs/database}/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt (100%) rename {database => libs/database}/src/dbTest/java/org/fdroid/database/RepositoryDiffTest.kt (100%) rename {database => libs/database}/src/dbTest/java/org/fdroid/database/TestUtils.kt (100%) rename {database => libs/database}/src/dbTest/java/org/fdroid/database/VersionTest.kt (100%) rename {database => libs/database}/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt (100%) rename {database => libs/database}/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt (100%) rename {database => libs/database}/src/main/AndroidManifest.xml (100%) rename {database => libs/database}/src/main/java/org/fdroid/database/App.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/database/AppDao.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/database/AppPrefs.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/database/AppPrefsDao.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/database/Converters.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/database/DbDiffUtils.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/database/DbUpdateChecker.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/database/FDroidDatabase.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/database/FDroidDatabaseHolder.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/database/Repository.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/database/RepositoryDao.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/database/Version.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/database/VersionDao.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/download/DownloaderFactory.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/index/IndexUpdater.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/index/RepoUpdater.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt (100%) rename {database => libs/database}/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt (100%) rename {database => libs/database}/src/sharedTest (100%) rename {database => libs/database}/src/test/java/org/fdroid/database/AppPrefsTest.kt (100%) rename {database => libs/database}/src/test/java/org/fdroid/database/ConvertersTest.kt (100%) rename {download => libs/download}/.gitignore (100%) rename {download => libs/download}/build.gradle (99%) rename {download => libs/download}/gradle.properties (100%) rename {download => libs/download}/lint.xml (52%) rename {download => libs/download}/src/androidAndroidTest/kotlin/org/fdroid/download/HttpManagerInstrumentationTest.kt (100%) rename {download => libs/download}/src/androidMain/AndroidManifest.xml (100%) rename {download => libs/download}/src/androidMain/kotlin/org/fdroid/download/Downloader.kt (100%) rename {download => libs/download}/src/androidMain/kotlin/org/fdroid/download/HttpDownloader.kt (100%) rename {download => libs/download}/src/androidMain/kotlin/org/fdroid/download/HttpManager.kt (100%) rename {download => libs/download}/src/androidMain/kotlin/org/fdroid/download/HttpPoster.kt (100%) rename {download => libs/download}/src/androidMain/kotlin/org/fdroid/download/glide/DownloadRequestLoader.kt (100%) rename {download => libs/download}/src/androidMain/kotlin/org/fdroid/download/glide/HttpFetcher.kt (100%) rename {download => libs/download}/src/androidMain/kotlin/org/fdroid/download/glide/HttpGlideUrlLoader.kt (100%) rename {download => libs/download}/src/androidMain/kotlin/org/fdroid/fdroid/HashUtils.kt (100%) rename {download => libs/download}/src/androidTest/kotlin/org/fdroid/download/HttpDownloaderTest.kt (100%) rename {download => libs/download}/src/androidTest/kotlin/org/fdroid/download/HttpPosterTest.kt (100%) rename {download => libs/download}/src/commonMain/kotlin/org/fdroid/download/DownloadRequest.kt (100%) rename {download => libs/download}/src/commonMain/kotlin/org/fdroid/download/HeadInfo.kt (100%) rename {download => libs/download}/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt (100%) rename {download => libs/download}/src/commonMain/kotlin/org/fdroid/download/Mirror.kt (100%) rename {download => libs/download}/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt (100%) rename {download => libs/download}/src/commonMain/kotlin/org/fdroid/download/Proxy.kt (100%) rename {download => libs/download}/src/commonMain/kotlin/org/fdroid/fdroid/ProgressListener.kt (100%) rename {download => libs/download}/src/commonTest/kotlin/org/fdroid/TestUtils.kt (100%) rename {download => libs/download}/src/commonTest/kotlin/org/fdroid/download/HttpManagerIntegrationTest.kt (100%) rename {download => libs/download}/src/commonTest/kotlin/org/fdroid/download/HttpManagerTest.kt (100%) rename {download => libs/download}/src/commonTest/kotlin/org/fdroid/download/MirrorChooserTest.kt (100%) rename {download => libs/download}/src/commonTest/kotlin/org/fdroid/download/MirrorTest.kt (100%) rename {download => libs/download}/src/jvmMain/kotlin/org/fdroid/download/HttpManager.kt (100%) rename {download => libs/download}/src/nativeMain/kotlin/org/fdroid/download/HttpManager.kt (100%) rename {index => libs/index}/.gitignore (100%) rename {index => libs/index}/build.gradle (96%) rename {index => libs/index}/consumer-rules.pro (100%) rename {index => libs/index}/gradle.properties (100%) rename {index => libs/index}/src/androidAndroidTest/kotlin/org/fdroid/BestLocaleTest.kt (100%) rename {index => libs/index}/src/androidAndroidTest/kotlin/org/fdroid/index/v1/IndexV1CreatorTest.kt (100%) rename {index => libs/index}/src/androidMain/AndroidManifest.xml (100%) rename {index => libs/index}/src/androidMain/kotlin/org/fdroid/CompatibilityChecker.kt (100%) rename {index => libs/index}/src/androidMain/kotlin/org/fdroid/LocaleChooser.kt (100%) rename {index => libs/index}/src/androidMain/kotlin/org/fdroid/UpdateChecker.kt (100%) rename {index => libs/index}/src/androidMain/kotlin/org/fdroid/index/IndexCreator.kt (100%) rename {index => libs/index}/src/androidMain/kotlin/org/fdroid/index/IndexParser.kt (100%) rename {index => libs/index}/src/androidMain/kotlin/org/fdroid/index/IndexUtils.kt (100%) rename {index => libs/index}/src/androidMain/kotlin/org/fdroid/index/JarIndexVerifier.kt (100%) rename {index => libs/index}/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Creator.kt (100%) rename {index => libs/index}/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1StreamProcessor.kt (100%) rename {index => libs/index}/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Verifier.kt (100%) rename {index => libs/index}/src/androidMain/kotlin/org/fdroid/index/v2/EntryVerifier.kt (100%) rename {index => libs/index}/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessor.kt (100%) rename {index => libs/index}/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessor.kt (100%) rename {index => libs/index}/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2StreamProcessor.kt (100%) rename {index => libs/index}/src/androidMain/kotlin/org/fdroid/index/v2/ReflectionDiffer.kt (100%) rename {index => libs/index}/src/androidTest/kotlin/org/fdroid/CompatibilityCheckerTest.kt (100%) rename {index => libs/index}/src/androidTest/kotlin/org/fdroid/UpdateCheckerTest.kt (100%) rename {index => libs/index}/src/androidTest/kotlin/org/fdroid/index/v1/IndexV1StreamProcessorTest.kt (90%) rename {index => libs/index}/src/androidTest/kotlin/org/fdroid/index/v1/IndexV1VerifierTest.kt (100%) rename {index => libs/index}/src/androidTest/kotlin/org/fdroid/index/v2/EntryVerifierTest.kt (100%) rename {index => libs/index}/src/androidTest/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessorTest.kt (90%) rename {index => libs/index}/src/androidTest/kotlin/org/fdroid/index/v2/ReflectionDifferTest.kt (83%) rename {index => libs/index}/src/commonMain/kotlin/org/fdroid/index/IndexConverter.kt (100%) rename {index => libs/index}/src/commonMain/kotlin/org/fdroid/index/IndexParser.kt (100%) rename {index => libs/index}/src/commonMain/kotlin/org/fdroid/index/v1/AppV1.kt (100%) rename {index => libs/index}/src/commonMain/kotlin/org/fdroid/index/v1/IndexV1.kt (100%) rename {index => libs/index}/src/commonMain/kotlin/org/fdroid/index/v1/IndexV1StreamReceiver.kt (100%) rename {index => libs/index}/src/commonMain/kotlin/org/fdroid/index/v1/PackageV1.kt (100%) rename {index => libs/index}/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2.kt (100%) rename {index => libs/index}/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2DiffStreamReceiver.kt (100%) rename {index => libs/index}/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2StreamReceiver.kt (100%) rename {index => libs/index}/src/commonMain/kotlin/org/fdroid/index/v2/PackageV2.kt (100%) rename {index => libs/index}/src/commonTest/kotlin/org/fdroid/index/IndexConverterTest.kt (67%) rename {index => libs/index}/src/commonTest/kotlin/org/fdroid/index/v1/IndexV1Test.kt (90%) rename {index => libs/index}/src/commonTest/kotlin/org/fdroid/index/v2/EntryTest.kt (86%) rename {index => libs/index}/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessorTest.kt (86%) rename {index => libs/index}/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2Test.kt (82%) rename {index => libs/index}/src/commonTest/resources/verification/invalid-MD5-MD5withRSA-v1.jar (100%) rename {index => libs/index}/src/commonTest/resources/verification/invalid-MD5-MD5withRSA-v2.jar (100%) rename {index => libs/index}/src/commonTest/resources/verification/invalid-MD5-SHA1withRSA-v1.jar (100%) rename {index => libs/index}/src/commonTest/resources/verification/invalid-MD5-SHA1withRSA-v2.jar (100%) rename {index => libs/index}/src/commonTest/resources/verification/invalid-SHA1-SHA1withRSA-v2.jar (100%) rename {index => libs/index}/src/commonTest/resources/verification/invalid-v1.jar (100%) rename {index => libs/index}/src/commonTest/resources/verification/invalid-v2.jar (100%) rename {index => libs/index}/src/commonTest/resources/verification/invalid-wrong-entry-v1.jar (100%) rename {index => libs/index}/src/commonTest/resources/verification/unsigned.jar (100%) rename {index => libs/index}/src/commonTest/resources/verification/valid-apksigner-v2.jar (100%) rename {index => libs/index}/src/commonTest/resources/verification/valid-v1.jar (100%) rename {index => libs/index}/src/commonTest/resources/verification/valid-v2.jar (100%) create mode 100644 libs/sharedTest/.gitignore create mode 100644 libs/sharedTest/build.gradle create mode 100644 libs/sharedTest/src/main/AndroidManifest.xml rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/diff-empty-max/1337.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/diff-empty-max/23.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/diff-empty-max/42.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/diff-empty-max/entry.jar (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/diff-empty-max/entry.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/diff-empty-mid/23.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/diff-empty-mid/42.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/diff-empty-mid/entry.jar (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/diff-empty-mid/entry.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/diff-empty-min/23.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/diff-empty-min/entry.jar (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/diff-empty-min/entry.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/entry-empty-v2.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/index-empty-v1.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/index-empty-v2.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/index-max-v1.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/index-max-v2.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/index-mid-v1.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/index-mid-v2.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/index-min-reordered-v2.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/index-min-v1.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/index-min-v2.json (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/testy.at.or.at_corrupt_app_package_name_index-v1.jar (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/testy.at.or.at_corrupt_package_name_index-v1.jar (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/testy.at.or.at_index-v1.jar (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/testy.at.or.at_no-.RSA_index-v1.jar (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/testy.at.or.at_no-.SF_index-v1.jar (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/testy.at.or.at_no-MANIFEST.MF_index-v1.jar (100%) rename {index/src/sharedTest/resources => libs/sharedTest/src/main/assets}/testy.at.or.at_no-signature_index-v1.jar (100%) rename {index/src/sharedTest => libs/sharedTest/src/main}/kotlin/org/fdroid/test/DiffUtils.kt (83%) rename {index/src/sharedTest => libs/sharedTest/src/main}/kotlin/org/fdroid/test/TestAppUtils.kt (91%) rename {index/src/sharedTest => libs/sharedTest/src/main}/kotlin/org/fdroid/test/TestDataEntryV2.kt (93%) rename {index/src/sharedTest => libs/sharedTest/src/main}/kotlin/org/fdroid/test/TestDataV1.kt (99%) rename {index/src/sharedTest => libs/sharedTest/src/main}/kotlin/org/fdroid/test/TestDataV2.kt (99%) rename {index/src/sharedTest => libs/sharedTest/src/main}/kotlin/org/fdroid/test/TestRepoUtils.kt (84%) rename {index/src/sharedTest => libs/sharedTest/src/main}/kotlin/org/fdroid/test/TestUtils.kt (82%) rename {index/src/sharedTest => libs/sharedTest/src/main}/kotlin/org/fdroid/test/TestVersionUtils.kt (98%) rename {index/src/sharedTest => libs/sharedTest/src/main}/kotlin/org/fdroid/test/VerifierConstants.kt (90%) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 21ee23063..7d7594f4d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -148,12 +148,12 @@ pages: only: - master script: - - ./gradlew :download:dokkaHtml :index:dokkaHtml :database:dokkaHtml - - mkdir public - - touch public/index.html - - cp -r download/build/dokka/html public/download - - cp -r index/build/dokka/html public/index - - cp -r database/build/dokka/html public/database + - ./gradlew :libs:download:dokkaHtml :libs:index:dokkaHtml :libs:database:dokkaHtml + - mkdir -p public/libs + - touch public/index.html public/libs/index.html + - cp -r libs/download/build/dokka/html public/libs/download + - cp -r libs/index/build/dokka/html public/libs/index + - cp -r libs/database/build/dokka/html public/libs/database artifacts: paths: - public @@ -174,7 +174,7 @@ deploy_nightly: - echo "${CI_PROJECT_PATH}-nightly" >> app/src/main/res/values/default_repos.xml - echo "${CI_PROJECT_URL}-nightly/raw/master/fdroid/repo" >> app/src/main/res/values/default_repos.xml - cat config/nightly-repo/repo.xml >> app/src/main/res/values/default_repos.xml - - export DB=`sed -n 's,.*version *= *\([0-9][0-9]*\).*,\1,p' database/src/main/java/org/fdroid/database/FDroidDatabase.kt` + - export DB=`sed -n 's,.*version *= *\([0-9][0-9]*\).*,\1,p' libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt` - export versionCode=`printf '%d%05d' $DB $(date '+%s'| cut -b1-8)` - sed -i "s,^\(\s*versionCode\) *[0-9].*,\1 $versionCode," app/build.gradle # build the APKs! diff --git a/README.md b/README.md index 0ba489fb7..500bb87d8 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,7 @@ from our site or [browse it in the repo](https://f-droid.org/app/org.fdroid.fdro Core F-Droid functionality is split into re-usable libraries to make using F-Droid technology in your own projects as easy as possible. -Note that all libraries are still in alpha stage. -While they work, their public APIs are still subject to change. - -* [download](download) library for handling (multi-platform) HTTP download of repository indexes and APKs +[More information about libraries](libs/README.md) ## Contributing diff --git a/app/build.gradle b/app/build.gradle index b9b0f6053..47eb3af07 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -142,7 +142,9 @@ android { } dependencies { - implementation project(":download") + implementation project(":libs:download") + implementation project(":libs:index") + implementation project(":libs:database") implementation 'androidx.appcompat:appcompat:1.3.0' implementation 'androidx.preference:preference:1.1.1' implementation 'androidx.gridlayout:gridlayout:1.0.0' diff --git a/download/README.md b/download/README.md deleted file mode 100644 index 37b51a47a..000000000 --- a/download/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# F-Droid multi-platform download library - -Note that advanced security and privacy features are only available for Android: - - * Rejection of TLS 1.1 and older as well as rejection of weak ciphers - * No DNS requests when using Tor as a proxy - * short TLS session timeout to prevent tracking and key re-use - -Other platforms besides Android have not been tested and might need additional work. - -## How to include in your project - -Add this to your `build.gradle` file -and replace `[version]` with the [latest version](gradle.properties): - - implementation 'org.fdroid:download:[version]' - -## Development - -You can list available gradle tasks by running the following command in the project root. - - ./gradlew :download:tasks - -### Making releases - -Bump version number in [`gradle.properties`](gradle.properties), ensure you didn't break a public API and run: - - ./gradlew :download:check :download:connectedCheck - ./gradlew :download:publish - ./gradlew closeAndReleaseRepository - -See https://github.com/vanniktech/gradle-maven-publish-plugin#gradle-maven-publish-plugin for more information. - -## License - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 diff --git a/index/LICENSE b/index/LICENSE deleted file mode 100644 index d64569567..000000000 --- a/index/LICENSE +++ /dev/null @@ -1,202 +0,0 @@ - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/index/README.md b/index/README.md deleted file mode 100644 index 130a7817a..000000000 --- a/index/README.md +++ /dev/null @@ -1,41 +0,0 @@ -# F-Droid multi-platform index library - -Note that some features are only available for Android: - - * index signature verification (`JarFile` is JVM only) - * index stream processing (`InputStream` is JVM only) - * index V2 diffing (reflection is JVM only) - * app device compatibility checking (requires Android) - -Other platforms besides Android have not been tested and might need additional work. - -## How to include in your project - -Add this to your `build.gradle` file -and replace `[version]` with the [latest version](gradle.properties): - - implementation 'org.fdroid:index:[version]' - -## Development - -You can list available gradle tasks by running the following command in the project root. - - ./gradlew :index:tasks - -### Making releases - -Bump version number in [`gradle.properties`](gradle.properties), ensure you didn't break a public API and run: - - ./gradlew :index:check :index:connectedCheck - ./gradlew :index:publish - ./gradlew closeAndReleaseRepository - -See https://github.com/vanniktech/gradle-maven-publish-plugin#gradle-maven-publish-plugin for more information. - -## License - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 diff --git a/download/LICENSE b/libs/LICENSE similarity index 100% rename from download/LICENSE rename to libs/LICENSE diff --git a/libs/README.md b/libs/README.md new file mode 100644 index 000000000..e9759399e --- /dev/null +++ b/libs/README.md @@ -0,0 +1,98 @@ +# F-Droid libraries + +Core F-Droid functionality is split into re-usable libraries +to make using F-Droid technology in your own projects as easy as possible. + +Note that all libraries are still in alpha stage. +While they work, their public APIs are still subject to change. + +* [download](libs/download) library for handling (multi-platform) HTTP download + of repository indexes and APKs +* [index](libs/index) library for parsing/verifying/creating repository indexes +* [database](libs/database) library to store and query F-Droid related information + in a Room-based database on Android + +## F-Droid multi-platform download library + +[API docs](https://fdroid.gitlab.io/fdroidclient/libs/download/) + +Note that advanced security and privacy features are only available for Android: + +* Rejection of TLS 1.1 and older as well as rejection of weak ciphers +* No DNS requests when using Tor as a proxy +* short TLS session timeout to prevent tracking and key re-use + +Other platforms besides Android have not been tested and might need additional work. + +### How to include in your project + +Add this to your `build.gradle` file +and replace `[version]` with the [latest version](download/index/gradle.properties): + + implementation 'org.fdroid:download:[version]' + +## F-Droid multi-platform index library + +[API docs](https://fdroid.gitlab.io/fdroidclient/libs/index/) + +Note that some features are only available for Android: + + * index signature verification (`JarFile` is JVM only) + * index stream processing (`InputStream` is JVM only) + * index V2 diffing (reflection is JVM only) + * app device compatibility checking (requires Android) + +Other platforms besides Android have not been tested and might need additional work. + +### How to include in your project + +Add this to your `build.gradle` file +and replace `[version]` with the [latest version](libs/index/gradle.properties): + + implementation 'org.fdroid:index:[version]' + +## F-Droid Android database library + +[API docs](https://fdroid.gitlab.io/fdroidclient/libs/database/) + +An Android-only database library to store and query F-Droid related information +such as repositories, apps and their versions. +This library should bring everything you need to build your own F-Droid client +that persists information. + +### How to include in your project + +Add this to your `build.gradle` file +and replace `[version]` with the [latest version](libs/database/gradle.properties): + + implementation 'org.fdroid:database:[version]' + +# Development + +You can list available gradle tasks by running the following command in the project root. + + ./gradlew :download:tasks + +Replace `download` with the name of the library you want to view tasks for. + +# Making releases + +Bump version number in the library's [`gradle.properties`](gradle.properties), +ensure you didn't break a public API and run: + + ./gradlew :download:check :index:connectedCheck + ./gradlew :download:publish + ./gradlew closeAndReleaseRepository + +Replace `download` with the name of the library you want to publish. + +See https://github.com/vanniktech/gradle-maven-publish-plugin#gradle-maven-publish-plugin +for more information. + +# License + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 diff --git a/database/.gitignore b/libs/database/.gitignore similarity index 100% rename from database/.gitignore rename to libs/database/.gitignore diff --git a/database/build.gradle b/libs/database/build.gradle similarity index 91% rename from database/build.gradle rename to libs/database/build.gradle index 5ecc75239..05f42c5e3 100644 --- a/database/build.gradle +++ b/libs/database/build.gradle @@ -10,7 +10,7 @@ android { compileSdkVersion 31 defaultConfig { - minSdkVersion 22 + minSdkVersion 21 consumerProguardFiles "consumer-rules.pro" javaCompileOptions { @@ -31,13 +31,9 @@ android { sourceSets { androidTest { java.srcDirs += "src/dbTest/java" - java.srcDirs += "src/sharedTest/kotlin" - assets.srcDirs += "src/sharedTest/resources" } test { java.srcDirs += "src/dbTest/java" - java.srcDirs += "src/sharedTest/kotlin" - assets.srcDirs += "src/sharedTest/resources" } } compileOptions { @@ -65,8 +61,8 @@ android { } dependencies { - implementation project(":download") - implementation project(":index") + implementation project(":libs:download") + implementation project(":libs:index") implementation 'androidx.core:core-ktx:1.8.0' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.4.1' @@ -81,6 +77,7 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2" + testImplementation project(":libs:sharedTest") testImplementation 'junit:junit:4.13.2' testImplementation 'io.mockk:mockk:1.12.4' testImplementation 'org.jetbrains.kotlin:kotlin-test' @@ -90,6 +87,7 @@ dependencies { testImplementation 'org.robolectric:robolectric:4.8.1' testImplementation 'commons-io:commons-io:2.6' + androidTestImplementation project(":libs:sharedTest") androidTestImplementation 'io.mockk:mockk-android:1.12.3' // 1.12.4 has strange error androidTestImplementation 'org.jetbrains.kotlin:kotlin-test' androidTestImplementation 'androidx.test.ext:junit:1.1.3' diff --git a/database/consumer-rules.pro b/libs/database/consumer-rules.pro similarity index 100% rename from database/consumer-rules.pro rename to libs/database/consumer-rules.pro diff --git a/database/proguard-rules.pro b/libs/database/proguard-rules.pro similarity index 100% rename from database/proguard-rules.pro rename to libs/database/proguard-rules.pro diff --git a/database/schemas/org.fdroid.database.FDroidDatabaseInt/1.json b/libs/database/schemas/org.fdroid.database.FDroidDatabaseInt/1.json similarity index 100% rename from database/schemas/org.fdroid.database.FDroidDatabaseInt/1.json rename to libs/database/schemas/org.fdroid.database.FDroidDatabaseInt/1.json diff --git a/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt similarity index 100% rename from database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt rename to libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt diff --git a/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt similarity index 100% rename from database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt rename to libs/database/src/dbTest/java/org/fdroid/database/AppListItemsTest.kt diff --git a/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt similarity index 100% rename from database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt rename to libs/database/src/dbTest/java/org/fdroid/database/AppOverviewItemsTest.kt diff --git a/database/src/dbTest/java/org/fdroid/database/AppTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/AppTest.kt similarity index 100% rename from database/src/dbTest/java/org/fdroid/database/AppTest.kt rename to libs/database/src/dbTest/java/org/fdroid/database/AppTest.kt diff --git a/database/src/dbTest/java/org/fdroid/database/DbTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/DbTest.kt similarity index 100% rename from database/src/dbTest/java/org/fdroid/database/DbTest.kt rename to libs/database/src/dbTest/java/org/fdroid/database/DbTest.kt diff --git a/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt similarity index 100% rename from database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt rename to libs/database/src/dbTest/java/org/fdroid/database/DbUpdateCheckerTest.kt diff --git a/database/src/dbTest/java/org/fdroid/database/IndexV1InsertTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/IndexV1InsertTest.kt similarity index 100% rename from database/src/dbTest/java/org/fdroid/database/IndexV1InsertTest.kt rename to libs/database/src/dbTest/java/org/fdroid/database/IndexV1InsertTest.kt diff --git a/database/src/dbTest/java/org/fdroid/database/IndexV2DiffTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/IndexV2DiffTest.kt similarity index 100% rename from database/src/dbTest/java/org/fdroid/database/IndexV2DiffTest.kt rename to libs/database/src/dbTest/java/org/fdroid/database/IndexV2DiffTest.kt diff --git a/database/src/dbTest/java/org/fdroid/database/IndexV2InsertTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/IndexV2InsertTest.kt similarity index 100% rename from database/src/dbTest/java/org/fdroid/database/IndexV2InsertTest.kt rename to libs/database/src/dbTest/java/org/fdroid/database/IndexV2InsertTest.kt diff --git a/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt similarity index 100% rename from database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt rename to libs/database/src/dbTest/java/org/fdroid/database/RepositoryDaoTest.kt diff --git a/database/src/dbTest/java/org/fdroid/database/RepositoryDiffTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/RepositoryDiffTest.kt similarity index 100% rename from database/src/dbTest/java/org/fdroid/database/RepositoryDiffTest.kt rename to libs/database/src/dbTest/java/org/fdroid/database/RepositoryDiffTest.kt diff --git a/database/src/dbTest/java/org/fdroid/database/TestUtils.kt b/libs/database/src/dbTest/java/org/fdroid/database/TestUtils.kt similarity index 100% rename from database/src/dbTest/java/org/fdroid/database/TestUtils.kt rename to libs/database/src/dbTest/java/org/fdroid/database/TestUtils.kt diff --git a/database/src/dbTest/java/org/fdroid/database/VersionTest.kt b/libs/database/src/dbTest/java/org/fdroid/database/VersionTest.kt similarity index 100% rename from database/src/dbTest/java/org/fdroid/database/VersionTest.kt rename to libs/database/src/dbTest/java/org/fdroid/database/VersionTest.kt diff --git a/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt b/libs/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt similarity index 100% rename from database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt rename to libs/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt diff --git a/database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt b/libs/database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt similarity index 100% rename from database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt rename to libs/database/src/dbTest/java/org/fdroid/index/v2/IndexV2UpdaterTest.kt diff --git a/database/src/main/AndroidManifest.xml b/libs/database/src/main/AndroidManifest.xml similarity index 100% rename from database/src/main/AndroidManifest.xml rename to libs/database/src/main/AndroidManifest.xml diff --git a/database/src/main/java/org/fdroid/database/App.kt b/libs/database/src/main/java/org/fdroid/database/App.kt similarity index 100% rename from database/src/main/java/org/fdroid/database/App.kt rename to libs/database/src/main/java/org/fdroid/database/App.kt diff --git a/database/src/main/java/org/fdroid/database/AppDao.kt b/libs/database/src/main/java/org/fdroid/database/AppDao.kt similarity index 100% rename from database/src/main/java/org/fdroid/database/AppDao.kt rename to libs/database/src/main/java/org/fdroid/database/AppDao.kt diff --git a/database/src/main/java/org/fdroid/database/AppPrefs.kt b/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt similarity index 100% rename from database/src/main/java/org/fdroid/database/AppPrefs.kt rename to libs/database/src/main/java/org/fdroid/database/AppPrefs.kt diff --git a/database/src/main/java/org/fdroid/database/AppPrefsDao.kt b/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt similarity index 100% rename from database/src/main/java/org/fdroid/database/AppPrefsDao.kt rename to libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt diff --git a/database/src/main/java/org/fdroid/database/Converters.kt b/libs/database/src/main/java/org/fdroid/database/Converters.kt similarity index 100% rename from database/src/main/java/org/fdroid/database/Converters.kt rename to libs/database/src/main/java/org/fdroid/database/Converters.kt diff --git a/database/src/main/java/org/fdroid/database/DbDiffUtils.kt b/libs/database/src/main/java/org/fdroid/database/DbDiffUtils.kt similarity index 100% rename from database/src/main/java/org/fdroid/database/DbDiffUtils.kt rename to libs/database/src/main/java/org/fdroid/database/DbDiffUtils.kt diff --git a/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt b/libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt similarity index 100% rename from database/src/main/java/org/fdroid/database/DbUpdateChecker.kt rename to libs/database/src/main/java/org/fdroid/database/DbUpdateChecker.kt diff --git a/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt b/libs/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt similarity index 100% rename from database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt rename to libs/database/src/main/java/org/fdroid/database/DbV1StreamReceiver.kt diff --git a/database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt b/libs/database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt similarity index 100% rename from database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt rename to libs/database/src/main/java/org/fdroid/database/DbV2DiffStreamReceiver.kt diff --git a/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt b/libs/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt similarity index 100% rename from database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt rename to libs/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabase.kt b/libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt similarity index 100% rename from database/src/main/java/org/fdroid/database/FDroidDatabase.kt rename to libs/database/src/main/java/org/fdroid/database/FDroidDatabase.kt diff --git a/database/src/main/java/org/fdroid/database/FDroidDatabaseHolder.kt b/libs/database/src/main/java/org/fdroid/database/FDroidDatabaseHolder.kt similarity index 100% rename from database/src/main/java/org/fdroid/database/FDroidDatabaseHolder.kt rename to libs/database/src/main/java/org/fdroid/database/FDroidDatabaseHolder.kt diff --git a/database/src/main/java/org/fdroid/database/Repository.kt b/libs/database/src/main/java/org/fdroid/database/Repository.kt similarity index 100% rename from database/src/main/java/org/fdroid/database/Repository.kt rename to libs/database/src/main/java/org/fdroid/database/Repository.kt diff --git a/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt similarity index 100% rename from database/src/main/java/org/fdroid/database/RepositoryDao.kt rename to libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt diff --git a/database/src/main/java/org/fdroid/database/Version.kt b/libs/database/src/main/java/org/fdroid/database/Version.kt similarity index 100% rename from database/src/main/java/org/fdroid/database/Version.kt rename to libs/database/src/main/java/org/fdroid/database/Version.kt diff --git a/database/src/main/java/org/fdroid/database/VersionDao.kt b/libs/database/src/main/java/org/fdroid/database/VersionDao.kt similarity index 100% rename from database/src/main/java/org/fdroid/database/VersionDao.kt rename to libs/database/src/main/java/org/fdroid/database/VersionDao.kt diff --git a/database/src/main/java/org/fdroid/download/DownloaderFactory.kt b/libs/database/src/main/java/org/fdroid/download/DownloaderFactory.kt similarity index 100% rename from database/src/main/java/org/fdroid/download/DownloaderFactory.kt rename to libs/database/src/main/java/org/fdroid/download/DownloaderFactory.kt diff --git a/database/src/main/java/org/fdroid/index/IndexUpdater.kt b/libs/database/src/main/java/org/fdroid/index/IndexUpdater.kt similarity index 100% rename from database/src/main/java/org/fdroid/index/IndexUpdater.kt rename to libs/database/src/main/java/org/fdroid/index/IndexUpdater.kt diff --git a/database/src/main/java/org/fdroid/index/RepoUpdater.kt b/libs/database/src/main/java/org/fdroid/index/RepoUpdater.kt similarity index 100% rename from database/src/main/java/org/fdroid/index/RepoUpdater.kt rename to libs/database/src/main/java/org/fdroid/index/RepoUpdater.kt diff --git a/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt b/libs/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt similarity index 100% rename from database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt rename to libs/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt diff --git a/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt b/libs/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt similarity index 100% rename from database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt rename to libs/database/src/main/java/org/fdroid/index/v2/IndexV2Updater.kt diff --git a/database/src/sharedTest b/libs/database/src/sharedTest similarity index 100% rename from database/src/sharedTest rename to libs/database/src/sharedTest diff --git a/database/src/test/java/org/fdroid/database/AppPrefsTest.kt b/libs/database/src/test/java/org/fdroid/database/AppPrefsTest.kt similarity index 100% rename from database/src/test/java/org/fdroid/database/AppPrefsTest.kt rename to libs/database/src/test/java/org/fdroid/database/AppPrefsTest.kt diff --git a/database/src/test/java/org/fdroid/database/ConvertersTest.kt b/libs/database/src/test/java/org/fdroid/database/ConvertersTest.kt similarity index 100% rename from database/src/test/java/org/fdroid/database/ConvertersTest.kt rename to libs/database/src/test/java/org/fdroid/database/ConvertersTest.kt diff --git a/download/.gitignore b/libs/download/.gitignore similarity index 100% rename from download/.gitignore rename to libs/download/.gitignore diff --git a/download/build.gradle b/libs/download/build.gradle similarity index 99% rename from download/build.gradle rename to libs/download/build.gradle index 9fbceb6e0..d8ddeb5cd 100644 --- a/download/build.gradle +++ b/libs/download/build.gradle @@ -99,7 +99,6 @@ android { } defaultConfig { minSdkVersion 21 - targetSdkVersion 25 testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' testInstrumentationRunnerArguments disableAnalytics: 'true' } diff --git a/download/gradle.properties b/libs/download/gradle.properties similarity index 100% rename from download/gradle.properties rename to libs/download/gradle.properties diff --git a/download/lint.xml b/libs/download/lint.xml similarity index 52% rename from download/lint.xml rename to libs/download/lint.xml index ce5cc7387..f8ca69ebc 100644 --- a/download/lint.xml +++ b/libs/download/lint.xml @@ -1,7 +1,5 @@ - - \ No newline at end of file diff --git a/download/src/androidAndroidTest/kotlin/org/fdroid/download/HttpManagerInstrumentationTest.kt b/libs/download/src/androidAndroidTest/kotlin/org/fdroid/download/HttpManagerInstrumentationTest.kt similarity index 100% rename from download/src/androidAndroidTest/kotlin/org/fdroid/download/HttpManagerInstrumentationTest.kt rename to libs/download/src/androidAndroidTest/kotlin/org/fdroid/download/HttpManagerInstrumentationTest.kt diff --git a/download/src/androidMain/AndroidManifest.xml b/libs/download/src/androidMain/AndroidManifest.xml similarity index 100% rename from download/src/androidMain/AndroidManifest.xml rename to libs/download/src/androidMain/AndroidManifest.xml diff --git a/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt similarity index 100% rename from download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt rename to libs/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt diff --git a/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloader.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloader.kt similarity index 100% rename from download/src/androidMain/kotlin/org/fdroid/download/HttpDownloader.kt rename to libs/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloader.kt diff --git a/download/src/androidMain/kotlin/org/fdroid/download/HttpManager.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpManager.kt similarity index 100% rename from download/src/androidMain/kotlin/org/fdroid/download/HttpManager.kt rename to libs/download/src/androidMain/kotlin/org/fdroid/download/HttpManager.kt diff --git a/download/src/androidMain/kotlin/org/fdroid/download/HttpPoster.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpPoster.kt similarity index 100% rename from download/src/androidMain/kotlin/org/fdroid/download/HttpPoster.kt rename to libs/download/src/androidMain/kotlin/org/fdroid/download/HttpPoster.kt diff --git a/download/src/androidMain/kotlin/org/fdroid/download/glide/DownloadRequestLoader.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/DownloadRequestLoader.kt similarity index 100% rename from download/src/androidMain/kotlin/org/fdroid/download/glide/DownloadRequestLoader.kt rename to libs/download/src/androidMain/kotlin/org/fdroid/download/glide/DownloadRequestLoader.kt diff --git a/download/src/androidMain/kotlin/org/fdroid/download/glide/HttpFetcher.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/HttpFetcher.kt similarity index 100% rename from download/src/androidMain/kotlin/org/fdroid/download/glide/HttpFetcher.kt rename to libs/download/src/androidMain/kotlin/org/fdroid/download/glide/HttpFetcher.kt diff --git a/download/src/androidMain/kotlin/org/fdroid/download/glide/HttpGlideUrlLoader.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/HttpGlideUrlLoader.kt similarity index 100% rename from download/src/androidMain/kotlin/org/fdroid/download/glide/HttpGlideUrlLoader.kt rename to libs/download/src/androidMain/kotlin/org/fdroid/download/glide/HttpGlideUrlLoader.kt diff --git a/download/src/androidMain/kotlin/org/fdroid/fdroid/HashUtils.kt b/libs/download/src/androidMain/kotlin/org/fdroid/fdroid/HashUtils.kt similarity index 100% rename from download/src/androidMain/kotlin/org/fdroid/fdroid/HashUtils.kt rename to libs/download/src/androidMain/kotlin/org/fdroid/fdroid/HashUtils.kt diff --git a/download/src/androidTest/kotlin/org/fdroid/download/HttpDownloaderTest.kt b/libs/download/src/androidTest/kotlin/org/fdroid/download/HttpDownloaderTest.kt similarity index 100% rename from download/src/androidTest/kotlin/org/fdroid/download/HttpDownloaderTest.kt rename to libs/download/src/androidTest/kotlin/org/fdroid/download/HttpDownloaderTest.kt diff --git a/download/src/androidTest/kotlin/org/fdroid/download/HttpPosterTest.kt b/libs/download/src/androidTest/kotlin/org/fdroid/download/HttpPosterTest.kt similarity index 100% rename from download/src/androidTest/kotlin/org/fdroid/download/HttpPosterTest.kt rename to libs/download/src/androidTest/kotlin/org/fdroid/download/HttpPosterTest.kt diff --git a/download/src/commonMain/kotlin/org/fdroid/download/DownloadRequest.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/DownloadRequest.kt similarity index 100% rename from download/src/commonMain/kotlin/org/fdroid/download/DownloadRequest.kt rename to libs/download/src/commonMain/kotlin/org/fdroid/download/DownloadRequest.kt diff --git a/download/src/commonMain/kotlin/org/fdroid/download/HeadInfo.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/HeadInfo.kt similarity index 100% rename from download/src/commonMain/kotlin/org/fdroid/download/HeadInfo.kt rename to libs/download/src/commonMain/kotlin/org/fdroid/download/HeadInfo.kt diff --git a/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt similarity index 100% rename from download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt rename to libs/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt diff --git a/download/src/commonMain/kotlin/org/fdroid/download/Mirror.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/Mirror.kt similarity index 100% rename from download/src/commonMain/kotlin/org/fdroid/download/Mirror.kt rename to libs/download/src/commonMain/kotlin/org/fdroid/download/Mirror.kt diff --git a/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt similarity index 100% rename from download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt rename to libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt diff --git a/download/src/commonMain/kotlin/org/fdroid/download/Proxy.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/Proxy.kt similarity index 100% rename from download/src/commonMain/kotlin/org/fdroid/download/Proxy.kt rename to libs/download/src/commonMain/kotlin/org/fdroid/download/Proxy.kt diff --git a/download/src/commonMain/kotlin/org/fdroid/fdroid/ProgressListener.kt b/libs/download/src/commonMain/kotlin/org/fdroid/fdroid/ProgressListener.kt similarity index 100% rename from download/src/commonMain/kotlin/org/fdroid/fdroid/ProgressListener.kt rename to libs/download/src/commonMain/kotlin/org/fdroid/fdroid/ProgressListener.kt diff --git a/download/src/commonTest/kotlin/org/fdroid/TestUtils.kt b/libs/download/src/commonTest/kotlin/org/fdroid/TestUtils.kt similarity index 100% rename from download/src/commonTest/kotlin/org/fdroid/TestUtils.kt rename to libs/download/src/commonTest/kotlin/org/fdroid/TestUtils.kt diff --git a/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerIntegrationTest.kt b/libs/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerIntegrationTest.kt similarity index 100% rename from download/src/commonTest/kotlin/org/fdroid/download/HttpManagerIntegrationTest.kt rename to libs/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerIntegrationTest.kt diff --git a/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerTest.kt b/libs/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerTest.kt similarity index 100% rename from download/src/commonTest/kotlin/org/fdroid/download/HttpManagerTest.kt rename to libs/download/src/commonTest/kotlin/org/fdroid/download/HttpManagerTest.kt diff --git a/download/src/commonTest/kotlin/org/fdroid/download/MirrorChooserTest.kt b/libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorChooserTest.kt similarity index 100% rename from download/src/commonTest/kotlin/org/fdroid/download/MirrorChooserTest.kt rename to libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorChooserTest.kt diff --git a/download/src/commonTest/kotlin/org/fdroid/download/MirrorTest.kt b/libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorTest.kt similarity index 100% rename from download/src/commonTest/kotlin/org/fdroid/download/MirrorTest.kt rename to libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorTest.kt diff --git a/download/src/jvmMain/kotlin/org/fdroid/download/HttpManager.kt b/libs/download/src/jvmMain/kotlin/org/fdroid/download/HttpManager.kt similarity index 100% rename from download/src/jvmMain/kotlin/org/fdroid/download/HttpManager.kt rename to libs/download/src/jvmMain/kotlin/org/fdroid/download/HttpManager.kt diff --git a/download/src/nativeMain/kotlin/org/fdroid/download/HttpManager.kt b/libs/download/src/nativeMain/kotlin/org/fdroid/download/HttpManager.kt similarity index 100% rename from download/src/nativeMain/kotlin/org/fdroid/download/HttpManager.kt rename to libs/download/src/nativeMain/kotlin/org/fdroid/download/HttpManager.kt diff --git a/index/.gitignore b/libs/index/.gitignore similarity index 100% rename from index/.gitignore rename to libs/index/.gitignore diff --git a/index/build.gradle b/libs/index/build.gradle similarity index 96% rename from index/build.gradle rename to libs/index/build.gradle index 37d40e84a..34353d10a 100644 --- a/index/build.gradle +++ b/libs/index/build.gradle @@ -39,19 +39,17 @@ kotlin { implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2" implementation 'io.github.microutils:kotlin-logging:2.1.21' - implementation project(":download") + implementation project(":libs:download") implementation "io.ktor:ktor-io:2.0.0" } } - sharedTest { + commonTest { dependencies { + implementation project(":libs:sharedTest") implementation kotlin('test') implementation "com.goncalossilva:resources:0.2.1" } } - commonTest { - dependsOn(sharedTest) - } // JVM is disabled for now, because Android app is including it instead of Android library jvmMain { dependencies { diff --git a/index/consumer-rules.pro b/libs/index/consumer-rules.pro similarity index 100% rename from index/consumer-rules.pro rename to libs/index/consumer-rules.pro diff --git a/index/gradle.properties b/libs/index/gradle.properties similarity index 100% rename from index/gradle.properties rename to libs/index/gradle.properties diff --git a/index/src/androidAndroidTest/kotlin/org/fdroid/BestLocaleTest.kt b/libs/index/src/androidAndroidTest/kotlin/org/fdroid/BestLocaleTest.kt similarity index 100% rename from index/src/androidAndroidTest/kotlin/org/fdroid/BestLocaleTest.kt rename to libs/index/src/androidAndroidTest/kotlin/org/fdroid/BestLocaleTest.kt diff --git a/index/src/androidAndroidTest/kotlin/org/fdroid/index/v1/IndexV1CreatorTest.kt b/libs/index/src/androidAndroidTest/kotlin/org/fdroid/index/v1/IndexV1CreatorTest.kt similarity index 100% rename from index/src/androidAndroidTest/kotlin/org/fdroid/index/v1/IndexV1CreatorTest.kt rename to libs/index/src/androidAndroidTest/kotlin/org/fdroid/index/v1/IndexV1CreatorTest.kt diff --git a/index/src/androidMain/AndroidManifest.xml b/libs/index/src/androidMain/AndroidManifest.xml similarity index 100% rename from index/src/androidMain/AndroidManifest.xml rename to libs/index/src/androidMain/AndroidManifest.xml diff --git a/index/src/androidMain/kotlin/org/fdroid/CompatibilityChecker.kt b/libs/index/src/androidMain/kotlin/org/fdroid/CompatibilityChecker.kt similarity index 100% rename from index/src/androidMain/kotlin/org/fdroid/CompatibilityChecker.kt rename to libs/index/src/androidMain/kotlin/org/fdroid/CompatibilityChecker.kt diff --git a/index/src/androidMain/kotlin/org/fdroid/LocaleChooser.kt b/libs/index/src/androidMain/kotlin/org/fdroid/LocaleChooser.kt similarity index 100% rename from index/src/androidMain/kotlin/org/fdroid/LocaleChooser.kt rename to libs/index/src/androidMain/kotlin/org/fdroid/LocaleChooser.kt diff --git a/index/src/androidMain/kotlin/org/fdroid/UpdateChecker.kt b/libs/index/src/androidMain/kotlin/org/fdroid/UpdateChecker.kt similarity index 100% rename from index/src/androidMain/kotlin/org/fdroid/UpdateChecker.kt rename to libs/index/src/androidMain/kotlin/org/fdroid/UpdateChecker.kt diff --git a/index/src/androidMain/kotlin/org/fdroid/index/IndexCreator.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/IndexCreator.kt similarity index 100% rename from index/src/androidMain/kotlin/org/fdroid/index/IndexCreator.kt rename to libs/index/src/androidMain/kotlin/org/fdroid/index/IndexCreator.kt diff --git a/index/src/androidMain/kotlin/org/fdroid/index/IndexParser.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/IndexParser.kt similarity index 100% rename from index/src/androidMain/kotlin/org/fdroid/index/IndexParser.kt rename to libs/index/src/androidMain/kotlin/org/fdroid/index/IndexParser.kt diff --git a/index/src/androidMain/kotlin/org/fdroid/index/IndexUtils.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/IndexUtils.kt similarity index 100% rename from index/src/androidMain/kotlin/org/fdroid/index/IndexUtils.kt rename to libs/index/src/androidMain/kotlin/org/fdroid/index/IndexUtils.kt diff --git a/index/src/androidMain/kotlin/org/fdroid/index/JarIndexVerifier.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/JarIndexVerifier.kt similarity index 100% rename from index/src/androidMain/kotlin/org/fdroid/index/JarIndexVerifier.kt rename to libs/index/src/androidMain/kotlin/org/fdroid/index/JarIndexVerifier.kt diff --git a/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Creator.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Creator.kt similarity index 100% rename from index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Creator.kt rename to libs/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Creator.kt diff --git a/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1StreamProcessor.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1StreamProcessor.kt similarity index 100% rename from index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1StreamProcessor.kt rename to libs/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1StreamProcessor.kt diff --git a/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Verifier.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Verifier.kt similarity index 100% rename from index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Verifier.kt rename to libs/index/src/androidMain/kotlin/org/fdroid/index/v1/IndexV1Verifier.kt diff --git a/index/src/androidMain/kotlin/org/fdroid/index/v2/EntryVerifier.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/EntryVerifier.kt similarity index 100% rename from index/src/androidMain/kotlin/org/fdroid/index/v2/EntryVerifier.kt rename to libs/index/src/androidMain/kotlin/org/fdroid/index/v2/EntryVerifier.kt diff --git a/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessor.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessor.kt similarity index 100% rename from index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessor.kt rename to libs/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessor.kt diff --git a/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessor.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessor.kt similarity index 100% rename from index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessor.kt rename to libs/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessor.kt diff --git a/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2StreamProcessor.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2StreamProcessor.kt similarity index 100% rename from index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2StreamProcessor.kt rename to libs/index/src/androidMain/kotlin/org/fdroid/index/v2/IndexV2StreamProcessor.kt diff --git a/index/src/androidMain/kotlin/org/fdroid/index/v2/ReflectionDiffer.kt b/libs/index/src/androidMain/kotlin/org/fdroid/index/v2/ReflectionDiffer.kt similarity index 100% rename from index/src/androidMain/kotlin/org/fdroid/index/v2/ReflectionDiffer.kt rename to libs/index/src/androidMain/kotlin/org/fdroid/index/v2/ReflectionDiffer.kt diff --git a/index/src/androidTest/kotlin/org/fdroid/CompatibilityCheckerTest.kt b/libs/index/src/androidTest/kotlin/org/fdroid/CompatibilityCheckerTest.kt similarity index 100% rename from index/src/androidTest/kotlin/org/fdroid/CompatibilityCheckerTest.kt rename to libs/index/src/androidTest/kotlin/org/fdroid/CompatibilityCheckerTest.kt diff --git a/index/src/androidTest/kotlin/org/fdroid/UpdateCheckerTest.kt b/libs/index/src/androidTest/kotlin/org/fdroid/UpdateCheckerTest.kt similarity index 100% rename from index/src/androidTest/kotlin/org/fdroid/UpdateCheckerTest.kt rename to libs/index/src/androidTest/kotlin/org/fdroid/UpdateCheckerTest.kt diff --git a/index/src/androidTest/kotlin/org/fdroid/index/v1/IndexV1StreamProcessorTest.kt b/libs/index/src/androidTest/kotlin/org/fdroid/index/v1/IndexV1StreamProcessorTest.kt similarity index 90% rename from index/src/androidTest/kotlin/org/fdroid/index/v1/IndexV1StreamProcessorTest.kt rename to libs/index/src/androidTest/kotlin/org/fdroid/index/v1/IndexV1StreamProcessorTest.kt index 143da029c..1240762b1 100644 --- a/index/src/androidTest/kotlin/org/fdroid/index/v1/IndexV1StreamProcessorTest.kt +++ b/libs/index/src/androidTest/kotlin/org/fdroid/index/v1/IndexV1StreamProcessorTest.kt @@ -1,6 +1,7 @@ package org.fdroid.index.v1 import kotlinx.serialization.SerializationException +import org.fdroid.index.assetPath import org.fdroid.index.v2.AntiFeatureV2 import org.fdroid.index.v2.CategoryV2 import org.fdroid.index.v2.IndexV2 @@ -28,38 +29,34 @@ internal class IndexV1StreamProcessorTest { @Test fun testEmpty() { - testStreamProcessing("src/sharedTest/resources/index-empty-v1.json", - TestDataEmptyV2.index.v1compat()) + testStreamProcessing("$assetPath/index-empty-v1.json", TestDataEmptyV2.index.v1compat()) } @Test(expected = OldIndexException::class) fun testEmptyEqualTimestamp() { - testStreamProcessing("src/sharedTest/resources/index-empty-v1.json", + testStreamProcessing("$assetPath/index-empty-v1.json", TestDataEmptyV2.index.v1compat(), TestDataEmptyV2.index.repo.timestamp) } @Test(expected = OldIndexException::class) fun testEmptyHigherTimestamp() { - testStreamProcessing("src/sharedTest/resources/index-empty-v1.json", + testStreamProcessing("$assetPath/index-empty-v1.json", TestDataEmptyV2.index.v1compat(), TestDataEmptyV2.index.repo.timestamp + 1) } @Test fun testMin() { - testStreamProcessing("src/sharedTest/resources/index-min-v1.json", - TestDataMinV2.index.v1compat()) + testStreamProcessing("$assetPath/index-min-v1.json", TestDataMinV2.index.v1compat()) } @Test fun testMid() { - testStreamProcessing("src/sharedTest/resources/index-mid-v1.json", - TestDataMidV2.indexCompat) + testStreamProcessing("$assetPath/index-mid-v1.json", TestDataMidV2.indexCompat) } @Test fun testMax() { - testStreamProcessing("src/sharedTest/resources/index-max-v1.json", - TestDataMaxV2.indexCompat) + testStreamProcessing("$assetPath/index-max-v1.json", TestDataMaxV2.indexCompat) } @Test diff --git a/index/src/androidTest/kotlin/org/fdroid/index/v1/IndexV1VerifierTest.kt b/libs/index/src/androidTest/kotlin/org/fdroid/index/v1/IndexV1VerifierTest.kt similarity index 100% rename from index/src/androidTest/kotlin/org/fdroid/index/v1/IndexV1VerifierTest.kt rename to libs/index/src/androidTest/kotlin/org/fdroid/index/v1/IndexV1VerifierTest.kt diff --git a/index/src/androidTest/kotlin/org/fdroid/index/v2/EntryVerifierTest.kt b/libs/index/src/androidTest/kotlin/org/fdroid/index/v2/EntryVerifierTest.kt similarity index 100% rename from index/src/androidTest/kotlin/org/fdroid/index/v2/EntryVerifierTest.kt rename to libs/index/src/androidTest/kotlin/org/fdroid/index/v2/EntryVerifierTest.kt diff --git a/index/src/androidTest/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessorTest.kt b/libs/index/src/androidTest/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessorTest.kt similarity index 90% rename from index/src/androidTest/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessorTest.kt rename to libs/index/src/androidTest/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessorTest.kt index fcc7c3df2..5ddb65f86 100644 --- a/index/src/androidTest/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessorTest.kt +++ b/libs/index/src/androidTest/kotlin/org/fdroid/index/v2/IndexV2FullStreamProcessorTest.kt @@ -2,6 +2,7 @@ package org.fdroid.index.v2 import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerializationException +import org.fdroid.index.assetPath import org.fdroid.test.TestDataEmptyV2 import org.fdroid.test.TestDataMaxV2 import org.fdroid.test.TestDataMidV2 @@ -27,29 +28,27 @@ internal class IndexV2FullStreamProcessorTest { @Test fun testEmpty() { - testStreamProcessing("src/sharedTest/resources/index-empty-v2.json", - TestDataEmptyV2.index, 0) + testStreamProcessing("$assetPath/index-empty-v2.json", TestDataEmptyV2.index, 0) } @Test fun testMin() { - testStreamProcessing("src/sharedTest/resources/index-min-v2.json", TestDataMinV2.index, 1) + testStreamProcessing("$assetPath/index-min-v2.json", TestDataMinV2.index, 1) } @Test fun testMinReordered() { - testStreamProcessing("src/sharedTest/resources/index-min-reordered-v2.json", - TestDataMinV2.index, 1) + testStreamProcessing("$assetPath/index-min-reordered-v2.json", TestDataMinV2.index, 1) } @Test fun testMid() { - testStreamProcessing("src/sharedTest/resources/index-mid-v2.json", TestDataMidV2.index, 2) + testStreamProcessing("$assetPath/index-mid-v2.json", TestDataMidV2.index, 2) } @Test fun testMax() { - testStreamProcessing("src/sharedTest/resources/index-max-v2.json", TestDataMaxV2.index, 3) + testStreamProcessing("$assetPath/index-max-v2.json", TestDataMaxV2.index, 3) } @Test diff --git a/index/src/androidTest/kotlin/org/fdroid/index/v2/ReflectionDifferTest.kt b/libs/index/src/androidTest/kotlin/org/fdroid/index/v2/ReflectionDifferTest.kt similarity index 83% rename from index/src/androidTest/kotlin/org/fdroid/index/v2/ReflectionDifferTest.kt rename to libs/index/src/androidTest/kotlin/org/fdroid/index/v2/ReflectionDifferTest.kt index fe55f2219..735302ebb 100644 --- a/index/src/androidTest/kotlin/org/fdroid/index/v2/ReflectionDifferTest.kt +++ b/libs/index/src/androidTest/kotlin/org/fdroid/index/v2/ReflectionDifferTest.kt @@ -8,6 +8,7 @@ import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonObject import org.fdroid.index.IndexParser import org.fdroid.index.IndexParser.json +import org.fdroid.index.assetPath import org.fdroid.index.parseV2 import org.fdroid.test.DiffUtils.clean import org.fdroid.test.DiffUtils.cleanMetadata @@ -25,44 +26,44 @@ internal class ReflectionDifferTest { @Test fun testEmptyToMin() = testDiff( - diffPath = "src/sharedTest/resources/diff-empty-min/23.json", - startPath = "src/sharedTest/resources/index-empty-v2.json", - endPath = "src/sharedTest/resources/index-min-v2.json", + diffPath = "$assetPath/diff-empty-min/23.json", + startPath = "$assetPath/index-empty-v2.json", + endPath = "$assetPath/index-min-v2.json", ) @Test fun testEmptyToMid() = testDiff( - diffPath = "src/sharedTest/resources/diff-empty-mid/23.json", - startPath = "src/sharedTest/resources/index-empty-v2.json", - endPath = "src/sharedTest/resources/index-mid-v2.json", + diffPath = "$assetPath/diff-empty-mid/23.json", + startPath = "$assetPath/index-empty-v2.json", + endPath = "$assetPath/index-mid-v2.json", ) @Test fun testEmptyToMax() = testDiff( - diffPath = "src/sharedTest/resources/diff-empty-max/23.json", - startPath = "src/sharedTest/resources/index-empty-v2.json", - endPath = "src/sharedTest/resources/index-max-v2.json", + diffPath = "$assetPath/diff-empty-max/23.json", + startPath = "$assetPath/index-empty-v2.json", + endPath = "$assetPath/index-max-v2.json", ) @Test fun testMinToMid() = testDiff( - diffPath = "src/sharedTest/resources/diff-empty-mid/42.json", - startPath = "src/sharedTest/resources/index-min-v2.json", - endPath = "src/sharedTest/resources/index-mid-v2.json", + diffPath = "$assetPath/diff-empty-mid/42.json", + startPath = "$assetPath/index-min-v2.json", + endPath = "$assetPath/index-mid-v2.json", ) @Test fun testMinToMax() = testDiff( - diffPath = "src/sharedTest/resources/diff-empty-max/42.json", - startPath = "src/sharedTest/resources/index-min-v2.json", - endPath = "src/sharedTest/resources/index-max-v2.json", + diffPath = "$assetPath/diff-empty-max/42.json", + startPath = "$assetPath/index-min-v2.json", + endPath = "$assetPath/index-max-v2.json", ) @Test fun testMidToMax() = testDiff( - diffPath = "src/sharedTest/resources/diff-empty-max/1337.json", - startPath = "src/sharedTest/resources/index-mid-v2.json", - endPath = "src/sharedTest/resources/index-max-v2.json", + diffPath = "$assetPath/diff-empty-max/1337.json", + startPath = "$assetPath/index-mid-v2.json", + endPath = "$assetPath/index-max-v2.json", ) @Test diff --git a/index/src/commonMain/kotlin/org/fdroid/index/IndexConverter.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/IndexConverter.kt similarity index 100% rename from index/src/commonMain/kotlin/org/fdroid/index/IndexConverter.kt rename to libs/index/src/commonMain/kotlin/org/fdroid/index/IndexConverter.kt diff --git a/index/src/commonMain/kotlin/org/fdroid/index/IndexParser.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/IndexParser.kt similarity index 100% rename from index/src/commonMain/kotlin/org/fdroid/index/IndexParser.kt rename to libs/index/src/commonMain/kotlin/org/fdroid/index/IndexParser.kt diff --git a/index/src/commonMain/kotlin/org/fdroid/index/v1/AppV1.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/AppV1.kt similarity index 100% rename from index/src/commonMain/kotlin/org/fdroid/index/v1/AppV1.kt rename to libs/index/src/commonMain/kotlin/org/fdroid/index/v1/AppV1.kt diff --git a/index/src/commonMain/kotlin/org/fdroid/index/v1/IndexV1.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/IndexV1.kt similarity index 100% rename from index/src/commonMain/kotlin/org/fdroid/index/v1/IndexV1.kt rename to libs/index/src/commonMain/kotlin/org/fdroid/index/v1/IndexV1.kt diff --git a/index/src/commonMain/kotlin/org/fdroid/index/v1/IndexV1StreamReceiver.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/IndexV1StreamReceiver.kt similarity index 100% rename from index/src/commonMain/kotlin/org/fdroid/index/v1/IndexV1StreamReceiver.kt rename to libs/index/src/commonMain/kotlin/org/fdroid/index/v1/IndexV1StreamReceiver.kt diff --git a/index/src/commonMain/kotlin/org/fdroid/index/v1/PackageV1.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/v1/PackageV1.kt similarity index 100% rename from index/src/commonMain/kotlin/org/fdroid/index/v1/PackageV1.kt rename to libs/index/src/commonMain/kotlin/org/fdroid/index/v1/PackageV1.kt diff --git a/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2.kt similarity index 100% rename from index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2.kt rename to libs/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2.kt diff --git a/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2DiffStreamReceiver.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2DiffStreamReceiver.kt similarity index 100% rename from index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2DiffStreamReceiver.kt rename to libs/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2DiffStreamReceiver.kt diff --git a/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2StreamReceiver.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2StreamReceiver.kt similarity index 100% rename from index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2StreamReceiver.kt rename to libs/index/src/commonMain/kotlin/org/fdroid/index/v2/IndexV2StreamReceiver.kt diff --git a/index/src/commonMain/kotlin/org/fdroid/index/v2/PackageV2.kt b/libs/index/src/commonMain/kotlin/org/fdroid/index/v2/PackageV2.kt similarity index 100% rename from index/src/commonMain/kotlin/org/fdroid/index/v2/PackageV2.kt rename to libs/index/src/commonMain/kotlin/org/fdroid/index/v2/PackageV2.kt diff --git a/index/src/commonTest/kotlin/org/fdroid/index/IndexConverterTest.kt b/libs/index/src/commonTest/kotlin/org/fdroid/index/IndexConverterTest.kt similarity index 67% rename from index/src/commonTest/kotlin/org/fdroid/index/IndexConverterTest.kt rename to libs/index/src/commonTest/kotlin/org/fdroid/index/IndexConverterTest.kt index b3cf9d28e..6a5133d6a 100644 --- a/index/src/commonTest/kotlin/org/fdroid/index/IndexConverterTest.kt +++ b/libs/index/src/commonTest/kotlin/org/fdroid/index/IndexConverterTest.kt @@ -12,30 +12,28 @@ import org.fdroid.test.v1compat import kotlin.test.Test import kotlin.test.assertEquals +internal const val assetPath = "../sharedTest/src/main/assets" + internal class IndexConverterTest { @Test fun testEmpty() { - testConversation("src/sharedTest/resources/index-empty-v1.json", - TestDataEmptyV2.index.v1compat()) + testConversation("$assetPath/index-empty-v1.json", TestDataEmptyV2.index.v1compat()) } @Test fun testMin() { - testConversation("src/sharedTest/resources/index-min-v1.json", - TestDataMinV2.index.v1compat()) + testConversation("$assetPath/index-min-v1.json", TestDataMinV2.index.v1compat()) } @Test fun testMid() { - testConversation("src/sharedTest/resources/index-mid-v1.json", - TestDataMidV2.indexCompat) + testConversation("$assetPath/index-mid-v1.json", TestDataMidV2.indexCompat) } @Test fun testMax() { - testConversation("src/sharedTest/resources/index-max-v1.json", - TestDataMaxV2.indexCompat) + testConversation("$assetPath/index-max-v1.json", TestDataMaxV2.indexCompat) } private fun testConversation(file: String, expectedIndex: IndexV2) { diff --git a/index/src/commonTest/kotlin/org/fdroid/index/v1/IndexV1Test.kt b/libs/index/src/commonTest/kotlin/org/fdroid/index/v1/IndexV1Test.kt similarity index 90% rename from index/src/commonTest/kotlin/org/fdroid/index/v1/IndexV1Test.kt rename to libs/index/src/commonTest/kotlin/org/fdroid/index/v1/IndexV1Test.kt index 2d7710c5c..b8aa10e7b 100644 --- a/index/src/commonTest/kotlin/org/fdroid/index/v1/IndexV1Test.kt +++ b/libs/index/src/commonTest/kotlin/org/fdroid/index/v1/IndexV1Test.kt @@ -3,6 +3,7 @@ package org.fdroid.index.v1 import com.goncalossilva.resources.Resource import kotlinx.serialization.SerializationException import org.fdroid.index.IndexParser.parseV1 +import org.fdroid.index.assetPath import org.fdroid.test.TestDataEmptyV1 import org.fdroid.test.TestDataMaxV1 import org.fdroid.test.TestDataMidV1 @@ -16,7 +17,7 @@ internal class IndexV1Test { @Test fun testIndexEmptyV1() { - val indexRes = Resource("src/sharedTest/resources/index-empty-v1.json") + val indexRes = Resource("$assetPath/index-empty-v1.json") val indexStr = indexRes.readText() val index = parseV1(indexStr) assertEquals(TestDataEmptyV1.index, index) @@ -24,7 +25,7 @@ internal class IndexV1Test { @Test fun testIndexMinV1() { - val indexRes = Resource("src/sharedTest/resources/index-min-v1.json") + val indexRes = Resource("$assetPath/index-min-v1.json") val indexStr = indexRes.readText() val index = parseV1(indexStr) assertEquals(TestDataMinV1.index, index) @@ -32,7 +33,7 @@ internal class IndexV1Test { @Test fun testIndexMidV1() { - val indexRes = Resource("src/sharedTest/resources/index-mid-v1.json") + val indexRes = Resource("$assetPath/index-mid-v1.json") val indexStr = indexRes.readText() val index = parseV1(indexStr) assertEquals(TestDataMidV1.index, index) @@ -40,7 +41,7 @@ internal class IndexV1Test { @Test fun testIndexMaxV1() { - val indexRes = Resource("src/sharedTest/resources/index-max-v1.json") + val indexRes = Resource("$assetPath/index-max-v1.json") val indexStr = indexRes.readText() val index = parseV1(indexStr) assertEquals(TestDataMaxV1.index, index) diff --git a/index/src/commonTest/kotlin/org/fdroid/index/v2/EntryTest.kt b/libs/index/src/commonTest/kotlin/org/fdroid/index/v2/EntryTest.kt similarity index 86% rename from index/src/commonTest/kotlin/org/fdroid/index/v2/EntryTest.kt rename to libs/index/src/commonTest/kotlin/org/fdroid/index/v2/EntryTest.kt index db078b6b2..af1b24c1a 100644 --- a/index/src/commonTest/kotlin/org/fdroid/index/v2/EntryTest.kt +++ b/libs/index/src/commonTest/kotlin/org/fdroid/index/v2/EntryTest.kt @@ -3,6 +3,7 @@ package org.fdroid.index.v2 import com.goncalossilva.resources.Resource import kotlinx.serialization.SerializationException import org.fdroid.index.IndexParser +import org.fdroid.index.assetPath import org.fdroid.test.TestDataEntryV2 import org.junit.Test import kotlin.test.assertContains @@ -13,26 +14,22 @@ internal class EntryTest { @Test fun testEmpty() { - testEntryEquality("src/sharedTest/resources/entry-empty-v2.json", - TestDataEntryV2.empty) + testEntryEquality("$assetPath/entry-empty-v2.json", TestDataEntryV2.empty) } @Test fun testEmptyToMin() { - testEntryEquality("src/sharedTest/resources/diff-empty-min/entry.json", - TestDataEntryV2.emptyToMin) + testEntryEquality("$assetPath/diff-empty-min/entry.json", TestDataEntryV2.emptyToMin) } @Test fun testEmptyToMid() { - testEntryEquality("src/sharedTest/resources/diff-empty-mid/entry.json", - TestDataEntryV2.emptyToMid) + testEntryEquality("$assetPath/diff-empty-mid/entry.json", TestDataEntryV2.emptyToMid) } @Test fun testEmptyToMax() { - testEntryEquality("src/sharedTest/resources/diff-empty-max/entry.json", - TestDataEntryV2.emptyToMax) + testEntryEquality("$assetPath/diff-empty-max/entry.json", TestDataEntryV2.emptyToMax) } @Test diff --git a/index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessorTest.kt b/libs/index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessorTest.kt similarity index 86% rename from index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessorTest.kt rename to libs/index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessorTest.kt index 28cd60753..1f146e417 100644 --- a/index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessorTest.kt +++ b/libs/index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2DiffStreamProcessorTest.kt @@ -5,6 +5,7 @@ import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject import org.fdroid.index.IndexParser +import org.fdroid.index.assetPath import org.junit.Test import java.io.ByteArrayInputStream import java.io.File @@ -16,22 +17,22 @@ import kotlin.test.fail internal class IndexV2DiffStreamProcessorTest { @Test - fun testEmptyToMin() = testDiff("src/sharedTest/resources/diff-empty-min/23.json", 1) + fun testEmptyToMin() = testDiff("$assetPath/diff-empty-min/23.json", 1) @Test - fun testEmptyToMid() = testDiff("src/sharedTest/resources/diff-empty-mid/23.json", 2) + fun testEmptyToMid() = testDiff("$assetPath/diff-empty-mid/23.json", 2) @Test - fun testEmptyToMax() = testDiff("src/sharedTest/resources/diff-empty-max/23.json", 3) + fun testEmptyToMax() = testDiff("$assetPath/diff-empty-max/23.json", 3) @Test - fun testMinToMid() = testDiff("src/sharedTest/resources/diff-empty-mid/42.json", 2) + fun testMinToMid() = testDiff("$assetPath/diff-empty-mid/42.json", 2) @Test - fun testMinToMax() = testDiff("src/sharedTest/resources/diff-empty-max/42.json", 3) + fun testMinToMax() = testDiff("$assetPath/diff-empty-max/42.json", 3) @Test - fun testMidToMax() = testDiff("src/sharedTest/resources/diff-empty-max/1337.json", 2) + fun testMidToMax() = testDiff("$assetPath/diff-empty-max/1337.json", 2) @Test fun testRemovePackage() { diff --git a/index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2Test.kt b/libs/index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2Test.kt similarity index 82% rename from index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2Test.kt rename to libs/index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2Test.kt index ae161840e..06bfd7601 100644 --- a/index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2Test.kt +++ b/libs/index/src/commonTest/kotlin/org/fdroid/index/v2/IndexV2Test.kt @@ -3,6 +3,7 @@ package org.fdroid.index.v2 import com.goncalossilva.resources.Resource import kotlinx.serialization.SerializationException import org.fdroid.index.IndexParser.parseV2 +import org.fdroid.index.assetPath import org.fdroid.test.TestDataEmptyV2 import org.fdroid.test.TestDataMaxV2 import org.fdroid.test.TestDataMidV2 @@ -16,28 +17,27 @@ internal class IndexV2Test { @Test fun testEmpty() { - testIndexEquality("src/sharedTest/resources/index-empty-v2.json", TestDataEmptyV2.index) + testIndexEquality("$assetPath/index-empty-v2.json", TestDataEmptyV2.index) } @Test fun testMin() { - testIndexEquality("src/sharedTest/resources/index-min-v2.json", TestDataMinV2.index) + testIndexEquality("$assetPath/index-min-v2.json", TestDataMinV2.index) } @Test fun testMinReordered() { - testIndexEquality("src/sharedTest/resources/index-min-reordered-v2.json", - TestDataMinV2.index) + testIndexEquality("$assetPath/index-min-reordered-v2.json", TestDataMinV2.index) } @Test fun testMid() { - testIndexEquality("src/sharedTest/resources/index-mid-v2.json", TestDataMidV2.index) + testIndexEquality("$assetPath/index-mid-v2.json", TestDataMidV2.index) } @Test fun testMax() { - testIndexEquality("src/sharedTest/resources/index-max-v2.json", TestDataMaxV2.index) + testIndexEquality("$assetPath/index-max-v2.json", TestDataMaxV2.index) } @Test diff --git a/index/src/commonTest/resources/verification/invalid-MD5-MD5withRSA-v1.jar b/libs/index/src/commonTest/resources/verification/invalid-MD5-MD5withRSA-v1.jar similarity index 100% rename from index/src/commonTest/resources/verification/invalid-MD5-MD5withRSA-v1.jar rename to libs/index/src/commonTest/resources/verification/invalid-MD5-MD5withRSA-v1.jar diff --git a/index/src/commonTest/resources/verification/invalid-MD5-MD5withRSA-v2.jar b/libs/index/src/commonTest/resources/verification/invalid-MD5-MD5withRSA-v2.jar similarity index 100% rename from index/src/commonTest/resources/verification/invalid-MD5-MD5withRSA-v2.jar rename to libs/index/src/commonTest/resources/verification/invalid-MD5-MD5withRSA-v2.jar diff --git a/index/src/commonTest/resources/verification/invalid-MD5-SHA1withRSA-v1.jar b/libs/index/src/commonTest/resources/verification/invalid-MD5-SHA1withRSA-v1.jar similarity index 100% rename from index/src/commonTest/resources/verification/invalid-MD5-SHA1withRSA-v1.jar rename to libs/index/src/commonTest/resources/verification/invalid-MD5-SHA1withRSA-v1.jar diff --git a/index/src/commonTest/resources/verification/invalid-MD5-SHA1withRSA-v2.jar b/libs/index/src/commonTest/resources/verification/invalid-MD5-SHA1withRSA-v2.jar similarity index 100% rename from index/src/commonTest/resources/verification/invalid-MD5-SHA1withRSA-v2.jar rename to libs/index/src/commonTest/resources/verification/invalid-MD5-SHA1withRSA-v2.jar diff --git a/index/src/commonTest/resources/verification/invalid-SHA1-SHA1withRSA-v2.jar b/libs/index/src/commonTest/resources/verification/invalid-SHA1-SHA1withRSA-v2.jar similarity index 100% rename from index/src/commonTest/resources/verification/invalid-SHA1-SHA1withRSA-v2.jar rename to libs/index/src/commonTest/resources/verification/invalid-SHA1-SHA1withRSA-v2.jar diff --git a/index/src/commonTest/resources/verification/invalid-v1.jar b/libs/index/src/commonTest/resources/verification/invalid-v1.jar similarity index 100% rename from index/src/commonTest/resources/verification/invalid-v1.jar rename to libs/index/src/commonTest/resources/verification/invalid-v1.jar diff --git a/index/src/commonTest/resources/verification/invalid-v2.jar b/libs/index/src/commonTest/resources/verification/invalid-v2.jar similarity index 100% rename from index/src/commonTest/resources/verification/invalid-v2.jar rename to libs/index/src/commonTest/resources/verification/invalid-v2.jar diff --git a/index/src/commonTest/resources/verification/invalid-wrong-entry-v1.jar b/libs/index/src/commonTest/resources/verification/invalid-wrong-entry-v1.jar similarity index 100% rename from index/src/commonTest/resources/verification/invalid-wrong-entry-v1.jar rename to libs/index/src/commonTest/resources/verification/invalid-wrong-entry-v1.jar diff --git a/index/src/commonTest/resources/verification/unsigned.jar b/libs/index/src/commonTest/resources/verification/unsigned.jar similarity index 100% rename from index/src/commonTest/resources/verification/unsigned.jar rename to libs/index/src/commonTest/resources/verification/unsigned.jar diff --git a/index/src/commonTest/resources/verification/valid-apksigner-v2.jar b/libs/index/src/commonTest/resources/verification/valid-apksigner-v2.jar similarity index 100% rename from index/src/commonTest/resources/verification/valid-apksigner-v2.jar rename to libs/index/src/commonTest/resources/verification/valid-apksigner-v2.jar diff --git a/index/src/commonTest/resources/verification/valid-v1.jar b/libs/index/src/commonTest/resources/verification/valid-v1.jar similarity index 100% rename from index/src/commonTest/resources/verification/valid-v1.jar rename to libs/index/src/commonTest/resources/verification/valid-v1.jar diff --git a/index/src/commonTest/resources/verification/valid-v2.jar b/libs/index/src/commonTest/resources/verification/valid-v2.jar similarity index 100% rename from index/src/commonTest/resources/verification/valid-v2.jar rename to libs/index/src/commonTest/resources/verification/valid-v2.jar diff --git a/libs/sharedTest/.gitignore b/libs/sharedTest/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/libs/sharedTest/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/libs/sharedTest/build.gradle b/libs/sharedTest/build.gradle new file mode 100644 index 000000000..25be3d328 --- /dev/null +++ b/libs/sharedTest/build.gradle @@ -0,0 +1,27 @@ +plugins { + id 'kotlin-android' + id 'com.android.library' + id "org.jlleitschuh.gradle.ktlint" version "10.2.1" +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +// not really an Android library, but index is not publishing for JVM at the moment +android { + compileSdkVersion 31 + defaultConfig { + minSdkVersion 21 + } +} + +dependencies { + implementation project(":libs:index") + + implementation 'org.jetbrains.kotlin:kotlin-test' + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2" +} + +apply from: "${rootProject.rootDir}/gradle/ktlint.gradle" diff --git a/libs/sharedTest/src/main/AndroidManifest.xml b/libs/sharedTest/src/main/AndroidManifest.xml new file mode 100644 index 000000000..0955a1977 --- /dev/null +++ b/libs/sharedTest/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/index/src/sharedTest/resources/diff-empty-max/1337.json b/libs/sharedTest/src/main/assets/diff-empty-max/1337.json similarity index 100% rename from index/src/sharedTest/resources/diff-empty-max/1337.json rename to libs/sharedTest/src/main/assets/diff-empty-max/1337.json diff --git a/index/src/sharedTest/resources/diff-empty-max/23.json b/libs/sharedTest/src/main/assets/diff-empty-max/23.json similarity index 100% rename from index/src/sharedTest/resources/diff-empty-max/23.json rename to libs/sharedTest/src/main/assets/diff-empty-max/23.json diff --git a/index/src/sharedTest/resources/diff-empty-max/42.json b/libs/sharedTest/src/main/assets/diff-empty-max/42.json similarity index 100% rename from index/src/sharedTest/resources/diff-empty-max/42.json rename to libs/sharedTest/src/main/assets/diff-empty-max/42.json diff --git a/index/src/sharedTest/resources/diff-empty-max/entry.jar b/libs/sharedTest/src/main/assets/diff-empty-max/entry.jar similarity index 100% rename from index/src/sharedTest/resources/diff-empty-max/entry.jar rename to libs/sharedTest/src/main/assets/diff-empty-max/entry.jar diff --git a/index/src/sharedTest/resources/diff-empty-max/entry.json b/libs/sharedTest/src/main/assets/diff-empty-max/entry.json similarity index 100% rename from index/src/sharedTest/resources/diff-empty-max/entry.json rename to libs/sharedTest/src/main/assets/diff-empty-max/entry.json diff --git a/index/src/sharedTest/resources/diff-empty-mid/23.json b/libs/sharedTest/src/main/assets/diff-empty-mid/23.json similarity index 100% rename from index/src/sharedTest/resources/diff-empty-mid/23.json rename to libs/sharedTest/src/main/assets/diff-empty-mid/23.json diff --git a/index/src/sharedTest/resources/diff-empty-mid/42.json b/libs/sharedTest/src/main/assets/diff-empty-mid/42.json similarity index 100% rename from index/src/sharedTest/resources/diff-empty-mid/42.json rename to libs/sharedTest/src/main/assets/diff-empty-mid/42.json diff --git a/index/src/sharedTest/resources/diff-empty-mid/entry.jar b/libs/sharedTest/src/main/assets/diff-empty-mid/entry.jar similarity index 100% rename from index/src/sharedTest/resources/diff-empty-mid/entry.jar rename to libs/sharedTest/src/main/assets/diff-empty-mid/entry.jar diff --git a/index/src/sharedTest/resources/diff-empty-mid/entry.json b/libs/sharedTest/src/main/assets/diff-empty-mid/entry.json similarity index 100% rename from index/src/sharedTest/resources/diff-empty-mid/entry.json rename to libs/sharedTest/src/main/assets/diff-empty-mid/entry.json diff --git a/index/src/sharedTest/resources/diff-empty-min/23.json b/libs/sharedTest/src/main/assets/diff-empty-min/23.json similarity index 100% rename from index/src/sharedTest/resources/diff-empty-min/23.json rename to libs/sharedTest/src/main/assets/diff-empty-min/23.json diff --git a/index/src/sharedTest/resources/diff-empty-min/entry.jar b/libs/sharedTest/src/main/assets/diff-empty-min/entry.jar similarity index 100% rename from index/src/sharedTest/resources/diff-empty-min/entry.jar rename to libs/sharedTest/src/main/assets/diff-empty-min/entry.jar diff --git a/index/src/sharedTest/resources/diff-empty-min/entry.json b/libs/sharedTest/src/main/assets/diff-empty-min/entry.json similarity index 100% rename from index/src/sharedTest/resources/diff-empty-min/entry.json rename to libs/sharedTest/src/main/assets/diff-empty-min/entry.json diff --git a/index/src/sharedTest/resources/entry-empty-v2.json b/libs/sharedTest/src/main/assets/entry-empty-v2.json similarity index 100% rename from index/src/sharedTest/resources/entry-empty-v2.json rename to libs/sharedTest/src/main/assets/entry-empty-v2.json diff --git a/index/src/sharedTest/resources/index-empty-v1.json b/libs/sharedTest/src/main/assets/index-empty-v1.json similarity index 100% rename from index/src/sharedTest/resources/index-empty-v1.json rename to libs/sharedTest/src/main/assets/index-empty-v1.json diff --git a/index/src/sharedTest/resources/index-empty-v2.json b/libs/sharedTest/src/main/assets/index-empty-v2.json similarity index 100% rename from index/src/sharedTest/resources/index-empty-v2.json rename to libs/sharedTest/src/main/assets/index-empty-v2.json diff --git a/index/src/sharedTest/resources/index-max-v1.json b/libs/sharedTest/src/main/assets/index-max-v1.json similarity index 100% rename from index/src/sharedTest/resources/index-max-v1.json rename to libs/sharedTest/src/main/assets/index-max-v1.json diff --git a/index/src/sharedTest/resources/index-max-v2.json b/libs/sharedTest/src/main/assets/index-max-v2.json similarity index 100% rename from index/src/sharedTest/resources/index-max-v2.json rename to libs/sharedTest/src/main/assets/index-max-v2.json diff --git a/index/src/sharedTest/resources/index-mid-v1.json b/libs/sharedTest/src/main/assets/index-mid-v1.json similarity index 100% rename from index/src/sharedTest/resources/index-mid-v1.json rename to libs/sharedTest/src/main/assets/index-mid-v1.json diff --git a/index/src/sharedTest/resources/index-mid-v2.json b/libs/sharedTest/src/main/assets/index-mid-v2.json similarity index 100% rename from index/src/sharedTest/resources/index-mid-v2.json rename to libs/sharedTest/src/main/assets/index-mid-v2.json diff --git a/index/src/sharedTest/resources/index-min-reordered-v2.json b/libs/sharedTest/src/main/assets/index-min-reordered-v2.json similarity index 100% rename from index/src/sharedTest/resources/index-min-reordered-v2.json rename to libs/sharedTest/src/main/assets/index-min-reordered-v2.json diff --git a/index/src/sharedTest/resources/index-min-v1.json b/libs/sharedTest/src/main/assets/index-min-v1.json similarity index 100% rename from index/src/sharedTest/resources/index-min-v1.json rename to libs/sharedTest/src/main/assets/index-min-v1.json diff --git a/index/src/sharedTest/resources/index-min-v2.json b/libs/sharedTest/src/main/assets/index-min-v2.json similarity index 100% rename from index/src/sharedTest/resources/index-min-v2.json rename to libs/sharedTest/src/main/assets/index-min-v2.json diff --git a/index/src/sharedTest/resources/testy.at.or.at_corrupt_app_package_name_index-v1.jar b/libs/sharedTest/src/main/assets/testy.at.or.at_corrupt_app_package_name_index-v1.jar similarity index 100% rename from index/src/sharedTest/resources/testy.at.or.at_corrupt_app_package_name_index-v1.jar rename to libs/sharedTest/src/main/assets/testy.at.or.at_corrupt_app_package_name_index-v1.jar diff --git a/index/src/sharedTest/resources/testy.at.or.at_corrupt_package_name_index-v1.jar b/libs/sharedTest/src/main/assets/testy.at.or.at_corrupt_package_name_index-v1.jar similarity index 100% rename from index/src/sharedTest/resources/testy.at.or.at_corrupt_package_name_index-v1.jar rename to libs/sharedTest/src/main/assets/testy.at.or.at_corrupt_package_name_index-v1.jar diff --git a/index/src/sharedTest/resources/testy.at.or.at_index-v1.jar b/libs/sharedTest/src/main/assets/testy.at.or.at_index-v1.jar similarity index 100% rename from index/src/sharedTest/resources/testy.at.or.at_index-v1.jar rename to libs/sharedTest/src/main/assets/testy.at.or.at_index-v1.jar diff --git a/index/src/sharedTest/resources/testy.at.or.at_no-.RSA_index-v1.jar b/libs/sharedTest/src/main/assets/testy.at.or.at_no-.RSA_index-v1.jar similarity index 100% rename from index/src/sharedTest/resources/testy.at.or.at_no-.RSA_index-v1.jar rename to libs/sharedTest/src/main/assets/testy.at.or.at_no-.RSA_index-v1.jar diff --git a/index/src/sharedTest/resources/testy.at.or.at_no-.SF_index-v1.jar b/libs/sharedTest/src/main/assets/testy.at.or.at_no-.SF_index-v1.jar similarity index 100% rename from index/src/sharedTest/resources/testy.at.or.at_no-.SF_index-v1.jar rename to libs/sharedTest/src/main/assets/testy.at.or.at_no-.SF_index-v1.jar diff --git a/index/src/sharedTest/resources/testy.at.or.at_no-MANIFEST.MF_index-v1.jar b/libs/sharedTest/src/main/assets/testy.at.or.at_no-MANIFEST.MF_index-v1.jar similarity index 100% rename from index/src/sharedTest/resources/testy.at.or.at_no-MANIFEST.MF_index-v1.jar rename to libs/sharedTest/src/main/assets/testy.at.or.at_no-MANIFEST.MF_index-v1.jar diff --git a/index/src/sharedTest/resources/testy.at.or.at_no-signature_index-v1.jar b/libs/sharedTest/src/main/assets/testy.at.or.at_no-signature_index-v1.jar similarity index 100% rename from index/src/sharedTest/resources/testy.at.or.at_no-signature_index-v1.jar rename to libs/sharedTest/src/main/assets/testy.at.or.at_no-signature_index-v1.jar diff --git a/index/src/sharedTest/kotlin/org/fdroid/test/DiffUtils.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/DiffUtils.kt similarity index 83% rename from index/src/sharedTest/kotlin/org/fdroid/test/DiffUtils.kt rename to libs/sharedTest/src/main/kotlin/org/fdroid/test/DiffUtils.kt index c84c2afb8..8fa84a34f 100644 --- a/index/src/sharedTest/kotlin/org/fdroid/test/DiffUtils.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/DiffUtils.kt @@ -7,12 +7,12 @@ import org.fdroid.index.v2.PackageVersionV2 import org.fdroid.index.v2.RepoV2 import kotlin.random.Random -public object DiffUtils { +object DiffUtils { /** * Create a map diff by adding or removing keys. Note that this does not change keys. */ - public fun Map.randomDiff(factory: () -> T): Map = buildMap { + fun Map.randomDiff(factory: () -> T): Map = buildMap { if (this@randomDiff.isNotEmpty()) { // remove random keys while (Random.nextBoolean()) put(this@randomDiff.keys.random(), null) @@ -25,13 +25,13 @@ public object DiffUtils { /** * Removes keys from a JSON object representing a [RepoV2] which need special handling. */ - internal fun JsonObject.cleanRepo(): JsonObject { + fun JsonObject.cleanRepo(): JsonObject { val keysToFilter = listOf("mirrors", "antiFeatures", "categories", "releaseChannels") val newMap = filterKeys { it !in keysToFilter } return JsonObject(newMap) } - internal fun RepoV2.clean() = copy( + fun RepoV2.clean() = copy( mirrors = emptyList(), antiFeatures = emptyMap(), categories = emptyMap(), @@ -41,14 +41,14 @@ public object DiffUtils { /** * Removes keys from a JSON object representing a [MetadataV2] which need special handling. */ - internal fun JsonObject.cleanMetadata(): JsonObject { + fun JsonObject.cleanMetadata(): JsonObject { val keysToFilter = listOf("icon", "featureGraphic", "promoGraphic", "tvBanner", "screenshots") val newMap = filterKeys { it !in keysToFilter } return JsonObject(newMap) } - internal fun MetadataV2.clean() = copy( + fun MetadataV2.clean() = copy( icon = null, featureGraphic = null, promoGraphic = null, @@ -59,7 +59,7 @@ public object DiffUtils { /** * Removes keys from a JSON object representing a [PackageVersionV2] which need special handling. */ - internal fun JsonObject.cleanVersion(): JsonObject { + fun JsonObject.cleanVersion(): JsonObject { if (!containsKey("manifest")) return this val keysToFilter = listOf("features", "usesPermission", "usesPermissionSdk23") val newMap = toMutableMap() @@ -68,7 +68,7 @@ public object DiffUtils { return JsonObject(newMap) } - internal fun PackageVersionV2.clean() = copy( + fun PackageVersionV2.clean() = copy( manifest = manifest.copy( features = emptyList(), usesPermission = emptyList(), @@ -76,7 +76,7 @@ public object DiffUtils { ), ) - public fun Map.applyDiff(diff: Map): Map = + fun Map.applyDiff(diff: Map): Map = toMutableMap().apply { diff.entries.forEach { (key, value) -> if (value == null) remove(key) diff --git a/index/src/sharedTest/kotlin/org/fdroid/test/TestAppUtils.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestAppUtils.kt similarity index 91% rename from index/src/sharedTest/kotlin/org/fdroid/test/TestAppUtils.kt rename to libs/sharedTest/src/main/kotlin/org/fdroid/test/TestAppUtils.kt index 29cf469db..4d7ee9830 100644 --- a/index/src/sharedTest/kotlin/org/fdroid/test/TestAppUtils.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestAppUtils.kt @@ -13,9 +13,9 @@ import org.fdroid.test.TestUtils.orNull import kotlin.random.Random import kotlin.test.assertEquals -public object TestAppUtils { +object TestAppUtils { - public fun getRandomMetadataV2(): MetadataV2 = MetadataV2( + fun getRandomMetadataV2(): MetadataV2 = MetadataV2( added = Random.nextLong(), lastUpdated = Random.nextLong(), name = getRandomLocalizedTextV2().orNull(), @@ -49,7 +49,7 @@ public object TestAppUtils { screenshots = getRandomScreenshots().orNull(), ) - public fun getRandomScreenshots(): Screenshots? = Screenshots( + fun getRandomScreenshots(): Screenshots? = Screenshots( phone = getRandomLocalizedFileListV2().orNull(), sevenInch = getRandomLocalizedFileListV2().orNull(), tenInch = getRandomLocalizedFileListV2().orNull(), @@ -57,7 +57,7 @@ public object TestAppUtils { tv = getRandomLocalizedFileListV2().orNull(), ).takeIf { !it.isNull } - public fun getRandomLocalizedFileListV2(): Map> = + fun getRandomLocalizedFileListV2(): Map> = TestUtils.getRandomMap(Random.nextInt(1, 3)) { getRandomString() to getRandomList(Random.nextInt(1, 7)) { getRandomFileV2() @@ -68,7 +68,7 @@ public object TestAppUtils { * [Screenshots] include lists which can be ordered differently, * so we need to ignore order when comparing them. */ - public fun assertScreenshotsEqual(s1: Screenshots?, s2: Screenshots?) { + fun assertScreenshotsEqual(s1: Screenshots?, s2: Screenshots?) { if (s1 != null && s2 != null) { assertLocalizedFileListV2Equal(s1.phone, s2.phone) assertLocalizedFileListV2Equal(s1.sevenInch, s2.sevenInch) diff --git a/index/src/sharedTest/kotlin/org/fdroid/test/TestDataEntryV2.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataEntryV2.kt similarity index 93% rename from index/src/sharedTest/kotlin/org/fdroid/test/TestDataEntryV2.kt rename to libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataEntryV2.kt index 3bda91a92..d209b65ee 100644 --- a/index/src/sharedTest/kotlin/org/fdroid/test/TestDataEntryV2.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataEntryV2.kt @@ -3,9 +3,9 @@ package org.fdroid.test import org.fdroid.index.v2.EntryFileV2 import org.fdroid.index.v2.EntryV2 -internal object TestDataEntryV2 { +object TestDataEntryV2 { - internal val empty = EntryV2( + val empty = EntryV2( timestamp = 23, version = 20001, index = EntryFileV2( @@ -16,7 +16,7 @@ internal object TestDataEntryV2 { ), ) - internal val emptyToMin = EntryV2( + val emptyToMin = EntryV2( timestamp = 42, version = 20001, maxAge = 7, @@ -36,7 +36,7 @@ internal object TestDataEntryV2 { ), ) - internal val emptyToMid = EntryV2( + val emptyToMid = EntryV2( timestamp = 1337, version = 20001, index = EntryFileV2( @@ -61,7 +61,7 @@ internal object TestDataEntryV2 { ), ) - internal val emptyToMax = EntryV2( + val emptyToMax = EntryV2( timestamp = Long.MAX_VALUE, version = Long.MAX_VALUE, maxAge = Int.MAX_VALUE, diff --git a/index/src/sharedTest/kotlin/org/fdroid/test/TestDataV1.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataV1.kt similarity index 99% rename from index/src/sharedTest/kotlin/org/fdroid/test/TestDataV1.kt rename to libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataV1.kt index 2c2109cc2..9d4e88cd1 100644 --- a/index/src/sharedTest/kotlin/org/fdroid/test/TestDataV1.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataV1.kt @@ -8,7 +8,7 @@ import org.fdroid.index.v1.PermissionV1 import org.fdroid.index.v1.RepoV1 import org.fdroid.index.v1.Requests -internal object TestDataEmptyV1 { +object TestDataEmptyV1 { val repo = RepoV1( timestamp = 23, version = 23, @@ -22,7 +22,7 @@ internal object TestDataEmptyV1 { ) } -internal object TestDataMinV1 { +object TestDataMinV1 { val repo = RepoV1( timestamp = 42, @@ -61,7 +61,7 @@ internal object TestDataMinV1 { ) } -internal object TestDataMidV1 { +object TestDataMidV1 { val repo = RepoV1( timestamp = 1337, @@ -408,7 +408,7 @@ internal object TestDataMidV1 { } -internal object TestDataMaxV1 { +object TestDataMaxV1 { val repo = RepoV1( timestamp = Long.MAX_VALUE, diff --git a/index/src/sharedTest/kotlin/org/fdroid/test/TestDataV2.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataV2.kt similarity index 99% rename from index/src/sharedTest/kotlin/org/fdroid/test/TestDataV2.kt rename to libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataV2.kt index a7d6f3495..0fc40ec79 100644 --- a/index/src/sharedTest/kotlin/org/fdroid/test/TestDataV2.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestDataV2.kt @@ -20,13 +20,13 @@ import org.fdroid.index.v2.Screenshots import org.fdroid.index.v2.SignerV2 import org.fdroid.index.v2.UsesSdkV2 -internal const val LOCALE = "en-US" +const val LOCALE = "en-US" -internal fun IndexV2.v1compat() = copy( +fun IndexV2.v1compat() = copy( repo = repo.v1compat(), ) -internal fun RepoV2.v1compat() = copy( +fun RepoV2.v1compat() = copy( name = name.filterKeys { it == LOCALE }, description = description.filterKeys { it == LOCALE }, icon = icon.filterKeys { it == LOCALE }.mapValues { it.value.v1compat() }, @@ -37,7 +37,7 @@ internal fun RepoV2.v1compat() = copy( antiFeatures = antiFeatures.mapValues { AntiFeatureV2(name = mapOf(LOCALE to it.key)) }, ) -internal fun PackageV2.v1compat(overrideLocale: Boolean = false) = copy( +fun PackageV2.v1compat(overrideLocale: Boolean = false) = copy( metadata = metadata.copy( name = if (overrideLocale) metadata.name?.filterKeys { it == LOCALE } else metadata.name, summary = if (overrideLocale) metadata.summary?.filterKeys { it == LOCALE } @@ -68,7 +68,7 @@ internal fun PackageV2.v1compat(overrideLocale: Boolean = false) = copy( ) ) -internal fun PackageVersionV2.v1compat() = copy( +fun PackageVersionV2.v1compat() = copy( src = src?.v1compat(), manifest = manifest.copy( signer = if (manifest.signer?.sha256?.size ?: 0 <= 1) manifest.signer else { @@ -79,12 +79,12 @@ internal fun PackageVersionV2.v1compat() = copy( antiFeatures = antiFeatures.mapValues { emptyMap() } ) -internal fun FileV2.v1compat() = copy( +fun FileV2.v1compat() = copy( sha256 = null, size = null, ) -internal object TestDataEmptyV2 { +object TestDataEmptyV2 { val repo = RepoV2( timestamp = 23, @@ -110,7 +110,7 @@ internal object TestDataEmptyV2 { ) } -internal object TestDataMinV2 { +object TestDataMinV2 { val repo = RepoV2( timestamp = 42, @@ -158,7 +158,7 @@ internal object TestDataMinV2 { ) } -internal object TestDataMidV2 { +object TestDataMidV2 { val repo = RepoV2( timestamp = 1337, @@ -699,7 +699,7 @@ internal object TestDataMidV2 { ) } -internal object TestDataMaxV2 { +object TestDataMaxV2 { val repo = RepoV2( timestamp = Long.MAX_VALUE, diff --git a/index/src/sharedTest/kotlin/org/fdroid/test/TestRepoUtils.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestRepoUtils.kt similarity index 84% rename from index/src/sharedTest/kotlin/org/fdroid/test/TestRepoUtils.kt rename to libs/sharedTest/src/main/kotlin/org/fdroid/test/TestRepoUtils.kt index d4d275870..e4726d8ea 100644 --- a/index/src/sharedTest/kotlin/org/fdroid/test/TestRepoUtils.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestRepoUtils.kt @@ -12,32 +12,32 @@ import org.fdroid.test.TestUtils.getRandomString import org.fdroid.test.TestUtils.orNull import kotlin.random.Random -public object TestRepoUtils { +object TestRepoUtils { - public fun getRandomMirror(): MirrorV2 = MirrorV2( + fun getRandomMirror(): MirrorV2 = MirrorV2( url = getRandomString(), location = getRandomString().orNull() ) - public fun getRandomLocalizedTextV2(size: Int = Random.nextInt(0, 23)): LocalizedTextV2 = + fun getRandomLocalizedTextV2(size: Int = Random.nextInt(0, 23)): LocalizedTextV2 = buildMap { repeat(size) { put(getRandomString(4), getRandomString()) } } - public fun getRandomFileV2(sha256Nullable: Boolean = true): FileV2 = FileV2( + fun getRandomFileV2(sha256Nullable: Boolean = true): FileV2 = FileV2( name = getRandomString(), sha256 = getRandomString(64).also { if (sha256Nullable) orNull() }, size = Random.nextLong(-1, Long.MAX_VALUE) ) - public fun getRandomLocalizedFileV2(): Map = + fun getRandomLocalizedFileV2(): Map = TestUtils.getRandomMap(Random.nextInt(1, 8)) { getRandomString(4) to getRandomFileV2() } - public fun getRandomRepo(): RepoV2 = RepoV2( + fun getRandomRepo(): RepoV2 = RepoV2( name = getRandomLocalizedTextV2(), icon = getRandomLocalizedFileV2(), address = getRandomString(), diff --git a/index/src/sharedTest/kotlin/org/fdroid/test/TestUtils.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestUtils.kt similarity index 82% rename from index/src/sharedTest/kotlin/org/fdroid/test/TestUtils.kt rename to libs/sharedTest/src/main/kotlin/org/fdroid/test/TestUtils.kt index eca7685d0..af79d6d8c 100644 --- a/index/src/sharedTest/kotlin/org/fdroid/test/TestUtils.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestUtils.kt @@ -6,16 +6,16 @@ import org.fdroid.index.v2.MetadataV2 import org.fdroid.index.v2.Screenshots import kotlin.random.Random -public object TestUtils { +object TestUtils { private val charPool: List = ('a'..'z') + ('A'..'Z') + ('0'..'9') - public fun getRandomString(length: Int = Random.nextInt(1, 128)): String = (1..length) + fun getRandomString(length: Int = Random.nextInt(1, 128)): String = (1..length) .map { Random.nextInt(0, charPool.size) } .map(charPool::get) .joinToString("") - public fun getRandomList( + fun getRandomList( size: Int = Random.nextInt(0, 23), factory: () -> T, ): List = if (size == 0) emptyList() else buildList { @@ -24,7 +24,7 @@ public object TestUtils { } } - public fun getRandomMap( + fun getRandomMap( size: Int = Random.nextInt(0, 23), factory: () -> Pair, ): Map = if (size == 0) emptyMap() else buildMap { @@ -34,11 +34,11 @@ public object TestUtils { } } - public fun T.orNull(): T? { + fun T.orNull(): T? { return if (Random.nextBoolean()) null else this } - public fun IndexV2.sorted(): IndexV2 = copy( + fun IndexV2.sorted(): IndexV2 = copy( packages = packages.toSortedMap().mapValues { entry -> entry.value.copy( metadata = entry.value.metadata.sort(), @@ -57,7 +57,7 @@ public object TestUtils { } ) - public fun MetadataV2.sort(): MetadataV2 = copy( + fun MetadataV2.sort(): MetadataV2 = copy( name = name?.toSortedMap(), summary = summary?.toSortedMap(), description = description?.toSortedMap(), @@ -65,7 +65,7 @@ public object TestUtils { screenshots = screenshots?.sort(), ) - public fun Screenshots.sort(): Screenshots = copy( + fun Screenshots.sort(): Screenshots = copy( phone = phone?.sort(), sevenInch = sevenInch?.sort(), tenInch = tenInch?.sort(), @@ -73,7 +73,7 @@ public object TestUtils { tv = tv?.sort(), ) - public fun LocalizedFileListV2.sort(): LocalizedFileListV2 { + fun LocalizedFileListV2.sort(): LocalizedFileListV2 { return toSortedMap().mapValues { entry -> entry.value.sortedBy { it.name } } } diff --git a/index/src/sharedTest/kotlin/org/fdroid/test/TestVersionUtils.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestVersionUtils.kt similarity index 98% rename from index/src/sharedTest/kotlin/org/fdroid/test/TestVersionUtils.kt rename to libs/sharedTest/src/main/kotlin/org/fdroid/test/TestVersionUtils.kt index 063bb7ff4..2416c72f2 100644 --- a/index/src/sharedTest/kotlin/org/fdroid/test/TestVersionUtils.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/TestVersionUtils.kt @@ -15,7 +15,7 @@ import org.fdroid.test.TestUtils.getRandomString import org.fdroid.test.TestUtils.orNull import kotlin.random.Random -internal object TestVersionUtils { +object TestVersionUtils { fun getRandomPackageVersionV2( versionCode: Long = Random.nextLong(1, Long.MAX_VALUE), diff --git a/index/src/sharedTest/kotlin/org/fdroid/test/VerifierConstants.kt b/libs/sharedTest/src/main/kotlin/org/fdroid/test/VerifierConstants.kt similarity index 90% rename from index/src/sharedTest/kotlin/org/fdroid/test/VerifierConstants.kt rename to libs/sharedTest/src/main/kotlin/org/fdroid/test/VerifierConstants.kt index 388c26cfe..273e9dcb0 100644 --- a/index/src/sharedTest/kotlin/org/fdroid/test/VerifierConstants.kt +++ b/libs/sharedTest/src/main/kotlin/org/fdroid/test/VerifierConstants.kt @@ -1,9 +1,9 @@ package org.fdroid.test -public object VerifierConstants { +object VerifierConstants { - public const val VERIFICATION_DIR: String = "src/commonTest/resources/verification/" - public const val CERTIFICATE: String = + const val VERIFICATION_DIR: String = "src/commonTest/resources/verification/" + const val CERTIFICATE: String = "308202cf308201b7a0030201020204410b599a300d06092a864886f70d01010b" + "05003018311630140603550403130d546f727374656e2047726f7465301e170d" + "3134303631363139303332305a170d3431313130313139303332305a30183116" + @@ -27,7 +27,7 @@ public object VerifierConstants { "b93eff762c4b3b4fb05f8b26256570607a1400cddad2ebd4762bcf4efe703248" + "fa5b9ab455e3a5c98cb46f10adb6979aed8f96a688fd1d2a3beab380308e2ebe" + "0a4a880615567aafc0bfe344c5d7ef677e060f" - public const val FINGERPRINT: String = + const val FINGERPRINT: String = "28e14fb3b280bce8ff1e0f8e82726ff46923662cecff2a0689108ce19e8b347c" } diff --git a/settings.gradle b/settings.gradle index 4110cba70..85eab6ce8 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,5 @@ include ':app' -include ':download' -include ':index' -include ':database' +include ':libs:sharedTest' +include ':libs:download' +include ':libs:index' +include ':libs:database' From 3a012e2d2d7f5ffa469c35cf8c81e984f287f1bd Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 9 Sep 2022 15:52:39 -0300 Subject: [PATCH 39/42] [db] Use constants for Room table names --- .../src/main/java/org/fdroid/database/App.kt | 38 ++++-- .../main/java/org/fdroid/database/AppDao.kt | 112 +++++++++--------- .../main/java/org/fdroid/database/AppPrefs.kt | 6 +- .../java/org/fdroid/database/AppPrefsDao.kt | 4 +- .../java/org/fdroid/database/Repository.kt | 42 +++++-- .../java/org/fdroid/database/RepositoryDao.kt | 55 ++++----- .../main/java/org/fdroid/database/Version.kt | 21 +++- .../java/org/fdroid/database/VersionDao.kt | 33 +++--- 8 files changed, 193 insertions(+), 118 deletions(-) diff --git a/libs/database/src/main/java/org/fdroid/database/App.kt b/libs/database/src/main/java/org/fdroid/database/App.kt index 127d50eec..ae6a1cbf1 100644 --- a/libs/database/src/main/java/org/fdroid/database/App.kt +++ b/libs/database/src/main/java/org/fdroid/database/App.kt @@ -34,6 +34,7 @@ public interface MinimalApp { * This largely represents [MetadataV2] in a database table. */ @Entity( + tableName = AppMetadata.TABLE, primaryKeys = ["repoId", "packageName"], foreignKeys = [ForeignKey( entity = CoreRepository::class, @@ -79,7 +80,11 @@ public data class AppMetadata( * that adds the [AppMetadata]. */ public val isCompatible: Boolean, -) +) { + internal companion object { + const val TABLE = "AppMetadata" + } +} internal fun MetadataV2.toAppMetadata( repoId: Long, @@ -119,7 +124,7 @@ internal fun MetadataV2.toAppMetadata( isCompatible = isCompatible, ) -@Entity +@Entity(tableName = AppMetadataFts.TABLE) @Fts4(contentEntity = AppMetadata::class) internal data class AppMetadataFts( val repoId: Long, @@ -128,7 +133,11 @@ internal data class AppMetadataFts( val name: String? = null, @ColumnInfo(name = "localizedSummary") val summary: String? = null, -) +) { + internal companion object { + const val TABLE = "AppMetadataFts" + } +} /** * A class to represent all data of an App. @@ -340,6 +349,7 @@ internal interface IFile { } @Entity( + tableName = LocalizedFile.TABLE, primaryKeys = ["repoId", "packageName", "type", "locale"], foreignKeys = [ForeignKey( entity = AppMetadata::class, @@ -356,7 +366,11 @@ internal data class LocalizedFile( override val name: String, override val sha256: String? = null, override val size: Long? = null, -) : IFile +) : IFile { + internal companion object { + const val TABLE = "LocalizedFile" + } +} internal fun LocalizedFileV2.toLocalizedFile( repoId: Long, @@ -385,7 +399,8 @@ internal fun List.toLocalizedFileV2(): LocalizedFileV2? = associate { fil // 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'") +@DatabaseView(viewName = LocalizedIcon.TABLE, + value = "SELECT * FROM ${LocalizedFile.TABLE} WHERE type='icon'") internal data class LocalizedIcon( val repoId: Long, val packageName: String, @@ -394,9 +409,14 @@ internal data class LocalizedIcon( override val name: String, override val sha256: String? = null, override val size: Long? = null, -) : IFile +) : IFile { + internal companion object { + const val TABLE = "LocalizedIcon" + } +} @Entity( + tableName = LocalizedFileList.TABLE, primaryKeys = ["repoId", "packageName", "type", "locale", "name"], foreignKeys = [ForeignKey( entity = AppMetadata::class, @@ -413,7 +433,11 @@ internal data class LocalizedFileList( val name: String, val sha256: String? = null, val size: Long? = null, -) +) { + internal companion object { + const val TABLE = "LocalizedFileList" + } +} internal fun LocalizedFileListV2.toLocalizedFileList( repoId: Long, diff --git a/libs/database/src/main/java/org/fdroid/database/AppDao.kt b/libs/database/src/main/java/org/fdroid/database/AppDao.kt index 7cefc0632..211980706 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppDao.kt @@ -271,19 +271,19 @@ internal interface AppDaoInt : AppDao { * 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 + @Query("""UPDATE ${AppMetadata.TABLE} SET preferredSigner = :preferredSigner WHERE repoId = :repoId AND packageName = :packageName""") fun updatePreferredSigner(repoId: Long, packageName: String, preferredSigner: String?) - @Query("""UPDATE AppMetadata + @Query("""UPDATE ${AppMetadata.TABLE} SET isCompatible = ( - SELECT TOTAL(isCompatible) > 0 FROM Version - WHERE repoId = :repoId AND AppMetadata.packageName = Version.packageName + SELECT TOTAL(isCompatible) > 0 FROM ${Version.TABLE} + WHERE repoId = :repoId AND ${AppMetadata.TABLE}.packageName = ${Version.TABLE}.packageName ) WHERE repoId = :repoId""") override fun updateCompatibility(repoId: Long) - @Query("""UPDATE AppMetadata SET localizedName = :name, localizedSummary = :summary + @Query("""UPDATE ${AppMetadata.TABLE} SET localizedName = :name, localizedSummary = :summary WHERE repoId = :repoId AND packageName = :packageName""") fun updateAppMetadata(repoId: Long, packageName: String, name: String?, summary: String?) @@ -291,42 +291,44 @@ internal interface AppDaoInt : AppDao { fun updateAppMetadata(appMetadata: AppMetadata): Int @Transaction - @Query("""SELECT AppMetadata.* FROM AppMetadata + @Query("""SELECT ${AppMetadata.TABLE}.* FROM ${AppMetadata.TABLE} JOIN RepositoryPreferences AS pref USING (repoId) WHERE packageName = :packageName ORDER BY pref.weight DESC LIMIT 1""") override fun getApp(packageName: String): LiveData @Transaction - @Query("""SELECT * FROM AppMetadata + @Query("""SELECT * FROM ${AppMetadata.TABLE} 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") + @Query("""SELECT * FROM ${AppMetadata.TABLE} + WHERE repoId = :repoId AND packageName = :packageName""") fun getAppMetadata(repoId: Long, packageName: String): AppMetadata? /** * Used for updating best locales. */ - @Query("SELECT * FROM AppMetadata") + @Query("SELECT * FROM ${AppMetadata.TABLE}") fun getAppMetadata(): List /** * used for diffing */ - @Query("SELECT * FROM LocalizedFile WHERE repoId = :repoId AND packageName = :packageName") + @Query("""SELECT * FROM ${LocalizedFile.TABLE} + WHERE repoId = :repoId AND packageName = :packageName""") fun getLocalizedFiles(repoId: Long, packageName: String): List @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) + FROM ${AppMetadata.TABLE} AS app + JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) + LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) + LEFT JOIN ${LocalizedIcon.TABLE} 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, @@ -337,10 +339,10 @@ internal interface AppDaoInt : AppDao { @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) + FROM ${AppMetadata.TABLE} AS app + JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) + LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) + LEFT JOIN ${LocalizedIcon.TABLE} 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, @@ -355,7 +357,7 @@ internal interface AppDaoInt : AppDao { @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""") + FROM ${AppMetadata.TABLE} AS app WHERE repoId = :repoId AND packageName = :packageName""") fun getAppOverviewItem(repoId: Long, packageName: String): AppOverviewItem? // @@ -403,11 +405,11 @@ internal interface AppDaoInt : AppDao { @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 || '*"' + FROM ${AppMetadata.TABLE} AS app + JOIN ${AppMetadataFts.TABLE} USING (repoId, packageName) + LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) + JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) + WHERE pref.enabled = 1 AND ${AppMetadataFts.TABLE} MATCH '"*' || :searchQuery || '*"' GROUP BY packageName HAVING MAX(pref.weight)""") fun getAppListItems(searchQuery: String): LiveData> @@ -415,12 +417,12 @@ internal interface AppDaoInt : AppDao { @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) + FROM ${AppMetadata.TABLE} AS app + JOIN ${AppMetadataFts.TABLE} USING (repoId, packageName) + LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) + JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) WHERE pref.enabled = 1 AND categories LIKE '%,' || :category || ',%' AND - AppMetadataFts MATCH '"*' || :searchQuery || '*"' + ${AppMetadataFts.TABLE} MATCH '"*' || :searchQuery || '*"' GROUP BY packageName HAVING MAX(pref.weight)""") fun getAppListItems(category: String, searchQuery: String): LiveData> @@ -428,9 +430,9 @@ internal interface AppDaoInt : AppDao { @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) + FROM ${AppMetadata.TABLE} AS app + LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) + JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) WHERE pref.enabled = 1 GROUP BY packageName HAVING MAX(pref.weight) ORDER BY localizedName COLLATE NOCASE ASC""") @@ -440,9 +442,9 @@ internal interface AppDaoInt : AppDao { @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) + FROM ${AppMetadata.TABLE} AS app + JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) + LEFT JOIN ${HighestVersion.TABLE} AS version USING (repoId, packageName) WHERE pref.enabled = 1 GROUP BY packageName HAVING MAX(pref.weight) ORDER BY app.lastUpdated DESC""") @@ -452,9 +454,9 @@ internal interface AppDaoInt : AppDao { @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) + FROM ${AppMetadata.TABLE} AS app + JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) + LEFT JOIN ${HighestVersion.TABLE} 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""") @@ -464,9 +466,9 @@ internal interface AppDaoInt : AppDao { @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) + FROM ${AppMetadata.TABLE} AS app + JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) + LEFT JOIN ${HighestVersion.TABLE} 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""") @@ -475,8 +477,8 @@ internal interface AppDaoInt : AppDao { @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) + FROM ${AppMetadata.TABLE} AS app + JOIN ${RepositoryPreferences.TABLE} 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""") @@ -491,49 +493,49 @@ internal interface AppDaoInt : AppDao { return getAppListItems(packageNames).map(packageManager, installedPackages) } - @Query("""SELECT COUNT(DISTINCT packageName) FROM AppMetadata - JOIN RepositoryPreferences AS pref USING (repoId) + @Query("""SELECT COUNT(DISTINCT packageName) FROM ${AppMetadata.TABLE} + JOIN ${RepositoryPreferences.TABLE} 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") + @Query("SELECT COUNT(*) FROM ${AppMetadata.TABLE} WHERE repoId = :repoId") override fun getNumberOfAppsInRepository(repoId: Long): Int - @Query("DELETE FROM AppMetadata WHERE repoId = :repoId AND packageName = :packageName") + @Query("DELETE FROM ${AppMetadata.TABLE} WHERE repoId = :repoId AND packageName = :packageName") fun deleteAppMetadata(repoId: Long, packageName: String) - @Query("""DELETE FROM LocalizedFile + @Query("""DELETE FROM ${LocalizedFile.TABLE} WHERE repoId = :repoId AND packageName = :packageName AND type = :type""") fun deleteLocalizedFiles(repoId: Long, packageName: String, type: String) - @Query("""DELETE FROM LocalizedFile + @Query("""DELETE FROM ${LocalizedFile.TABLE} 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 + @Query("""DELETE FROM ${LocalizedFileList.TABLE} WHERE repoId = :repoId AND packageName = :packageName""") fun deleteLocalizedFileLists(repoId: Long, packageName: String) - @Query("""DELETE FROM LocalizedFileList + @Query("""DELETE FROM ${LocalizedFileList.TABLE} WHERE repoId = :repoId AND packageName = :packageName AND type = :type""") fun deleteLocalizedFileLists(repoId: Long, packageName: String, type: String) - @Query("""DELETE FROM LocalizedFileList + @Query("""DELETE FROM ${LocalizedFileList.TABLE} 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") + @Query("SELECT COUNT(*) FROM ${AppMetadata.TABLE}") fun countApps(): Int @VisibleForTesting - @Query("SELECT COUNT(*) FROM LocalizedFile") + @Query("SELECT COUNT(*) FROM ${LocalizedFile.TABLE}") fun countLocalizedFiles(): Int @VisibleForTesting - @Query("SELECT COUNT(*) FROM LocalizedFileList") + @Query("SELECT COUNT(*) FROM ${LocalizedFileList.TABLE}") fun countLocalizedFileLists(): Int } diff --git a/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt b/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt index ba54de720..3b08bdc09 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppPrefs.kt @@ -8,7 +8,7 @@ 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 +@Entity(tableName = AppPrefs.TABLE) public data class AppPrefs( @PrimaryKey val packageName: String, @@ -17,6 +17,10 @@ public data class AppPrefs( // which had exactly the same field. internal val appPrefReleaseChannels: List? = null, ) : PackagePreference { + internal companion object { + const val TABLE = "AppPrefs" + } + public val ignoreAllUpdates: Boolean get() = ignoreVersionCodeUpdate == Long.MAX_VALUE public override val releaseChannels: List get() = appPrefReleaseChannels ?: emptyList() public fun shouldIgnoreUpdate(versionCode: Long): Boolean = diff --git a/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt b/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt index aa8fe0b4b..137a6dd7d 100644 --- a/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/AppPrefsDao.kt @@ -22,10 +22,10 @@ internal interface AppPrefsDaoInt : AppPrefsDao { } } - @Query("SELECT * FROM AppPrefs WHERE packageName = :packageName") + @Query("SELECT * FROM ${AppPrefs.TABLE} WHERE packageName = :packageName") fun getLiveAppPrefs(packageName: String): LiveData - @Query("SELECT * FROM AppPrefs WHERE packageName = :packageName") + @Query("SELECT * FROM ${AppPrefs.TABLE} WHERE packageName = :packageName") fun getAppPrefsOrNull(packageName: String): AppPrefs? @Insert(onConflict = REPLACE) diff --git a/libs/database/src/main/java/org/fdroid/database/Repository.kt b/libs/database/src/main/java/org/fdroid/database/Repository.kt index 586466a2f..c585d7bf0 100644 --- a/libs/database/src/main/java/org/fdroid/database/Repository.kt +++ b/libs/database/src/main/java/org/fdroid/database/Repository.kt @@ -19,7 +19,7 @@ import org.fdroid.index.v2.MirrorV2 import org.fdroid.index.v2.ReleaseChannelV2 import org.fdroid.index.v2.RepoV2 -@Entity +@Entity(tableName = CoreRepository.TABLE) internal data class CoreRepository( @PrimaryKey(autoGenerate = true) val repoId: Long = 0, val name: LocalizedTextV2 = emptyMap(), @@ -32,7 +32,11 @@ internal data class CoreRepository( val maxAge: Int?, val description: LocalizedTextV2 = emptyMap(), val certificate: String?, -) +) { + internal companion object { + const val TABLE = "CoreRepository" + } +} internal fun RepoV2.toCoreRepository( repoId: Long = 0, @@ -199,6 +203,7 @@ public data class Repository internal constructor( * A database table to store repository mirror information. */ @Entity( + tableName = Mirror.TABLE, primaryKeys = ["repoId", "url"], foreignKeys = [ForeignKey( entity = CoreRepository::class, @@ -212,6 +217,10 @@ internal data class Mirror( val url: String, val location: String? = null, ) { + internal companion object { + const val TABLE = "Mirror" + } + fun toDownloadMirror(): org.fdroid.download.Mirror = org.fdroid.download.Mirror( baseUrl = url, location = location, @@ -243,6 +252,7 @@ public abstract class RepoAttribute { * An anti-feature belonging to a [Repository]. */ @Entity( + tableName = AntiFeature.TABLE, primaryKeys = ["repoId", "id"], foreignKeys = [ForeignKey( entity = CoreRepository::class, @@ -257,7 +267,11 @@ public data class AntiFeature internal constructor( @Embedded(prefix = "icon_") public override val icon: FileV2? = null, override val name: LocalizedTextV2, override val description: LocalizedTextV2, -) : RepoAttribute() +) : RepoAttribute() { + internal companion object { + const val TABLE = "AntiFeature" + } +} internal fun Map.toRepoAntiFeatures(repoId: Long) = map { AntiFeature( @@ -273,6 +287,7 @@ internal fun Map.toRepoAntiFeatures(repoId: Long) = map { * A category of apps belonging to a [Repository]. */ @Entity( + tableName = Category.TABLE, primaryKeys = ["repoId", "id"], foreignKeys = [ForeignKey( entity = CoreRepository::class, @@ -287,7 +302,11 @@ public data class Category internal constructor( @Embedded(prefix = "icon_") public override val icon: FileV2? = null, override val name: LocalizedTextV2, override val description: LocalizedTextV2, -) : RepoAttribute() +) : RepoAttribute() { + internal companion object { + const val TABLE = "Category" + } +} internal fun Map.toRepoCategories(repoId: Long) = map { Category( @@ -303,6 +322,7 @@ internal fun Map.toRepoCategories(repoId: Long) = map { * A release-channel for apps belonging to a [Repository]. */ @Entity( + tableName = ReleaseChannel.TABLE, primaryKeys = ["repoId", "id"], foreignKeys = [ForeignKey( entity = CoreRepository::class, @@ -317,7 +337,11 @@ public data class ReleaseChannel( @Embedded(prefix = "icon_") public override val icon: FileV2? = null, override val name: LocalizedTextV2, override val description: LocalizedTextV2, -) : RepoAttribute() +) : RepoAttribute() { + internal companion object { + const val TABLE = "ReleaseChannel" + } +} internal fun Map.toRepoReleaseChannel(repoId: Long) = map { ReleaseChannel( @@ -328,7 +352,7 @@ internal fun Map.toRepoReleaseChannel(repoId: Long) = ) } -@Entity +@Entity(tableName = RepositoryPreferences.TABLE) internal data class RepositoryPreferences( @PrimaryKey internal val repoId: Long, val weight: Int, @@ -339,7 +363,11 @@ internal data class RepositoryPreferences( val disabledMirrors: List? = null, val username: String? = null, val password: String? = null, -) +) { + internal companion object { + const val TABLE = "RepositoryPreferences" + } +} /** * A reduced version of [Repository] used to pre-populate the [FDroidDatabase]. diff --git a/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt b/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt index a6e080803..e096256cf 100644 --- a/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt @@ -182,27 +182,27 @@ internal interface RepositoryDaoInt : RepositoryDao { return repoId } - @Query("SELECT MAX(weight) FROM RepositoryPreferences") + @Query("SELECT MAX(weight) FROM ${RepositoryPreferences.TABLE}") fun getMaxRepositoryWeight(): Int @Transaction - @Query("SELECT * FROM CoreRepository WHERE repoId = :repoId") + @Query("SELECT * FROM ${CoreRepository.TABLE} WHERE repoId = :repoId") override fun getRepository(repoId: Long): Repository? @Transaction - @Query("SELECT * FROM CoreRepository") + @Query("SELECT * FROM ${CoreRepository.TABLE}") override fun getRepositories(): List @Transaction - @Query("SELECT * FROM CoreRepository") + @Query("SELECT * FROM ${CoreRepository.TABLE}") override fun getLiveRepositories(): LiveData> - @Query("SELECT * FROM RepositoryPreferences WHERE repoId = :repoId") + @Query("SELECT * FROM ${RepositoryPreferences.TABLE} WHERE repoId = :repoId") fun getRepositoryPreferences(repoId: Long): RepositoryPreferences? @RewriteQueriesToDropUnusedColumns - @Query("""SELECT * FROM Category - JOIN RepositoryPreferences AS pref USING (repoId) + @Query("""SELECT * FROM ${Category.TABLE} + JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) WHERE pref.enabled = 1 GROUP BY id HAVING MAX(pref.weight)""") override fun getLiveCategories(): LiveData> @@ -239,7 +239,7 @@ internal interface RepositoryDaoInt : RepositoryDao { * 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") + @Query("UPDATE ${CoreRepository.TABLE} SET certificate = :certificate WHERE repoId = :repoId") fun updateRepository(repoId: Long, certificate: String) @Update @@ -301,17 +301,18 @@ internal interface RepositoryDaoInt : RepositoryDao { ) } - @Query("UPDATE RepositoryPreferences SET enabled = :enabled WHERE repoId = :repoId") + @Query("UPDATE ${RepositoryPreferences.TABLE} SET enabled = :enabled WHERE repoId = :repoId") override fun setRepositoryEnabled(repoId: Long, enabled: Boolean) - @Query("UPDATE RepositoryPreferences SET userMirrors = :mirrors WHERE repoId = :repoId") + @Query("""UPDATE ${RepositoryPreferences.TABLE} SET userMirrors = :mirrors + WHERE repoId = :repoId""") override fun updateUserMirrors(repoId: Long, mirrors: List) - @Query("""UPDATE RepositoryPreferences SET username = :username, password = :password + @Query("""UPDATE ${RepositoryPreferences.TABLE} SET username = :username, password = :password WHERE repoId = :repoId""") override fun updateUsernameAndPassword(repoId: Long, username: String?, password: String?) - @Query("""UPDATE RepositoryPreferences SET disabledMirrors = :disabledMirrors + @Query("""UPDATE ${RepositoryPreferences.TABLE} SET disabledMirrors = :disabledMirrors WHERE repoId = :repoId""") override fun updateDisabledMirrors(repoId: Long, disabledMirrors: List) @@ -323,58 +324,58 @@ internal interface RepositoryDaoInt : RepositoryDao { deleteRepositoryPreferences(repoId) } - @Query("DELETE FROM CoreRepository WHERE repoId = :repoId") + @Query("DELETE FROM ${CoreRepository.TABLE} WHERE repoId = :repoId") fun deleteCoreRepository(repoId: Long) - @Query("DELETE FROM RepositoryPreferences WHERE repoId = :repoId") + @Query("DELETE FROM ${RepositoryPreferences.TABLE} WHERE repoId = :repoId") fun deleteRepositoryPreferences(repoId: Long) - @Query("DELETE FROM CoreRepository") + @Query("DELETE FROM ${CoreRepository.TABLE}") fun deleteAllCoreRepositories() - @Query("DELETE FROM RepositoryPreferences") + @Query("DELETE FROM ${RepositoryPreferences.TABLE}") fun deleteAllRepositoryPreferences() /** * Used for diffing. */ - @Query("DELETE FROM Mirror WHERE repoId = :repoId") + @Query("DELETE FROM ${Mirror.TABLE} WHERE repoId = :repoId") fun deleteMirrors(repoId: Long) /** * Used for diffing. */ - @Query("DELETE FROM AntiFeature WHERE repoId = :repoId") + @Query("DELETE FROM ${AntiFeature.TABLE} WHERE repoId = :repoId") fun deleteAntiFeatures(repoId: Long) /** * Used for diffing. */ - @Query("DELETE FROM AntiFeature WHERE repoId = :repoId AND id = :id") + @Query("DELETE FROM ${AntiFeature.TABLE} WHERE repoId = :repoId AND id = :id") fun deleteAntiFeature(repoId: Long, id: String) /** * Used for diffing. */ - @Query("DELETE FROM Category WHERE repoId = :repoId") + @Query("DELETE FROM ${Category.TABLE} WHERE repoId = :repoId") fun deleteCategories(repoId: Long) /** * Used for diffing. */ - @Query("DELETE FROM Category WHERE repoId = :repoId AND id = :id") + @Query("DELETE FROM ${Category.TABLE} WHERE repoId = :repoId AND id = :id") fun deleteCategory(repoId: Long, id: String) /** * Used for diffing. */ - @Query("DELETE FROM ReleaseChannel WHERE repoId = :repoId") + @Query("DELETE FROM ${ReleaseChannel.TABLE} WHERE repoId = :repoId") fun deleteReleaseChannels(repoId: Long) /** * Used for diffing. */ - @Query("DELETE FROM ReleaseChannel WHERE repoId = :repoId AND id = :id") + @Query("DELETE FROM ${ReleaseChannel.TABLE} WHERE repoId = :repoId AND id = :id") fun deleteReleaseChannel(repoId: Long, id: String) /** @@ -397,19 +398,19 @@ internal interface RepositoryDaoInt : RepositoryDao { } @VisibleForTesting - @Query("SELECT COUNT(*) FROM Mirror") + @Query("SELECT COUNT(*) FROM ${Mirror.TABLE}") fun countMirrors(): Int @VisibleForTesting - @Query("SELECT COUNT(*) FROM AntiFeature") + @Query("SELECT COUNT(*) FROM ${AntiFeature.TABLE}") fun countAntiFeatures(): Int @VisibleForTesting - @Query("SELECT COUNT(*) FROM Category") + @Query("SELECT COUNT(*) FROM ${Category.TABLE}") fun countCategories(): Int @VisibleForTesting - @Query("SELECT COUNT(*) FROM ReleaseChannel") + @Query("SELECT COUNT(*) FROM ${ReleaseChannel.TABLE}") fun countReleaseChannels(): Int } diff --git a/libs/database/src/main/java/org/fdroid/database/Version.kt b/libs/database/src/main/java/org/fdroid/database/Version.kt index 3e2ad9d11..3b1227572 100644 --- a/libs/database/src/main/java/org/fdroid/database/Version.kt +++ b/libs/database/src/main/java/org/fdroid/database/Version.kt @@ -27,6 +27,7 @@ import org.fdroid.index.v2.UsesSdkV2 * This holds the data of [PackageVersionV2]. */ @Entity( + tableName = Version.TABLE, primaryKeys = ["repoId", "packageName", "versionId"], foreignKeys = [ForeignKey( entity = AppMetadata::class, @@ -48,6 +49,10 @@ internal data class Version( val whatsNew: LocalizedTextV2? = null, val isCompatible: Boolean, ) : PackageVersion { + internal companion object { + const val TABLE = "Version" + } + override val versionCode: Long get() = manifest.versionCode override val signer: SignerV2? get() = manifest.signer override val packageManifest: PackageManifest get() = manifest @@ -141,13 +146,18 @@ internal fun ManifestV2.toManifest() = AppManifest( features = features.map { it.name }, ) -@DatabaseView("""SELECT repoId, packageName, antiFeatures FROM Version +@DatabaseView(viewName = HighestVersion.TABLE, + value = """SELECT repoId, packageName, antiFeatures FROM ${Version.TABLE} GROUP BY repoId, packageName HAVING MAX(manifest_versionCode)""") internal class HighestVersion( val repoId: Long, val packageName: String, val antiFeatures: Map? = null, -) +) { + internal companion object { + const val TABLE = "HighestVersion" + } +} internal enum class VersionedStringType { PERMISSION, @@ -155,6 +165,7 @@ internal enum class VersionedStringType { } @Entity( + tableName = VersionedString.TABLE, primaryKeys = ["repoId", "packageName", "versionId", "type", "name"], foreignKeys = [ForeignKey( entity = Version::class, @@ -170,7 +181,11 @@ internal data class VersionedString( val type: VersionedStringType, val name: String, val version: Int? = null, -) +) { + internal companion object { + const val TABLE = "VersionedString" + } +} internal fun List.toVersionedString( version: Version, diff --git a/libs/database/src/main/java/org/fdroid/database/VersionDao.kt b/libs/database/src/main/java/org/fdroid/database/VersionDao.kt index 4890ddfa9..dd5369aeb 100644 --- a/libs/database/src/main/java/org/fdroid/database/VersionDao.kt +++ b/libs/database/src/main/java/org/fdroid/database/VersionDao.kt @@ -155,8 +155,8 @@ internal interface VersionDaoInt : VersionDao { @Transaction @RewriteQueriesToDropUnusedColumns - @Query("""SELECT * FROM Version - JOIN RepositoryPreferences AS pref USING (repoId) + @Query("""SELECT * FROM ${Version.TABLE} + JOIN ${RepositoryPreferences.TABLE} 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> @@ -165,11 +165,11 @@ internal interface VersionDaoInt : VersionDao { * Only use for testing, not sorted, does take disabled repos into account. */ @Transaction - @Query("""SELECT * FROM Version + @Query("""SELECT * FROM ${Version.TABLE} WHERE repoId = :repoId AND packageName = :packageName""") fun getAppVersions(repoId: Long, packageName: String): List - @Query("""SELECT * FROM Version + @Query("""SELECT * FROM ${Version.TABLE} WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""") fun getVersion(repoId: Long, packageName: String, versionId: String): Version? @@ -178,19 +178,20 @@ internal interface VersionDaoInt : VersionDao { * so takes [AppPrefs.ignoreVersionCodeUpdate] into account. */ @RewriteQueriesToDropUnusedColumns - @Query("""SELECT * FROM Version - JOIN RepositoryPreferences AS pref USING (repoId) - LEFT JOIN AppPrefs USING (packageName) + @Query("""SELECT * FROM ${Version.TABLE} + JOIN ${RepositoryPreferences.TABLE} AS pref USING (repoId) + LEFT JOIN ${AppPrefs.TABLE} AS appPrefs USING (packageName) WHERE pref.enabled = 1 AND - manifest_versionCode > COALESCE(AppPrefs.ignoreVersionCodeUpdate, 0) AND + manifest_versionCode > COALESCE(appPrefs.ignoreVersionCodeUpdate, 0) AND packageName IN (:packageNames) ORDER BY manifest_versionCode DESC, pref.weight DESC""") fun getVersions(packageNames: List): List - @Query("SELECT * FROM VersionedString WHERE repoId = :repoId AND packageName = :packageName") + @Query("""SELECT * FROM ${VersionedString.TABLE} + WHERE repoId = :repoId AND packageName = :packageName""") fun getVersionedStrings(repoId: Long, packageName: String): List - @Query("""SELECT * FROM VersionedString + @Query("""SELECT * FROM ${VersionedString.TABLE} WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""") fun getVersionedStrings( repoId: Long, @@ -198,18 +199,18 @@ internal interface VersionDaoInt : VersionDao { versionId: String, ): List - @Query("""DELETE FROM Version WHERE repoId = :repoId AND packageName = :packageName""") + @Query("""DELETE FROM ${Version.TABLE} WHERE repoId = :repoId AND packageName = :packageName""") fun deleteAppVersion(repoId: Long, packageName: String) - @Query("""DELETE FROM Version + @Query("""DELETE FROM ${Version.TABLE} WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""") fun deleteAppVersion(repoId: Long, packageName: String, versionId: String) - @Query("""DELETE FROM VersionedString + @Query("""DELETE FROM ${VersionedString.TABLE} WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId""") fun deleteVersionedStrings(repoId: Long, packageName: String, versionId: String) - @Query("""DELETE FROM VersionedString WHERE repoId = :repoId + @Query("""DELETE FROM ${VersionedString.TABLE} WHERE repoId = :repoId AND packageName = :packageName AND versionId = :versionId AND type = :type""") fun deleteVersionedStrings( repoId: Long, @@ -218,10 +219,10 @@ internal interface VersionDaoInt : VersionDao { type: VersionedStringType, ) - @Query("SELECT COUNT(*) FROM Version") + @Query("SELECT COUNT(*) FROM ${Version.TABLE}") fun countAppVersions(): Int - @Query("SELECT COUNT(*) FROM VersionedString") + @Query("SELECT COUNT(*) FROM ${VersionedString.TABLE}") fun countVersionedStrings(): Int } From f9f81dc89467d4ae704d3b2ffbd46c4311c24b6e Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 9 Sep 2022 16:45:42 -0300 Subject: [PATCH 40/42] [db] Allow use of IndexV1Updater with v2 repos --- .../java/org/fdroid/index/v1/IndexV1Updater.kt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/libs/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt b/libs/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt index 5cc29d745..36471e6ff 100644 --- a/libs/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt +++ b/libs/database/src/main/java/org/fdroid/index/v1/IndexV1Updater.kt @@ -1,5 +1,8 @@ +@file:Suppress("DEPRECATION") + package org.fdroid.index.v1 +import mu.KotlinLogging import org.fdroid.CompatibilityChecker import org.fdroid.database.DbV1StreamReceiver import org.fdroid.database.FDroidDatabase @@ -18,7 +21,6 @@ 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, @@ -28,6 +30,7 @@ public class IndexV1Updater( private val listener: IndexUpdateListener? = null, ) : IndexUpdater() { + private val log = KotlinLogging.logger {} public override val formatVersion: IndexFormatVersion = ONE private val db: FDroidDatabaseInt = database as FDroidDatabaseInt @@ -36,10 +39,11 @@ public class IndexV1Updater( 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}" + // Normally, we shouldn't allow repository downgrades and assert the condition below. + // However, F-Droid is concerned that late v2 bugs will require users to downgrade to v1, + // as it happened already with the migration from v0 to v1. + if (repo.formatVersion != null && repo.formatVersion != ONE) { + log.error { "Format downgrade for ${repo.address}" } } val uri = repoUriBuilder.getUri(repo, SIGNED_FILE_NAME) val file = tempFileProvider.createTempFile() From 6b4e91ca0b64cbddeb56d4cab8c592d335dab7ec Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 9 Sep 2022 16:49:51 -0300 Subject: [PATCH 41/42] [db] Assert that DB transaction gets rolled back when index update fails --- .../dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/libs/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt b/libs/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt index c39cbc96d..7dadd8031 100644 --- a/libs/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt +++ b/libs/database/src/dbTest/java/org/fdroid/index/v1/IndexV1UpdaterTest.kt @@ -130,6 +130,11 @@ internal class IndexV1UpdaterTest : DbTest() { val result = indexUpdater.updateNewRepo(repo, "not the right fingerprint") assertIs(result) assertIs(result.e) + + // check that the DB transaction was rolled back and the DB wasn't changed + assertEquals(repo, repoDao.getRepository(repoId) ?: fail()) + assertEquals(0, appDao.countApps()) + assertEquals(0, versionDao.countAppVersions()) } @Test From 7d846890680132ff8abe09371a6d32b22feb1fa8 Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Fri, 9 Sep 2022 17:19:59 -0300 Subject: [PATCH 42/42] [db] Verify that v2 files start with / and add a DbV2StreamReceiverTest for it --- .../org/fdroid/database/DbV2StreamReceiver.kt | 3 ++ .../fdroid/database/DbV2StreamReceiverTest.kt | 51 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 libs/database/src/test/java/org/fdroid/database/DbV2StreamReceiverTest.kt diff --git a/libs/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt b/libs/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt index 51c1781bf..e4bce8e99 100644 --- a/libs/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt +++ b/libs/database/src/main/java/org/fdroid/database/DbV2StreamReceiver.kt @@ -29,6 +29,9 @@ internal class DbV2StreamReceiver( 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") + if (!fileV2.name.startsWith('/')) { + throw SerializationException("${fileV2.name} does not start with /") + } } } diff --git a/libs/database/src/test/java/org/fdroid/database/DbV2StreamReceiverTest.kt b/libs/database/src/test/java/org/fdroid/database/DbV2StreamReceiverTest.kt new file mode 100644 index 000000000..004720c30 --- /dev/null +++ b/libs/database/src/test/java/org/fdroid/database/DbV2StreamReceiverTest.kt @@ -0,0 +1,51 @@ +package org.fdroid.database + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.mockk.every +import io.mockk.mockk +import kotlinx.serialization.SerializationException +import org.fdroid.CompatibilityChecker +import org.fdroid.index.v2.FileV2 +import org.fdroid.index.v2.RepoV2 +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertFailsWith + +@RunWith(AndroidJUnit4::class) +internal class DbV2StreamReceiverTest { + + private val db: FDroidDatabaseInt = mockk() + private val compatChecker: CompatibilityChecker = mockk() + private val dbV2StreamReceiver = DbV2StreamReceiver(db, 42L, compatChecker) + + @Test + fun testFileV2Verified() { + // proper icon file passes + val repoV2 = RepoV2( + icon = mapOf("en" to FileV2(name = "/foo", sha256 = "bar", size = 23L)), + address = "http://example.org", + timestamp = 42L, + ) + every { db.getRepositoryDao() } returns mockk(relaxed = true) + dbV2StreamReceiver.receive(repoV2, 42L, "cert") + + // icon file without leading / does not pass + val repoV2NoSlash = + repoV2.copy(icon = mapOf("en" to FileV2(name = "foo", sha256 = "bar", size = 23L))) + assertFailsWith { + dbV2StreamReceiver.receive(repoV2NoSlash, 42L, "cert") + } + + // icon file without sha256 hash fails + val repoNoSha256 = repoV2.copy(icon = mapOf("en" to FileV2(name = "/foo", size = 23L))) + assertFailsWith { + dbV2StreamReceiver.receive(repoNoSha256, 42L, "cert") + } + + // icon file without size fails + val repoNoSize = repoV2.copy(icon = mapOf("en" to FileV2(name = "/foo", sha256 = "bar"))) + assertFailsWith { + dbV2StreamReceiver.receive(repoNoSize, 42L, "cert") + } + } +}