diff --git a/app/build.gradle b/app/build.gradle index e4725a811..cd2cdeb6a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -149,6 +149,7 @@ android { } dependencies { + implementation project(":download") implementation 'androidx.appcompat:appcompat:1.3.0' implementation 'androidx.preference:preference:1.1.1' implementation 'androidx.gridlayout:gridlayout:1.0.0' diff --git a/app/src/androidTest/java/org/fdroid/fdroid/net/HttpDownloaderTest.java b/app/src/androidTest/java/org/fdroid/fdroid/net/HttpDownloaderTest.java index 4356f4564..8f0422076 100644 --- a/app/src/androidTest/java/org/fdroid/fdroid/net/HttpDownloaderTest.java +++ b/app/src/androidTest/java/org/fdroid/fdroid/net/HttpDownloaderTest.java @@ -28,7 +28,7 @@ public class HttpDownloaderTest { ArrayList tempUrls = new ArrayList<>(Arrays.asList( "https://f-droid.org/repo/index-v1.jar", // sites that use SNI for HTTPS - "https://mirrors.kernel.org/debian/dists/stable/Release", + "https://mirrors.edge.kernel.org/debian/dists/stable/Release", "https://fdroid.tetaneutral.net/fdroid/repo/index-v1.jar", "https://ftp.fau.de/fdroid/repo/index-v1.jar", //"https://microg.org/fdroid/repo/index-v1.jar", diff --git a/app/src/main/java/org/fdroid/fdroid/FDroidApp.java b/app/src/main/java/org/fdroid/fdroid/FDroidApp.java index 4c568b875..2677c3c05 100644 --- a/app/src/main/java/org/fdroid/fdroid/FDroidApp.java +++ b/app/src/main/java/org/fdroid/fdroid/FDroidApp.java @@ -64,7 +64,6 @@ import org.fdroid.fdroid.nearby.SDCardScannerService; import org.fdroid.fdroid.nearby.WifiStateChangeService; import org.fdroid.fdroid.net.ConnectivityMonitorService; import org.fdroid.fdroid.net.Downloader; -import org.fdroid.fdroid.net.HttpDownloader; import org.fdroid.fdroid.panic.HidingManager; import org.fdroid.fdroid.work.CleanCacheWorker; @@ -126,6 +125,9 @@ public class FDroidApp extends Application implements androidx.work.Configuratio public static final SubnetUtils.SubnetInfo UNSET_SUBNET_INFO = new SubnetUtils("0.0.0.0/32").getInfo(); + @Nullable + public static volatile String queryString; + private static volatile LongSparseArray lastWorkingMirrorArray = new LongSparseArray<>(1); private static volatile int numTries = Integer.MAX_VALUE; private static volatile int timeout = Downloader.DEFAULT_TIMEOUT; @@ -468,8 +470,8 @@ public class FDroidApp extends Application implements androidx.work.Configuratio final String queryStringKey = "http-downloader-query-string"; if (preferences.sendVersionAndUUIDToServers()) { - HttpDownloader.queryString = atStartTime.getString(queryStringKey, null); - if (HttpDownloader.queryString == null) { + queryString = atStartTime.getString(queryStringKey, null); + if (queryString == null) { UUID uuid = UUID.randomUUID(); ByteBuffer buffer = ByteBuffer.allocate(Long.SIZE / Byte.SIZE * 2); buffer.putLong(uuid.getMostSignificantBits()); @@ -481,10 +483,8 @@ public class FDroidApp extends Application implements androidx.work.Configuratio if (versionName != null) { builder.append("&client_version=").append(versionName); } - HttpDownloader.queryString = builder.toString(); - } - if (!atStartTime.contains(queryStringKey)) { - atStartTime.edit().putString(queryStringKey, HttpDownloader.queryString).apply(); + queryString = builder.toString(); + atStartTime.edit().putString(queryStringKey, queryString).apply(); } } else { atStartTime.edit().remove(queryStringKey).apply(); diff --git a/app/src/main/java/org/fdroid/fdroid/net/HttpDownloader.java b/app/src/main/java/org/fdroid/fdroid/net/HttpDownloader.java index 4c2705954..1a2ba2322 100644 --- a/app/src/main/java/org/fdroid/fdroid/net/HttpDownloader.java +++ b/app/src/main/java/org/fdroid/fdroid/net/HttpDownloader.java @@ -24,24 +24,22 @@ package org.fdroid.fdroid.net; import android.annotation.TargetApi; import android.net.Uri; import android.os.Build; -import android.text.TextUtils; -import android.util.Base64; import org.apache.commons.io.FileUtils; +import org.fdroid.download.DownloadRequest; +import org.fdroid.download.HeadInfo; +import org.fdroid.download.JvmDownloadManager; import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.Utils; import java.io.BufferedInputStream; import java.io.File; -import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; -import java.net.HttpURLConnection; import java.net.MalformedURLException; -import java.net.SocketTimeoutException; import java.net.URL; - -import info.guardianproject.netcipher.NetCipher; +import java.util.Collections; +import java.util.List; /** * Download files over HTTP, with support for proxies, {@code .onion} addresses, @@ -53,22 +51,16 @@ import info.guardianproject.netcipher.NetCipher; public class HttpDownloader extends Downloader { private static final String TAG = "HttpDownloader"; - public static final String HEADER_FIELD_ETAG = "ETag"; - + private final JvmDownloadManager downloadManager = + new JvmDownloadManager(Utils.getUserAgent(), FDroidApp.queryString); private final String username; private final String password; - private URL sourceUrl; - private HttpURLConnection connection; + private final URL sourceUrl; + private boolean newFileAvailableOnServer; - private long fileFullSize = -1L; - /** - * String to append to all HTTP downloads, created in {@link FDroidApp#onCreate()} - */ - public static String queryString; - HttpDownloader(Uri uri, File destFile) - throws FileNotFoundException, MalformedURLException { + HttpDownloader(Uri uri, File destFile) throws MalformedURLException { this(uri, destFile, null, null); } @@ -83,7 +75,7 @@ public class HttpDownloader extends Downloader { * @throws MalformedURLException */ HttpDownloader(Uri uri, File destFile, String username, String password) - throws FileNotFoundException, MalformedURLException { + throws MalformedURLException { super(uri, destFile); this.sourceUrl = new URL(urlString); this.username = username; @@ -92,8 +84,10 @@ public class HttpDownloader extends Downloader { @Override protected InputStream getDownloadersInputStream() throws IOException { - setupConnection(false); - return new BufferedInputStream(connection.getInputStream()); + List mirrors = Collections.singletonList(""); // TODO get real mirrors here + DownloadRequest request = new DownloadRequest(urlString, mirrors, username, password, isSwapUrl(sourceUrl)); + // TODO why do we need to wrap this in a BufferedInputStream here? + return new BufferedInputStream(downloadManager.getBlocking(request)); } /** @@ -130,115 +124,47 @@ public class HttpDownloader extends Downloader { */ @Override public void download() throws IOException, InterruptedException { - // get the file size from the server - HttpURLConnection tmpConn = getConnection(); - tmpConn.setRequestMethod("HEAD"); - - int contentLength = -1; - int statusCode = tmpConn.getResponseCode(); - tmpConn.disconnect(); - newFileAvailableOnServer = false; - switch (statusCode) { - case HttpURLConnection.HTTP_OK: - String headETag = tmpConn.getHeaderField(HEADER_FIELD_ETAG); - contentLength = tmpConn.getContentLength(); - fileFullSize = contentLength; - if (!TextUtils.isEmpty(cacheTag)) { - if (cacheTag.equals(headETag)) { - Utils.debugLog(TAG, urlString + " cached, not downloading: " + headETag); - return; - } else { - String calcedETag = String.format("\"%x-%x\"", - tmpConn.getLastModified() / 1000, contentLength); - if (cacheTag.equals(calcedETag)) { - Utils.debugLog(TAG, urlString + " cached based on calced ETag, not downloading: " + - calcedETag); - return; - } - } - } - newFileAvailableOnServer = true; - break; - case HttpURLConnection.HTTP_NOT_FOUND: - notFound = true; - return; - default: - Utils.debugLog(TAG, "HEAD check of " + urlString + " returned " + statusCode + ": " - + tmpConn.getResponseMessage()); + boolean isSwap = isSwapUrl(sourceUrl); + DownloadRequest request = + new DownloadRequest(urlString, Collections.singletonList(""), username, password, isSwap); + HeadInfo headInfo = downloadManager.headBlocking(request, cacheTag); + fileFullSize = headInfo.getContentLength() == null ? -1 : headInfo.getContentLength(); + if (!headInfo.getETagChanged()) { + // ETag has not changed, don't download again + Utils.debugLog(TAG, urlString + " cached, not downloading."); + newFileAvailableOnServer = false; + return; } + newFileAvailableOnServer = true; boolean resumable = false; long fileLength = outputFile.length(); - if (fileLength > contentLength) { + if (fileLength > fileFullSize) { FileUtils.deleteQuietly(outputFile); - } else if (fileLength == contentLength && outputFile.isFile()) { + } else if (fileLength == fileFullSize && outputFile.isFile()) { + Utils.debugLog(TAG, "Already have outputFile, not download. " + outputFile.getAbsolutePath()); return; // already have it! } else if (fileLength > 0) { resumable = true; } - setupConnection(resumable); Utils.debugLog(TAG, "downloading " + urlString + " (is resumable: " + resumable + ")"); downloadFromStream(resumable); - cacheTag = connection.getHeaderField(HEADER_FIELD_ETAG); } public static boolean isSwapUrl(Uri uri) { return isSwapUrl(uri.getHost(), uri.getPort()); } - public static boolean isSwapUrl(URL url) { + static boolean isSwapUrl(URL url) { return isSwapUrl(url.getHost(), url.getPort()); } - public static boolean isSwapUrl(String host, int port) { + static boolean isSwapUrl(String host, int port) { return port > 1023 // only root can use <= 1023, so never a swap repo && host.matches("[0-9.]+") // host must be an IP address && FDroidApp.subnetInfo.isInRange(host); // on the same subnet as we are } - HttpURLConnection getConnection() throws SocketTimeoutException, IOException { - HttpURLConnection connection; - if (isSwapUrl(sourceUrl)) { - // swap never works with a proxy, its unrouted IP on the same subnet - connection = (HttpURLConnection) sourceUrl.openConnection(); - connection.setRequestProperty("Connection", "Close"); // avoid keep-alive - } else { - if (queryString != null) { - connection = NetCipher.getHttpURLConnection(new URL(urlString + "?" + queryString)); - } else { - connection = NetCipher.getHttpURLConnection(sourceUrl); - } - } - - connection.setRequestProperty("User-Agent", Utils.getUserAgent()); - connection.setConnectTimeout(getTimeout()); - connection.setReadTimeout(getTimeout()); - - if (Build.VERSION.SDK_INT < 19) { // gzip encoding can be troublesome on old Androids - connection.setRequestProperty("Accept-Encoding", "identity"); - } - - if (username != null && password != null) { - // add authorization header from username / password if set - String authString = username + ":" + password; - connection.setRequestProperty("Authorization", "Basic " - + Base64.encodeToString(authString.getBytes(), Base64.NO_WRAP)); - } - return connection; - } - - private void setupConnection(boolean resumable) throws IOException { - if (connection != null) { - return; - } - connection = getConnection(); - - if (resumable) { - // partial file exists, resume the download - connection.setRequestProperty("Range", "bytes=" + outputFile.length() + "-"); - } - } - // Testing in the emulator for me, showed that figuring out the // filesize took about 1 to 1.5 seconds. // To put this in context, downloading a repo of: @@ -264,8 +190,6 @@ public class HttpDownloader extends Downloader { @Override public void close() { - if (connection != null) { - connection.disconnect(); - } + // TODO abort ongoing download somehow } } diff --git a/app/src/main/java/org/fdroid/fdroid/net/HttpPoster.java b/app/src/main/java/org/fdroid/fdroid/net/HttpPoster.java index 52a194177..226922f04 100644 --- a/app/src/main/java/org/fdroid/fdroid/net/HttpPoster.java +++ b/app/src/main/java/org/fdroid/fdroid/net/HttpPoster.java @@ -2,6 +2,9 @@ package org.fdroid.fdroid.net; import android.net.Uri; +import org.fdroid.fdroid.FDroidApp; +import org.fdroid.fdroid.Utils; + import java.io.BufferedWriter; import java.io.File; import java.io.FileNotFoundException; @@ -10,6 +13,9 @@ import java.io.OutputStream; import java.io.OutputStreamWriter; import java.net.HttpURLConnection; import java.net.MalformedURLException; +import java.net.URL; + +import info.guardianproject.netcipher.NetCipher; /** * HTTP POST a JSON string to the URL configured in the constructor. @@ -44,4 +50,15 @@ public class HttpPoster extends HttpDownloader { throw new IOException("HTTP POST failed with " + statusCode + " " + connection.getResponseMessage()); } } + + private HttpURLConnection getConnection() throws IOException { + HttpURLConnection connection; + if (FDroidApp.queryString != null) { + connection = NetCipher.getHttpURLConnection(new URL(urlString + "?" + FDroidApp.queryString)); + } else { + connection = NetCipher.getHttpURLConnection(new URL(urlString)); + } + connection.setRequestProperty("User-Agent", Utils.getUserAgent()); + return connection; + } } diff --git a/app/src/testFull/java/org/fdroid/fdroid/nearby/LocalHTTPDTest.java b/app/src/testFull/java/org/fdroid/fdroid/nearby/LocalHTTPDTest.java index fbd0e9170..edec9f894 100644 --- a/app/src/testFull/java/org/fdroid/fdroid/nearby/LocalHTTPDTest.java +++ b/app/src/testFull/java/org/fdroid/fdroid/nearby/LocalHTTPDTest.java @@ -75,6 +75,8 @@ import static org.junit.Assert.assertTrue; @RunWith(RobolectricTestRunner.class) public class LocalHTTPDTest { + private static final String HEADER_FIELD_ETAG = "ETag"; + private static ClassLoader classLoader; private static LocalHTTPD localHttpd; private static Thread serverStartThread; @@ -217,7 +219,7 @@ public class LocalHTTPDTest { assertEquals(indexFile.length(), connection.getContentLength()); assertNotEquals(0, connection.getContentLength()); - String etag = connection.getHeaderField(HttpDownloader.HEADER_FIELD_ETAG); + String etag = connection.getHeaderField(HEADER_FIELD_ETAG); assertFalse(TextUtils.isEmpty(etag)); assertEquals(200, connection.getResponseCode()); diff --git a/download/.gitignore b/download/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/download/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/download/build.gradle b/download/build.gradle new file mode 100644 index 000000000..9fad9fed0 --- /dev/null +++ b/download/build.gradle @@ -0,0 +1,90 @@ +plugins { + id 'org.jetbrains.kotlin.multiplatform' + id 'com.android.library' +} + +group = 'org.fdroid' +version = '0.1' + +kotlin { + jvm { + compilations.all { + kotlinOptions.jvmTarget = '1.8' + } + testRuns["test"].executionTask.configure { + useJUnit() + } + } + jvmToolchain { + languageVersion.set(JavaLanguageVersion.of(8)) + } + def hostOs = System.getProperty("os.name") + def isMingwX64 = hostOs.startsWith("Windows") + def nativeTarget + if (hostOs == "Mac OS X") nativeTarget = macosX64('native') + else if (hostOs == "Linux") nativeTarget = linuxX64("native") + else if (isMingwX64) nativeTarget = mingwX64("native") + else throw new GradleException("Host OS is not supported in Kotlin/Native.") + + ext { + ktor_version = "2.0.0-beta-1" + } + + android() + sourceSets { + commonMain { + dependencies { + implementation "io.ktor:ktor-client-core:$ktor_version" + } + } + commonTest { + dependencies { + implementation kotlin('test') + } + } + jvmMain { + kotlin.srcDir('src/commonJvmAndroid/kotlin') + dependencies { + implementation "io.ktor:ktor-client-cio:$ktor_version" + } + } + jvmTest { + + } + androidMain { + kotlin.srcDir('src/commonJvmAndroid/kotlin') + dependencies { + implementation "io.ktor:ktor-client-okhttp:$ktor_version" + } + } + androidTest { + dependencies { + implementation 'junit:junit:4.13.2' + } + } + nativeMain { + dependencies { + implementation "io.ktor:ktor-client-curl:$ktor_version" + } + } + nativeTest { + + } + } +} + +android { + compileSdkVersion 30 + sourceSets.main.manifest.srcFile('src/androidMain/AndroidManifest.xml') + defaultConfig { + minSdkVersion 22 + targetSdkVersion 25 + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + lintOptions { + disable "InvalidPackage" // FIXME remove when Ktor 2.0 has been released + } +} diff --git a/download/src/androidMain/AndroidManifest.xml b/download/src/androidMain/AndroidManifest.xml new file mode 100644 index 000000000..5cedd19b9 --- /dev/null +++ b/download/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/download/src/commonJvmAndroid/kotlin/org/fdroid/download/JvmDownloadManager.kt b/download/src/commonJvmAndroid/kotlin/org/fdroid/download/JvmDownloadManager.kt new file mode 100644 index 000000000..7467b5f1c --- /dev/null +++ b/download/src/commonJvmAndroid/kotlin/org/fdroid/download/JvmDownloadManager.kt @@ -0,0 +1,51 @@ +package org.fdroid.download + +import io.ktor.client.plugins.ResponseException +import io.ktor.utils.io.jvm.javaio.toInputStream +import kotlinx.coroutines.runBlocking +import java.io.IOException +import java.io.InputStream +import java.util.Date + +public class JvmDownloadManager( + userAgent: String, + queryString: String?, +) : DownloadManager(userAgent, queryString) { + + @Throws(IOException::class) + fun headBlocking(request: DownloadRequest, eTag: String?): HeadInfo = runBlocking { + val headInfo = head(request, eTag) ?: throw IOException() + if (eTag == null) return@runBlocking headInfo + // If the ETag does not match, it could be because the file is on a mirror + // running a different webserver, e.g. Apache vs Nginx. + // Content-Length and Last-Modified could be used as well. + // Nginx and Apache 2.4 defaults use only those two values to generate the ETag. + // Unfortunately, other webservers and CDNs have totally different methods. + // And mirrors that are syncing using a method other than rsync + // could easily have different Last-Modified times on the exact same file. + // On top of that, some services like GitHub's and GitLab's raw file support + // do not set the header at all. + val lastModified = try { + // this method is not available multi-platform, so for now only done in JVM + Date.parse(headInfo.lastModified) / 1000 + } catch (e: Exception) { + 0L + } + val calculatedEtag: String = + String.format("\"%x-%x\"", lastModified, headInfo.contentLength) + if (calculatedEtag == eTag) headInfo.copy(eTagChanged = false) + else headInfo + } + + @JvmOverloads + @Throws(IOException::class) + fun getBlocking(request: DownloadRequest, skipFirstBytes: Long? = null): InputStream = + runBlocking { + try { + get(request, skipFirstBytes).toInputStream() + } catch (e: ResponseException) { + throw IOException(e) + } + } + +} diff --git a/download/src/commonMain/kotlin/org/fdroid/download/DownloadManager.kt b/download/src/commonMain/kotlin/org/fdroid/download/DownloadManager.kt new file mode 100644 index 000000000..6e12e9cd7 --- /dev/null +++ b/download/src/commonMain/kotlin/org/fdroid/download/DownloadManager.kt @@ -0,0 +1,104 @@ +package org.fdroid.download + +import io.ktor.client.HttpClient +import io.ktor.client.plugins.ResponseException +import io.ktor.client.plugins.UserAgent +import io.ktor.client.plugins.onDownload +import io.ktor.client.request.get +import io.ktor.client.request.head +import io.ktor.client.request.header +import io.ktor.client.request.parameter +import io.ktor.client.statement.bodyAsChannel +import io.ktor.http.HttpHeaders.Authorization +import io.ktor.http.HttpHeaders.Connection +import io.ktor.http.HttpHeaders.ETag +import io.ktor.http.HttpHeaders.LastModified +import io.ktor.http.contentLength +import io.ktor.util.encodeBase64 +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.charsets.Charsets +import io.ktor.utils.io.core.toByteArray +import kotlin.jvm.JvmOverloads + +public open class DownloadManager( + private val userAgent: String, + queryString: String? = null, +) { + + private val httpClient by lazy { + HttpClient { + followRedirects = false + expectSuccess = true + developmentMode = true // TODO remove + engine { + proxy = null // TODO use proxy except when swap + threadsCount = 4 + pipelining = true + } + install(UserAgent) { + agent = userAgent + } + } + } + private val parameters = queryString?.split('&')?.map { p -> + val (key, value) = p.split('=') + Pair(key, value) + } + + // TODO try to force onion addresses over proxy like NetCipher.getHttpURLConnection() + + /** + * Performs a HEAD request and returns [HeadInfo]. + * + * This is useful for checking if the repository index has changed before downloading it again. + * However, due to non-standard ETags on mirrors, change detection is unreliable. + */ + suspend fun head(request: DownloadRequest, eTag: String?): HeadInfo? { + val authString = constructBasicAuthValue(request) + val response = try { + httpClient.head(request.url) { + // add authorization header from username / password if set + if (authString != null) header(Authorization, authString) + } + } catch (e: ResponseException) { + println(e) + return null + } + val contentLength = response.contentLength() + val lastModified = response.headers[LastModified] + if (eTag != null && response.headers[ETag] == eTag) { + return HeadInfo(false, contentLength, lastModified) + } + return HeadInfo(true, contentLength, lastModified) + } + + @JvmOverloads + suspend fun get(request: DownloadRequest, skipFirstBytes: Long? = null): ByteReadChannel { + val authString = constructBasicAuthValue(request) + val response = httpClient.get(request.url) { + // add query string parameters if existing + parameters?.forEach { (key, value) -> + parameter(key, value) + } + // add authorization header from username / password if set + if (authString != null) header(Authorization, authString) + // add range header if set + if (skipFirstBytes != null) header("Range", "bytes=${skipFirstBytes}-") + // avoid keep-alive for swap due to strange errors observed in the past + if (request.isSwap) header(Connection, "Close") + + onDownload { bytesSentTotal, contentLength -> + println("Received $bytesSentTotal bytes from $contentLength") + } + } + return response.bodyAsChannel() + } + + private fun constructBasicAuthValue(request: DownloadRequest): String? { + if (request.username == null || request.password == null) return null + val authString = "${request.username}:${request.password}" + val authBuf = authString.toByteArray(Charsets.UTF_8).encodeBase64() + return "Basic $authBuf" + } + +} diff --git a/download/src/commonMain/kotlin/org/fdroid/download/DownloadRequest.kt b/download/src/commonMain/kotlin/org/fdroid/download/DownloadRequest.kt new file mode 100644 index 000000000..7a881fe42 --- /dev/null +++ b/download/src/commonMain/kotlin/org/fdroid/download/DownloadRequest.kt @@ -0,0 +1,11 @@ +package org.fdroid.download + +import kotlin.jvm.JvmOverloads + +data class DownloadRequest @JvmOverloads constructor( + val url: String, + val mirrors: List, + val username: String? = null, + val password: String? = null, + val isSwap: Boolean = false, +) diff --git a/download/src/commonMain/kotlin/org/fdroid/download/HeadInfo.kt b/download/src/commonMain/kotlin/org/fdroid/download/HeadInfo.kt new file mode 100644 index 000000000..8e2a90a8a --- /dev/null +++ b/download/src/commonMain/kotlin/org/fdroid/download/HeadInfo.kt @@ -0,0 +1,7 @@ +package org.fdroid.download + +data class HeadInfo( + val eTagChanged: Boolean, + val contentLength: Long?, + val lastModified: String?, +) diff --git a/gradle.properties b/gradle.properties index 031f6b9ec..baf7a83fc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,3 +1,8 @@ org.gradle.jvmargs=-Xms1g -Xmx2g -XX:MaxPermSize=2g android.enableJetifier=true android.useAndroidX=true + +kotlin.code.style=official +kotlin.mpp.stability.nowarn=true +kotlin.mpp.enableGranularSourceSetsMetadata=true +kotlin.native.enableDependencyPropagation=false diff --git a/gradle/verification-keyring.gpg b/gradle/verification-keyring.gpg index f2830afda..9d19b2644 100644 Binary files a/gradle/verification-keyring.gpg and b/gradle/verification-keyring.gpg differ diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index 9133a831b..82600014a 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -3,6 +3,10 @@ false true + + + + @@ -37,8 +41,12 @@ + - + + + + @@ -78,6 +86,7 @@ + @@ -88,7 +97,10 @@ - + + + + @@ -133,16 +145,25 @@ + - + + + + + + + + + @@ -1949,6 +1970,11 @@ + + + + + @@ -2109,6 +2135,239 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -3002,6 +3261,11 @@ + + + + + @@ -3087,6 +3351,19 @@ + + + + + + + + + + + + + @@ -3097,12 +3374,22 @@ + + + + + + + + + + @@ -3350,6 +3637,11 @@ + + + + + diff --git a/settings.gradle b/settings.gradle index e7b4def49..8bf885599 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,2 @@ include ':app' +include ':download'