From d5134ffe8d3fa2147ab1a88199b30f652a486478 Mon Sep 17 00:00:00 2001 From: space woodchuck Date: Wed, 4 Mar 2026 18:56:19 +0000 Subject: [PATCH] Replace picasso with coil --- gradle/libs.versions.toml | 8 +- ultrasonic/build.gradle | 11 +- ultrasonic/lint-baseline.xml | 8 - ultrasonic/minify/proguard-coil.pro | 5 + .../kotlin/org/moire/ultrasonic/app/UApp.kt | 42 +++- .../imageloader/ArtworkBitmapLoader.kt | 6 +- .../ultrasonic/imageloader/AvatarFetcher.kt | 65 ++++++ .../imageloader/AvatarRequestHandler.kt | 35 ---- .../ultrasonic/imageloader/CoverArtFetcher.kt | 147 ++++++++++++++ .../imageloader/CoverArtRequestHandler.kt | 103 ---------- .../ultrasonic/imageloader/ImageLoader.kt | 108 ++++------ .../ultrasonic/imageloader/RequestCreator.kt | 27 --- .../imageloader/AvatarFetcherTest.kt | 129 ++++++++++++ .../imageloader/AvatarRequestHandlerTest.kt | 79 -------- .../imageloader/CoverArtFetcherTest.kt | 186 ++++++++++++++++++ .../imageloader/CoverArtRequestHandlerTest.kt | 103 ---------- .../imageloader/RequestCreatorTest.kt | 28 --- 17 files changed, 624 insertions(+), 466 deletions(-) create mode 100644 ultrasonic/minify/proguard-coil.pro create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/AvatarFetcher.kt delete mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/AvatarRequestHandler.kt create mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/CoverArtFetcher.kt delete mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/CoverArtRequestHandler.kt delete mode 100644 ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/RequestCreator.kt create mode 100644 ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/AvatarFetcherTest.kt delete mode 100644 ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/AvatarRequestHandlerTest.kt create mode 100644 ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/CoverArtFetcherTest.kt delete mode 100644 ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/CoverArtRequestHandlerTest.kt delete mode 100644 ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/RequestCreatorTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 73789ea6..ac349a43 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,6 +6,7 @@ navigation = "2.9.6" gradlePlugin = "9.0.0" androidxcar = "1.7.0" androidxcore = "1.17.0" +androidxTestCore = "1.7.0" ktlint = "1.0.1" ktlintGradle = "14.0.1" detekt = "1.23.8" @@ -27,7 +28,7 @@ retrofit = "3.0.0" jackson = "2.21.0" okhttp = "5.3.2" koin = "4.1.1" -picasso = "2.8" +coil = "3.3.0" junit4 = "4.13.2" junit = "6.0.2" @@ -51,6 +52,7 @@ detekt = { module = "io.gitlab.arturbosch.detekt:detekt-gradle- car = { module = "androidx.car.app:app", version.ref = "androidxcar" } core = { module = "androidx.core:core-ktx", version.ref = "androidxcore" } +testCore = { module = "androidx.test:core-ktx", version.ref = "androidxTestCore" } design = { module = "com.google.android.material:material", version.ref = "materialDesign" } multidex = { module = "androidx.multidex:multidex", version.ref = "multidex" } documentfile = { module = "androidx.documentfile:documentfile", version.ref = "documentfile" } @@ -76,13 +78,15 @@ kotlinStdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", versi kotlinReflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlinxCoroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinxCoroutines" } kotlinxGuava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-guava", version.ref = "kotlinxCoroutines"} +kotlinxCoroutinesTest = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutines" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } jacksonConverter = { module = "com.squareup.retrofit2:converter-jackson", version.ref = "retrofit" } jacksonKotlin = { module = "com.fasterxml.jackson.module:jackson-module-kotlin", version.ref = "jackson" } okhttpLogging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } koinCore = { module = "io.insert-koin:koin-core", version.ref = "koin" } koinAndroid = { module = "io.insert-koin:koin-android", version.ref = "koin" } -picasso = { module = "com.squareup.picasso:picasso", version.ref = "picasso" } +koinTest = { module = "io.insert-koin:koin-test", version.ref = "koin" } +coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } fastScroll = { module = "com.simplecityapps:recyclerview-fastscroll", version.ref = "fastScroll" } colorPickerView = { module = "com.github.skydoves:colorpickerview", version.ref = "colorPicker" } diff --git a/ultrasonic/build.gradle b/ultrasonic/build.gradle index 8cce7462..6608181f 100644 --- a/ultrasonic/build.gradle +++ b/ultrasonic/build.gradle @@ -31,7 +31,8 @@ android { 'minify/proguard-retrofit.pro', 'minify/proguard-jackson.pro', 'minify/proguard-kotlin-reflect.pro', - 'minify/proguard-kotlin.pro' + 'minify/proguard-kotlin.pro', + 'minify/proguard-coil.pro' } debug { minifyEnabled = false @@ -96,10 +97,6 @@ dependencies { implementation project(':core:domain') implementation project(':core:subsonic-api') - api(libs.picasso) { - exclude group: "com.android.support" - } - implementation libs.car implementation libs.core implementation libs.design @@ -127,6 +124,7 @@ dependencies { implementation libs.kotlinxCoroutines implementation libs.kotlinxGuava implementation libs.koinAndroid + implementation libs.coil implementation libs.okhttpLogging implementation libs.fastScroll implementation libs.colorPickerView @@ -145,6 +143,9 @@ dependencies { testImplementation libs.mockito testImplementation libs.mockitoKotlin testImplementation libs.robolectric + testImplementation libs.kotlinxCoroutinesTest + testImplementation libs.koinTest + testImplementation libs.testCore implementation libs.timber } diff --git a/ultrasonic/lint-baseline.xml b/ultrasonic/lint-baseline.xml index 2041798d..a1964e47 100644 --- a/ultrasonic/lint-baseline.xml +++ b/ultrasonic/lint-baseline.xml @@ -1,13 +1,5 @@ - - - - { - return executorService.submit { - load(uri) - } + return load(uri) } private fun decode(data: ByteArray): Bitmap { @@ -57,7 +55,7 @@ class ArtworkBitmapLoader : BitmapLoader, KoinComponent { } @Throws(IOException::class) - private fun load(uri: Uri): Bitmap { + private fun load(uri: Uri): ListenableFuture { val parts = uri.path?.trim('/')?.split('|') require(parts!!.count() == 2) { "Invalid bitmap Uri" } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/AvatarFetcher.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/AvatarFetcher.kt new file mode 100644 index 00000000..32f51cad --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/AvatarFetcher.kt @@ -0,0 +1,65 @@ +/* + * AvatarFetcher.kt + * Copyright (C) 2009-2026 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.imageloader + +import coil3.decode.DataSource +import coil3.decode.ImageSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.SourceFetchResult +import coil3.key.Keyer +import coil3.request.Options +import java.io.IOException +import okio.buffer +import okio.source +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient + +data class AvatarRequest( + val username: String +) + +class AvatarKeyer : Keyer { + override fun key(data: AvatarRequest, options: Options): String { + return data.username + } +} + +class AvatarFetcher( + private val avatarRequest: AvatarRequest, + private val options: Options +) : Fetcher, KoinComponent { + private val client: SubsonicAPIClient by inject() + + override suspend fun fetch(): FetchResult { + // Inverted call order, because Mockito has problems with chained calls. + val response = client.toStreamResponse( + client.api.getAvatar(avatarRequest.username).execute() + ) + + if (response.hasError() || response.stream == null) { + throw IOException("${response.apiError}") + } + return SourceFetchResult( + ImageSource(response.stream!!.source().buffer(), options.fileSystem), + null, + DataSource.NETWORK + ) + } + + class Factory : Fetcher.Factory { + override fun create( + data: AvatarRequest, + options: Options, + imageLoader: coil3.ImageLoader + ): Fetcher { + return AvatarFetcher(data, options) + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/AvatarRequestHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/AvatarRequestHandler.kt deleted file mode 100644 index fa71e9c9..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/AvatarRequestHandler.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.moire.ultrasonic.imageloader - -import com.squareup.picasso.Picasso -import com.squareup.picasso.Request -import com.squareup.picasso.RequestHandler -import java.io.IOException -import okio.source -import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient - -/** - * Loads avatars from subsonic api. - */ -class AvatarRequestHandler( - private val client: SubsonicAPIClient -) : RequestHandler() { - override fun canHandleRequest(data: Request): Boolean { - return with(data.uri) { - scheme == SCHEME && path == "/$AVATAR_PATH" - } - } - - override fun load(request: Request, networkPolicy: Int): Result { - val username = request.uri.getQueryParameter(QUERY_USERNAME) - ?: throw IllegalArgumentException("Nullable username") - - // Inverted call order, because Mockito has problems with chained calls. - val response = client.toStreamResponse(client.api.getAvatar(username).execute()) - - if (response.hasError() || response.stream == null) { - throw IOException("${response.apiError}") - } else { - return Result(response.stream!!.source(), Picasso.LoadedFrom.NETWORK) - } - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/CoverArtFetcher.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/CoverArtFetcher.kt new file mode 100644 index 00000000..bbeb2279 --- /dev/null +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/CoverArtFetcher.kt @@ -0,0 +1,147 @@ +/* + * CoverArtFetcher.kt + * Copyright (C) 2009-2026 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.imageloader + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import coil3.asImage +import coil3.decode.DataSource +import coil3.decode.ImageSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.ImageFetchResult +import coil3.fetch.SourceFetchResult +import coil3.key.Keyer +import coil3.request.Options +import java.io.File +import java.io.IOException +import okio.buffer +import okio.source +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient +import org.moire.ultrasonic.util.FileUtil +import org.moire.ultrasonic.util.FileUtil.SUFFIX_LARGE +import org.moire.ultrasonic.util.FileUtil.SUFFIX_SMALL +import org.moire.ultrasonic.util.Util +import timber.log.Timber + +data class CoverArtRequest( + val id: String, + val cacheKey: String, + val size: Int +) + +class CoverArtKeyer : Keyer { + override fun key(data: CoverArtRequest, options: Options): String? { + return data.cacheKey + } +} + +class CoverArtFetcher( + private val coverArtRequest: CoverArtRequest, + private val options: Options +) : Fetcher, KoinComponent { + private val client: SubsonicAPIClient by inject() + + override suspend fun fetch(): FetchResult { + // Check the cache before sending out a network request + val result = getCachedImage() + if (result != null) return result + + // Try to fetch the image from the API + // Inverted call order, because Mockito has problems with chained calls. + val response = client.toStreamResponse( + client.api.getCoverArt( + coverArtRequest.id, + coverArtRequest.size.toLong() + ).execute() + ) + + // Handle the response + if (!response.hasError() && response.stream != null) { + return SourceFetchResult( + ImageSource(response.stream!!.source().buffer(), options.fileSystem), + null, + DataSource.NETWORK + ) + } + + // Throw an error if still not successful + throw IOException("${response.apiError}") + } + + private fun getCachedImage(): FetchResult? { + val result = getCachedImage(coverArtRequest.cacheKey) + if (result != null) return result + // The image wasn't cached, check if a large version is cached that we can later downsize. + val largeKey = coverArtRequest.cacheKey.replace(SUFFIX_SMALL, SUFFIX_LARGE) + if (largeKey == coverArtRequest.cacheKey) { + // We were already checking for the large image, nothing found in the cache. + return null + } + // Check for the large size in the cache. + return getCachedImage(largeKey) + } + + private fun getCachedImage(cacheKey: String): FetchResult? { + val bitmap = getAlbumArtBitmapFromDisk(cacheKey, coverArtRequest.size) ?: return null + return ImageFetchResult( + bitmap.asImage(), + false, + DataSource.DISK + ) + } + + private fun getAlbumArtBitmapFromDisk(filename: String, size: Int?): Bitmap? { + val albumArtFile = FileUtil.getAlbumArtFile(filename) + val bitmap: Bitmap? = null + if (File(albumArtFile).exists()) { + return getBitmapFromDisk(albumArtFile, size, bitmap) + } + return null + } + + private fun getBitmapFromDisk(path: String, size: Int?, bitmap: Bitmap?): Bitmap? { + var bitmap1 = bitmap + val opt = BitmapFactory.Options() + if (size != null && size > 0) { + // With this flag we only calculate the size first + opt.inJustDecodeBounds = true + + // Decode the size + BitmapFactory.decodeFile(path, opt) + + // Now set the remaining flags + opt.inSampleSize = Util.calculateInSampleSize( + opt, + size, + Util.getScaledHeight(opt.outHeight.toDouble(), opt.outWidth.toDouble(), size) + ) + + // Enable real decoding + opt.inJustDecodeBounds = false + } + try { + bitmap1 = BitmapFactory.decodeFile(path, opt) + } catch (expected: Exception) { + Timber.e(expected, "Exception in BitmapFactory.decodeFile()") + } + return bitmap1 + } + + class Factory : Fetcher.Factory { + override fun create( + data: CoverArtRequest, + options: Options, + imageLoader: coil3.ImageLoader + ): Fetcher { + return CoverArtFetcher(data, options) + } + } +} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/CoverArtRequestHandler.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/CoverArtRequestHandler.kt deleted file mode 100644 index ea494873..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/CoverArtRequestHandler.kt +++ /dev/null @@ -1,103 +0,0 @@ -package org.moire.ultrasonic.imageloader - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import com.squareup.picasso.Picasso.LoadedFrom.DISK -import com.squareup.picasso.Picasso.LoadedFrom.NETWORK -import com.squareup.picasso.Request -import com.squareup.picasso.RequestHandler -import java.io.File -import java.io.IOException -import okio.source -import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient -import org.moire.ultrasonic.util.FileUtil -import org.moire.ultrasonic.util.FileUtil.SUFFIX_LARGE -import org.moire.ultrasonic.util.FileUtil.SUFFIX_SMALL -import org.moire.ultrasonic.util.Util -import timber.log.Timber - -/** - * Loads cover arts from subsonic api. - */ -class CoverArtRequestHandler(private val client: SubsonicAPIClient) : RequestHandler() { - override fun canHandleRequest(data: Request): Boolean { - return with(data.uri) { - scheme == SCHEME && - path == "/$COVER_ART_PATH" - } - } - - override fun load(request: Request, networkPolicy: Int): Result { - val id = request.uri.getQueryParameter(QUERY_ID) - ?: throw IllegalArgumentException("Nullable id") - val size = request.uri.getQueryParameter(SIZE)?.toLong() - - // Check if we have a hit in the disk cache - // First check for a large and fallback to the small size. - // because scaling down a larger size image on the device is quicker than - // requesting the down-sized image from the network. - val key = request.stableKey!! - val largeKey = key.replace(SUFFIX_SMALL, SUFFIX_LARGE) - var cache = getAlbumArtBitmapFromDisk(largeKey, size?.toInt()) - if (cache == null && key != largeKey) { - cache = getAlbumArtBitmapFromDisk(key, size?.toInt()) - } - if (cache != null) { - return Result(cache, DISK) - } - - // Cancel early if we are offline - if (client.isOffline) { - throw UnsupportedOperationException() - } - - // Try to fetch the image from the API - // Inverted call order, because Mockito has problems with chained calls. - val response = client.toStreamResponse(client.api.getCoverArt(id, size).execute()) - - // Handle the response - if (!response.hasError() && response.stream != null) { - return Result(response.stream!!.source(), NETWORK) - } - - // Throw an error if still not successful - throw IOException("${response.apiError}") - } - - private fun getAlbumArtBitmapFromDisk(filename: String, size: Int?): Bitmap? { - val albumArtFile = FileUtil.getAlbumArtFile(filename) - val bitmap: Bitmap? = null - if (File(albumArtFile).exists()) { - return getBitmapFromDisk(albumArtFile, size, bitmap) - } - return null - } - - private fun getBitmapFromDisk(path: String, size: Int?, bitmap: Bitmap?): Bitmap? { - var bitmap1 = bitmap - val opt = BitmapFactory.Options() - if (size != null && size > 0) { - // With this flag we only calculate the size first - opt.inJustDecodeBounds = true - - // Decode the size - BitmapFactory.decodeFile(path, opt) - - // Now set the remaining flags - opt.inSampleSize = Util.calculateInSampleSize( - opt, - size, - Util.getScaledHeight(opt.outHeight.toDouble(), opt.outWidth.toDouble(), size) - ) - - // Enable real decoding - opt.inJustDecodeBounds = false - } - try { - bitmap1 = BitmapFactory.decodeFile(path, opt) - } catch (expected: Exception) { - Timber.e(expected, "Exception in BitmapFactory.decodeFile()") - } - return bitmap1 - } -} diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt index 2aa37b1f..99803084 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/ImageLoader.kt @@ -7,18 +7,19 @@ package org.moire.ultrasonic.imageloader -import android.app.ActivityManager import android.content.Context -import android.content.pm.ApplicationInfo import android.graphics.Bitmap import android.view.View import android.widget.ImageView -import androidx.core.content.ContextCompat -import com.squareup.picasso.LruCache -import com.squareup.picasso.Picasso -import com.squareup.picasso.RequestCreator +import coil3.SingletonImageLoader +import coil3.load +import coil3.request.error +import coil3.request.placeholder +import coil3.toBitmap +import com.google.common.util.concurrent.ListenableFuture import java.io.File import java.io.FileOutputStream +import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.util.concurrent.ConcurrentHashMap @@ -26,6 +27,7 @@ import java.util.concurrent.CountDownLatch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.guava.future import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.moire.ultrasonic.R @@ -40,10 +42,10 @@ import org.moire.ultrasonic.util.Util.safeClose import timber.log.Timber /** - * Our new image loader which uses Picasso as a backend. + * Our new image loader which uses coil as a backend. */ class ImageLoader( - context: Context, + private val context: Context, apiClient: SubsonicAPIClient, private val config: ImageLoaderConfig ) : CoroutineScope by CoroutineScope(Dispatchers.Main + SupervisorJob()) { @@ -53,57 +55,42 @@ class ImageLoader( @Suppress("VariableNaming", "PropertyName") val API = apiClient.api - private val picasso = Picasso.Builder(context) - .addRequestHandler(CoverArtRequestHandler(apiClient)) - .addRequestHandler(AvatarRequestHandler(apiClient)) - .memoryCache(LruCache(calculateMemoryCacheSize(context))) - .build().apply { - // setIndicatorsEnabled(BuildConfig.DEBUG) - } - private fun load(request: ImageRequest) = when (request) { is ImageRequest.CoverArt -> loadCoverArt(request) is ImageRequest.Avatar -> loadAvatar(request) } private fun loadCoverArt(request: ImageRequest.CoverArt) { - picasso.load(createLoadCoverArtRequest(request.entityId, request.size.toLong())) - .addPlaceholder(request) - .addError(request) - .stableKey(request.cacheKey) - .into(request.imageView) + request.imageView?.load( + CoverArtRequest(request.entityId, request.cacheKey, request.size) + ) { + if (request.placeHolderDrawableRes != null) { + placeholder(request.placeHolderDrawableRes) + } + if (request.errorDrawableRes != null) { + error(request.errorDrawableRes) + } + } } - private fun getCoverArt(request: ImageRequest.CoverArt): Bitmap { - return picasso.load(createLoadCoverArtRequest(request.entityId, request.size.toLong())) - .addPlaceholder(request) - .addError(request) - .stableKey(request.cacheKey) - .get() + private fun getCoverArt(request: CoverArtRequest): ListenableFuture { + val imageRequest = coil3.request.ImageRequest.Builder(context).data(request).build() + return future { + val image = + SingletonImageLoader.get(context).execute(imageRequest).image ?: throw IOException() + image.toBitmap() + } } private fun loadAvatar(request: ImageRequest.Avatar) { - picasso.load(createLoadAvatarRequest(request.username)) - .addPlaceholder(request) - .addError(request) - .stableKey(request.username) - .into(request.imageView) - } - - private fun RequestCreator.addPlaceholder(request: ImageRequest): RequestCreator { - if (request.placeHolderDrawableRes != null) { - placeholder(request.placeHolderDrawableRes) + request.imageView?.load(AvatarRequest(request.username)) { + if (request.placeHolderDrawableRes != null) { + placeholder(request.placeHolderDrawableRes) + } + if (request.errorDrawableRes != null) { + error(request.errorDrawableRes) + } } - - return this - } - - private fun RequestCreator.addError(request: ImageRequest): RequestCreator { - if (request.errorDrawableRes != null) { - error(request.errorDrawableRes) - } - - return this } /** @@ -113,19 +100,10 @@ class ImageLoader( id: String?, cacheKey: String?, large: Boolean, - size: Int, - defaultResourceId: Int = R.drawable.unknown_album - ): Bitmap { + size: Int + ): ListenableFuture { val requestedSize = resolveSize(size, large) - - val request = ImageRequest.CoverArt( - id!!, - cacheKey!!, - null, - requestedSize, - placeHolderDrawableRes = defaultResourceId, - errorDrawableRes = defaultResourceId - ) + val request = CoverArtRequest(id!!, cacheKey!!, requestedSize) return getCoverArt(request) } @@ -267,18 +245,6 @@ class ImageLoader( requested } } - - private fun calculateMemoryCacheSize(context: Context): Int { - val am = ContextCompat.getSystemService( - context, - ActivityManager::class.java - ) - val largeHeap = context.applicationInfo.flags and ApplicationInfo.FLAG_LARGE_HEAP != 0 - val memoryClass = if (largeHeap) am!!.largeMemoryClass else am!!.memoryClass - // Target 25% of the available heap. - @Suppress("MagicNumber") - return (1024L * 1024L * memoryClass / 4).toInt() - } } /** diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/RequestCreator.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/RequestCreator.kt deleted file mode 100644 index d2657ef3..00000000 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/imageloader/RequestCreator.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.moire.ultrasonic.imageloader - -import android.net.Uri - -internal const val SCHEME = "subsonic_api" -internal const val COVER_ART_PATH = "cover_art" -internal const val AVATAR_PATH = "avatar" -internal const val QUERY_ID = "id" -internal const val SIZE = "size" -internal const val QUERY_USERNAME = "username" - -/** - * Picasso.load() only accepts an URI as parameter. Therefore we create a bogus URI, in which - * we encode the data that we need in the RequestHandler. - */ -internal fun createLoadCoverArtRequest(entityId: String, size: Long? = 0): Uri = Uri.Builder() - .scheme(SCHEME) - .appendPath(COVER_ART_PATH) - .appendQueryParameter(QUERY_ID, entityId) - .appendQueryParameter(SIZE, size.toString()) - .build() - -internal fun createLoadAvatarRequest(username: String): Uri = Uri.Builder() - .scheme(SCHEME) - .appendPath(AVATAR_PATH) - .appendQueryParameter(QUERY_USERNAME, username) - .build() diff --git a/ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/AvatarFetcherTest.kt b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/AvatarFetcherTest.kt new file mode 100644 index 00000000..618b9e75 --- /dev/null +++ b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/AvatarFetcherTest.kt @@ -0,0 +1,129 @@ +/* + * AvatarFetcherTest.kt + * Copyright (C) 2009-2026 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.imageloader + +import android.content.Context +import android.os.Environment +import androidx.test.core.app.ApplicationProvider +import coil3.decode.DataSource +import coil3.fetch.SourceFetchResult +import coil3.request.Options +import java.io.File +import java.io.IOException +import kotlinx.coroutines.test.runTest +import okhttp3.ResponseBody +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldNotBe +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.mockito.Answers +import org.mockito.Mockito.`when` +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient +import org.moire.ultrasonic.api.subsonic.response.StreamResponse +import org.moire.ultrasonic.util.FileUtil +import org.robolectric.RobolectricTestRunner +import retrofit2.Response +import timber.log.Timber + +@RunWith(RobolectricTestRunner::class) +class AvatarFetcherTest : KoinTest { + private val mockApiClient: SubsonicAPIClient = mock(defaultAnswer = Answers.RETURNS_DEEP_STUBS) + private val context: Context = ApplicationProvider.getApplicationContext() + private val imageLoader = coil3.ImageLoader.Builder(context).build() + + @Before + fun setUp() { + startKoin { + modules( + module { + single { mockApiClient } + } + ) + } + } + + @After + fun tearDown() { + stopKoin() + } + + @Test + fun `Should throw IOException when request to api failed`() = runTest { + // Create the fetcher under test. + val fetcher = AvatarFetcher.Factory().create( + AvatarRequest("user"), + Options(context), + imageLoader + ) + + // Create and return a fake stream response with an error code. + val streamResponse = StreamResponse(null, null, 500) + FileUtil.cachedUltrasonicDirectory = File( + Environment.getExternalStorageDirectory(), "Android/data/org.moire.ultrasonic" + ) + whenever( + mockApiClient.toStreamResponse(any()) + ).thenReturn(streamResponse) + + // Verify we throw an IOException when getting a bad stream response. + var thrown = false + try { + fetcher.fetch() + } catch (ioException: IOException) { + thrown = true + Timber.d("Caught expected exception: %s", ioException) + } + thrown shouldBeEqualTo true + } + + @Test + fun `Should load bitmap from network`() = runTest { + // Create the fetcher under test. + val user = "user" + val fetcher = AvatarFetcher.Factory().create( + AvatarRequest(user), + Options(context), + imageLoader + ) + + // Prepare a fake stream response with a test image. + val testImage = "Big_Buck_Bunny.jpeg" + val streamResponse = StreamResponse( + loadResourceStream(testImage), + apiError = null, + responseHttpCode = 200 + ) + FileUtil.cachedUltrasonicDirectory = File( + Environment.getExternalStorageDirectory(), "Android/data/org.moire.ultrasonic" + ) + + // If the correct subsonic API is called return the above fake stream response. + val mockResponse: Response = mock() + `when`(mockApiClient.api.getAvatar(user).execute()).thenReturn(mockResponse) + `when`( + mockApiClient.toStreamResponse(mockResponse) + ).thenReturn(streamResponse) + + // Run the fetch and validate the result. + val result = fetcher.fetch() as SourceFetchResult + result.dataSource shouldBeEqualTo DataSource.NETWORK + result.source shouldNotBe null + val expectedFile = File(javaClass.classLoader!!.getResource(testImage).file) + val actualFile = result.source.file().toFile() + actualFile.readLines() shouldBeEqualTo expectedFile.readLines() + } +} diff --git a/ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/AvatarRequestHandlerTest.kt b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/AvatarRequestHandlerTest.kt deleted file mode 100644 index 424012b0..00000000 --- a/ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/AvatarRequestHandlerTest.kt +++ /dev/null @@ -1,79 +0,0 @@ -package org.moire.ultrasonic.imageloader - -import android.net.Uri -import com.squareup.picasso.Picasso -import com.squareup.picasso.Request -import org.amshove.kluent.`should be equal to` -import org.amshove.kluent.`should not be` -import org.amshove.kluent.`should throw` -import org.amshove.kluent.shouldBeEqualTo -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Answers -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever -import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient -import org.moire.ultrasonic.api.subsonic.response.StreamResponse -import org.moire.ultrasonic.api.subsonic.toStreamResponse -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config - -@RunWith(RobolectricTestRunner::class) -@Config(manifest = Config.NONE) -class AvatarRequestHandlerTest { - private val mockApiClient: SubsonicAPIClient = mock(defaultAnswer = Answers.RETURNS_DEEP_STUBS) - private val handler = AvatarRequestHandler(mockApiClient) - - @Test - fun `Should accept only cover art request`() { - val requestUri = createLoadAvatarRequest("some-username") - - handler.canHandleRequest(requestUri.buildRequest()) shouldBeEqualTo true - } - - @Test - fun `Should not accept random request uri`() { - val requestUri = Uri.Builder() - .scheme(SCHEME) - .appendPath("something") - .build() - - handler.canHandleRequest(requestUri.buildRequest()) shouldBeEqualTo false - } - - @Test - fun `Should fail loading if uri doesn't contain username`() { - var requestUri = createLoadAvatarRequest("some-username") - requestUri = requestUri.buildUpon().clearQuery().build() - - val fail = { - handler.load(requestUri.buildRequest(), 0) - } - - fail `should throw` IllegalArgumentException::class - } - - @Test - fun `Should load avatar from network`() { - val streamResponse = StreamResponse( - loadResourceStream("Big_Buck_Bunny.jpeg"), - apiError = null, - responseHttpCode = 200 - ) - - whenever( - mockApiClient.toStreamResponse(any()) - ).thenReturn(streamResponse) - - val response = handler.load( - createLoadAvatarRequest("some-username").buildRequest(), - 0 - ) - - response.loadedFrom `should be equal to` Picasso.LoadedFrom.NETWORK - response.source `should not be` null - } - - private fun Uri.buildRequest() = Request.Builder(this).build() -} diff --git a/ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/CoverArtFetcherTest.kt b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/CoverArtFetcherTest.kt new file mode 100644 index 00000000..85bfa6a0 --- /dev/null +++ b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/CoverArtFetcherTest.kt @@ -0,0 +1,186 @@ +/* + * CoverArtFetcherTest.kt + * Copyright (C) 2009-2026 Ultrasonic developers + * + * Distributed under terms of the GNU GPLv3 license. + */ + +package org.moire.ultrasonic.imageloader + +import android.content.Context +import android.os.Environment +import androidx.test.core.app.ApplicationProvider +import coil3.decode.DataSource +import coil3.fetch.ImageFetchResult +import coil3.fetch.SourceFetchResult +import coil3.request.Options +import java.io.File +import java.io.IOException +import kotlinx.coroutines.test.runTest +import okhttp3.ResponseBody +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldNotBe +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.koin.test.KoinTest +import org.mockito.Answers +import org.mockito.Mockito.`when` +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient +import org.moire.ultrasonic.api.subsonic.response.StreamResponse +import org.moire.ultrasonic.util.FileUtil +import org.robolectric.RobolectricTestRunner +import retrofit2.Response +import timber.log.Timber + +@RunWith(RobolectricTestRunner::class) +class CoverArtFetcherTest : KoinTest { + private val mockApiClient: SubsonicAPIClient = mock(defaultAnswer = Answers.RETURNS_DEEP_STUBS) + private val context: Context = ApplicationProvider.getApplicationContext() + private val imageLoader = coil3.ImageLoader.Builder(context).build() + + @Before + fun setUp() { + startKoin { + modules( + module { + single { mockApiClient } + } + ) + } + } + + @After + fun tearDown() { + stopKoin() + } + + @Test + fun `Should throw IOException when request to api failed`() = runTest { + // Create the fetcher under test. + val fetcher = CoverArtFetcher.Factory().create( + CoverArtRequest("some", "-1", 0), + Options(context), + imageLoader + ) + + // Create and return a fake stream response with an error code. + val streamResponse = StreamResponse(null, null, 500) + FileUtil.cachedUltrasonicDirectory = File( + Environment.getExternalStorageDirectory(), "Android/data/org.moire.ultrasonic" + ) + whenever( + mockApiClient.toStreamResponse(any()) + ).thenReturn(streamResponse) + + // Verify we throw an IOException when getting a bad stream response. + var thrown = false + try { + fetcher.fetch() + } catch (ioException: IOException) { + thrown = true + Timber.d("Caught expected exception: %s", ioException) + } + thrown shouldBeEqualTo true + } + + @Test + fun `Should load bitmap from network`() = runTest { + // Create the fetcher under test. + val id = "id" + val size = 0 + val fetcher = CoverArtFetcher.Factory().create( + CoverArtRequest(id, "someCacheKey.jpg", size), + Options(context), + imageLoader + ) + + // Prepare a fake stream response with a test image. + val testImage = "Big_Buck_Bunny.jpeg" + val streamResponse = StreamResponse( + loadResourceStream(testImage), + apiError = null, + responseHttpCode = 200 + ) + FileUtil.cachedUltrasonicDirectory = File( + Environment.getExternalStorageDirectory(), "Android/data/org.moire.ultrasonic" + ) + + // If the correct subsonic API is called return the above fake stream response. + val mockResponse: Response = mock() + `when`(mockApiClient.api.getCoverArt(id, size.toLong()).execute()).thenReturn(mockResponse) + `when`( + mockApiClient.toStreamResponse(mockResponse) + ).thenReturn(streamResponse) + + // Run the fetch and validate the result. + val result = fetcher.fetch() as SourceFetchResult + result.dataSource shouldBeEqualTo DataSource.NETWORK + result.source shouldNotBe null + val expectedFile = File(javaClass.classLoader!!.getResource(testImage).file) + val actualFile = result.source.file().toFile() + actualFile.readLines() shouldBeEqualTo expectedFile.readLines() + } + + @Test + fun `Fetch pinned image from disk`() = runTest { + // Create the fetcher under test. + val id = "id" + val size = 0 + val cacheKey = "Big_Buck_Bunny.jpeg" + val fetcher = CoverArtFetcher.Factory().create( + CoverArtRequest(id, cacheKey, size), + Options(context), + imageLoader + ) + + // Copy the test image into the pinned album art directory so the fetcher can find it. + FileUtil.cachedUltrasonicDirectory = File( + Environment.getExternalStorageDirectory(), "Android/data/org.moire.ultrasonic" + ) + val sourceFile = File(javaClass.classLoader!!.getResource(cacheKey).file) + val cachedAlbumArtFile = File(FileUtil.albumArtDirectory, cacheKey) + sourceFile.copyTo(cachedAlbumArtFile) + + // Fetch and validate the result. + val result = fetcher.fetch() as ImageFetchResult + result.dataSource shouldBeEqualTo DataSource.DISK + result.image shouldNotBe null + } + + @Test + fun `Fetch pinned image from disk falling back to larger image`() = runTest { + // Create the fetcher under test. The fetch request is for a small image. + val id = "id" + val size = 0 + val cacheKeySmall = "Big_Buck_Bunny.jpeg-small" + val cacheKeyLarge = "Big_Buck_Bunny.jpeg" + val fetcher = CoverArtFetcher.Factory().create( + CoverArtRequest(id, cacheKeySmall, size), + Options(context), + imageLoader + ) + + // Copy the test image into the pinned album art directory so the fetcher can find it. We + // only make a large version of the image so the initial cache check for the small image + // will fail but we should fall back to the larger image. + FileUtil.cachedUltrasonicDirectory = File( + Environment.getExternalStorageDirectory(), "Android/data/org.moire.ultrasonic" + ) + val sourceFile = File(javaClass.classLoader!!.getResource(cacheKeyLarge).file) + val cachedAlbumArtFile = File(FileUtil.albumArtDirectory, cacheKeyLarge) + sourceFile.copyTo(cachedAlbumArtFile) + + // Fetch and validate the result. + val result = fetcher.fetch() as ImageFetchResult + result.dataSource shouldBeEqualTo DataSource.DISK + result.image shouldNotBe null + } +} diff --git a/ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/CoverArtRequestHandlerTest.kt b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/CoverArtRequestHandlerTest.kt deleted file mode 100644 index 2476fa5a..00000000 --- a/ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/CoverArtRequestHandlerTest.kt +++ /dev/null @@ -1,103 +0,0 @@ -package org.moire.ultrasonic.imageloader - -import android.net.Uri -import android.os.Environment -import com.squareup.picasso.Picasso -import com.squareup.picasso.Request -import java.io.File -import java.io.IOException -import org.amshove.kluent.`should be equal to` -import org.amshove.kluent.`should not be` -import org.amshove.kluent.`should throw` -import org.amshove.kluent.shouldBeEqualTo -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.Answers -import org.mockito.kotlin.any -import org.mockito.kotlin.mock -import org.mockito.kotlin.whenever -import org.moire.ultrasonic.api.subsonic.SubsonicAPIClient -import org.moire.ultrasonic.api.subsonic.response.StreamResponse -import org.moire.ultrasonic.util.FileUtil -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -class CoverArtRequestHandlerTest { - private val mockApiClient: SubsonicAPIClient = mock(defaultAnswer = Answers.RETURNS_DEEP_STUBS) - private val handler = CoverArtRequestHandler(mockApiClient) - - @Test - fun `Should accept only cover art request`() { - val requestUri = createLoadCoverArtRequest("some-id") - - handler.canHandleRequest(requestUri.buildRequest()) shouldBeEqualTo true - } - - @Test - fun `Should not accept random request uri`() { - val requestUri = Uri.Builder() - .scheme(SCHEME) - .appendPath("random") - .build() - - handler.canHandleRequest(requestUri.buildRequest()) shouldBeEqualTo false - } - - @Test - fun `Should fail loading if uri doesn't contain id`() { - var requestUri = createLoadCoverArtRequest("some-id") - requestUri = requestUri.buildUpon().clearQuery().build() - - val fail = { - handler.load(requestUri.buildRequest(), 0) - } - - fail `should throw` IllegalArgumentException::class - } - - @Test - fun `Should throw IOException when request to api failed`() { - val streamResponse = StreamResponse(null, null, 500) - FileUtil.cachedUltrasonicDirectory = File( - Environment.getExternalStorageDirectory(), - "Android/data/org.moire.ultrasonic" - ) - - whenever( - mockApiClient.toStreamResponse(any()) - ).thenReturn(streamResponse) - - val fail = { - handler.load(createLoadCoverArtRequest("some").buildRequest(), 0) - } - - fail `should throw` IOException::class - } - - @Test - fun `Should load bitmap from network`() { - val streamResponse = StreamResponse( - loadResourceStream("Big_Buck_Bunny.jpeg"), - apiError = null, - responseHttpCode = 200 - ) - FileUtil.cachedUltrasonicDirectory = File( - Environment.getExternalStorageDirectory(), - "Android/data/org.moire.ultrasonic" - ) - - whenever( - mockApiClient.toStreamResponse(any()) - ).thenReturn(streamResponse) - - val response = handler.load( - createLoadCoverArtRequest("some").buildRequest(), - 0 - ) - - response.loadedFrom `should be equal to` Picasso.LoadedFrom.NETWORK - response.source `should not be` null - } - - private fun Uri.buildRequest() = Request.Builder(this).stableKey("-1").build() -} diff --git a/ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/RequestCreatorTest.kt b/ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/RequestCreatorTest.kt deleted file mode 100644 index 4e4bcf5c..00000000 --- a/ultrasonic/src/test/kotlin/org/moire/ultrasonic/imageloader/RequestCreatorTest.kt +++ /dev/null @@ -1,28 +0,0 @@ -package org.moire.ultrasonic.imageloader - -import android.net.Uri -import org.amshove.kluent.shouldBeEqualTo -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -class RequestCreatorTest { - @Test - fun `Should create valid load cover art request`() { - val entityId = "299" - val size = 100L - val expectedUri = - Uri.parse("$SCHEME:/$COVER_ART_PATH?$QUERY_ID=$entityId&$SIZE=$size") - - createLoadCoverArtRequest(entityId, size).compareTo(expectedUri).shouldBeEqualTo(0) - } - - @Test - fun `Should create valid avatar request`() { - val username = "some-username" - val expectedUri = Uri.parse("$SCHEME:/$AVATAR_PATH?$QUERY_USERNAME=$username") - - createLoadAvatarRequest(username).compareTo(expectedUri).shouldBeEqualTo(0) - } -}