Merge branch 'coil' into 'develop'

Replace picasso with coil

Closes #1318

See merge request ultrasonic/ultrasonic!1264
This commit is contained in:
Nite committed 2026-03-04 18:56:19 +00:00
commit 4fb22cdaeb
17 files changed
+621 -466

No files matched your search

+3 -2
View File
@@ -28,7 +28,7 @@ retrofit = "3.0.0"
jackson = "2.21.1"
okhttp = "5.3.2"
koin = "4.1.1"
picasso = "2.8"
coil = "3.3.0"
junit4 = "4.13.2"
junit = "6.0.3"
@@ -86,7 +86,8 @@ jacksonKotlin = { module = "com.fasterxml.jackson.module:jackson-modul
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" }
+6 -5
View File
@@ -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.testCore
@@ -130,6 +127,7 @@ dependencies {
implementation libs.kotlinxGuava
implementation libs.kotlinxCoroutinesTest
implementation libs.koinAndroid
implementation libs.coil
implementation libs.okhttpLogging
implementation libs.fastScroll
implementation libs.colorPickerView
@@ -148,6 +146,9 @@ dependencies {
testImplementation libs.mockito
testImplementation libs.mockitoKotlin
testImplementation libs.robolectric
testImplementation libs.kotlinxCoroutinesTest
testImplementation libs.koinTest
testImplementation libs.testCore
implementation libs.timber
}
-8
View File
@@ -1,13 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<issues format="6" by="lint 8.0.1" type="baseline" client="gradle" dependencies="true" name="AGP (8.0.1)" variant="all" version="8.0.1">
<issue
id="NewerVersionAvailable"
message="A newer version of com.squareup.picasso:picasso than 2.8 is available: 2.71828">
<location
file="gradle/libs.versions.toml"
line="31" />
</issue>
<issue
id="PluralsCandidate"
+5
View File
@@ -0,0 +1,5 @@
-keep class coil3.util.DecoderServiceLoaderTarget { *; }
-keep class coil3.util.FetcherServiceLoaderTarget { *; }
-keep class coil3.util.ServiceLoaderComponentRegistry { *; }
-keep class * implements coil3.util.DecoderServiceLoaderTarget { *; }
-keep class * implements coil3.util.FetcherServiceLoaderTarget { *; }
@@ -1,11 +1,18 @@
package org.moire.ultrasonic.app
import android.app.ActivityManager
import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.Build
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import android.os.StrictMode.VmPolicy
import androidx.core.content.ContextCompat
import androidx.multidex.MultiDexApplication
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.memory.MemoryCache
import coil3.request.crossfade
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -18,6 +25,10 @@ import org.moire.ultrasonic.di.applicationModule
import org.moire.ultrasonic.di.baseNetworkModule
import org.moire.ultrasonic.di.mediaPlayerModule
import org.moire.ultrasonic.di.musicServiceModule
import org.moire.ultrasonic.imageloader.AvatarFetcher
import org.moire.ultrasonic.imageloader.AvatarKeyer
import org.moire.ultrasonic.imageloader.CoverArtFetcher
import org.moire.ultrasonic.imageloader.CoverArtKeyer
import org.moire.ultrasonic.log.FileLoggerTree
import org.moire.ultrasonic.log.TimberKoinLogger
import org.moire.ultrasonic.util.FileUtil
@@ -31,7 +42,7 @@ import timber.log.Timber.DebugTree
* The Main class of the Application
*/
class UApp : MultiDexApplication() {
class UApp : MultiDexApplication(), SingletonImageLoader.Factory {
private var ioScope = CoroutineScope(Dispatchers.IO)
@@ -72,6 +83,35 @@ class UApp : MultiDexApplication() {
startKoin()
}
override fun newImageLoader(context: Context): ImageLoader {
return ImageLoader.Builder(context)
.components {
add(CoverArtFetcher.Factory())
add(CoverArtKeyer())
add(AvatarFetcher.Factory())
add(AvatarKeyer())
}
.memoryCache(
MemoryCache.Builder().maxSizeBytes(
calculateMemoryCacheSize(context)
).build()
)
.crossfade(true)
.build()
}
private fun calculateMemoryCacheSize(context: Context): Long {
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
}
internal fun startKoin() {
initiated = true
startKoin {
@@ -46,9 +46,7 @@ class ArtworkBitmapLoader : BitmapLoader, KoinComponent {
}
override fun loadBitmap(uri: Uri): ListenableFuture<Bitmap> {
return executorService.submit<Bitmap> {
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<Bitmap> {
val parts = uri.path?.trim('/')?.split('|')
require(parts!!.count() == 2) { "Invalid bitmap Uri" }
@@ -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<AvatarRequest> {
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<AvatarRequest> {
override fun create(
data: AvatarRequest,
options: Options,
imageLoader: coil3.ImageLoader
): Fetcher {
return AvatarFetcher(data, options)
}
}
}
@@ -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)
}
}
}
@@ -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<CoverArtRequest> {
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<CoverArtRequest> {
override fun create(
data: CoverArtRequest,
options: Options,
imageLoader: coil3.ImageLoader
): Fetcher {
return CoverArtFetcher(data, options)
}
}
}
@@ -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
}
}
@@ -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<Bitmap> {
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<Bitmap> {
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)
}
@@ -268,18 +246,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()
}
}
/**
@@ -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()
@@ -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<ResponseBody> = 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()
}
}
@@ -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()
}
@@ -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<ResponseBody> = 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
}
}
@@ -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()
}
@@ -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)
}
}