diff --git a/libs/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt index 0a8d165fd..b2b997866 100644 --- a/libs/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt +++ b/libs/download/src/androidMain/kotlin/org/fdroid/download/Downloader.kt @@ -1,6 +1,7 @@ package org.fdroid.download import mu.KotlinLogging +import org.fdroid.IndexFile import org.fdroid.fdroid.ProgressListener import org.fdroid.fdroid.isMatching import java.io.File @@ -11,6 +12,7 @@ import java.io.OutputStream import java.security.MessageDigest public abstract class Downloader constructor( + protected val indexFile: IndexFile, @JvmField protected val outputFile: File, ) { @@ -19,13 +21,6 @@ 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 @@ -46,19 +41,8 @@ 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, NotFoundException::class) @@ -110,7 +94,7 @@ 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 { + val messageDigest: MessageDigest? = if (indexFile.sha256 == null) null else { MessageDigest.getInstance("SHA-256") } FileOutputStream(outputFile, isResume).use { outputStream -> @@ -128,7 +112,7 @@ public abstract class Downloader constructor( lastTimeReported = reportProgress(lastTimeReported, bytesCopied, total) } // check if expected sha256 hash matches - sha256?.let { expectedHash -> + indexFile.sha256?.let { expectedHash -> if (!messageDigest.isMatching(expectedHash)) { throw IOException("Hash not matching") } @@ -152,7 +136,7 @@ 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 { + val messageDigest: MessageDigest? = if (indexFile.sha256 == null) null else { MessageDigest.getInstance("SHA-256") } try { @@ -170,7 +154,7 @@ public abstract class Downloader constructor( numBytes = input.read(buffer) } // check if expected sha256 hash matches - sha256?.let { expectedHash -> + indexFile.sha256?.let { expectedHash -> if (!messageDigest.isMatching(expectedHash)) { throw IOException("Hash not matching") } diff --git a/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloader.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloader.kt index 5858a7963..3f88b5854 100644 --- a/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloader.kt +++ b/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloader.kt @@ -33,18 +33,19 @@ import java.util.Date /** * Download files over HTTP, with support for proxies, `.onion` addresses, HTTP Basic Auth, etc. */ +@Deprecated("Only for v1 repos") public class HttpDownloader constructor( private val httpManager: HttpManager, private val request: DownloadRequest, destFile: File, -) : Downloader(destFile) { +) : Downloader(request.indexFile, destFile) { private companion object { val log = KotlinLogging.logger {} } - @Deprecated("Only for v1 repos") private var hasChanged = false + private var fileSize: Long? = request.indexFile.size override fun getInputStream(resumable: Boolean): InputStream { throw NotImplementedError("Use getInputStreamSuspend instead.") @@ -61,12 +62,6 @@ public class HttpDownloader constructor( } } - 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. @@ -112,7 +107,7 @@ public class HttpDownloader constructor( } val expectedETag = cacheTag cacheTag = headInfo.eTag - fileSize = headInfo.contentLength ?: -1 + fileSize = headInfo.contentLength ?: request.indexFile.size ?: -1 // 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. @@ -137,7 +132,7 @@ public class HttpDownloader constructor( // calculatedEtag == expectedETag (ETag calculated from server response matches expected ETag) if (!headInfo.eTagChanged || calculatedEtag == expectedETag) { // ETag has not changed, don't download again - log.info { "${request.path} cached, not downloading." } + log.info { "${request.indexFile.name} cached, not downloading." } hasChanged = false return } @@ -149,7 +144,7 @@ public class HttpDownloader constructor( private fun downloadToFile() { var resumable = false val fileLength = outputFile.length() - if (fileLength > fileSize ?: -1) { + if (fileLength > (fileSize ?: -1)) { if (!outputFile.delete()) log.warn { "Warning: " + outputFile.absolutePath + " not deleted" } @@ -159,7 +154,7 @@ public class HttpDownloader constructor( } else if (fileLength > 0) { resumable = true } - log.info { "downloading ${request.path} (is resumable: $resumable)" } + log.info { "downloading ${request.indexFile.name} (is resumable: $resumable)" } runBlocking { try { downloadFromBytesReceiver(resumable) diff --git a/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloaderV2.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloaderV2.kt new file mode 100644 index 000000000..6dce71ea9 --- /dev/null +++ b/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpDownloaderV2.kt @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2014-2017 Peter Serwylo + * Copyright (C) 2014-2018 Hans-Christoph Steiner + * Copyright (C) 2015-2016 Daniel Martí + * Copyright (c) 2018 Senecto Limited + * Copyright (C) 2022 Torsten Grote + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 3 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.fdroid.download + +import io.ktor.client.plugins.ResponseException +import io.ktor.http.HttpStatusCode.Companion.NotFound +import kotlinx.coroutines.runBlocking +import mu.KotlinLogging +import java.io.File +import java.io.IOException +import java.io.InputStream + +/** + * Download files over HTTP, with support for proxies, `.onion` addresses, HTTP Basic Auth, etc. + */ +public class HttpDownloaderV2 constructor( + private val httpManager: HttpManager, + private val request: DownloadRequest, + destFile: File, +) : Downloader(request.indexFile, destFile) { + + private companion object { + val log = KotlinLogging.logger {} + } + + override fun getInputStream(resumable: Boolean): InputStream { + throw NotImplementedError("Use getInputStreamSuspend instead.") + } + + @Throws(IOException::class, NoResumeException::class) + 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) { + if (e.response.status == NotFound) throw NotFoundException(e) + else throw IOException(e) + } + } + + @Throws(IOException::class, InterruptedException::class) + public override fun download() { + var resumable = false + val fileLength = outputFile.length() + if (fileLength > (request.indexFile.size ?: -1)) { + if (!outputFile.delete()) log.warn { + "Warning: " + outputFile.absolutePath + " not deleted" + } + } else if (fileLength == request.indexFile.size && outputFile.isFile) { + log.info { "Already have outputFile, not download. ${outputFile.absolutePath}" } + return // already have it! + } else if (fileLength > 0) { + resumable = true + } + log.info { "downloading ${request.indexFile.name} (is resumable: $resumable)" } + runBlocking { + try { + downloadFromBytesReceiver(resumable) + } catch (e: NoResumeException) { + require(resumable) { "Got $e even though download was not resumable" } + if (!outputFile.delete()) log.warn { + "Warning: " + outputFile.absolutePath + " not deleted" + } + downloadFromBytesReceiver(false) + } + } + } + + protected override fun totalDownloadSize(): Long = request.indexFile.size ?: -1L + + @Deprecated("Only for v1 repos") + override fun hasChanged(): Boolean { + error("hasChanged() was called for V2 where it should not be needed.") + } + + override fun close() { + } + +} diff --git a/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/DownloadRequestLoader.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/DownloadRequestLoader.kt index 85cd78a61..966b9d99c 100644 --- a/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/DownloadRequestLoader.kt +++ b/libs/download/src/androidMain/kotlin/org/fdroid/download/glide/DownloadRequestLoader.kt @@ -42,6 +42,7 @@ public class DownloadRequestLoader( } internal fun DownloadRequest.getKey(): ObjectKey { - // TODO should we choose a unique key or is it ok for this to work cross-repo based on file path only? - return ObjectKey(path) + // TODO should we always choose a unique key + // or is it ok for this to work cross-repo based on file path only? + return ObjectKey(indexFile.sha256 ?: indexFile.name) } diff --git a/libs/download/src/androidTest/kotlin/org/fdroid/download/HttpDownloaderTest.kt b/libs/download/src/androidTest/kotlin/org/fdroid/download/HttpDownloaderTest.kt index ab4b65ceb..a9eafb9d8 100644 --- a/libs/download/src/androidTest/kotlin/org/fdroid/download/HttpDownloaderTest.kt +++ b/libs/download/src/androidTest/kotlin/org/fdroid/download/HttpDownloaderTest.kt @@ -14,6 +14,7 @@ import io.ktor.http.HttpStatusCode.Companion.OK import io.ktor.http.HttpStatusCode.Companion.PartialContent import io.ktor.http.headersOf import org.fdroid.get +import org.fdroid.getIndexFile import org.fdroid.getRandomString import org.fdroid.runSuspend import org.junit.Assume.assumeTrue @@ -41,7 +42,7 @@ internal class HttpDownloaderTest { private val userAgent = getRandomString() private val mirror1 = Mirror("http://example.org") private val mirrors = listOf(mirror1) - private val downloadRequest = DownloadRequest("foo/bar", mirrors) + private val downloadRequest = DownloadRequest(getIndexFile("foo/bar"), mirrors) @Test fun testDownload() = runSuspend { @@ -60,6 +61,12 @@ internal class HttpDownloaderTest { fun testDownloadWithCorrectHash() = runSuspend { val file = folder.newFile() val bytes = "We know the hash for this string".encodeToByteArray() + val indexFile = getIndexFile( + name = "/foo/bar", + sha256 = "e3802e5f8ae3dc7bbf5f1f4f7fb825d9bce9d1ddce50ac564fcbcfdeb31f1b90", + size = bytes.size.toLong(), + ) + val downloadRequest = DownloadRequest(indexFile, mirrors = mirrors) var progressReported = false val mockEngine = MockEngine { respond(bytes) } @@ -69,8 +76,7 @@ internal class HttpDownloaderTest { assertEquals(bytes.size.toLong(), totalBytes) progressReported = true } - httpDownloader.download(bytes.size.toLong(), - "e3802e5f8ae3dc7bbf5f1f4f7fb825d9bce9d1ddce50ac564fcbcfdeb31f1b90") + httpDownloader.download() assertContentEquals(bytes, file.readBytes()) assertTrue(progressReported) @@ -80,11 +86,17 @@ internal class HttpDownloaderTest { fun testDownloadWithWrongHash() = runSuspend { val file = folder.newFile() val bytes = "We know the hash for this string".encodeToByteArray() + val indexFile = getIndexFile( + name = "/foo/bar", + sha256 = "This is not the right hash", + size = bytes.size.toLong(), + ) + val downloadRequest = DownloadRequest(indexFile, mirrors = mirrors) 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") + httpDownloader.download() assertContentEquals(bytes, file.readBytes()) } diff --git a/libs/download/src/commonMain/kotlin/org/fdroid/IndexFile.kt b/libs/download/src/commonMain/kotlin/org/fdroid/IndexFile.kt new file mode 100644 index 000000000..84b646754 --- /dev/null +++ b/libs/download/src/commonMain/kotlin/org/fdroid/IndexFile.kt @@ -0,0 +1,10 @@ +package org.fdroid + +public interface IndexFile { + public val name: String + public val sha256: String? + public val size: Long? + public val ipfsCidV1: String? + + public fun serialize(): String +} diff --git a/libs/download/src/commonMain/kotlin/org/fdroid/download/DownloadRequest.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/DownloadRequest.kt index c2f74469b..410d43399 100644 --- a/libs/download/src/commonMain/kotlin/org/fdroid/download/DownloadRequest.kt +++ b/libs/download/src/commonMain/kotlin/org/fdroid/download/DownloadRequest.kt @@ -1,10 +1,10 @@ package org.fdroid.download import io.ktor.client.engine.ProxyConfig -import kotlin.jvm.JvmOverloads +import org.fdroid.IndexFile public data class DownloadRequest @JvmOverloads constructor( - val path: String, + val indexFile: IndexFile, val mirrors: List, val proxy: ProxyConfig? = null, val username: String? = null, @@ -19,5 +19,24 @@ public data class DownloadRequest @JvmOverloads constructor( */ val tryFirstMirror: Mirror? = null, ) { + @JvmOverloads + @Deprecated("Use other constructor instead") + public constructor( + path: String, + mirrors: List, + proxy: ProxyConfig? = null, + username: String? = null, + password: String? = null, + tryFirstMirror: Mirror? = null, + ) : this(object : IndexFile { + override val name = path + override val sha256: String? = null + override val size = 0L + override val ipfsCidV1: String? = null + override fun serialize(): String { + throw NotImplementedError("Serialization is not implemented.") + } + }, mirrors, proxy, username, password, tryFirstMirror) + val hasCredentials: Boolean = username != null && password != null } diff --git a/libs/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt index 60ed5e9df..9294a070e 100644 --- a/libs/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt +++ b/libs/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt @@ -25,6 +25,7 @@ import io.ktor.http.HttpHeaders.ETag import io.ktor.http.HttpHeaders.LastModified import io.ktor.http.HttpHeaders.Range import io.ktor.http.HttpMessageBuilder +import io.ktor.http.HttpStatusCode.Companion.NotFound import io.ktor.http.HttpStatusCode.Companion.PartialContent import io.ktor.http.Url import io.ktor.http.contentLength @@ -104,6 +105,7 @@ public open class HttpManager @JvmOverloads constructor( } } catch (e: ResponseException) { log.warn(e) { "Error getting HEAD" } + if (e.response.status == NotFound) throw NotFoundException() return null } val contentLength = response.contentLength() diff --git a/libs/download/src/commonMain/kotlin/org/fdroid/download/Mirror.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/Mirror.kt index 821358d63..587bba8b7 100644 --- a/libs/download/src/commonMain/kotlin/org/fdroid/download/Mirror.kt +++ b/libs/download/src/commonMain/kotlin/org/fdroid/download/Mirror.kt @@ -9,6 +9,11 @@ import mu.KotlinLogging public data class Mirror @JvmOverloads constructor( val baseUrl: String, val location: String? = null, + /** + * If this is true, this as an IPFS HTTP gateway that only accepts CIDv1 and not regular paths. + * So use this mirror only, if you have a CIDv1 available for supplying it to [getUrl]. + */ + val isIpfsGateway: Boolean = false, ) { public val url: Url by lazy { try { diff --git a/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt index 18b67b25a..41227e5e4 100644 --- a/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt +++ b/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt @@ -1,5 +1,6 @@ package org.fdroid.download +import io.ktor.client.network.sockets.SocketTimeoutException import io.ktor.client.plugins.ResponseException import io.ktor.http.HttpStatusCode.Companion.Forbidden import io.ktor.http.HttpStatusCode.Companion.NotFound @@ -38,7 +39,16 @@ internal abstract class MirrorChooserImpl : MirrorChooser { orderMirrors(downloadRequest) } mirrors.forEachIndexed { index, mirror -> - val url = mirror.getUrl(downloadRequest.path) + val ipfsCidV1 = downloadRequest.indexFile.ipfsCidV1 + val url = if (mirror.isIpfsGateway) { + if (ipfsCidV1 == null) { + val e = IOException("Got IPFS gateway without CID") + throwOnLastMirror(e, index == mirrors.size - 1) + return@forEachIndexed + } else mirror.getUrl(ipfsCidV1) + } else { + mirror.getUrl(downloadRequest.indexFile.name) + } try { return request(mirror, url) } catch (e: ResponseException) { @@ -47,9 +57,11 @@ internal abstract class MirrorChooserImpl : MirrorChooser { // 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) + throwOnLastMirror(e, index == mirrors.size - 1) } catch (e: IOException) { - throwOnLastMirror(e, index == downloadRequest.mirrors.size - 1) + throwOnLastMirror(e, index == mirrors.size - 1) + } catch (e: SocketTimeoutException) { + throwOnLastMirror(e, index == mirrors.size - 1) } } error("Reached code that was thought to be unreachable.") diff --git a/libs/download/src/commonTest/kotlin/org/fdroid/TestUtils.kt b/libs/download/src/commonTest/kotlin/org/fdroid/TestUtils.kt index d8be6be71..5e935bffe 100644 --- a/libs/download/src/commonTest/kotlin/org/fdroid/TestUtils.kt +++ b/libs/download/src/commonTest/kotlin/org/fdroid/TestUtils.kt @@ -23,3 +23,18 @@ fun get(mockEngine: MockEngine) = object : HttpClientEngineFactory { + httpManager.head(downloadRequest) + } val e = assertFailsWith { httpManager.getBytes(downloadRequest) } diff --git a/libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorChooserTest.kt b/libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorChooserTest.kt index 5ffad0045..b2ff72ba5 100644 --- a/libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorChooserTest.kt +++ b/libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorChooserTest.kt @@ -1,10 +1,13 @@ package org.fdroid.download +import io.ktor.client.network.sockets.SocketTimeoutException import io.ktor.utils.io.errors.IOException +import org.fdroid.getIndexFile import org.fdroid.runSuspend import kotlin.random.Random import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertTrue class MirrorChooserTest { @@ -12,6 +15,8 @@ class MirrorChooserTest { private val mirrors = listOf(Mirror("foo"), Mirror("bar"), Mirror("42"), Mirror("1337")) private val downloadRequest = DownloadRequest("foo", mirrors) + private val ipfsIndexFile = getIndexFile(name = "foo", ipfsCidV1 = "CIDv1") + @Test fun testMirrorChooserDefaultImpl() = runSuspend { val mirrorChooser = MirrorChooserRandom() @@ -19,19 +24,19 @@ class MirrorChooserTest { val result = mirrorChooser.mirrorRequest(downloadRequest) { mirror, url -> assertTrue { mirrors.contains(mirror) } - assertEquals(mirror.getUrl(downloadRequest.path), url) + assertEquals(mirror.getUrl(downloadRequest.indexFile.name), url) expectedResult } assertEquals(expectedResult, result) } @Test - fun testFallbackToNextMirror() = runSuspend { + fun testFallbackToNextMirrorWithIOException() = runSuspend { val mirrorChooser = MirrorChooserRandom() val expectedResult = Random.nextInt() val result = mirrorChooser.mirrorRequest(downloadRequest) { mirror, url -> - assertEquals(mirror.getUrl(downloadRequest.path), url) + assertEquals(mirror.getUrl(downloadRequest.indexFile.name), url) // fails with all except last mirror if (mirror != downloadRequest.mirrors.last()) throw IOException("foo") expectedResult @@ -39,6 +44,20 @@ class MirrorChooserTest { assertEquals(expectedResult, result) } + @Test + fun testFallbackToNextMirrorWithSocketTimeoutException() = runSuspend { + val mirrorChooser = MirrorChooserRandom() + val expectedResult = Random.nextInt() + + val result = mirrorChooser.mirrorRequest(downloadRequest) { mirror, url -> + assertEquals(mirror.getUrl(downloadRequest.indexFile.name), url) + // fails with all except last mirror + if (mirror != downloadRequest.mirrors.last()) throw SocketTimeoutException("foo") + expectedResult + } + assertEquals(expectedResult, result) + } + @Test fun testMirrorChooserRandom() { val mirrorChooser = MirrorChooserRandom() @@ -73,4 +92,39 @@ class MirrorChooserTest { assertEquals(mirrors.toSet(), orderedMirrors.toSet()) } + @Test + fun testMirrorChooserIgnoresIpfsGatewayIfNoCid() = runSuspend { + val mirrorChooser = object : MirrorChooserImpl() { + override fun orderMirrors(downloadRequest: DownloadRequest): List { + return downloadRequest.mirrors // keep mirror list stable, no random please + } + } + val mirrors = listOf( + Mirror("http://ipfs.com", isIpfsGateway = true), + Mirror("http://example.com", isIpfsGateway = false), + ) + val ipfsRequest = downloadRequest.copy(mirrors = mirrors) + + val result = mirrorChooser.mirrorRequest(ipfsRequest) { _, url -> + url.toString() + } + assertEquals("http://example.com/foo", result) + } + + @Test + fun testMirrorChooserThrowsIfOnlyIpfsGateways() = runSuspend { + val mirrorChooser = MirrorChooserRandom() + val mirrors = listOf( + Mirror("foo/bar", isIpfsGateway = true), + Mirror("bar/foo", isIpfsGateway = true), + ) + val ipfsRequest = downloadRequest.copy(mirrors = mirrors) + + val e = assertFailsWith { + mirrorChooser.mirrorRequest(ipfsRequest) { _, _ -> + } + } + assertEquals("Got IPFS gateway without CID", e.message) + } + } diff --git a/libs/sharedTest/build.gradle b/libs/sharedTest/build.gradle index 941922ddf..cd00be8f8 100644 --- a/libs/sharedTest/build.gradle +++ b/libs/sharedTest/build.gradle @@ -18,6 +18,7 @@ android { } dependencies { + implementation project(":libs:download") implementation project(":libs:index") implementation 'org.jetbrains.kotlin:kotlin-test'