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 }