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 }