mirror of
https://github.com/f-droid/fdroidclient.git
synced 2026-04-20 06:47:06 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
202
index/LICENSE
202
index/LICENSE
@@ -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.
|
||||
@@ -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
|
||||
98
libs/README.md
Normal file
98
libs/README.md
Normal 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
|
||||
109
libs/database/build.gradle
Normal file
109
libs/database/build.gradle
Normal 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"
|
||||
0
libs/database/consumer-rules.pro
Normal file
0
libs/database/consumer-rules.pro
Normal file
21
libs/database/proguard-rules.pro
vendored
Normal file
21
libs/database/proguard-rules.pro
vendored
Normal 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
|
||||
1100
libs/database/schemas/org.fdroid.database.FDroidDatabaseInt/1.json
Normal file
1100
libs/database/schemas/org.fdroid.database.FDroidDatabaseInt/1.json
Normal file
File diff suppressed because it is too large
Load Diff
145
libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt
Normal file
145
libs/database/src/dbTest/java/org/fdroid/database/AppDaoTest.kt
Normal 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))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
47
libs/database/src/dbTest/java/org/fdroid/database/AppTest.kt
Normal file
47
libs/database/src/dbTest/java/org/fdroid/database/AppTest.kt
Normal 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()
|
||||
|
||||
}
|
||||
120
libs/database/src/dbTest/java/org/fdroid/database/DbTest.kt
Normal file
120
libs/database/src/dbTest/java/org/fdroid/database/DbTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
120
libs/database/src/dbTest/java/org/fdroid/database/TestUtils.kt
Normal file
120
libs/database/src/dbTest/java/org/fdroid/database/TestUtils.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
234
libs/database/src/dbTest/java/org/fdroid/database/VersionTest.kt
Normal file
234
libs/database/src/dbTest/java/org/fdroid/database/VersionTest.kt
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
5
libs/database/src/main/AndroidManifest.xml
Normal file
5
libs/database/src/main/AndroidManifest.xml
Normal 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>
|
||||
463
libs/database/src/main/java/org/fdroid/database/App.kt
Normal file
463
libs/database/src/main/java/org/fdroid/database/App.kt
Normal 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,
|
||||
)
|
||||
541
libs/database/src/main/java/org/fdroid/database/AppDao.kt
Normal file
541
libs/database/src/main/java/org/fdroid/database/AppDao.kt
Normal 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
|
||||
|
||||
}
|
||||
55
libs/database/src/main/java/org/fdroid/database/AppPrefs.kt
Normal file
55
libs/database/src/main/java/org/fdroid/database/AppPrefs.kt
Normal 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) }
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.fdroid.database
|
||||
|
||||
import androidx.room.TypeConverter
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import org.fdroid.index.IndexParser.json
|
||||
import org.fdroid.index.v2.FileV2
|
||||
import org.fdroid.index.v2.LocalizedFileV2
|
||||
import org.fdroid.index.v2.LocalizedTextV2
|
||||
|
||||
internal object Converters {
|
||||
|
||||
private val localizedTextV2Serializer = MapSerializer(String.serializer(), String.serializer())
|
||||
private val localizedFileV2Serializer = MapSerializer(String.serializer(), FileV2.serializer())
|
||||
private val mapOfLocalizedTextV2Serializer =
|
||||
MapSerializer(String.serializer(), localizedTextV2Serializer)
|
||||
|
||||
@TypeConverter
|
||||
fun fromStringToLocalizedTextV2(value: String?): LocalizedTextV2? {
|
||||
return value?.let { json.decodeFromString(localizedTextV2Serializer, it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun localizedTextV2toString(text: LocalizedTextV2?): String? {
|
||||
return text?.let { json.encodeToString(localizedTextV2Serializer, it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromStringToLocalizedFileV2(value: String?): LocalizedFileV2? {
|
||||
return value?.let { json.decodeFromString(localizedFileV2Serializer, it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun localizedFileV2toString(file: LocalizedFileV2?): String? {
|
||||
return file?.let { json.encodeToString(localizedFileV2Serializer, it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromStringToMapOfLocalizedTextV2(value: String?): Map<String, LocalizedTextV2>? {
|
||||
return value?.let { json.decodeFromString(mapOfLocalizedTextV2Serializer, it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun mapOfLocalizedTextV2toString(text: Map<String, LocalizedTextV2>?): String? {
|
||||
return text?.let { json.encodeToString(mapOfLocalizedTextV2Serializer, it) }
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromStringToListString(value: String?): List<String> {
|
||||
return value?.split(',')?.filter { it.isNotEmpty() } ?: emptyList()
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun listStringToString(text: List<String>?): String? {
|
||||
if (text.isNullOrEmpty()) return null
|
||||
return text.joinToString(
|
||||
prefix = ",",
|
||||
separator = ",",
|
||||
postfix = ",",
|
||||
) { it.replace(',', '_') }
|
||||
}
|
||||
}
|
||||
125
libs/database/src/main/java/org/fdroid/database/DbDiffUtils.kt
Normal file
125
libs/database/src/main/java/org/fdroid/database/DbDiffUtils.kt
Normal file
@@ -0,0 +1,125 @@
|
||||
package org.fdroid.database
|
||||
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import org.fdroid.index.v2.ReflectionDiffer
|
||||
|
||||
internal object DbDiffUtils {
|
||||
|
||||
/**
|
||||
* Applies the diff from the given [jsonObject] identified by the given [jsonObjectKey]
|
||||
* to [itemList] and updates the DB as needed.
|
||||
*
|
||||
* @param newItem A function to produce a new [T] which typically contains the primary key(s).
|
||||
*/
|
||||
@Throws(SerializationException::class)
|
||||
fun <T : Any> diffAndUpdateTable(
|
||||
jsonObject: JsonObject,
|
||||
jsonObjectKey: String,
|
||||
itemList: List<T>,
|
||||
itemFinder: (String, T) -> Boolean,
|
||||
newItem: (String) -> T,
|
||||
deleteAll: () -> Unit,
|
||||
deleteOne: (String) -> Unit,
|
||||
insertReplace: (List<T>) -> Unit,
|
||||
isNewItemValid: (T) -> Boolean = { true },
|
||||
keyDenyList: List<String>? = null,
|
||||
) {
|
||||
if (!jsonObject.containsKey(jsonObjectKey)) return
|
||||
if (jsonObject[jsonObjectKey] == JsonNull) {
|
||||
deleteAll()
|
||||
} else {
|
||||
val obj = jsonObject[jsonObjectKey]?.jsonObject ?: error("no $jsonObjectKey object")
|
||||
val list = itemList.toMutableList()
|
||||
obj.entries.forEach { (key, value) ->
|
||||
if (value is JsonNull) {
|
||||
list.removeAll { itemFinder(key, it) }
|
||||
deleteOne(key)
|
||||
} else {
|
||||
value.jsonObject.checkDenyList(keyDenyList)
|
||||
val index = list.indexOfFirst { itemFinder(key, it) }
|
||||
val item = if (index == -1) null else list[index]
|
||||
if (item == null) {
|
||||
val itemToInsert =
|
||||
ReflectionDiffer.applyDiff(newItem(key), value.jsonObject)
|
||||
if (!isNewItemValid(itemToInsert)) throw SerializationException("$newItem")
|
||||
list.add(itemToInsert)
|
||||
} else {
|
||||
list[index] = ReflectionDiffer.applyDiff(item, value.jsonObject)
|
||||
}
|
||||
}
|
||||
}
|
||||
insertReplace(list)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a list diff from a map of lists.
|
||||
* The map is identified by the given [jsonObjectKey] in the given [jsonObject].
|
||||
* The diff is applied for each key
|
||||
* by replacing the existing list using [deleteList] and [insertNewList].
|
||||
*
|
||||
* @param listParser returns a list of [T] from the given [JsonArray].
|
||||
*/
|
||||
@Throws(SerializationException::class)
|
||||
fun <T : Any> diffAndUpdateListTable(
|
||||
jsonObject: JsonObject,
|
||||
jsonObjectKey: String,
|
||||
listParser: (String, JsonArray) -> List<T>,
|
||||
deleteAll: () -> Unit,
|
||||
deleteList: (String) -> Unit,
|
||||
insertNewList: (String, List<T>) -> Unit,
|
||||
) {
|
||||
if (!jsonObject.containsKey(jsonObjectKey)) return
|
||||
if (jsonObject[jsonObjectKey] == JsonNull) {
|
||||
deleteAll()
|
||||
} else {
|
||||
val obj = jsonObject[jsonObjectKey]?.jsonObject ?: error("no $jsonObjectKey object")
|
||||
obj.entries.forEach { (key, list) ->
|
||||
if (list is JsonNull) {
|
||||
deleteList(key)
|
||||
} else {
|
||||
val newList = listParser(key, list.jsonArray)
|
||||
deleteList(key)
|
||||
insertNewList(key, newList)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the list diff from the given [jsonObject] identified by the given [jsonObjectKey]
|
||||
* by replacing an existing list using [deleteList] and [insertNewList].
|
||||
*
|
||||
* @param listParser returns a list of [T] from the given [JsonArray].
|
||||
*/
|
||||
@Throws(SerializationException::class)
|
||||
fun <T : Any> diffAndUpdateListTable(
|
||||
jsonObject: JsonObject,
|
||||
jsonObjectKey: String,
|
||||
listParser: (JsonArray) -> List<T>,
|
||||
deleteList: () -> Unit,
|
||||
insertNewList: (List<T>) -> Unit,
|
||||
) {
|
||||
if (!jsonObject.containsKey(jsonObjectKey)) return
|
||||
if (jsonObject[jsonObjectKey] == JsonNull) {
|
||||
deleteList()
|
||||
} else {
|
||||
val jsonArray = jsonObject[jsonObjectKey]?.jsonArray ?: error("no $jsonObjectKey array")
|
||||
val list = listParser(jsonArray)
|
||||
deleteList()
|
||||
insertNewList(list)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.checkDenyList(list: List<String>?) {
|
||||
list?.forEach { forbiddenKey ->
|
||||
if (containsKey(forbiddenKey)) throw SerializationException(forbiddenKey)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package org.fdroid.database
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.PackageManager.GET_SIGNATURES
|
||||
import android.os.Build
|
||||
import org.fdroid.CompatibilityCheckerImpl
|
||||
import org.fdroid.PackagePreference
|
||||
import org.fdroid.UpdateChecker
|
||||
|
||||
public class DbUpdateChecker(
|
||||
db: FDroidDatabase,
|
||||
private val packageManager: PackageManager,
|
||||
) {
|
||||
|
||||
private val appDao = db.getAppDao() as AppDaoInt
|
||||
private val versionDao = db.getVersionDao() as VersionDaoInt
|
||||
private val appPrefsDao = db.getAppPrefsDao() as AppPrefsDaoInt
|
||||
private val compatibilityChecker = CompatibilityCheckerImpl(packageManager)
|
||||
private val updateChecker = UpdateChecker(compatibilityChecker)
|
||||
|
||||
/**
|
||||
* Returns a list of apps that can be updated.
|
||||
* @param releaseChannels optional list of release channels to consider on top of stable.
|
||||
* If this is null or empty, only versions without channel (stable) will be considered.
|
||||
*/
|
||||
public fun getUpdatableApps(releaseChannels: List<String>? = null): List<UpdatableApp> {
|
||||
val updatableApps = ArrayList<UpdatableApp>()
|
||||
|
||||
@Suppress("DEPRECATION") // we'll use this as long as it works, new one was broken
|
||||
val installedPackages = packageManager.getInstalledPackages(GET_SIGNATURES)
|
||||
val packageNames = installedPackages.map { it.packageName }
|
||||
val versionsByPackage = HashMap<String, ArrayList<Version>>(packageNames.size)
|
||||
versionDao.getVersions(packageNames).forEach { version ->
|
||||
val list = versionsByPackage.getOrPut(version.packageName) { ArrayList() }
|
||||
list.add(version)
|
||||
}
|
||||
installedPackages.iterator().forEach { packageInfo ->
|
||||
val packageName = packageInfo.packageName
|
||||
val versions = versionsByPackage[packageName] ?: return@forEach // continue
|
||||
val version = getVersion(versions, packageName, packageInfo, null, releaseChannels)
|
||||
if (version != null) {
|
||||
val versionCode = packageInfo.getVersionCode()
|
||||
val app = getUpdatableApp(version, versionCode)
|
||||
if (app != null) updatableApps.add(app)
|
||||
}
|
||||
}
|
||||
return updatableApps
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an [AppVersion] for the given [packageName] that is an update or new install
|
||||
* or null if there is none.
|
||||
* @param releaseChannels optional list of release channels to consider on top of stable.
|
||||
* If this is null or empty, only versions without channel (stable) will be considered.
|
||||
*/
|
||||
@SuppressLint("PackageManagerGetSignatures")
|
||||
public fun getSuggestedVersion(
|
||||
packageName: String,
|
||||
preferredSigner: String? = null,
|
||||
releaseChannels: List<String>? = null,
|
||||
): AppVersion? {
|
||||
val versions = versionDao.getVersions(listOf(packageName))
|
||||
if (versions.isEmpty()) return null
|
||||
val packageInfo = try {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.getPackageInfo(packageName, GET_SIGNATURES)
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
null
|
||||
}
|
||||
val version = getVersion(versions, packageName, packageInfo, preferredSigner,
|
||||
releaseChannels) ?: return null
|
||||
val versionedStrings = versionDao.getVersionedStrings(
|
||||
repoId = version.repoId,
|
||||
packageName = version.packageName,
|
||||
versionId = version.versionId,
|
||||
)
|
||||
return version.toAppVersion(versionedStrings)
|
||||
}
|
||||
|
||||
private fun getVersion(
|
||||
versions: List<Version>,
|
||||
packageName: String,
|
||||
packageInfo: PackageInfo?,
|
||||
preferredSigner: String?,
|
||||
releaseChannels: List<String>?,
|
||||
): Version? {
|
||||
val preferencesGetter: (() -> PackagePreference?) = {
|
||||
appPrefsDao.getAppPrefsOrNull(packageName)
|
||||
}
|
||||
return if (packageInfo == null) {
|
||||
updateChecker.getSuggestedVersion(
|
||||
versions = versions,
|
||||
preferredSigner = preferredSigner,
|
||||
releaseChannels = releaseChannels,
|
||||
preferencesGetter = preferencesGetter,
|
||||
)
|
||||
} else {
|
||||
updateChecker.getUpdate(
|
||||
versions = versions,
|
||||
packageInfo = packageInfo,
|
||||
releaseChannels = releaseChannels,
|
||||
preferencesGetter = preferencesGetter,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getUpdatableApp(version: Version, installedVersionCode: Long): UpdatableApp? {
|
||||
val versionedStrings = versionDao.getVersionedStrings(
|
||||
repoId = version.repoId,
|
||||
packageName = version.packageName,
|
||||
versionId = version.versionId,
|
||||
)
|
||||
val appOverviewItem =
|
||||
appDao.getAppOverviewItem(version.repoId, version.packageName) ?: return null
|
||||
return UpdatableApp(
|
||||
repoId = version.repoId,
|
||||
packageName = version.packageName,
|
||||
installedVersionCode = installedVersionCode,
|
||||
update = version.toAppVersion(versionedStrings),
|
||||
hasKnownVulnerability = version.hasKnownVulnerability,
|
||||
name = appOverviewItem.name,
|
||||
summary = appOverviewItem.summary,
|
||||
localizedIcon = appOverviewItem.localizedIcon,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun PackageInfo.getVersionCode(): Long {
|
||||
return if (Build.VERSION.SDK_INT >= 28) {
|
||||
longVersionCode
|
||||
} else {
|
||||
@Suppress("DEPRECATION") // we use the new one above, if available
|
||||
versionCode.toLong()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.fdroid.database
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.core.os.ConfigurationCompat.getLocales
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import org.fdroid.CompatibilityChecker
|
||||
import org.fdroid.index.IndexFormatVersion.ONE
|
||||
import org.fdroid.index.v1.IndexV1StreamReceiver
|
||||
import org.fdroid.index.v2.AntiFeatureV2
|
||||
import org.fdroid.index.v2.CategoryV2
|
||||
import org.fdroid.index.v2.MetadataV2
|
||||
import org.fdroid.index.v2.PackageVersionV2
|
||||
import org.fdroid.index.v2.ReleaseChannelV2
|
||||
import org.fdroid.index.v2.RepoV2
|
||||
|
||||
/**
|
||||
* Note that this class expects that its [receive] method with [RepoV2] gets called first.
|
||||
* A different order of calls is not supported.
|
||||
*/
|
||||
@Deprecated("Use DbV2StreamReceiver instead")
|
||||
internal class DbV1StreamReceiver(
|
||||
private val db: FDroidDatabaseInt,
|
||||
private val repoId: Long,
|
||||
private val compatibilityChecker: CompatibilityChecker,
|
||||
) : IndexV1StreamReceiver {
|
||||
|
||||
private val locales: LocaleListCompat = getLocales(Resources.getSystem().configuration)
|
||||
|
||||
override fun receive(repo: RepoV2, version: Long, certificate: String?) {
|
||||
db.getRepositoryDao().clear(repoId)
|
||||
db.getRepositoryDao().update(repoId, repo, version, ONE, certificate)
|
||||
}
|
||||
|
||||
override fun receive(packageName: String, m: MetadataV2) {
|
||||
db.getAppDao().insert(repoId, packageName, m, locales)
|
||||
}
|
||||
|
||||
override fun receive(packageName: String, v: Map<String, PackageVersionV2>) {
|
||||
db.getVersionDao().insert(repoId, packageName, v) {
|
||||
compatibilityChecker.isCompatible(it.manifest)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateRepo(
|
||||
antiFeatures: Map<String, AntiFeatureV2>,
|
||||
categories: Map<String, CategoryV2>,
|
||||
releaseChannels: Map<String, ReleaseChannelV2>,
|
||||
) {
|
||||
val repoDao = db.getRepositoryDao()
|
||||
repoDao.insertAntiFeatures(antiFeatures.toRepoAntiFeatures(repoId))
|
||||
repoDao.insertCategories(categories.toRepoCategories(repoId))
|
||||
repoDao.insertReleaseChannels(releaseChannels.toRepoReleaseChannel(repoId))
|
||||
|
||||
db.afterUpdatingRepo(repoId)
|
||||
}
|
||||
|
||||
override fun updateAppMetadata(packageName: String, preferredSigner: String?) {
|
||||
db.getAppDao().updatePreferredSigner(repoId, packageName, preferredSigner)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.fdroid.database
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.core.os.ConfigurationCompat.getLocales
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import org.fdroid.CompatibilityChecker
|
||||
import org.fdroid.index.v2.IndexV2DiffStreamReceiver
|
||||
|
||||
internal class DbV2DiffStreamReceiver(
|
||||
private val db: FDroidDatabaseInt,
|
||||
private val repoId: Long,
|
||||
private val compatibilityChecker: CompatibilityChecker,
|
||||
) : IndexV2DiffStreamReceiver {
|
||||
|
||||
private val locales: LocaleListCompat = getLocales(Resources.getSystem().configuration)
|
||||
|
||||
override fun receiveRepoDiff(version: Long, repoJsonObject: JsonObject) {
|
||||
db.getRepositoryDao().updateRepository(repoId, version, repoJsonObject)
|
||||
}
|
||||
|
||||
override fun receivePackageMetadataDiff(packageName: String, packageJsonObject: JsonObject?) {
|
||||
db.getAppDao().updateApp(repoId, packageName, packageJsonObject, locales)
|
||||
}
|
||||
|
||||
override fun receiveVersionsDiff(
|
||||
packageName: String,
|
||||
versionsDiffMap: Map<String, JsonObject?>?,
|
||||
) {
|
||||
db.getVersionDao().update(repoId, packageName, versionsDiffMap) {
|
||||
compatibilityChecker.isCompatible(it)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
override fun onStreamEnded() {
|
||||
db.afterUpdatingRepo(repoId)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package org.fdroid.database
|
||||
|
||||
import android.content.res.Resources
|
||||
import androidx.core.os.ConfigurationCompat.getLocales
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverters
|
||||
import org.fdroid.LocaleChooser.getBestLocale
|
||||
import java.util.Locale
|
||||
|
||||
@Database(
|
||||
// When bumping this version, please make sure to add one (or more) migration(s) below!
|
||||
// Consider also providing tests for that migration.
|
||||
// Don't forget to commit the new schema to the git repo as well.
|
||||
version = 1,
|
||||
entities = [
|
||||
// repo
|
||||
CoreRepository::class,
|
||||
Mirror::class,
|
||||
AntiFeature::class,
|
||||
Category::class,
|
||||
ReleaseChannel::class,
|
||||
RepositoryPreferences::class,
|
||||
// packages
|
||||
AppMetadata::class,
|
||||
AppMetadataFts::class,
|
||||
LocalizedFile::class,
|
||||
LocalizedFileList::class,
|
||||
// versions
|
||||
Version::class,
|
||||
VersionedString::class,
|
||||
// app user preferences
|
||||
AppPrefs::class,
|
||||
],
|
||||
views = [
|
||||
LocalizedIcon::class,
|
||||
HighestVersion::class,
|
||||
],
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
// add future migrations here (if they are easy enough to be done automatically)
|
||||
],
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
internal abstract class FDroidDatabaseInt internal constructor() : RoomDatabase(), FDroidDatabase {
|
||||
abstract override fun getRepositoryDao(): RepositoryDaoInt
|
||||
abstract override fun getAppDao(): AppDaoInt
|
||||
abstract override fun getVersionDao(): VersionDaoInt
|
||||
abstract override fun getAppPrefsDao(): AppPrefsDaoInt
|
||||
override fun afterLocalesChanged(locales: LocaleListCompat) {
|
||||
val appDao = getAppDao()
|
||||
runInTransaction {
|
||||
appDao.getAppMetadata().forEach { appMetadata ->
|
||||
appDao.updateAppMetadata(
|
||||
repoId = appMetadata.repoId,
|
||||
packageName = appMetadata.packageName,
|
||||
name = appMetadata.name.getBestLocale(locales),
|
||||
summary = appMetadata.summary.getBestLocale(locales),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this after updating the data belonging to the given [repoId],
|
||||
* so the [AppMetadata.isCompatible] can be recalculated in case new versions were added.
|
||||
*/
|
||||
fun afterUpdatingRepo(repoId: Long) {
|
||||
getAppDao().updateCompatibility(repoId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The F-Droid database offering methods to retrieve the various data access objects.
|
||||
*/
|
||||
public interface FDroidDatabase {
|
||||
public fun getRepositoryDao(): RepositoryDao
|
||||
public fun getAppDao(): AppDao
|
||||
public fun getVersionDao(): VersionDao
|
||||
public fun getAppPrefsDao(): AppPrefsDao
|
||||
|
||||
/**
|
||||
* Call this after the system [Locale]s have changed.
|
||||
* If this isn't called, the cached localized app metadata (e.g. name, summary) will be wrong.
|
||||
*/
|
||||
public fun afterLocalesChanged(
|
||||
locales: LocaleListCompat = getLocales(Resources.getSystem().configuration),
|
||||
)
|
||||
|
||||
/**
|
||||
* Call this to run all of the given [body] inside a database transaction.
|
||||
* Please run as little code as possible to keep the time the database is blocked minimal.
|
||||
*/
|
||||
public fun runInTransaction(body: Runnable)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package org.fdroid.database
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.annotation.GuardedBy
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* A way to pre-populate the database with a fixture.
|
||||
* This can be supplied to [FDroidDatabaseHolder.getDb]
|
||||
* and will then be called when a new database is created.
|
||||
*/
|
||||
public fun interface FDroidFixture {
|
||||
/**
|
||||
* Called when a new database gets created.
|
||||
* Multiple DB operations should use [FDroidDatabase.runInTransaction].
|
||||
*/
|
||||
public fun prePopulateDb(db: FDroidDatabase)
|
||||
}
|
||||
|
||||
/**
|
||||
* A database holder using a singleton pattern to ensure
|
||||
* that only one database is open at the same time.
|
||||
*/
|
||||
public object FDroidDatabaseHolder {
|
||||
// Singleton prevents multiple instances of database opening at the same time.
|
||||
@Volatile
|
||||
@GuardedBy("lock")
|
||||
private var INSTANCE: FDroidDatabaseInt? = null
|
||||
private val lock = Object()
|
||||
|
||||
internal val TAG = FDroidDatabase::class.simpleName
|
||||
internal val dispatcher get() = Dispatchers.IO
|
||||
|
||||
/**
|
||||
* Give you an existing instance of [FDroidDatabase] or creates/opens a new one if none exists.
|
||||
* Note: The given [name] is only used when calling this for the first time.
|
||||
* Subsequent calls with a different name will return the instance created by the first call.
|
||||
*/
|
||||
@JvmStatic
|
||||
@JvmOverloads
|
||||
public fun getDb(
|
||||
context: Context,
|
||||
name: String = "fdroid_db",
|
||||
fixture: FDroidFixture? = null,
|
||||
): FDroidDatabase {
|
||||
// if the INSTANCE is not null, then return it,
|
||||
// if it is, then create the database
|
||||
return INSTANCE ?: synchronized(lock) {
|
||||
val builder = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
FDroidDatabaseInt::class.java,
|
||||
name,
|
||||
).apply {
|
||||
// We allow destructive migration (if no real migration was provided),
|
||||
// so we have the option to nuke the DB in production (if that will ever be needed).
|
||||
fallbackToDestructiveMigration()
|
||||
// Add our [FixtureCallback] if a fixture was provided
|
||||
if (fixture != null) addCallback(FixtureCallback(fixture))
|
||||
}
|
||||
val instance = builder.build()
|
||||
INSTANCE = instance
|
||||
// return instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
|
||||
private class FixtureCallback(private val fixture: FDroidFixture) : RoomDatabase.Callback() {
|
||||
override fun onCreate(db: SupportSQLiteDatabase) {
|
||||
super.onCreate(db)
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
GlobalScope.launch(dispatcher) {
|
||||
val database: FDroidDatabase
|
||||
synchronized(lock) {
|
||||
database = INSTANCE ?: error("DB not yet initialized")
|
||||
}
|
||||
fixture.prePopulateDb(database)
|
||||
Log.d(TAG, "Loaded fixtures")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestructiveMigration(db: SupportSQLiteDatabase) {
|
||||
onCreate(db)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
383
libs/database/src/main/java/org/fdroid/database/Repository.kt
Normal file
383
libs/database/src/main/java/org/fdroid/database/Repository.kt
Normal 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,
|
||||
)
|
||||
416
libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt
Normal file
416
libs/database/src/main/java/org/fdroid/database/RepositoryDao.kt
Normal 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
|
||||
|
||||
}
|
||||
236
libs/database/src/main/java/org/fdroid/database/Version.kt
Normal file
236
libs/database/src/main/java/org/fdroid/database/Version.kt
Normal 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()
|
||||
}
|
||||
228
libs/database/src/main/java/org/fdroid/database/VersionDao.kt
Normal file
228
libs/database/src/main/java/org/fdroid/database/VersionDao.kt
Normal 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
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.fdroid.download
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import org.fdroid.database.Repository
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* This is in the database library, because only that knows about the [Repository] class.
|
||||
*/
|
||||
public abstract class DownloaderFactory {
|
||||
|
||||
/**
|
||||
* Same as [create], but trying canonical address first.
|
||||
*
|
||||
* See https://gitlab.com/fdroid/fdroidclient/-/issues/1708 for why this is still needed.
|
||||
*/
|
||||
@Throws(IOException::class)
|
||||
public fun createWithTryFirstMirror(repo: Repository, uri: Uri, destFile: File): Downloader {
|
||||
val tryFirst = repo.getMirrors().find { mirror ->
|
||||
mirror.baseUrl == repo.address
|
||||
}
|
||||
if (tryFirst == null) {
|
||||
Log.w("DownloaderFactory", "Try-first mirror not found, disabled by user?")
|
||||
}
|
||||
val mirrors: List<Mirror> = repo.getMirrors()
|
||||
return create(repo, mirrors, uri, destFile, tryFirst)
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
public abstract fun create(repo: Repository, uri: Uri, destFile: File): Downloader
|
||||
|
||||
@Throws(IOException::class)
|
||||
protected abstract fun create(
|
||||
repo: Repository,
|
||||
mirrors: List<Mirror>,
|
||||
uri: Uri,
|
||||
destFile: File,
|
||||
tryFirst: Mirror?,
|
||||
): Downloader
|
||||
|
||||
}
|
||||
103
libs/database/src/main/java/org/fdroid/index/IndexUpdater.kt
Normal file
103
libs/database/src/main/java/org/fdroid/index/IndexUpdater.kt
Normal file
@@ -0,0 +1,103 @@
|
||||
package org.fdroid.index
|
||||
|
||||
import android.net.Uri
|
||||
import org.fdroid.database.Repository
|
||||
import org.fdroid.download.Downloader
|
||||
import org.fdroid.download.NotFoundException
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* The currently known (and supported) format versions of the F-Droid index.
|
||||
*/
|
||||
public enum class IndexFormatVersion { ONE, TWO }
|
||||
|
||||
public sealed class IndexUpdateResult {
|
||||
public object Unchanged : IndexUpdateResult()
|
||||
public object Processed : IndexUpdateResult()
|
||||
public object NotFound : IndexUpdateResult()
|
||||
public class Error(public val e: Exception) : IndexUpdateResult()
|
||||
}
|
||||
|
||||
public interface IndexUpdateListener {
|
||||
public fun onDownloadProgress(repo: Repository, bytesRead: Long, totalBytes: Long)
|
||||
public fun onUpdateProgress(repo: Repository, appsProcessed: Int, totalApps: Int)
|
||||
}
|
||||
|
||||
public fun interface RepoUriBuilder {
|
||||
/**
|
||||
* Returns an [Uri] for downloading a file from the [Repository].
|
||||
* Allowing different implementations for this is useful for exotic repository locations
|
||||
* that do not allow for simple concatenation.
|
||||
*/
|
||||
public fun getUri(repo: Repository, vararg pathElements: String): Uri
|
||||
}
|
||||
|
||||
internal val defaultRepoUriBuilder = RepoUriBuilder { repo, pathElements ->
|
||||
val builder = Uri.parse(repo.address).buildUpon()
|
||||
pathElements.forEach { builder.appendEncodedPath(it) }
|
||||
builder.build()
|
||||
}
|
||||
|
||||
public fun interface TempFileProvider {
|
||||
@Throws(IOException::class)
|
||||
public fun createTempFile(): File
|
||||
}
|
||||
|
||||
/**
|
||||
* A class to update information of a [Repository] in the database with a new downloaded index.
|
||||
*/
|
||||
public abstract class IndexUpdater {
|
||||
|
||||
/**
|
||||
* The [IndexFormatVersion] used by this updater.
|
||||
* One updater usually handles exactly one format version.
|
||||
* If you need a higher level of abstraction, check [RepoUpdater].
|
||||
*/
|
||||
public abstract val formatVersion: IndexFormatVersion
|
||||
|
||||
/**
|
||||
* Updates a new [repo] for the first time.
|
||||
*/
|
||||
public fun updateNewRepo(
|
||||
repo: Repository,
|
||||
expectedSigningFingerprint: String?,
|
||||
): IndexUpdateResult = catchExceptions {
|
||||
update(repo, null, expectedSigningFingerprint)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing [repo] with a known [Repository.certificate].
|
||||
*/
|
||||
public fun update(
|
||||
repo: Repository,
|
||||
): IndexUpdateResult = catchExceptions {
|
||||
require(repo.certificate != null) { "Repo ${repo.address} had no certificate" }
|
||||
update(repo, repo.certificate, null)
|
||||
}
|
||||
|
||||
private fun catchExceptions(block: () -> IndexUpdateResult): IndexUpdateResult {
|
||||
return try {
|
||||
block()
|
||||
} catch (e: NotFoundException) {
|
||||
IndexUpdateResult.NotFound
|
||||
} catch (e: Exception) {
|
||||
IndexUpdateResult.Error(e)
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract fun update(
|
||||
repo: Repository,
|
||||
certificate: String?,
|
||||
fingerprint: String?,
|
||||
): IndexUpdateResult
|
||||
}
|
||||
|
||||
internal fun Downloader.setIndexUpdateListener(
|
||||
listener: IndexUpdateListener?,
|
||||
repo: Repository,
|
||||
) {
|
||||
if (listener != null) setListener { bytesRead, totalBytes ->
|
||||
listener.onDownloadProgress(repo, bytesRead, totalBytes)
|
||||
}
|
||||
}
|
||||
98
libs/database/src/main/java/org/fdroid/index/RepoUpdater.kt
Normal file
98
libs/database/src/main/java/org/fdroid/index/RepoUpdater.kt
Normal file
@@ -0,0 +1,98 @@
|
||||
package org.fdroid.index
|
||||
|
||||
import mu.KotlinLogging
|
||||
import org.fdroid.CompatibilityChecker
|
||||
import org.fdroid.database.FDroidDatabase
|
||||
import org.fdroid.database.Repository
|
||||
import org.fdroid.download.DownloaderFactory
|
||||
import org.fdroid.index.v1.IndexV1Updater
|
||||
import org.fdroid.index.v2.IndexV2Updater
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
/**
|
||||
* Updates a [Repository] with a downloaded index, detects changes and chooses the right
|
||||
* [IndexUpdater] automatically.
|
||||
*/
|
||||
public class RepoUpdater(
|
||||
tempDir: File,
|
||||
db: FDroidDatabase,
|
||||
downloaderFactory: DownloaderFactory,
|
||||
repoUriBuilder: RepoUriBuilder = defaultRepoUriBuilder,
|
||||
compatibilityChecker: CompatibilityChecker,
|
||||
listener: IndexUpdateListener,
|
||||
) {
|
||||
private val log = KotlinLogging.logger {}
|
||||
private val tempFileProvider = TempFileProvider {
|
||||
File.createTempFile("dl-", "", tempDir)
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of [IndexUpdater]s to try, sorted by newest first.
|
||||
*/
|
||||
private val indexUpdater = listOf(
|
||||
IndexV2Updater(
|
||||
database = db,
|
||||
tempFileProvider = tempFileProvider,
|
||||
downloaderFactory = downloaderFactory,
|
||||
repoUriBuilder = repoUriBuilder,
|
||||
compatibilityChecker = compatibilityChecker,
|
||||
listener = listener,
|
||||
),
|
||||
IndexV1Updater(
|
||||
database = db,
|
||||
tempFileProvider = tempFileProvider,
|
||||
downloaderFactory = downloaderFactory,
|
||||
repoUriBuilder = repoUriBuilder,
|
||||
compatibilityChecker = compatibilityChecker,
|
||||
listener = listener,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Updates the given [repo].
|
||||
* If [Repository.certificate] is null,
|
||||
* the repo is considered to be new this being the first update.
|
||||
*/
|
||||
public fun update(
|
||||
repo: Repository,
|
||||
fingerprint: String? = null,
|
||||
): IndexUpdateResult {
|
||||
return if (repo.certificate == null) {
|
||||
// This is a new repo without a certificate
|
||||
updateNewRepo(repo, fingerprint)
|
||||
} else {
|
||||
update(repo)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateNewRepo(
|
||||
repo: Repository,
|
||||
expectedSigningFingerprint: String?,
|
||||
): IndexUpdateResult = update(repo) { updater ->
|
||||
updater.updateNewRepo(repo, expectedSigningFingerprint)
|
||||
}
|
||||
|
||||
private fun update(repo: Repository): IndexUpdateResult = update(repo) { updater ->
|
||||
updater.update(repo)
|
||||
}
|
||||
|
||||
private fun update(
|
||||
repo: Repository,
|
||||
doUpdate: (IndexUpdater) -> IndexUpdateResult,
|
||||
): IndexUpdateResult {
|
||||
indexUpdater.forEach { updater ->
|
||||
// don't downgrade to older updaters if repo used new format already
|
||||
val repoFormatVersion = repo.formatVersion
|
||||
if (repoFormatVersion != null && repoFormatVersion > updater.formatVersion) {
|
||||
val updaterVersion = updater.formatVersion.name
|
||||
log.warn { "Not using updater $updaterVersion for repo ${repo.address}" }
|
||||
return@forEach
|
||||
}
|
||||
val result = doUpdate(updater)
|
||||
if (result != IndexUpdateResult.NotFound) return result
|
||||
}
|
||||
return IndexUpdateResult.Error(FileNotFoundException("No files found for ${repo.address}"))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
1
libs/database/src/sharedTest
Symbolic link
1
libs/database/src/sharedTest
Symbolic link
@@ -0,0 +1 @@
|
||||
../../index/src/sharedTest
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,6 @@ android {
|
||||
}
|
||||
defaultConfig {
|
||||
minSdkVersion 21
|
||||
targetSdkVersion 25
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
testInstrumentationRunnerArguments disableAnalytics: 'true'
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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?)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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) }
|
||||
@@ -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) {
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
1
libs/index/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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
Reference in New Issue
Block a user