Merge branch 'db-library' into 'master'

Add a new database library for Index V2 and diffs

See merge request fdroid/fdroidclient!1132
This commit is contained in:
Michael Pöhn
2022-09-12 11:20:53 +00:00
186 changed files with 8575 additions and 639 deletions

View File

@@ -148,11 +148,12 @@ pages:
only:
- master
script:
- ./gradlew :download:dokkaHtml :index:dokkaHtml
- mkdir public
- touch public/index.html
- cp -r download/build/dokka/html public/download
- cp -r index/build/dokka/html public/index
- ./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
@@ -173,8 +174,8 @@ deploy_nightly:
- echo "<item>${CI_PROJECT_PATH}-nightly</item>" >> app/src/main/res/values/default_repos.xml
- echo "<item>${CI_PROJECT_URL}-nightly/raw/master/fdroid/repo</item>" >> 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' 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!
- ./gradlew assembleDebug

View File

@@ -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

View File

@@ -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'

View File

@@ -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);

View File

@@ -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()) {

View File

@@ -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

View File

@@ -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

View File

@@ -10,12 +10,17 @@
<ignored-keys>
<ignored-key id="3967d4eda591b991" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="02216ed811210daa" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="5f7786df73e61f56" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="bf984b4145ea13f7" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="eb380dc13c39f675" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="fefe78456eddc34a" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="4dbf5995d492505d" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="280d66a55f5316c5" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="d9c565aa72ba2fdd" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="cb43338e060cf9fa" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="d89d05374952262b" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="6a65176a0fb1cd0b" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="dea3d207428ef16d" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="c1b12a5d99c0729d" reason="Key couldn't be downloaded from any key server"/>
<ignored-key id="056aca74d46000bf" reason="Key couldn't be downloaded from any key server"/>
</ignored-keys>
@@ -51,7 +56,10 @@
</trusted-key>
<trusted-key id="2e3a1affe42b5f53af19f780bcf4173966770193" group="org.jetbrains" name="annotations" version="13.0"/>
<trusted-key id="31bae2e51d95e0f8ad9b7bcc40a3c4432bd7308c" group="com.googlecode.juniversalchardet" name="juniversalchardet" version="1.0.3"/>
<trusted-key id="3288b8be8512d6c0ca185268c51e6cbc7ff46f0b" group="com.google.auto.service" name="auto-service" version="1.0-rc4"/>
<trusted-key id="3288b8be8512d6c0ca185268c51e6cbc7ff46f0b">
<trusting group="com.google.auto.service" name="auto-service" version="1.0-rc4"/>
<trusting group="^com[.]google[.]auto($|([.].*))" regex="true"/>
</trusted-key>
<trusted-key id="3872ed7d5904493d23d78fa2c4c8cb73b1435348" group="com.android.tools.build" name="transform-api" version="2.0.0-deprecated-use-gradle-api"/>
<trusted-key id="394cb436c56916fc01eea4a77c30f7b1329dba87" group="io.ktor"/>
<trusted-key id="3d11126ea77e4e07fbabb38614a84c976d265b25" group="com.google.protobuf"/>
@@ -243,7 +251,7 @@
</component>
<component group="androidx.annotation" name="annotation-experimental" version="1.1.0">
<artifact name="annotation-experimental-1.1.0.aar">
<sha256 value="0157de61a2064047896a058080f3fd67ba57ad9a94857b3f7a363660243e3f90" origin="Generated by Gradle"/>
<sha256 value="0157de61a2064047896a058080f3fd67ba57ad9a94857b3f7a363660243e3f90" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.appcompat" name="appcompat" version="1.1.0">
@@ -283,10 +291,18 @@
</artifact>
</component>
<component group="androidx.arch.core" name="core-common" version="2.0.0">
<artifact name="core-common-2.0.0.jar">
<sha256 value="4b80b337779b526e64b0ee0ca9e0df43b808344d145f8e9b1c42a134dac57ad8" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
<artifact name="core-common-2.0.0.pom">
<sha256 value="4b6f1d459ddd146b4e85ed6d46e86eb8c2639c5de47904e6db4d698721334220" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.arch.core" name="core-common" version="2.0.1">
<artifact name="core-common-2.0.1.jar">
<sha256 value="e7316a84b899eb2afb1551784e9807fb64bdfcc105636fe0551cd036801f97c8" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.arch.core" name="core-common" version="2.1.0">
<artifact name="core-common-2.1.0.jar">
<sha256 value="fe1237bf029d063e7f29fe39aeaf73ef74c8b0a3658486fc29d3c54326653889" origin="Generated by Gradle because artifact wasn't signed"/>
@@ -295,6 +311,11 @@
<sha256 value="83bbb3960eaabc600ac366c94cb59414e441532a1d6aa9388b0b8bfface5cf01" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.arch.core" name="core-runtime" version="2.0.1">
<artifact name="core-runtime-2.0.1.aar">
<sha256 value="0527703682f06f3afa8303ca7bfc5804e3d0e5432df425ac62d08c4e93cc05d3" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.arch.core" name="core-runtime" version="2.1.0">
<artifact name="core-runtime-2.1.0.aar">
<sha256 value="dd77615bd3dd275afb11b62df25bae46b10b4a117cd37943af45bdcbf8755852" origin="Generated by Gradle because artifact wasn't signed"/>
@@ -326,7 +347,7 @@
</component>
<component group="androidx.collection" name="collection" version="1.0.0">
<artifact name="collection-1.0.0.jar">
<sha256 value="9c8d117b5c2bc120a1cdfeb857e05b495b16c36013570372a708f7827e3ac9f9" origin="Generated by Gradle"/>
<sha256 value="9c8d117b5c2bc120a1cdfeb857e05b495b16c36013570372a708f7827e3ac9f9" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
<artifact name="collection-1.0.0.pom">
<sha256 value="a7913a5275ad68e555d2612ebe8c14c367b153e14ca48a1872a64899020e54ef" origin="Generated by Gradle because artifact wasn't signed"/>
@@ -397,11 +418,26 @@
<sha256 value="2b279712795689069cfb63e48b3ab63c32a5649bdda44c482eb8f81ca1a72161" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.core" name="core" version="1.7.0">
<artifact name="core-1.7.0.aar">
<sha256 value="aaf6734226fff923784f92f65d78a2984dbf17534138855c5ce2038f18656e0b" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.core" name="core" version="1.8.0">
<artifact name="core-1.8.0.aar">
<sha256 value="48c64a15ec3eb11bfb33339e5ceb70ec7f821bd2dfa2eb8675ebd5353317e792" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.core" name="core-ktx" version="1.1.0">
<artifact name="core-ktx-1.1.0.aar">
<sha256 value="070cc5f8864f449128a2f4b25ca5b67aa3adca3ee1bd611e2eaf1a18fad83178" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.core" name="core-ktx" version="1.5.0">
<artifact name="core-ktx-1.5.0.aar">
<sha256 value="5964cfe7a4882da2a00fb6ca3d3a072d04139208186f7bc4b3cb66022764fc42" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.core" name="core-ktx" version="1.8.0">
<artifact name="core-ktx-1.8.0.aar">
<sha256 value="1790bb9b0b3efe6a27fb1ba3a3530b0b9aa6654e58bf8a9ef783f767e50d31b4" origin="Generated by Gradle"/>
@@ -564,6 +600,11 @@
<sha256 value="15848fb56db32f4c7cdc72b324003183d52a4884d6bf09be708ac7f587d139b5" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-common" version="2.4.1">
<artifact name="lifecycle-common-2.4.1.jar">
<sha256 value="20ad1520f625cf455e6afd7290988306d3a9886efa993e0860fbabf4bb3f7bda" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-livedata" version="2.0.0">
<artifact name="lifecycle-livedata-2.0.0.aar">
<sha256 value="c82609ced8c498f0a701a30fb6771bb7480860daee84d82e0a81ee86edf7ba39" origin="Generated by Gradle because artifact wasn't signed"/>
@@ -577,6 +618,11 @@
<sha256 value="242e446bed3db36f0df0aab0cb7f91060bd2dab7adcad1117adf54e724cd1d26" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-livedata" version="2.4.1">
<artifact name="lifecycle-livedata-2.4.1.aar">
<sha256 value="78d04726be73a905870dafed01e107b350f84b38258c8f6a12acd310393e68ea" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-livedata-core" version="2.2.0">
<artifact name="lifecycle-livedata-core-2.2.0.aar">
<sha256 value="556c1f3af90aa9d7d0d330565adbf6da71b2429148bac91e07c485f4f9abf614" origin="Generated by Gradle because artifact wasn't signed"/>
@@ -590,7 +636,25 @@
<sha256 value="e55d38c372460f0a03997ddc950c67227511340fd74f8634d99d29653cd81ab1" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-livedata-core" version="2.4.1">
<artifact name="lifecycle-livedata-core-2.4.1.aar">
<sha256 value="b97765bd945edd179692a6eb8d193898f676e1ed945fbac0bed62b5925ef6d6a" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-livedata-core-ktx" version="2.4.1">
<artifact name="lifecycle-livedata-core-ktx-2.4.1.aar">
<sha256 value="a30f3a1934529c41161b20224b5a552dccd9c8e827c913e7b04f6af7b47c1af1" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-livedata-ktx" version="2.4.1">
<artifact name="lifecycle-livedata-ktx-2.4.1.aar">
<sha256 value="2a4f5fbbbc9fc039115dd1d78efa3a49dc55476ded36fad853626369b994318d" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-runtime" version="2.0.0">
<artifact name="lifecycle-runtime-2.0.0.aar">
<sha256 value="e4afc9e636183f6f3e0edf1cf46121a492ffd2c673075bb07f55c7a99dd43cfb" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
<artifact name="lifecycle-runtime-2.0.0.pom">
<sha256 value="a92a46fa7aec8ac326a5d578734a2d5b63206976996b9e06cae171b35b0ab6de" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
@@ -705,11 +769,46 @@
<sha256 value="2b130dd4a1d3d91b6701ed33096d389f01c4fc1197a7acd6b91724ddc5acfc06" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.room" name="room-common" version="2.4.2">
<artifact name="room-common-2.4.2.jar">
<sha256 value="6505f987e696f54475cd82c922e4f4df8c6cd5282e2601bf118e1de7320c36cf" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.room" name="room-compiler" version="2.4.2">
<artifact name="room-compiler-2.4.2.jar">
<sha256 value="0e6930971a8b15f503e308da2c2f75587540cf5f014b664a555ac299197e4fca" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.room" name="room-compiler-processing" version="2.4.2">
<artifact name="room-compiler-processing-2.4.2.jar">
<sha256 value="e2d8462db15394945f5fe0be69792e5c25399a54d2fe17c6a954845e70f06377" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.room" name="room-ktx" version="2.4.2">
<artifact name="room-ktx-2.4.2.aar">
<sha256 value="23aac021051bce72413e037be3dc636380693a07f7dad914c1dafff54899293a" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.room" name="room-migration" version="2.4.2">
<artifact name="room-migration-2.4.2.jar">
<sha256 value="e0efe1ed8557f82628bfcb0b2058a5125472dcf31ef9af85c646d7eaaf900d20" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.room" name="room-runtime" version="2.2.5">
<artifact name="room-runtime-2.2.5.aar">
<sha256 value="24a5549b796e43e337513d2908adac67f45350d9a90bca7e2e6120692140bb14" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.room" name="room-runtime" version="2.4.1">
<artifact name="room-runtime-2.4.1.aar">
<sha256 value="6696d47c0573b67e015f99de467d2be83fd2051c49388e25a95e854417592045" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.room" name="room-runtime" version="2.4.2">
<artifact name="room-runtime-2.4.2.aar">
<sha256 value="b49477511a14b0d3f713d8b90ffce686ac161314111a5897a13aa82d4c892217" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.savedstate" name="savedstate" version="1.0.0">
<artifact name="savedstate-1.0.0.aar">
<sha256 value="2510a5619c37579c9ce1a04574faaf323cd0ffe2fc4e20fa8f8f01e5bb402e83" origin="Generated by Gradle because artifact wasn't signed"/>
@@ -736,11 +835,21 @@
<sha256 value="8341ff092d6060d62a07227f29237155fff36fb16f96c95fbd9a884e375db912" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.sqlite" name="sqlite" version="2.2.0">
<artifact name="sqlite-2.2.0.aar">
<sha256 value="6156d5d2c17bd8c5460f199142e4283053b1da750994f6b396c62c50fcc7270c" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.sqlite" name="sqlite-framework" version="2.1.0">
<artifact name="sqlite-framework-2.1.0.aar">
<sha256 value="8673737fdb2efbad91aeaeed1927ebb29212d36a867d93b9639c8069019f8a1e" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.sqlite" name="sqlite-framework" version="2.2.0">
<artifact name="sqlite-framework-2.2.0.aar">
<sha256 value="e5f5fbe7c209e21cde21d1d781481c9b0245839bc03bdd89fa4a798945bdb6a5" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.swiperefreshlayout" name="swiperefreshlayout" version="1.0.0">
<artifact name="swiperefreshlayout-1.0.0.aar">
<sha256 value="9761b3a809c9b093fd06a3c4bbc645756dec0e95b5c9da419bc9f2a3f3026e8d" origin="Generated by Gradle because artifact wasn't signed"/>
@@ -829,6 +938,11 @@
<sha256 value="23ebf6014645e0c60aec7d1ed924d4d4c848ae8c3673b7d8d06b2ec6a56cafee" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.test.espresso" name="espresso-core" version="3.4.0">
<artifact name="espresso-core-3.4.0.aar">
<sha256 value="fa2f4d80afb8c17828d25be75a99f241c28ec9fe971e2b86e09e62a839113282" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.test.espresso" name="espresso-idling-resource" version="3.2.0">
<artifact name="espresso-idling-resource-3.2.0.aar">
<sha256 value="c1a0454fe95788122ba652c3ecff7ec538c7e27de206aed970f2809fb8090d09" origin="Generated by Gradle because artifact wasn't signed"/>
@@ -865,21 +979,11 @@
<sha256 value="a97209d75a9a85815fa8934f5a4a320de1163ffe94e2f0b328c0c98a59660690" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.test.ext" name="junit" version="1.1.3">
<artifact name="junit-1.1.3.aar">
<sha256 value="a97209d75a9a85815fa8934f5a4a320de1163ffe94e2f0b328c0c98a59660690" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.test.services" name="storage" version="1.4.0">
<artifact name="storage-1.4.0.aar">
<sha256 value="35cfbf442abb83e5876cd5deb9de02ae047459f18f831097c5caa76d626bc38a" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.test.ext" name="junit" version="1.1.3">
<artifact name="junit-1.1.3.aar">
<sha256 value="a97209d75a9a85815fa8934f5a4a320de1163ffe94e2f0b328c0c98a59660690" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="androidx.test.uiautomator" name="uiautomator" version="2.2.0">
<artifact name="uiautomator-2.2.0.aar">
<sha256 value="2838e9d961dbffefbbd229a2bd4f6f82ac4fb2462975862a9e75e9ed325a3197" origin="Generated by Gradle because artifact wasn't signed"/>
@@ -2236,6 +2340,11 @@
<sha256 value="f1dd23f8ae34a8e91366723991ead0d6499d1a3e9163ce550c200b02d76a872b" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.devtools.ksp" name="symbol-processing-api" version="1.6.10-1.0.2">
<artifact name="symbol-processing-api-1.6.10-1.0.2.jar">
<sha256 value="caa18d15fc54b6da32746a79fe74f6c267ae24364c426f3fc61f209fdb87cb50" origin="Generated by Gradle because a key couldn't be downloaded"/>
</artifact>
</component>
<component group="com.google.errorprone" name="error_prone_annotation" version="2.2.0">
<artifact name="error_prone_annotation-2.2.0.jar">
<sha256 value="93b39756da13eda02bd217394dcbfd993fc1fbf93c8b37541e17b3433f2120e4" origin="Generated by Gradle"/>
@@ -2764,6 +2873,11 @@
<sha256 value="1690340a222279f2cbadf373e88826fa20f7f3cc3ec0252f36818fed32701ab1" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.squareup" name="javapoet" version="1.13.0">
<artifact name="javapoet-1.13.0.jar">
<sha256 value="4c7517e848a71b36d069d12bb3bf46a70fd4cda3105d822b0ed2e19c00b69291" origin="Generated by Gradle because a key couldn't be downloaded"/>
</artifact>
</component>
<component group="com.squareup" name="javawriter" version="2.1.1">
<artifact name="javawriter-2.1.1.jar">
<pgp value="90ee19787a7bcf6fd37a1e9180c08b1c29100955"/>
@@ -2782,6 +2896,12 @@
<sha256 value="e1abd7f1116cf5e0c59947693e2189208ec94296b2a3394c959e3511d399a7b0" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.squareup" name="kotlinpoet" version="1.8.0">
<artifact name="kotlinpoet-1.8.0.jar">
<pgp value="afa2b1823fc021bfd08c211fd5f4c07a434ab3da"/>
<sha256 value="a4f7f1f1306a97740b2c18c6089cddf65626764c4cd848aa83363681268e57f0" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.squareup.moshi" name="moshi" version="1.12.0">
<artifact name="moshi-1.12.0.jar">
<sha256 value="ee9091e1d1a5926fa9b4df2642c1fb7bb96aec08639b6219c19e0b8724d42475" origin="Generated by Gradle"/>
@@ -2797,12 +2917,6 @@
<sha256 value="2570fab55515cbf881d7a4ceef49fc515490bc027057e666776a2832465aeca0" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.squareup" name="kotlinpoet" version="1.8.0">
<artifact name="kotlinpoet-1.8.0.jar">
<pgp value="afa2b1823fc021bfd08c211fd5f4c07a434ab3da"/>
<sha256 value="a4f7f1f1306a97740b2c18c6089cddf65626764c4cd848aa83363681268e57f0" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.squareup.okhttp3" name="okhttp" version="4.9.2">
<artifact name="okhttp-4.9.2.jar">
<sha256 value="3b2ee1b768c1df28c30d2fe6b38dda4d2c519210e30ca3d27950618c563c92de" origin="Generated by Gradle"/>
@@ -2920,6 +3034,11 @@
<sha256 value="21fba22f830e9268f07cf4ab2d99e8181abbdcb0cb91ee0228eb3cb918dcdd1d" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.vanniktech" name="gradle-maven-publish-plugin" version="0.18.0">
<artifact name="gradle-maven-publish-plugin-0.18.0.jar">
<sha256 value="01c647a5d1e77c73d0b2b4f9fdf5ea61255dd3a967dcda7716ba63b9f9eec996" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.vanniktech" name="gradle-maven-publish-plugin" version="0.18.0">
<artifact name="gradle-maven-publish-plugin-0.18.0.jar">
<pgp value="7b72c18d9c362314bf8e981b0caebc0883c5bb65"/>
@@ -3937,66 +4056,6 @@
<sha256 value="29fc79401082301542cab89d7054d2f0825f184492654c950020553ef4ff0ef8" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.netty" name="netty-buffer" version="4.1.34.Final">
<artifact name="netty-buffer-4.1.34.Final.jar">
<sha256 value="39dfe88df8505fd01fbf9c1dbb6b6fa9b0297e453c3dc4ce039ea578aea2eaa3" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.netty" name="netty-codec" version="4.1.34.Final">
<artifact name="netty-codec-4.1.34.Final.jar">
<sha256 value="52e9eeb3638a8ed0911c72a508c05fa4f9d3391125eae46f287d3a8a0776211d" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.netty" name="netty-codec-http" version="4.1.34.Final">
<artifact name="netty-codec-http-4.1.34.Final.jar">
<sha256 value="5df5556ef6b0e7ce7c72a359e4ca774fcdf8d8fe12f0b6332715eaa44cfe41f8" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.netty" name="netty-codec-http2" version="4.1.34.Final">
<artifact name="netty-codec-http2-4.1.34.Final.jar">
<sha256 value="319f66f3ab0d3aac3477febf19c259990ee8c639fc7da8822dfa58e7dab1bdcf" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.netty" name="netty-codec-socks" version="4.1.34.Final">
<artifact name="netty-codec-socks-4.1.34.Final.jar">
<sha256 value="9c4ff58b648193942654db20f172d017441625754b902394f620f04074830346" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.netty" name="netty-common" version="4.1.34.Final">
<artifact name="netty-common-4.1.34.Final.jar">
<sha256 value="122931117eacf370b054d0e8a2411efa81de4956a6c3f938b0f0eb915969a425" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.netty" name="netty-handler" version="4.1.34.Final">
<artifact name="netty-handler-4.1.34.Final.jar">
<sha256 value="035616801fe9894ca2490832cf9976536dac740f41e90de1cdd4ba46f04263d1" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.netty" name="netty-handler-proxy" version="4.1.34.Final">
<artifact name="netty-handler-proxy-4.1.34.Final.jar">
<sha256 value="f506c6acb97b3e0b0795cf9f0971d80bbab7c17086312fa225b98ccc94be6dff" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.netty" name="netty-resolver" version="4.1.34.Final">
<artifact name="netty-resolver-4.1.34.Final.jar">
<sha256 value="774221ed4c130b532865770b10630bc12d0d400127da617ee0ac8de2a7ac2097" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.netty" name="netty-transport" version="4.1.34.Final">
<artifact name="netty-transport-4.1.34.Final.jar">
<sha256 value="2b3f7d3a595101def7d411793a675bf2a325964475fd7bdbbe448e908de09445" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.opencensus" name="opencensus-api" version="0.21.0">
<artifact name="opencensus-api-0.21.0.jar">
<sha256 value="8e2cb0f6391d8eb0a1bcd01e7748883f0033b1941754f4ed3f19d2c3e4276fc8" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.opencensus" name="opencensus-contrib-grpc-metrics" version="0.21.0">
<artifact name="opencensus-contrib-grpc-metrics-0.21.0.jar">
<sha256 value="29fc79401082301542cab89d7054d2f0825f184492654c950020553ef4ff0ef8" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="io.reactivex" name="rxjava" version="1.1.0">
<artifact name="rxjava-1.1.0.jar">
<pgp value="1d9aa7f9e1e2824728b8cd1794b291aef984a085"/>
@@ -4148,11 +4207,6 @@
<sha256 value="211918dc24f0fdef4335ce8af40ef5616e15e818b962a21146397c7701eb75a7" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="net.bytebuddy" name="byte-buddy-agent" version="1.10.20">
<artifact name="byte-buddy-agent-1.10.20.jar">
<sha256 value="b592a6c43e752bf41659717956c57fbb790394d2ee5f8941876659f9c5c0e7e8" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="net.bytebuddy" name="byte-buddy-agent" version="1.10.5">
<artifact name="byte-buddy-agent-1.10.5.jar">
<sha256 value="290c9930965ef5810ddb15baf3b3647ce952f40fa2f0af82d5f669e04ba87e5b" origin="Generated by Gradle"/>
@@ -4207,11 +4261,6 @@
<sha256 value="5557e235a8aa2f9766d5dc609d67948f2a8832c2d796cea9ef1d6cbe0b3b7eaf" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="net.java.dev.jna" name="jna-platform" version="5.6.0">
<artifact name="jna-platform-5.6.0.jar">
<sha256 value="9ecea8bf2b1b39963939d18b70464eef60c508fed8820f9dcaba0c35518eabf7" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="net.java.dev.jna" name="jna-platform" version="5.5.0">
<artifact name="jna-platform-5.5.0.jar">
<sha256 value="24d81621f82ac29fcdd9a74116031f5907a2343158e616f4573bbfa2434ae0d5" origin="Generated by Gradle"/>
@@ -5170,11 +5219,6 @@
<sha256 value="c5fd725bffab51846bf3c77db1383c60aaaebfe1b7fe2f00d23fe1b7df0a439d" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.intellij.deps" name="trove4j" version="1.0.20200330">
<artifact name="trove4j-1.0.20200330.jar">
<sha256 value="c5fd725bffab51846bf3c77db1383c60aaaebfe1b7fe2f00d23fe1b7df0a439d" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-android-extensions" version="1.6.10">
<artifact name="kotlin-android-extensions-1.6.10.jar">
<sha256 value="8e1dacd7d52cca7a824eadcf60f844fcb90a43ba3ec8e1c22c0acc415efe7917" origin="Generated by Gradle"/>
@@ -5185,11 +5229,6 @@
<sha256 value="d16afc76f89a89173a52ea8fdcfd6eb6685aac5331db7f5defcd8ddbe2834eba" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-android-extensions" version="1.6.20">
<artifact name="kotlin-android-extensions-1.6.20.jar">
<sha256 value="d16afc76f89a89173a52ea8fdcfd6eb6685aac5331db7f5defcd8ddbe2834eba" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-android-extensions" version="1.6.21">
<artifact name="kotlin-android-extensions-1.6.21.jar">
<sha256 value="418db930efe17afb36ec09e8a06235eb5e4f600b2b5ca14878422c9f99446cfb" origin="Generated by Gradle"/>
@@ -5215,11 +5254,6 @@
<sha256 value="6c93db1a10263fe1001d15ec17608707c37a73d6a9b4076219a9eae382dfeca2" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-build-common" version="1.6.20">
<artifact name="kotlin-build-common-1.6.20.jar">
<sha256 value="58c91897ac7b6a710ada1f99b68262d9f29ce98bb1f88ffeee0af3f24f6a5b73" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-build-common" version="1.6.21">
<artifact name="kotlin-build-common-1.6.21.jar">
<sha256 value="63eea405d35e34e82025c2f4156e3d4757cb8f259499623356ca666bd210019d" origin="Generated by Gradle"/>
@@ -5240,11 +5274,6 @@
<sha256 value="a4a3096b6159659245be113960a6883863c43f3a53926f38c16b8c5c5993b49d" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-compiler-embeddable" version="1.6.20">
<artifact name="kotlin-compiler-embeddable-1.6.20.jar">
<sha256 value="be634faaafb56816b6ef6d583e57ab33e4d6e5180cde2f505ccf7d45dc738ef8" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-compiler-embeddable" version="1.6.21">
<artifact name="kotlin-compiler-embeddable-1.6.21.jar">
<sha256 value="5f96503cd822aa6c20eda6c716a553c414d800ed0c6e7d655fa1b1fbfd4fec2b" origin="Generated by Gradle"/>
@@ -5270,11 +5299,6 @@
<sha256 value="2061b0e3b7b7530bf6720d8b0410be1326fb16cd4dadc37e77c02acfefe5a0b3" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-compiler-runner" version="1.6.20">
<artifact name="kotlin-compiler-runner-1.6.20.jar">
<sha256 value="c36190658aa3a58b27a2365e7336322512d8a4ff819626cc5e3ec3597aed06f2" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-daemon-client" version="1.6.10">
<artifact name="kotlin-daemon-client-1.6.10.jar">
<sha256 value="fd5700dc2f987783e1eff4d14fe2084af9712e7259dd777349dc4e72472dd214" origin="Generated by Gradle"/>
@@ -5335,11 +5359,6 @@
<sha256 value="6753a2e1126d3f993a951af2adc06b1ec4ca27176e96c8f632777990104d15ad" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-gradle-plugin" version="1.6.20">
<artifact name="kotlin-gradle-plugin-1.6.20.jar">
<sha256 value="53fb7770ad9880e289b701d9fb729c824c59f44f2b0349ee6aac94380c4de129" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-gradle-plugin-api" version="1.6.10">
<artifact name="kotlin-gradle-plugin-api-1.6.10.jar">
<sha256 value="7aa62528de3da7b66d97430c6d1b3f3781455b053231bbbf967e82900f527c44" origin="Generated by Gradle"/>
@@ -5370,11 +5389,6 @@
<sha256 value="add20d08051ce2c1a7cb0c8b0513f8b4580e24437aca39cc6144b23e0dc54709" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-gradle-plugin-model" version="1.6.20">
<artifact name="kotlin-gradle-plugin-model-1.6.20.jar">
<sha256 value="add20d08051ce2c1a7cb0c8b0513f8b4580e24437aca39cc6144b23e0dc54709" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-gradle-plugin-model" version="1.6.21">
<artifact name="kotlin-gradle-plugin-model-1.6.21.jar">
<sha256 value="6059b1c61308fb859a01e758b2b8fd0adaff741b3df80235fade95ad6ab89687" origin="Generated by Gradle"/>
@@ -5435,11 +5449,6 @@
<sha256 value="8d9f5e8e5402a05fb8c20447a56b697dc0cf73ea5c55534a9ee6a463920869c9" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-project-model" version="1.6.20">
<artifact name="kotlin-project-model-1.6.20.jar">
<sha256 value="5cbabaeb981f0fb6271ce553b8798d5753763f693f57cad88d8325b9a9d30459" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-project-model" version="1.6.21">
<artifact name="kotlin-project-model-1.6.21.jar">
<sha256 value="14b684f8a73e2296780124bf381dde013f582ea23367ace0184580a3849ae8d8" origin="Generated by Gradle"/>
@@ -5585,11 +5594,6 @@
<sha256 value="da25598d1a5ab1a747fbdbbb61ac2f04cea7608fa01966d5712b7a93186cc225" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-scripting-jvm" version="1.6.20">
<artifact name="kotlin-scripting-jvm-1.6.20.jar">
<sha256 value="b53a20cc9526459a8fb0d9c23e86de3166f8d65c1c66691fad52174a75fd5d09" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib" version="1.3.71">
<artifact name="kotlin-stdlib-1.3.71.jar">
<sha256 value="5ace22b102a96425e4ac44e0558b927f3857b56a33cbc289cf1b70aee645e6a7" origin="Generated by Gradle"/>
@@ -5605,6 +5609,11 @@
<sha256 value="52283996fe4067cd7330288b96ae67ecd463614dc741172c54d9d349ab6a9cd7" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib" version="1.5.21">
<artifact name="kotlin-stdlib-1.5.21.jar">
<sha256 value="9d212b5aef23f805e0161853a5f5aefe8965b8766c889317b60f1b58bd1cf9ad" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib" version="1.5.30">
<artifact name="kotlin-stdlib-1.5.30.jar">
<sha256 value="c55608e9eb6df7327e74b21e271d324dc523cef31587b8d6d2393db08d6e000c" origin="Generated by Gradle"/>
@@ -5680,11 +5689,6 @@
<sha256 value="183bec59cd9f3a14957b190e8c879cf1194bd1f106b0a7b6e1cbb8790d242363" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib-common" version="1.6.20">
<artifact name="kotlin-stdlib-common-1.6.20.jar">
<sha256 value="8da40a2520d30dcb1012176fe93d24e82d08a3e346c37e0343b0fb6f64f6be01" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk7" version="1.3.71">
<artifact name="kotlin-stdlib-jdk7-1.3.71.jar">
<sha256 value="b046a5ef54c7006db852e48e547aaff525a9e7a0a5909ffe5fe2c966c1a3a72e" origin="Generated by Gradle"/>
@@ -5705,16 +5709,6 @@
<sha256 value="7ce040646e6b9af662c96bbb988a1adcd4c994834d424e16960fa79fff93825d" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk7" version="1.4.32">
<artifact name="kotlin-stdlib-jdk7-1.4.32.jar">
<sha256 value="5f801e75ca27d8791c14b07943c608da27620d910a8093022af57f543d5d98b6" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk7" version="1.5.0">
<artifact name="kotlin-stdlib-jdk7-1.5.0.jar">
<sha256 value="ac12f092f12b575c1f9e0ab5025b1e610b0fe95663e26371c16c328895711bae" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk7" version="1.5.31">
<artifact name="kotlin-stdlib-jdk7-1.5.31.jar">
<sha256 value="a25bf47353ce899d843cbddee516d621a73473e7fba97f8d0301e7b4aed7c15f" origin="Generated by Gradle"/>
@@ -5725,11 +5719,6 @@
<sha256 value="870d35fd266b2daf64c1080fe51824d3c368f7995384a8d7c5fc2fdc40eb7b3a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk7" version="1.6.0">
<artifact name="kotlin-stdlib-jdk7-1.6.0.jar">
<sha256 value="870d35fd266b2daf64c1080fe51824d3c368f7995384a8d7c5fc2fdc40eb7b3a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-stdlib-jdk7" version="1.6.10">
<artifact name="kotlin-stdlib-jdk7-1.6.10.jar">
<sha256 value="2aedcdc6b69b33bdf5cc235bcea88e7cf6601146bb6bcdffdb312bbacd7be261" origin="Generated by Gradle"/>
@@ -5800,11 +5789,6 @@
<sha256 value="b891453cafbf961532d2ba0fb8969e40b0f7c168c9a2fc6a8cdf7c1b0577a36a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-test" version="1.6.20">
<artifact name="kotlin-test-1.6.20.jar">
<sha256 value="a1cfb87948c214495f3a6fd777fffd5fc2090de6a6632bba815f36ad718f64d2" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-test" version="1.6.21">
<artifact name="kotlin-test-1.6.21.jar">
<sha256 value="5507a7f3da534e221d75c7dd039b6e8e8107496a64ee8e334007ff904676d5e4" origin="Generated by Gradle"/>
@@ -5825,11 +5809,6 @@
<sha256 value="13f5838285cd6b2af2b6ecbe6406662acb589c0b248ff82f30f0864db4cc24b3" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-test-annotations-common" version="1.6.20">
<artifact name="kotlin-test-annotations-common-1.6.20.jar">
<sha256 value="13f5838285cd6b2af2b6ecbe6406662acb589c0b248ff82f30f0864db4cc24b3" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-test-annotations-common" version="1.6.21">
<artifact name="kotlin-test-annotations-common-1.6.21.jar">
<sha256 value="05d0a8ce35a4c973658625e0b5230c185cbd0a97c093b44375c035c45248d771" origin="Generated by Gradle"/>
@@ -5880,11 +5859,6 @@
<sha256 value="4ec93d0516066b0b648ba987c6d3d65f6fb067dc0bbba95c1ece61e0428488e5" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-tooling-metadata" version="1.6.20">
<artifact name="kotlin-tooling-metadata-1.6.20.jar">
<sha256 value="4ec93d0516066b0b648ba987c6d3d65f6fb067dc0bbba95c1ece61e0428488e5" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlin" name="kotlin-util-io" version="1.6.10">
<artifact name="kotlin-util-io-1.6.10.jar">
<sha256 value="187203549f2b670fc7ae71bf757c668dded386f4199f3ff1e6754b259e506571" origin="Generated by Gradle"/>
@@ -5969,16 +5943,16 @@
<sha256 value="f36ea75c31934bfad0682cfc435cce922e28b3bffa5af26cf86f07db13008f8a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-android" version="1.6.0">
<artifact name="kotlinx-coroutines-android-1.6.0.jar">
<sha256 value="ad89b520c22eab46e63610588a8c424040243294015ec214e30643c0efb7e5d4" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-android" version="1.5.2">
<artifact name="kotlinx-coroutines-android-1.5.2.jar">
<sha256 value="86cf9892b0bd5306a8f4d7ad8a82356f614dc7d519eb3063b0887d7c2b405928" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-android" version="1.6.0">
<artifact name="kotlinx-coroutines-android-1.6.0.jar">
<sha256 value="ad89b520c22eab46e63610588a8c424040243294015ec214e30643c0efb7e5d4" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-core" version="1.3.4">
<artifact name="kotlinx-coroutines-core-1.3.4.jar">
<sha256 value="17bec6112d93f5fcb11c27ecc8a14b48e30a5689ccf42c95025b89ba2210c28f" origin="Generated by Gradle"/>
@@ -5989,6 +5963,11 @@
<sha256 value="f8c8b7485d4a575e38e5e94945539d1d4eccd3228a199e1a9aa094e8c26174ee" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-core" version="1.5.2">
<artifact name="kotlinx-coroutines-core-metadata-1.5.2-all.jar">
<sha256 value="4d19a1c1c82bd973d034644f4ffa3d5355cb61bd34575aff86cc609e0e41d6e1" origin="Generated by Gradle because artifact wasn't signed"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-core" version="1.5.2-native-mt">
<artifact name="kotlinx-coroutines-core-1.5.2-native-mt.jar">
<sha256 value="78492527a0d09e0c53c81aacc2e073a83ee0fc3105e701496819ec67c98df16f" origin="Generated by Gradle"/>
@@ -6056,16 +6035,6 @@
<sha256 value="bef600516dbb41b237a883609a4f7468c2ed06d437ac13082ff4471723b4e88f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-jdk8" version="1.6.0">
<artifact name="kotlinx-coroutines-jdk8-1.6.0.jar">
<sha256 value="cf93f59cafabea454b0bb03c4c3ea055f6ea17f7ff06770a765eca94dd5de867" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-coroutines-slf4j" version="1.6.0">
<artifact name="kotlinx-coroutines-slf4j-1.6.0.jar">
<sha256 value="d8a019ae7be13992867be62d97e6993afc141a956010f5f704d569f5e9677167" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-datetime-linuxx64" version="0.3.1">
<artifact name="kotlinx-datetime-cinterop-date.klib">
<sha256 value="b7a3f64fb70f8931cab585eb58ebc6471da23712e964f243e5299b51a0876ffb" origin="Generated by Gradle because artifact wasn't signed"/>
@@ -6232,6 +6201,7 @@
</component>
<component group="org.mockito" name="mockito-core" version="2.19.0">
<artifact name="mockito-core-2.19.0.jar">
<pgp value="cc4483cd6a3eb2939b948667a1b4460d8ba7b9af"/>
<sha256 value="d6ac2e04164c5d5c89e73838dc1c8b3856ca6582d3f2daf91816fd9d7ba3c9a9" origin="Generated by Gradle"/>
</artifact>
</component>

View File

@@ -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.

View File

@@ -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

View File

98
libs/README.md Normal file
View File

@@ -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

View File

109
libs/database/build.gradle Normal file
View File

@@ -0,0 +1,109 @@
plugins {
id 'kotlin-android'
id 'com.android.library'
id 'kotlin-kapt'
id 'org.jetbrains.dokka'
id "org.jlleitschuh.gradle.ktlint" version "10.2.1"
}
android {
compileSdkVersion 31
defaultConfig {
minSdkVersion 21
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'
}
}
sourceSets {
androidTest {
java.srcDirs += "src/dbTest/java"
}
test {
java.srcDirs += "src/dbTest/java"
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
testOptions {
unitTests {
includeAndroidResources = true
}
}
kotlinOptions {
jvmTarget = '1.8'
freeCompilerArgs += "-Xexplicit-api=strict"
freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn"
}
aaptOptions {
// needed only for instrumentation tests: assets.openFd()
noCompress "json"
}
packagingOptions {
exclude 'META-INF/AL2.0'
exclude 'META-INF/LGPL2.1'
}
}
dependencies {
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'
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 project(":libs:sharedTest")
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 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'
androidTestImplementation 'androidx.arch.core:core-testing:2.1.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
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"

View File

21
libs/database/proguard-rules.pro vendored Normal file
View File

@@ -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

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,145 @@
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"))
}
@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))
}
}

View File

@@ -0,0 +1,433 @@
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.LocaleChooser.getBestLocale
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 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].packageName == 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].packageName == 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
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].packageName)
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, null, 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].packageName)
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, null, LAST_UPDATED).getOrFail(),
).forEach { apps ->
assertEquals(2, apps.size)
// the installed app should have app data
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)
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].packageName == 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].antiFeatureKeys)
}
// 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].antiFeatureKeys)
}
// 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].antiFeatureKeys)
}
}
@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].packageName)
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].packageName)
assertNotEquals(packageName2, apps[1].packageName)
}
// 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<AppListItem>) -> 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))
}
}

View File

@@ -0,0 +1,298 @@
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
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].packageName)
assertEquals(icons2, apps[0].localizedIcon?.toLocalizedFileV2())
// app 1 is next, because has icon
assertEquals(packageName1, apps[1].packageName)
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].packageName)
assertEquals(packageName1, apps[1].packageName)
assertEquals(packageName3, apps[2].packageName)
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].packageName)
assertEquals(emptyList(), apps[0].antiFeatureKeys)
assertEquals(packageName2, apps[1].packageName)
assertEquals(packageName1, apps[2].packageName)
}
}
@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].packageName)
assertEquals(icons2, apps[0].localizedIcon?.toLocalizedFileV2())
// app 1 is next, because has icon
assertEquals(packageName1, apps[1].packageName)
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].packageName)
assertEquals(packageName1, apps[1].packageName)
assertEquals(packageName3, apps[2].packageName)
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].packageName)
assertEquals(emptyList(), apps[0].antiFeatureKeys)
assertEquals(packageName2, apps[1].packageName)
assertEquals(packageName1, apps[2].packageName)
}
// 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))
}
}

View File

@@ -0,0 +1,47 @@
package org.fdroid.database
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import org.fdroid.test.TestAppUtils.getRandomMetadataV2
import org.fdroid.test.TestRepoUtils.getRandomFileV2
import org.fdroid.test.TestUtils.getRandomString
import org.fdroid.test.TestUtils.sort
import org.junit.Rule
internal abstract class AppTest : DbTest() {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
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")
).sort()
protected val app2 = getRandomMetadataV2().copy(
name = name2,
icon = icons2,
summary = name2,
lastUpdated = 20,
categories = listOf("A")
).sort()
protected val app3 = getRandomMetadataV2().copy(
name = name3,
icon = null,
summary = name3,
lastUpdated = 30,
categories = listOf("A", "B")
).sort()
}

View File

@@ -0,0 +1,120 @@
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.getApplicationContext
import io.mockk.every
import io.mockk.mockkObject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
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
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 java.io.IOException
import java.util.Locale
import kotlin.test.assertEquals
import kotlin.test.fail
@OptIn(ExperimentalCoroutinesApi::class)
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
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)
.allowMainThreadQueries()
.build()
repoDao = db.getRepositoryDao()
appDao = db.getAppDao()
appPrefsDao = db.getAppPrefsDao()
versionDao = db.getVersionDao()
mockkObject(FDroidDatabaseHolder)
every { FDroidDatabaseHolder.dispatcher } returns testCoroutineDispatcher
}
@After
@Throws(IOException::class)
fun closeDb() {
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].
*/
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)
}
}
}
}

View File

@@ -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,
packageName = 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].packageName)
assertEquals(TestDataMinV2.version.file.sha256, appVersions[0].update.version.versionId)
}
}

View File

@@ -0,0 +1,134 @@
package org.fdroid.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.serialization.SerializationException
import org.apache.commons.io.input.CountingInputStream
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.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.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class)
internal class IndexV1InsertTest : DbTest() {
private val indexConverter = IndexConverter()
@Test
fun testStreamEmptyIntoDb() {
val repoId = streamIndex("index-empty-v1.json")
assertEquals(1, repoDao.getRepositories().size)
val index = indexConverter.toIndexV2(TestDataEmptyV1.index)
assertDbEquals(repoId, index)
}
@Test
fun testStreamMinIntoDb() {
val repoId = streamIndex("index-min-v1.json")
assertTrue(repoDao.getRepositories().size == 1)
val index = indexConverter.toIndexV2(TestDataMinV1.index)
assertDbEquals(repoId, index)
}
@Test
fun testStreamMidIntoDb() {
val repoId = streamIndex("index-mid-v1.json")
assertTrue(repoDao.getRepositories().size == 1)
val index = indexConverter.toIndexV2(TestDataMidV1.index)
assertDbEquals(repoId, index)
}
@Test
fun testStreamMaxIntoDb() {
val repoId = streamIndex("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, -1)
db.runInTransaction {
assets.open(path).use { indexStream ->
indexProcessor.process(indexStream)
}
}
return repoId
}
@Test
fun testExceptionWhileStreamingDoesNotSaveIntoDb() {
val cIn = CountingInputStream(assets.open("index-max-v1.json"))
assertFailsWith<SerializationException> {
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, -1)
cIn.use { indexStream ->
indexProcessor.process(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)
}
@Suppress("DEPRECATION")
inner class TestStreamReceiver(
repoId: Long,
private val callback: () -> Unit = {},
) : IndexV1StreamReceiver {
private val streamReceiver = DbV1StreamReceiver(db, repoId) { true }
override fun receive(repo: RepoV2, version: Long, certificate: String?) {
streamReceiver.receive(repo, version, certificate)
callback()
}
override fun receive(packageName: String, m: MetadataV2) {
streamReceiver.receive(packageName, m)
callback()
}
override fun receive(packageName: String, v: Map<String, PackageVersionV2>) {
streamReceiver.receive(packageName, v)
callback()
}
override fun updateRepo(
antiFeatures: Map<String, AntiFeatureV2>,
categories: Map<String, CategoryV2>,
releaseChannels: Map<String, ReleaseChannelV2>,
) {
streamReceiver.updateRepo(antiFeatures, categories, releaseChannels)
callback()
}
override fun updateAppMetadata(packageName: String, preferredSigner: String?) {
streamReceiver.updateAppMetadata(packageName, preferredSigner)
callback()
}
}
}

View File

@@ -0,0 +1,314 @@
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.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 = "tmp/index-end.json"
val endIndex = IndexParser.parseV2(assets.open(endPath))
testDiff(
startPath = "tmp/index-start.json",
diffPath = "tmp/diff.json",
endIndex = endIndex,
)
}
@Test
fun testEmptyToMin() = testDiff(
startPath = "index-empty-v2.json",
diffPath = "diff-empty-min/23.json",
endIndex = TestDataMinV2.index,
)
@Test
fun testEmptyToMid() = testDiff(
startPath = "index-empty-v2.json",
diffPath = "diff-empty-mid/23.json",
endIndex = TestDataMidV2.index,
)
@Test
fun testEmptyToMax() = testDiff(
startPath = "index-empty-v2.json",
diffPath = "diff-empty-max/23.json",
endIndex = TestDataMaxV2.index,
)
@Test
fun testMinToMid() = testDiff(
startPath = "index-min-v2.json",
diffPath = "diff-empty-mid/42.json",
endIndex = TestDataMidV2.index,
)
@Test
fun testMinToMax() = testDiff(
startPath = "index-min-v2.json",
diffPath = "diff-empty-max/42.json",
endIndex = TestDataMaxV2.index,
)
@Test
fun testMidToMax() = testDiff(
startPath = "index-mid-v2.json",
diffPath = "diff-empty-max/1337.json",
endIndex = TestDataMaxV2.index,
)
@Test
fun testMinRemoveApp() {
val diffJson = """{
"packages": {
"org.fdroid.min1": null
}
}""".trimIndent()
testJsonDiff(
startPath = "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 = "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 = "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 = "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 = "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 = "index-min-v2.json",
diff = diffJson,
endIndex = TestDataMinV2.index,
)
}
@Test
fun testAppDenyKeyList() {
val diffRepoIdJson = """{
"packages": {
"org.fdroid.min1": {
"metadata": {
"repoId": 1
}
}
}
}""".trimIndent()
assertFailsWith<SerializationException> {
testJsonDiff(
startPath = "index-min-v2.json",
diff = diffRepoIdJson,
endIndex = TestDataMinV2.index,
)
}
val diffPackageNameJson = """{
"packages": {
"org.fdroid.min1": {
"metadata": {
"packageName": "foo"
}
}
}
}""".trimIndent()
assertFailsWith<SerializationException> {
testJsonDiff(
startPath = "index-min-v2.json",
diff = diffPackageNameJson,
endIndex = TestDataMinV2.index,
)
}
}
@Test
fun testVersionsDenyKeyList() {
assertFailsWith<SerializationException> {
testJsonDiff(
startPath = "index-min-v2.json",
diff = getMinVersionJson(""""packageName": "foo""""),
endIndex = TestDataMinV2.index,
)
}
assertFailsWith<SerializationException> {
testJsonDiff(
startPath = "index-min-v2.json",
diff = getMinVersionJson(""""repoId": 1"""),
endIndex = TestDataMinV2.index,
)
}
assertFailsWith<SerializationException> {
testJsonDiff(
startPath = "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 = "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 = streamIndexV2IntoDb(startPath)
// apply diff stream to the DB
val streamReceiver = DbV2DiffStreamReceiver(db, repoId) { true }
val streamProcessor = IndexV2DiffStreamProcessor(streamReceiver)
db.runInTransaction {
streamProcessor.process(42, diffStream) {}
}
// assert that changed DB data is equal to given endIndex
assertDbEquals(repoId, endIndex)
}
}

View File

@@ -0,0 +1,81 @@
package org.fdroid.database
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.IndexV2FullStreamProcessor
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.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class)
internal class IndexV2InsertTest : DbTest() {
@Test
fun testStreamEmptyIntoDb() {
val repoId = streamIndexV2IntoDb("index-empty-v2.json")
assertEquals(1, repoDao.getRepositories().size)
assertDbEquals(repoId, TestDataEmptyV2.index)
}
@Test
fun testStreamMinIntoDb() {
val repoId = streamIndexV2IntoDb("index-min-v2.json")
assertEquals(1, repoDao.getRepositories().size)
assertDbEquals(repoId, TestDataMinV2.index)
}
@Test
fun testStreamMinReorderedIntoDb() {
val repoId = streamIndexV2IntoDb("index-min-reordered-v2.json")
assertEquals(1, repoDao.getRepositories().size)
assertDbEquals(repoId, TestDataMinV2.index)
}
@Test
fun testStreamMidIntoDb() {
val repoId = streamIndexV2IntoDb("index-mid-v2.json")
assertEquals(1, repoDao.getRepositories().size)
assertDbEquals(repoId, TestDataMidV2.index)
}
@Test
fun testStreamMaxIntoDb() {
val repoId = streamIndexV2IntoDb("index-max-v2.json")
assertEquals(1, repoDao.getRepositories().size)
assertDbEquals(repoId, TestDataMaxV2.index)
}
@Test
fun testExceptionWhileStreamingDoesNotSaveIntoDb() {
val cIn = CountingInputStream(assets.open("index-max-v2.json"))
val compatibilityChecker = CompatibilityChecker {
if (cIn.byteCount > 0) throw SerializationException()
true
}
assertFailsWith<SerializationException> {
db.runInTransaction {
val repoId = db.getRepositoryDao().insertEmptyRepo("http://example.org")
val streamReceiver = DbV2StreamReceiver(db, repoId, compatibilityChecker)
val indexProcessor = IndexV2FullStreamProcessor(streamReceiver, "")
cIn.use { indexStream ->
indexProcessor.process(42, 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)
}
}

View File

@@ -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(listOf(org.fdroid.download.Mirror(repo.address)), actualRepo.getMirrors())
assertEquals(emptyList(), actualRepo.antiFeatures)
assertEquals(emptyList(), actualRepo.categories)
assertEquals(emptyList(), actualRepo.releaseChannels)
assertNull(actualRepo.formatVersion)
assertNull(actualRepo.repository.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(listOf(org.fdroid.download.Mirror(address)), actualRepo.getMirrors())
assertEquals(emptyList(), actualRepo.antiFeatures)
assertEquals(emptyList(), actualRepo.categories)
assertEquals(emptyList(), actualRepo.releaseChannels)
assertNull(actualRepo.formatVersion)
assertNull(actualRepo.repository.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 packageName = getRandomString()
val versionId = getRandomString()
appDao.insert(repoId, packageName, getRandomMetadataV2())
val packageVersion = getRandomPackageVersionV2()
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, packageName).size)
assertTrue(versionDao.getVersionedStrings(repoId, packageName).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, packageName).size)
assertEquals(0, versionDao.getVersionedStrings(repoId, packageName).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)
}
}

View File

@@ -0,0 +1,241 @@
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.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
import kotlin.test.assertEquals
/**
* Tests that repository diffs get applied to the database correctly.
*/
@RunWith(AndroidJUnit4::class)
internal 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].timestamp)
assertRepoEquals(repo.copy(timestamp = updateTimestamp), repos[0])
}
}
@Test
fun timestampDiffTwoReposInDb() {
// insert repo
val repo = getRandomRepo()
repoDao.insertOrReplace(repo)
// insert another repo before updating
repoDao.insertOrReplace(getRandomRepo())
// check that the repo got added and retrieved as expected
var repos = repoDao.getRepositories().sortedBy { it.repoId }
assertEquals(2, repos.size)
val repoId = repos[0].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<RepoDiffV2>(json)
repoDao.updateRepository(repoId, 42, diff)
// fetch repos again and check that the result is as expected
repos = repoDao.getRepositories().sortedBy { it.repoId }
assertEquals(2, repos.size)
assertEquals(repoId, repos[0].repoId)
assertEquals(updateTimestamp, repos[0].timestamp)
assertRepoEquals(repo.copy(timestamp = updateTimestamp), 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].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(
icon = getRandomFileV2(),
name = getRandomLocalizedTextV2(),
description = getRandomLocalizedTextV2(),
)
})
val antiFeatures = repo.antiFeatures.randomDiff {
AntiFeatureV2(getRandomFileV2(), getRandomLocalizedTextV2(), getRandomLocalizedTextV2())
}
val json = """
{
"antiFeatures": ${Json.encodeToString(antiFeatures)}
}""".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
fun antiFeatureKeyChangeDiff() {
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
fun categoriesDiff() {
val repo = getRandomRepo().copy(categories = getRandomMap {
getRandomString() to CategoryV2(
icon = getRandomFileV2(),
name = getRandomLocalizedTextV2(),
description = getRandomLocalizedTextV2(),
)
})
val categories = repo.categories.randomDiff {
CategoryV2(getRandomFileV2(), getRandomLocalizedTextV2(), getRandomLocalizedTextV2())
}
val json = """
{
"categories": ${Json.encodeToString(categories)}
}""".trimIndent()
testDiff(repo, json) { repos ->
val expectedFeatures = repo.categories.applyDiff(categories)
val expectedRepoCategories =
expectedFeatures.toRepoCategories(repos[0].repoId)
assertEquals(expectedRepoCategories.toSet(), repos[0].categories.toSet())
assertRepoEquals(repo.copy(categories = expectedFeatures), repos[0])
}
}
@Test
fun releaseChannelsDiff() {
val repo = getRandomRepo().copy(releaseChannels = getRandomMap {
getRandomString() to ReleaseChannelV2(
name = getRandomLocalizedTextV2(),
description = getRandomLocalizedTextV2(),
)
})
val releaseChannels = repo.releaseChannels.randomDiff {
ReleaseChannelV2(getRandomLocalizedTextV2(), getRandomLocalizedTextV2())
}
val json = """
{
"releaseChannels": ${Json.encodeToString(releaseChannels)}
}""".trimIndent()
testDiff(repo, json) { repos ->
val expectedFeatures = repo.releaseChannels.applyDiff(releaseChannels)
val expectedRepoReleaseChannels =
expectedFeatures.toRepoReleaseChannel(repos[0].repoId)
assertEquals(expectedRepoReleaseChannels.toSet(), repos[0].releaseChannels.toSet())
assertRepoEquals(repo.copy(releaseChannels = expectedFeatures), repos[0])
}
}
private fun testDiff(repo: RepoV2, json: String, repoChecker: (List<Repository>) -> Unit) {
// insert repo
repoDao.insertOrReplace(repo)
// check that the repo got added and retrieved as expected
var repos = repoDao.getRepositories()
assertEquals(1, repos.size)
val repoId = repos[0].repoId
// decode diff from JSON and update DB with it
val diff = j.parseToJsonElement(json).jsonObject
repoDao.updateRepository(repoId, 42, diff)
// fetch repos again and check that the result is as expected
repos = repoDao.getRepositories().sortedBy { it.repoId }
assertEquals(1, repos.size)
assertEquals(repoId, repos[0].repoId)
repoChecker(repos)
}
}

View File

@@ -0,0 +1,120 @@
package org.fdroid.database
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
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
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
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(
version = repo.repository.version!!.toLong(),
formatVersion = repo.repository.formatVersion,
certificate = repo.repository.certificate,
).copy(repoId = repoId)
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 },
usesPermissionSdk23 = usesPermissionSdk23.sortedBy { it.name },
nativecode = manifest.nativecode?.sorted() ?: emptyList(),
features = manifest.features?.map { FeatureV2(it) } ?: emptyList(),
),
releaseChannels = releaseChannels,
antiFeatures = version.antiFeatures ?: emptyMap(),
whatsNew = version.whatsNew ?: emptyMap(),
)
fun <T> LiveData<T>.getOrAwaitValue(): T? {
val data = arrayOfNulls<Any>(1)
val latch = CountDownLatch(1)
val observer: Observer<T> = object : Observer<T> {
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?
}
fun <T> LiveData<T>.getOrFail(): T {
return getOrAwaitValue() ?: fail()
}
}

View File

@@ -0,0 +1,234 @@
package org.fdroid.database
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import androidx.test.ext.junit.runners.AndroidJUnit4
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
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.fail
@RunWith(AndroidJUnit4::class)
internal class VersionTest : DbTest() {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
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, packageName, getRandomMetadataV2())
versionDao.insert(repoId, packageName, versionId1, packageVersion1, isCompatible1)
val appVersions = versionDao.getAppVersions(repoId, packageName)
assertEquals(1, appVersions.size)
val appVersion = appVersions[0]
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.features.map { it.name }.toSet(),
appVersion.version.manifest.features?.toSet()
)
val versionedStrings = versionDao.getVersionedStrings(repoId, packageName)
val expectedSize = manifest.usesPermission.size + manifest.usesPermissionSdk23.size
assertEquals(expectedSize, versionedStrings.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, 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(packageName).getOrFail()
assertEquals(2, appVersions.size)
val appVersion = if (versionId1 == appVersions[0].version.versionId) {
appVersions[0]
} else appVersions[1]
val appVersion2 = if (versionId2 == appVersions[0].version.versionId) {
appVersions[0]
} else appVersions[1]
// 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.features.map { it.name }.toSet(),
appVersion.version.manifest.features?.toSet()
)
// check second version matches
assertEquals(getVersion2(repoId), appVersion2.version)
val manifest2 = packageVersion2.manifest
assertEquals(manifest2.usesPermission.toSet(), appVersion2.usesPermission.toSet())
assertEquals(manifest2.usesPermissionSdk23.toSet(),
appVersion2.usesPermissionSdk23.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, 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)
}
}

View File

@@ -0,0 +1,269 @@
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(
database = db,
tempFileProvider = tempFileProvider,
downloaderFactory = downloaderFactory,
compatibilityChecker = 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<IndexUpdateResult.Processed>(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.packageName)).size
assertTrue(numVersions > 0)
}
assertEquals(1497639511824, updatedRepo.timestamp)
assertEquals(TESTY_CANONICAL_URL, updatedRepo.address)
assertEquals("non-public test repo", updatedRepo.repository.name.values.first())
assertEquals(18, updatedRepo.version)
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.repository.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 }
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<IndexUpdateResult.Unchanged>(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<IndexUpdateResult.Error>(result)
assertIs<SigningException>(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
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<IndexUpdateResult.Error>(result)
assertIs<OldIndexException>(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<IndexUpdateResult.Error>(result)
}
@Test
fun testIndexV1WithCorruptPackageName() {
val result = testBadTestyJar("testy.at.or.at_corrupt_package_name_index-v1.jar")
assertIs<IndexUpdateResult.Error>(result)
}
@Test
fun testIndexV1WithBadTestyJarNoManifest() {
val result = testBadTestyJar("testy.at.or.at_no-MANIFEST.MF_index-v1.jar")
assertIs<IndexUpdateResult.Error>(result)
assertIs<SigningException>(result.e)
}
@Test
fun testIndexV1WithBadTestyJarNoSigningCert() {
val result = testBadTestyJar("testy.at.or.at_no-.RSA_index-v1.jar")
assertIs<IndexUpdateResult.Error>(result)
}
@Test
fun testIndexV1WithBadTestyJarNoSignature() {
val result = testBadTestyJar("testy.at.or.at_no-.SF_index-v1.jar")
assertIs<IndexUpdateResult.Error>(result)
}
@Test
fun testIndexV1WithBadTestyJarNoSignatureFiles() {
val result = testBadTestyJar("testy.at.or.at_no-signature_index-v1.jar")
assertIs<IndexUpdateResult.Error>(result)
assertIs<SigningException>(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"

View File

@@ -0,0 +1,294 @@
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.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
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.assertNull
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(
database = db,
tempFileProvider = tempFileProvider,
downloaderFactory = downloaderFactory,
compatibilityChecker = compatibilityChecker,
)
}
@Test
fun testFullIndexEmptyToMin() {
val repoId = repoDao.insertEmptyRepo("http://example.org")
val repo = prepareUpdate(
repoId = repoId,
entryPath = "diff-empty-min/entry.jar",
jsonPath = "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)
assertTimestampRecent(repoDao.getRepository(repoId)?.lastUpdated)
}
@Test
fun testFullIndexEmptyToMid() {
val repoId = repoDao.insertEmptyRepo("http://example.org")
val repo = prepareUpdate(
repoId = repoId,
entryPath = "diff-empty-mid/entry.jar",
jsonPath = "index-mid-v2.json",
entryFileV2 = TestDataEntryV2.emptyToMid.index
)
val result = indexUpdater.updateNewRepo(repo, FINGERPRINT).noError()
assertEquals(IndexUpdateResult.Processed, result)
assertDbEquals(repoId, TestDataMidV2.index)
assertTimestampRecent(repoDao.getRepository(repoId)?.lastUpdated)
}
@Test
fun testFullIndexEmptyToMax() {
val repoId = repoDao.insertEmptyRepo("http://example.org")
val repo = prepareUpdate(
repoId = repoId,
entryPath = "diff-empty-max/entry.jar",
jsonPath = "index-max-v2.json",
entryFileV2 = TestDataEntryV2.emptyToMax.index
)
val result = indexUpdater.updateNewRepo(repo, FINGERPRINT).noError()
assertEquals(IndexUpdateResult.Processed, result)
assertDbEquals(repoId, TestDataMaxV2.index)
assertTimestampRecent(repoDao.getRepository(repoId)?.lastUpdated)
}
@Test
fun testDiffMinToMid() {
val repoId = streamIndexV2IntoDb("index-min-v2.json")
val repo = prepareUpdate(
repoId = repoId,
entryPath = "diff-empty-mid/entry.jar",
jsonPath = "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)
assertTimestampRecent(repoDao.getRepository(repoId)?.lastUpdated)
}
@Test
fun testDiffEmptyToMin() {
val repoId = streamIndexV2IntoDb("index-empty-v2.json")
repoDao.updateRepository(repoId, CERTIFICATE)
val repo = prepareUpdate(
repoId = repoId,
entryPath = "diff-empty-min/entry.jar",
jsonPath = "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)
assertTimestampRecent(repoDao.getRepository(repoId)?.lastUpdated)
}
@Test
fun testDiffMidToMax() {
val repoId = streamIndexV2IntoDb("index-mid-v2.json")
repoDao.updateRepository(repoId, CERTIFICATE)
val repo = prepareUpdate(
repoId = repoId,
entryPath = "diff-empty-max/entry.jar",
jsonPath = "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)
assertTimestampRecent(repoDao.getRepository(repoId)?.lastUpdated)
}
@Test
fun testSameTimestampUnchanged() {
val repoId = streamIndexV2IntoDb("index-min-v2.json")
repoDao.updateRepository(repoId, CERTIFICATE)
val repo = prepareUpdate(
repoId = repoId,
entryPath = "diff-empty-min/entry.jar",
jsonPath = "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)
assertNull(repoDao.getRepository(repoId)?.lastUpdated)
}
@Test
fun testHigherTimestampUnchanged() {
val repoId = streamIndexV2IntoDb("index-mid-v2.json")
repoDao.updateRepository(repoId, CERTIFICATE)
val repo = prepareUpdate(
repoId = repoId,
entryPath = "diff-empty-min/entry.jar",
jsonPath = "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("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 = "diff-empty-min/entry.jar",
jsonPath = "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 = "diff-empty-min/entry.jar",
jsonPath = "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 = "diff-empty-min/entry.jar",
jsonPath = "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("index-min-v1.json")
val repo = prepareUpdate(
repoId = repoId,
entryPath = "diff-empty-mid/entry.jar",
jsonPath = "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
}
}

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.fdroid.database">
</manifest>

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
package org.fdroid.database
import androidx.room.Entity
import androidx.room.PrimaryKey
import org.fdroid.PackagePreference
/**
* User-defined preferences related to [App]s that get stored in the database,
* so they can be used for queries.
*/
@Entity(tableName = AppPrefs.TABLE)
public data class AppPrefs(
@PrimaryKey
val packageName: String,
override val ignoreVersionCodeUpdate: Long = 0,
// This is named like this, because it hit a Room bug when joining with Version table
// which had exactly the same field.
internal val appPrefReleaseChannels: List<String>? = null,
) : PackagePreference {
internal companion object {
const val TABLE = "AppPrefs"
}
public val ignoreAllUpdates: Boolean get() = ignoreVersionCodeUpdate == Long.MAX_VALUE
public override val releaseChannels: List<String> get() = appPrefReleaseChannels ?: emptyList()
public fun shouldIgnoreUpdate(versionCode: Long): Boolean =
ignoreVersionCodeUpdate >= versionCode
/**
* Returns a new instance of [AppPrefs] toggling [ignoreAllUpdates].
*/
public fun toggleIgnoreAllUpdates(): AppPrefs = copy(
ignoreVersionCodeUpdate = if (ignoreAllUpdates) 0 else Long.MAX_VALUE,
)
/**
* Returns a new instance of [AppPrefs] ignoring the given [versionCode] or stop ignoring it
* if it was already ignored.
*/
public fun toggleIgnoreVersionCodeUpdate(versionCode: Long): AppPrefs = copy(
ignoreVersionCodeUpdate = if (shouldIgnoreUpdate(versionCode)) 0 else versionCode,
)
/**
* Returns a new instance of [AppPrefs] enabling the given [releaseChannel] or disabling it
* if it was already enabled.
*/
public fun toggleReleaseChannel(releaseChannel: String): AppPrefs = copy(
appPrefReleaseChannels = if (appPrefReleaseChannels?.contains(releaseChannel) == true) {
appPrefReleaseChannels.toMutableList().apply { remove(releaseChannel) }
} else {
(appPrefReleaseChannels?.toMutableList() ?: ArrayList()).apply { add(releaseChannel) }
},
)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
@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
import org.fdroid.database.FDroidDatabaseInt
import org.fdroid.database.Repository
import org.fdroid.download.DownloaderFactory
import org.fdroid.index.IndexFormatVersion
import org.fdroid.index.IndexFormatVersion.ONE
import org.fdroid.index.IndexUpdateListener
import org.fdroid.index.IndexUpdateResult
import org.fdroid.index.IndexUpdater
import org.fdroid.index.RepoUriBuilder
import org.fdroid.index.TempFileProvider
import org.fdroid.index.defaultRepoUriBuilder
import org.fdroid.index.setIndexUpdateListener
internal const val SIGNED_FILE_NAME = "index-v1.jar"
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() {
private val log = KotlinLogging.logger {}
public override val formatVersion: IndexFormatVersion = ONE
private val db: FDroidDatabaseInt = database as FDroidDatabaseInt
override fun update(
repo: Repository,
certificate: String?,
fingerprint: String?,
): IndexUpdateResult {
// 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()
val downloader = downloaderFactory.createWithTryFirstMirror(repo, uri, file).apply {
cacheTag = repo.lastETag
setIndexUpdateListener(listener, repo)
}
try {
downloader.download()
if (!downloader.hasChanged()) return IndexUpdateResult.Unchanged
val eTag = downloader.cacheTag
val verifier = IndexV1Verifier(file, certificate, fingerprint)
db.runInTransaction {
val (cert, _) = verifier.getStreamAndVerify { inputStream ->
listener?.onUpdateProgress(repo, 0, 0)
val streamReceiver = DbV1StreamReceiver(db, repo.repoId, compatibilityChecker)
val streamProcessor =
IndexV1StreamProcessor(streamReceiver, certificate, repo.timestamp)
streamProcessor.process(inputStream)
}
// update certificate, if we didn't have any before
val repoDao = db.getRepositoryDao()
if (certificate == null) {
repoDao.updateRepository(repo.repoId, cert)
}
// update RepositoryPreferences with timestamp and ETag (for v1)
val updatedPrefs = repo.preferences.copy(
lastUpdated = System.currentTimeMillis(),
lastETag = eTag,
)
repoDao.updateRepositoryPreferences(updatedPrefs)
}
} catch (e: OldIndexException) {
if (e.isSameTimestamp) return IndexUpdateResult.Unchanged
else throw e
} finally {
file.delete()
}
return IndexUpdateResult.Processed
}
}

View File

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

View File

@@ -0,0 +1 @@
../../index/src/sharedTest

View File

@@ -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)
}
}

View File

@@ -0,0 +1,41 @@
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
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<String>()
val str = Converters.listStringToString(list)
assertNull(str)
assertNull(Converters.listStringToString(null))
val convertedList = Converters.fromStringToListString(str)
assertEquals(list, convertedList)
}
@Test
fun testFileV2Conversion() {
val file = getRandomLocalizedFileV2()
val str = Converters.localizedFileV2toString(file)
val convertedFile = Converters.fromStringToLocalizedFileV2(str)
assertEquals(file, convertedFile)
}
}

View File

@@ -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<SerializationException> {
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<SerializationException> {
dbV2StreamReceiver.receive(repoNoSha256, 42L, "cert")
}
// icon file without size fails
val repoNoSize = repoV2.copy(icon = mapOf("en" to FileV2(name = "/foo", sha256 = "bar")))
assertFailsWith<SerializationException> {
dbV2StreamReceiver.receive(repoNoSize, 42L, "cert")
}
}
}

View File

View File

@@ -99,7 +99,6 @@ android {
}
defaultConfig {
minSdkVersion 21
targetSdkVersion 25
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
testInstrumentationRunnerArguments disableAnalytics: 'true'
}

View File

@@ -1,7 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<!-- TODO bump our targetSdkVersion when we are ready for it -->
<issue id="ExpiredTargetSdkVersion" severity="ignore"/>
<issue id="GradleDependency" severity="warning"/>
<issue id="InvalidPackage" severity="ignore"/>
</lint>

View File

@@ -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)
@Throws(IOException::class, NotFoundException::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?)
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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()

View File

@@ -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()
@@ -224,4 +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)

View File

@@ -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<String>): List<Mirror> = list.map { Mirror(it) }

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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<ClientRequestException> {
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")
}

1
libs/index/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -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 {

Some files were not shown because too many files have changed in this diff Show More