Warning, /network/kdeconnect-android/src/org/kde/kdeconnect/Plugins/MprisPlugin/AlbumArtCache.kt is written in an unsupported language. File is not indexed.
0001 /*
0002 * SPDX-FileCopyrightText: 2017 Matthijs Tijink <matthijstijink@gmail.com>
0003 *
0004 * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
0005 */
0006 package org.kde.kdeconnect.Plugins.MprisPlugin
0007
0008 import android.content.Context
0009 import android.graphics.Bitmap
0010 import android.graphics.BitmapFactory
0011 import android.net.ConnectivityManager
0012 import android.util.Log
0013 import androidx.collection.LruCache
0014 import androidx.core.content.getSystemService
0015 import androidx.core.net.ConnectivityManagerCompat
0016 import com.jakewharton.disklrucache.DiskLruCache
0017 import kotlinx.coroutines.Dispatchers
0018 import kotlinx.coroutines.GlobalScope
0019 import kotlinx.coroutines.launch
0020 import kotlinx.coroutines.withContext
0021 import org.kde.kdeconnect.NetworkPacket.Payload
0022 import org.kde.kdeconnect_tp.BuildConfig
0023 import java.io.File
0024 import java.io.IOException
0025 import java.io.InputStream
0026 import java.net.HttpURLConnection
0027 import java.net.MalformedURLException
0028 import java.net.URL
0029 import java.net.URLDecoder
0030 import java.security.MessageDigest
0031 import java.util.concurrent.CopyOnWriteArrayList
0032
0033 /**
0034 * Handles the cache for album art
0035 */
0036 internal object AlbumArtCache {
0037 /**
0038 * An in-memory cache for album art bitmaps. Holds at most 10 entries (to prevent too much memory usage)
0039 * Also remembers failure to fetch urls.
0040 */
0041 private val memoryCache = LruCache<String, MemoryCacheItem>(10)
0042
0043 /**
0044 * An on-disk cache for album art bitmaps.
0045 */
0046 private lateinit var diskCache: DiskLruCache
0047
0048 /**
0049 * Used to check if the connection is metered
0050 */
0051 private lateinit var connectivityManager: ConnectivityManager
0052
0053 /**
0054 * A list of urls yet to be fetched.
0055 */
0056 private val fetchUrlList = ArrayList<URL>()
0057
0058 /**
0059 * A list of urls currently being fetched
0060 */
0061 private val isFetchingList = ArrayList<URL>()
0062
0063 /**
0064 * A integer indicating how many fetches are in progress.
0065 */
0066 private var numFetching = 0
0067
0068 /**
0069 * A list of plugins to notify on fetched album art
0070 */
0071 private val registeredPlugins = CopyOnWriteArrayList<MprisPlugin>()
0072
0073 /**
0074 * Initializes the disk cache. Needs to be called at least once before trying to use the cache
0075 *
0076 * @param context The context
0077 */
0078 @JvmStatic
0079 fun initializeDiskCache(context: Context) {
0080 if (this::diskCache.isInitialized) return
0081 val cacheDir = File(context.cacheDir, "album_art")
0082 try {
0083 //Initialize the disk cache with a limit of 5 MB storage (fits ~830 images, taking Spotify as reference)
0084 diskCache = DiskLruCache.open(cacheDir, BuildConfig.VERSION_CODE, 1, 1000 * 1000 * 5.toLong())
0085 } catch (e: IOException) {
0086 Log.e("KDE/Mpris/AlbumArtCache", "Could not open the album art disk cache!", e)
0087 }
0088 connectivityManager = context.applicationContext.getSystemService()!!
0089 }
0090
0091 /**
0092 * Registers a mpris plugin, such that it gets notified of fetched album art
0093 *
0094 * @param mpris The mpris plugin
0095 */
0096 @JvmStatic
0097 fun registerPlugin(mpris: MprisPlugin) {
0098 registeredPlugins.add(mpris)
0099 }
0100
0101 /**
0102 * Deregister a mpris plugin
0103 *
0104 * @param mpris The mpris plugin
0105 */
0106 @JvmStatic
0107 fun deregisterPlugin(mpris: MprisPlugin?) {
0108 registeredPlugins.remove(mpris)
0109 }
0110
0111 /**
0112 * Get the album art for the given url. Currently only handles http(s) urls.
0113 * If it's not in the cache, will initiate a request to fetch it.
0114 *
0115 * @param albumUrl The album art url
0116 * @return A bitmap for the album art. Can be null if not (yet) found
0117 */
0118 @JvmStatic
0119 fun getAlbumArt(albumUrl: String?, plugin: MprisPlugin, player: String?): Bitmap? {
0120 //If the url is invalid, return "no album art"
0121 if (albumUrl.isNullOrEmpty()) {
0122 return null
0123 }
0124 val url = try {
0125 URL(albumUrl)
0126 } catch (e: MalformedURLException) {
0127 //Invalid url, so just return "no album art"
0128 //Shouldn't happen (checked on receival of the url), but just to be sure
0129 return null
0130 }
0131
0132 //We currently only support http(s) and file urls
0133 if (url.protocol !in arrayOf("http", "https", "file")) {
0134 return null
0135 }
0136
0137 //First, check the in-memory cache
0138 val albumItem = memoryCache[albumUrl]
0139 if (albumItem != null) {
0140 //Do not retry failed fetches
0141 return if (albumItem.failedFetch) {
0142 null
0143 } else {
0144 albumItem.albumArt
0145 }
0146 }
0147
0148 //If not found, check the disk cache
0149 if (!this::diskCache.isInitialized) {
0150 Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!")
0151 return null
0152 }
0153 try {
0154 val item = diskCache[urlToDiskCacheKey(albumUrl)]
0155 if (item != null) {
0156 val result = BitmapFactory.decodeStream(item.getInputStream(0))
0157 item.close()
0158 val memItem = MemoryCacheItem()
0159 if (result != null) {
0160 memItem.failedFetch = false
0161 memItem.albumArt = result
0162 } else {
0163 //Invalid bitmap, so remember it as a "failed fetch" and remove it from the disk cache
0164 memItem.failedFetch = true
0165 memItem.albumArt = null
0166 diskCache.remove(urlToDiskCacheKey(albumUrl))
0167 Log.d("KDE/Mpris/AlbumArtCache", "Invalid image: $albumUrl")
0168 }
0169 memoryCache.put(albumUrl, memItem)
0170 return result
0171 }
0172 } catch (e: IOException) {
0173 return null
0174 }
0175
0176 /* If not found, we have not tried fetching it (recently), or a fetch is in-progress.
0177 Either way, just add it to the fetch queue and starting fetching it if no fetch is running. */
0178 if ("file" == url.protocol) {
0179 //Special-case file, since we need to fetch it from the remote
0180 if (url in isFetchingList) return null
0181 if (!plugin.askTransferAlbumArt(albumUrl, player)) {
0182 //It doesn't support transferring the art, so mark it as failed in the memory cache
0183 memoryCache.put(url.toString(), MemoryCacheItem(true))
0184 }
0185 } else {
0186 fetchUrl(url)
0187 }
0188 return null
0189 }
0190
0191 /**
0192 * Fetches an album art url and puts it in the cache
0193 *
0194 * @param url The url
0195 */
0196 private fun fetchUrl(url: URL) {
0197 //We need the disk cache for this
0198 if (!this::diskCache.isInitialized) {
0199 Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!")
0200 return
0201 }
0202 if (ConnectivityManagerCompat.isActiveNetworkMetered(connectivityManager)) {
0203 //Only download art on unmetered networks (wifi etc.)
0204 return
0205 }
0206
0207 //Only fetch an URL if we're not fetching it already
0208 synchronized(fetchUrlList) {
0209 if (url in fetchUrlList || url in isFetchingList) {
0210 return
0211 }
0212 fetchUrlList.add(url)
0213 }
0214 initiateFetch()
0215 }
0216
0217 /**
0218 * Does the actual fetching and makes sure only not too many fetches are running at the same time
0219 */
0220 private fun initiateFetch() {
0221 var url : URL;
0222 synchronized(fetchUrlList) {
0223 if (numFetching >= 2 || fetchUrlList.isEmpty()) return
0224 //Fetch the last-requested url first, it will probably be needed first
0225 url = fetchUrlList.last()
0226 //Remove the url from the to-fetch list
0227 fetchUrlList.remove(url)
0228 }
0229 if ("file" == url.protocol) {
0230 throw AssertionError("Not file urls should be possible here!")
0231 }
0232
0233 //Download the album art ourselves
0234 ++numFetching
0235 //Add the url to the currently-fetching list
0236 isFetchingList.add(url)
0237 try {
0238 val cacheItem = diskCache.edit(urlToDiskCacheKey(url.toString()))
0239 if (cacheItem == null) {
0240 Log.e("KDE/Mpris/AlbumArtCache",
0241 "Two disk cache edits happened at the same time, should be impossible!")
0242 --numFetching
0243 return
0244 }
0245
0246 //Do the actual fetch in the background
0247 GlobalScope.launch { fetchURL(url, null, cacheItem) }
0248 } catch (e: IOException) {
0249 Log.e("KDE/Mpris/AlbumArtCache", "Problems with the disk cache", e)
0250 --numFetching
0251 }
0252 }
0253
0254 /**
0255 * The disk cache requires mostly alphanumeric characters, and at most 64 characters.
0256 * So hash the url to get a valid key
0257 *
0258 * @param url The url
0259 * @return A valid disk cache key
0260 */
0261 private fun urlToDiskCacheKey(url: String): String {
0262 return MessageDigest.getInstance("MD5").digest(url.toByteArray())
0263 .joinToString(separator = "") { String.format("%02x", it) }
0264 }
0265
0266 /**
0267 * Transfer an asked-for album art payload to the disk cache.
0268 *
0269 * @param albumUrl The url of the album art (should be a file:// url)
0270 * @param payload The payload input stream
0271 */
0272 @JvmStatic
0273 fun payloadToDiskCache(albumUrl: String, payload: Payload?) {
0274 //We need the disk cache for this
0275 if (payload == null) {
0276 return
0277 }
0278 if (!this::diskCache.isInitialized) {
0279 Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!")
0280 payload.close()
0281 return
0282 }
0283 val url = try {
0284 URL(albumUrl)
0285 } catch (e: MalformedURLException) {
0286 //Shouldn't happen (checked on receival of the url), but just to be sure
0287 payload.close()
0288 return
0289 }
0290 if ("file" != url.protocol) {
0291 //Shouldn't happen (otherwise we wouldn't have asked for the payload), but just to be sure
0292 payload.close()
0293 return
0294 }
0295
0296 //Only fetch the URL if we're not fetching it already
0297 if (url in isFetchingList) {
0298 payload.close()
0299 return
0300 }
0301
0302 //Check if we already have this art
0303 try {
0304 if (memoryCache[albumUrl] != null || diskCache[urlToDiskCacheKey(albumUrl)] != null) {
0305 payload.close()
0306 return
0307 }
0308 } catch (e: IOException) {
0309 Log.e("KDE/Mpris/AlbumArtCache", "Disk cache problem!", e)
0310 payload.close()
0311 return
0312 }
0313
0314 //Add it to the currently-fetching list
0315 isFetchingList.add(url)
0316 ++numFetching
0317 try {
0318 val cacheItem = diskCache.edit(urlToDiskCacheKey(url.toString()))
0319 if (cacheItem == null) {
0320 Log.e("KDE/Mpris/AlbumArtCache",
0321 "Two disk cache edits happened at the same time, should be impossible!")
0322 --numFetching
0323 payload.close()
0324 return
0325 }
0326
0327 //Do the actual fetch in the background
0328 GlobalScope.launch { fetchURL(url, payload, cacheItem) }
0329 } catch (e: IOException) {
0330 Log.e("KDE/Mpris/AlbumArtCache", "Problems with the disk cache", e)
0331 --numFetching
0332 }
0333 }
0334
0335 private class MemoryCacheItem(var failedFetch: Boolean = false, var albumArt: Bitmap? = null)
0336
0337 /**
0338 * Initialize an url fetch
0339 *
0340 * @param url The url being fetched
0341 * @param payload A NetworkPacket Payload (if from the connected device). null if fetched from http(s)
0342 * @param cacheItem The disk cache item to edit
0343 */
0344 private suspend fun fetchURL(url: URL, payload: Payload?, cacheItem: DiskLruCache.Editor) {
0345 var success = withContext(Dispatchers.IO) {
0346 //See if we need to open a http(s) connection here, or if we use a payload input stream
0347 val output = cacheItem.newOutputStream(0)
0348 try {
0349 val inputStream = payload?.inputStream ?: openHttp(url)
0350 val buffer = ByteArray(4096)
0351 var bytesRead: Int
0352 if (inputStream != null) {
0353 while (inputStream.read(buffer).also { bytesRead = it } != -1) {
0354 output.write(buffer, 0, bytesRead)
0355 }
0356 }
0357 output.flush()
0358 output.close()
0359 return@withContext true
0360 } catch (e: IOException) {
0361 return@withContext false
0362 } catch (e: AssertionError) {
0363 return@withContext false
0364 } finally {
0365 payload?.close()
0366 }
0367 }
0368
0369 try {
0370 // Since commit() and abort() are blocking calls, they have to be executed in the IO
0371 // dispatcher.
0372 withContext(Dispatchers.IO) {
0373 if (success) {
0374 cacheItem.commit()
0375 } else {
0376 cacheItem.abort()
0377 }
0378 }
0379 } catch (e: IOException) {
0380 success = false
0381 Log.e("KDE/Mpris/AlbumArtCache", "Problem with the disk cache", e)
0382 }
0383 if (success) {
0384 //Now it's in the disk cache, the getAlbumArt() function should be able to read it
0385
0386 //So notify the mpris plugins of the fetched art
0387 for (mpris in registeredPlugins) {
0388 val stringUrl = url.toString()
0389 mpris.fetchedAlbumArt(stringUrl)
0390 }
0391 } else {
0392 //Mark the fetch as failed in the memory cache
0393 memoryCache.put(url.toString(), MemoryCacheItem(true))
0394 }
0395
0396 //Remove the url from the fetching list
0397 isFetchingList.remove(url)
0398 //Fetch the next url (if any)
0399 --numFetching
0400 initiateFetch()
0401 }
0402
0403 /**
0404 * Opens the http(s) connection
0405 *
0406 * @return True if succeeded
0407 */
0408 private fun openHttp(url: URL): InputStream? {
0409 //Default android behaviour does not follow https -> http urls, so do this manually
0410 if (url.protocol !in arrayOf("http", "https")) {
0411 throw AssertionError("Invalid url: not http(s) in background album art fetch")
0412 }
0413 var currentUrl = url
0414 var connection: HttpURLConnection
0415 loop@ for (i in 0..4) {
0416 connection = currentUrl.openConnection() as HttpURLConnection
0417 connection.connectTimeout = 10000
0418 connection.readTimeout = 10000
0419 connection.instanceFollowRedirects = false
0420 when (connection.responseCode) {
0421 HttpURLConnection.HTTP_MOVED_PERM, HttpURLConnection.HTTP_MOVED_TEMP -> {
0422 var location = connection.getHeaderField("Location")
0423 location = URLDecoder.decode(location, "UTF-8")
0424 currentUrl = URL(currentUrl, location) // Deal with relative URLs
0425 //Again, only support http(s)
0426 if (currentUrl.protocol !in arrayOf("http", "https")) {
0427 return null
0428 }
0429 connection.disconnect()
0430 continue@loop
0431 }
0432 }
0433
0434 //Found a non-redirecting connection, so do something with it
0435 return connection.inputStream
0436 }
0437 return null
0438 }
0439 }