funkwhale-app-android/app/src/main/java/audio/funkwhale/ffa/utils/CoverArt.kt

267 lines
9.1 KiB
Kotlin

package audio.funkwhale.ffa.utils
import android.content.Context
import android.net.Uri
import android.transition.CircularPropagation
import android.util.Log
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import audio.funkwhale.ffa.BuildConfig
import audio.funkwhale.ffa.FFA
import audio.funkwhale.ffa.R
import com.squareup.picasso.Downloader
import com.squareup.picasso.NetworkPolicy
import com.squareup.picasso.OkHttp3Downloader
import com.squareup.picasso.Picasso
import com.squareup.picasso.Picasso.LoadedFrom
import com.squareup.picasso.Request
import com.squareup.picasso.RequestCreator
import com.squareup.picasso.RequestHandler
import okhttp3.CacheControl
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okio.Okio
import java.io.File
import java.security.MessageDigest
/**
* Represent bytes as hex values.
*/
fun ByteArray.toHex(): String = joinToString("") { b -> "%02x".format(b) }
/**
* Convert the string to its SHA-256 hash in hex format.
*/
fun String.sha256(): String =
let { MessageDigest.getInstance("SHA-256").digest(it.encodeToByteArray()).toHex() }
/**
* Remove the query string and fragment from a URI.
* Mostly, this is to get rid of pre-signed URL silliness.
* If we ever need to keep some query params, we'll need a more robust approach.
*/
fun Uri.asStableKey(): String = buildUpon().clearQuery().fragment("").build().toString()
/**
* Try to extract a file suffix from the URI. This isn't strictly
* necessary, but it can make debugging easier when you're going through
* the app cache with a filesystem browser.
*/
fun Uri.fileSuffix(): String = let {
val p = it.path
val ext = p?.substringAfterLast(".", "")?.lowercase() ?: ""
if (ext == "") ext else ".$ext"
}
/**
* Wrapper around Picasso with some smarter caching of image files.
*/
open class CoverArt private constructor() {
companion object {
// For logging
val TAG: String = CoverArt::class.java.simpleName
// This is just a nice-to-have for API admins
private const val userAgent =
"${BuildConfig.APPLICATION_ID} ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
// This client has the UA above, and has caching intentionally disabled.
// (Because we cache the images ourselves and cannot rely on replaying requests.)
private var httpClient: OkHttpClient? = null
// Same: this has caching disabled.
private var downloader: OkHttp3Downloader? = null
// Cache with some useful concurrency semantics. See its docs for details.
val fileCache = Bottleneck<File>()
private val picasso = with (FFA.get()) {
Picasso.Builder(this)
.addRequestHandler(CoverNetworkRequestHandler(this))
// Be careful with this. There's at least one place in Picasso where it
// doesn't null-check when logging, so it'll throw errors in places you
// wouldn't get them with logging turned off. /sigh
.loggingEnabled(false) // (BuildConfig.DEBUG)
// Occasionally, we may get transient HTTP issues, or bogus files.
// Listen for Picasso errors and invalidate those files
.listener(invalidateIn(this))
.build()
}
/**
* We don't need to hang onto the Context, just the Path it gets us.
*/
fun cacheDirForContext(context: Context): File {
return context.applicationContext.cacheDir.resolve("covers")
}
/**
* Shim for Picasso which acts like a NetworkRequestHandler, but is opinionated
* about how we want to use it.
*/
open class CoverNetworkRequestHandler(context: Context) : RequestHandler() {
/**
* Path to the actual cache directory.
*/
val coverCacheDir: File
/**
* This goes out with every request and never changes.
*/
val noCacheControl: CacheControl = CacheControl.Builder()
.noCache()
.noStore()
.noTransform()
.build()
init {
coverCacheDir = cacheDirForContext(context)
// Make the cache directory if it doesn't already exist.
if (!coverCacheDir.isDirectory) {
coverCacheDir.mkdir()
}
}
/**
* The primary logic of going from a Request to a usable File.
* tl;dr: Use a local file if you can, otherwise download it and use that.
*/
private fun materializeFile(request: Request): (String) -> File? {
return fun(fileName: String): File? {
val existing = coverCacheDir.resolve(fileName)
if (existing.isFile) {
return existing
}
val key = request.stableKey ?: request.uri.asStableKey()
val httpUrl = HttpUrl.parse(request.uri.toString()) ?: return null
return fetchToFile(httpUrl, fileName, key)
}
}
/**
* Required by Picasso, we only want to handle HTTP traffic.
*/
override fun canHandleRequest(data: Request?): Boolean {
return data != null && ("http" == data.uri.scheme || "https" == data.uri.scheme)
}
/**
* Required by Picasso, this is the main entrypoint.
*/
override fun load(request: Request?, networkPolicy: Int): Result? {
if (request == null || !NetworkPolicy.shouldReadFromDiskCache(networkPolicy)) {
return null
}
// Ditch any query params.
val key = request.stableKey ?: request.uri.asStableKey()
// Convert to a short, stable filename.
val fileName =
key.sha256() + request.uri.fileSuffix() // file extension for easier forensics
// Actually find or fetch the file.
val file = fileCache.getOrCompute(fileName, materializeFile(request))
// Hand it back to Picasso in a way it can understand.
return if (file == null) null else Result(Okio.source(file), LoadedFrom.DISK)
}
/**
* The actual fetch logic is straightforward: download to a file.
* Sadly, this is more manual than you might expect.
*/
private fun fetchToFile(httpUrl: HttpUrl, fileName: String, cacheKey: String): File? {
val httpRequest = okhttp3.Request.Builder()
.get()
.url(httpUrl)
.cacheControl(noCacheControl)
.build()
val response = nonCachingDownloader().load(httpRequest)
if (!response.isSuccessful) {
return null
}
val body = response.body() ?: return null
val file = coverCacheDir.resolve(fileName)
if (BuildConfig.DEBUG) {
Log.d(TAG, "fetchToFile($cacheKey) <- $fileName <- NETWORK")
}
val bytesWritten: Long
body.use { b ->
Okio.buffer(Okio.sink(file)).use { sink ->
bytesWritten = sink.writeAll(b.source())
}
}
return if (bytesWritten > 0) file else null
}
}
/**
* Picasso can send back notification that files are busted.
* In those cases, it could be a transient problem, or credentials, etc.
* We probably don't want to trust the file, so we invalidate it
* from the memory cache and delete it from the filesystem.
* This uses Bottleneck, so it's thread-safe.
*/
fun invalidateIn(context: Context): (Picasso, Uri, Exception) -> Unit {
val coverCacheDir = cacheDirForContext(context)
return fun(_, uri: Uri, _) {
val key = uri.asStableKey()
val fileName = key.sha256() + uri.fileSuffix()
fileCache.remove(fileName) { f, _ ->
val file = f ?: coverCacheDir.resolve(fileName)
if (file.isFile) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "Deleting failed cover: $file")
}
file.delete()
}
}
}
}
/**
* Low-level Picasso wiring.
*/
/**
* We don't want to cache the HTTP part of the flow, because:
* 1. It's double-caching, since we're saving the images already.
* 2. The URL may include pre-signed credentials, which expire, making the URL useless.
*/
protected fun nonCachingDownloader(): Downloader {
val downloader = this.downloader ?: OkHttp3Downloader(nonCachingHttpClient())
if (this.downloader == null) {
this.downloader = downloader
}
return downloader
}
/**
* Same here: build a non-caching version just for cover art.
*/
protected fun nonCachingHttpClient(): OkHttpClient {
val hc = httpClient ?: OkHttpClient.Builder()
.addInterceptor { chain ->
chain.proceed(
chain.request()
.newBuilder()
.addHeader("User-Agent", userAgent)
.build()
)
}
.cache(null) // No cache here, intentionally
.build()
if (httpClient == null) {
httpClient = hc
}
return hc
}
/**
* The primary entrypoint for the codebase.
*/
fun requestCreator(url: String?): RequestCreator {
val request = picasso.load(url)
if(url == null) request.placeholder(R.drawable.cover)
else request.placeholder(CircularProgressDrawable(FFA.get()))
return request.error(R.drawable.cover)
}
}
}