mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2026-10-25 01:06:17 +03:00
Merge branch 'coil' into 'develop'
Replace picasso with coil Closes #1318 See merge request ultrasonic/ultrasonic!1264
This commit is contained in:
commit
4fb22cdaeb
17 files changed
+621
-466
No files matched your search
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
-103
@@ -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()
|
||||
}
|
||||
}
|
||||
-79
@@ -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
|
||||
}
|
||||
}
|
||||
-103
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user