mirror of
https://github.com/KDE/kdeconnect-android
synced 2025-08-22 09:58:08 +00:00
Rewrite AlbumArtCache to make use of coroutines instead of AsyncTask.
This commit is contained in:
parent
e4774b5d17
commit
7c25fa64a1
@ -1,499 +0,0 @@
|
||||
/*
|
||||
* Copyright 2017 Matthijs Tijink <matthijstijink@gmail.com>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License as
|
||||
* published by the Free Software Foundation; either version 2 of
|
||||
* the License or (at your option) version 3 or any later version
|
||||
* accepted by the membership of KDE e.V. (or its successor approved
|
||||
* by the membership of KDE e.V.), which shall act as a proxy
|
||||
* defined in Section 14 of version 3 of the license.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.kde.kdeconnect.Plugins.MprisPlugin;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.os.AsyncTask;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.collection.LruCache;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.net.ConnectivityManagerCompat;
|
||||
|
||||
import com.jakewharton.disklrucache.DiskLruCache;
|
||||
|
||||
import org.kde.kdeconnect.NetworkPacket;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.net.URLDecoder;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
|
||||
/**
|
||||
* Handles the cache for album art
|
||||
*/
|
||||
final class AlbumArtCache {
|
||||
private static final class MemoryCacheItem {
|
||||
boolean failedFetch;
|
||||
Bitmap albumArt;
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-memory cache for album art bitmaps. Holds at most 10 entries (to prevent too much memory usage)
|
||||
* Also remembers failure to fetch urls.
|
||||
*/
|
||||
private static final LruCache<String, MemoryCacheItem> memoryCache = new LruCache<>(10);
|
||||
/**
|
||||
* An on-disk cache for album art bitmaps.
|
||||
*/
|
||||
private static DiskLruCache diskCache;
|
||||
/**
|
||||
* Used to check if the connection is metered
|
||||
*/
|
||||
private static ConnectivityManager connectivityManager;
|
||||
|
||||
/**
|
||||
* A list of urls yet to be fetched.
|
||||
*/
|
||||
private static final ArrayList<URL> fetchUrlList = new ArrayList<>();
|
||||
/**
|
||||
* A list of urls currently being fetched
|
||||
*/
|
||||
private static final ArrayList<URL> isFetchingList = new ArrayList<>();
|
||||
/**
|
||||
* A integer indicating how many fetches are in progress.
|
||||
*/
|
||||
private static int numFetching = 0;
|
||||
|
||||
/**
|
||||
* A list of plugins to notify on fetched album art
|
||||
*/
|
||||
private static final ArrayList<MprisPlugin> registeredPlugins = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Initializes the disk cache. Needs to be called at least once before trying to use the cache
|
||||
*
|
||||
* @param context The context
|
||||
*/
|
||||
static void initializeDiskCache(Context context) {
|
||||
if (diskCache != null) return;
|
||||
|
||||
File cacheDir = new File(context.getCacheDir(), "album_art");
|
||||
int versionCode;
|
||||
try {
|
||||
PackageInfo info = context.getPackageManager().getPackageInfo(context.getPackageName(), 0);
|
||||
versionCode = info.versionCode;
|
||||
//Initialize the disk cache with a limit of 5 MB storage (fits ~830 images, taking Spotify as reference)
|
||||
diskCache = DiskLruCache.open(cacheDir, versionCode, 1, 1000 * 1000 * 5);
|
||||
} catch (PackageManager.NameNotFoundException e) {
|
||||
throw new AssertionError(e);
|
||||
} catch (IOException e) {
|
||||
Log.e("KDE/Mpris/AlbumArtCache", "Could not open the album art disk cache!", e);
|
||||
}
|
||||
|
||||
connectivityManager = ContextCompat.getSystemService(context.getApplicationContext(),
|
||||
ConnectivityManager.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a mpris plugin, such that it gets notified of fetched album art
|
||||
*
|
||||
* @param mpris The mpris plugin
|
||||
*/
|
||||
static void registerPlugin(MprisPlugin mpris) {
|
||||
registeredPlugins.add(mpris);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deregister a mpris plugin
|
||||
*
|
||||
* @param mpris The mpris plugin
|
||||
*/
|
||||
static void deregisterPlugin(MprisPlugin mpris) {
|
||||
registeredPlugins.remove(mpris);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the album art for the given url. Currently only handles http(s) urls.
|
||||
* If it's not in the cache, will initiate a request to fetch it.
|
||||
*
|
||||
* @param albumUrl The album art url
|
||||
* @return A bitmap for the album art. Can be null if not (yet) found
|
||||
*/
|
||||
static Bitmap getAlbumArt(String albumUrl, MprisPlugin plugin, String player) {
|
||||
//If the url is invalid, return "no album art"
|
||||
if (TextUtils.isEmpty(albumUrl)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
URL url;
|
||||
try {
|
||||
url = new URL(albumUrl);
|
||||
} catch (MalformedURLException e) {
|
||||
//Invalid url, so just return "no album art"
|
||||
//Shouldn't happen (checked on receival of the url), but just to be sure
|
||||
return null;
|
||||
}
|
||||
|
||||
//We currently only support http(s) and file urls
|
||||
if (!url.getProtocol().equals("http") && !url.getProtocol().equals("https") && !url.getProtocol().equals("file")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
//First, check the in-memory cache
|
||||
if (memoryCache.get(albumUrl) != null) {
|
||||
MemoryCacheItem item = memoryCache.get(albumUrl);
|
||||
|
||||
//Do not retry failed fetches
|
||||
if (item.failedFetch) {
|
||||
return null;
|
||||
} else {
|
||||
return item.albumArt;
|
||||
}
|
||||
}
|
||||
|
||||
//If not found, check the disk cache
|
||||
if (diskCache == null) {
|
||||
Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
DiskLruCache.Snapshot item = diskCache.get(urlToDiskCacheKey(albumUrl));
|
||||
if (item != null) {
|
||||
Bitmap result = BitmapFactory.decodeStream(item.getInputStream(0));
|
||||
item.close();
|
||||
MemoryCacheItem memItem = new MemoryCacheItem();
|
||||
if (result != null) {
|
||||
memItem.failedFetch = false;
|
||||
memItem.albumArt = result;
|
||||
} else {
|
||||
//Invalid bitmap, so remember it as a "failed fetch" and remove it from the disk cache
|
||||
memItem.failedFetch = true;
|
||||
memItem.albumArt = null;
|
||||
diskCache.remove(urlToDiskCacheKey(albumUrl));
|
||||
Log.d("KDE/Mpris/AlbumArtCache", "Invalid image: " + albumUrl);
|
||||
}
|
||||
memoryCache.put(albumUrl, memItem);
|
||||
return result;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/* If not found, we have not tried fetching it (recently), or a fetch is in-progress.
|
||||
Either way, just add it to the fetch queue and starting fetching it if no fetch is running. */
|
||||
if ("file".equals(url.getProtocol())) {
|
||||
//Special-case file, since we need to fetch it from the remote
|
||||
if (isFetchingList.contains(url)) return null;
|
||||
|
||||
if (!plugin.askTransferAlbumArt(albumUrl, player)) {
|
||||
//It doesn't support transferring the art, so mark it as failed in the memory cache
|
||||
MemoryCacheItem cacheItem = new MemoryCacheItem();
|
||||
cacheItem.failedFetch = true;
|
||||
cacheItem.albumArt = null;
|
||||
memoryCache.put(url.toString(), cacheItem);
|
||||
}
|
||||
} else {
|
||||
fetchUrl(url);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches an album art url and puts it in the cache
|
||||
*
|
||||
* @param url The url
|
||||
*/
|
||||
private static void fetchUrl(URL url) {
|
||||
//We need the disk cache for this
|
||||
if (diskCache == null) {
|
||||
Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!");
|
||||
return;
|
||||
}
|
||||
if (ConnectivityManagerCompat.isActiveNetworkMetered(connectivityManager)) {
|
||||
//Only download art on unmetered networks (wifi etc.)
|
||||
return;
|
||||
}
|
||||
|
||||
//Only fetch an URL if we're not fetching it already
|
||||
if (fetchUrlList.contains(url) || isFetchingList.contains(url)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchUrlList.add(url);
|
||||
initiateFetch();
|
||||
}
|
||||
|
||||
private static final class FetchURLTask extends AsyncTask<Void, Void, Boolean> {
|
||||
private final URL url;
|
||||
private NetworkPacket.Payload payload;
|
||||
private final DiskLruCache.Editor cacheItem;
|
||||
private OutputStream output;
|
||||
|
||||
/**
|
||||
* Initialize an url fetch
|
||||
*
|
||||
* @param url The url being fetched
|
||||
* @param payload A NetworkPacket Payload (if from the connected device). null if fetched from http(s)
|
||||
* @param cacheItem The disk cache item to edit
|
||||
*/
|
||||
FetchURLTask(URL url, NetworkPacket.Payload payload, DiskLruCache.Editor cacheItem) throws IOException {
|
||||
this.url = url;
|
||||
this.payload = payload;
|
||||
this.cacheItem = cacheItem;
|
||||
output = cacheItem.newOutputStream(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the http(s) connection
|
||||
*
|
||||
* @return True if succeeded
|
||||
*/
|
||||
private InputStream openHttp() throws IOException {
|
||||
//Default android behaviour does not follow https -> http urls, so do this manually
|
||||
if (!url.getProtocol().equals("http") && !url.getProtocol().equals("https")) {
|
||||
throw new AssertionError("Invalid url: not http(s) in background album art fetch");
|
||||
}
|
||||
URL currentUrl = url;
|
||||
HttpURLConnection connection;
|
||||
for (int i = 0; i < 5; ++i) {
|
||||
connection = (HttpURLConnection) currentUrl.openConnection();
|
||||
connection.setConnectTimeout(10000);
|
||||
connection.setReadTimeout(10000);
|
||||
connection.setInstanceFollowRedirects(false);
|
||||
|
||||
switch (connection.getResponseCode()) {
|
||||
case HttpURLConnection.HTTP_MOVED_PERM:
|
||||
case HttpURLConnection.HTTP_MOVED_TEMP:
|
||||
String location = connection.getHeaderField("Location");
|
||||
location = URLDecoder.decode(location, "UTF-8");
|
||||
currentUrl = new URL(currentUrl, location); // Deal with relative URLs
|
||||
//Again, only support http(s)
|
||||
if (!currentUrl.getProtocol().equals("http") && !currentUrl.getProtocol().equals("https")) {
|
||||
return null;
|
||||
}
|
||||
connection.disconnect();
|
||||
continue;
|
||||
}
|
||||
|
||||
//Found a non-redirecting connection, so do something with it
|
||||
return connection.getInputStream();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Boolean doInBackground(Void... params) {
|
||||
//See if we need to open a http(s) connection here, or if we use a payload input stream
|
||||
try (InputStream input = payload == null ? openHttp() : payload.getInputStream()) {
|
||||
if (input == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] buffer = new byte[4096];
|
||||
int bytesRead;
|
||||
while ((bytesRead = input.read(buffer)) != -1) {
|
||||
output.write(buffer, 0, bytesRead);
|
||||
}
|
||||
output.flush();
|
||||
output.close();
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
return false;
|
||||
} finally {
|
||||
if (payload != null) {
|
||||
payload.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onPostExecute(Boolean success) {
|
||||
try {
|
||||
if (success) {
|
||||
cacheItem.commit();
|
||||
} else {
|
||||
cacheItem.abort();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
success = false;
|
||||
Log.e("KDE/Mpris/AlbumArtCache", "Problem with the disk cache", e);
|
||||
}
|
||||
if (success) {
|
||||
//Now it's in the disk cache, the getAlbumArt() function should be able to read it
|
||||
|
||||
//So notify the mpris plugins of the fetched art
|
||||
for (MprisPlugin mpris : registeredPlugins) {
|
||||
mpris.fetchedAlbumArt(url.toString());
|
||||
}
|
||||
} else {
|
||||
//Mark the fetch as failed in the memory cache
|
||||
MemoryCacheItem cacheItem = new MemoryCacheItem();
|
||||
cacheItem.failedFetch = true;
|
||||
cacheItem.albumArt = null;
|
||||
memoryCache.put(url.toString(), cacheItem);
|
||||
}
|
||||
|
||||
//Remove the url from the fetching list
|
||||
isFetchingList.remove(url);
|
||||
//Fetch the next url (if any)
|
||||
--numFetching;
|
||||
initiateFetch();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the actual fetching and makes sure only not too many fetches are running at the same time
|
||||
*/
|
||||
private static void initiateFetch() {
|
||||
if (numFetching >= 2) return;
|
||||
if (fetchUrlList.isEmpty()) return;
|
||||
|
||||
//Fetch the last-requested url first, it will probably be needed first
|
||||
URL url = fetchUrlList.get(fetchUrlList.size() - 1);
|
||||
//Remove the url from the to-fetch list
|
||||
fetchUrlList.remove(url);
|
||||
|
||||
if ("file".equals(url.getProtocol())) {
|
||||
throw new AssertionError("Not file urls should be possible here!");
|
||||
}
|
||||
|
||||
//Download the album art ourselves
|
||||
++numFetching;
|
||||
//Add the url to the currently-fetching list
|
||||
isFetchingList.add(url);
|
||||
try {
|
||||
DiskLruCache.Editor cacheItem = diskCache.edit(urlToDiskCacheKey(url.toString()));
|
||||
if (cacheItem == null) {
|
||||
Log.e("KDE/Mpris/AlbumArtCache",
|
||||
"Two disk cache edits happened at the same time, should be impossible!");
|
||||
--numFetching;
|
||||
return;
|
||||
}
|
||||
|
||||
//Do the actual fetch in the background
|
||||
new FetchURLTask(url, null, cacheItem).execute();
|
||||
} catch (IOException e) {
|
||||
Log.e("KDE/Mpris/AlbumArtCache", "Problems with the disk cache", e);
|
||||
--numFetching;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The disk cache requires mostly alphanumeric characters, and at most 64 characters.
|
||||
* So hash the url to get a valid key
|
||||
*
|
||||
* @param url The url
|
||||
* @return A valid disk cache key
|
||||
*/
|
||||
private static String urlToDiskCacheKey(String url) {
|
||||
MessageDigest hasher;
|
||||
try {
|
||||
hasher = MessageDigest.getInstance("MD5");
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
//Should always be available
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
for (byte singleByte : hasher.digest(url.getBytes())) {
|
||||
builder.append(String.format("%02x", singleByte));
|
||||
}
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer an asked-for album art payload to the disk cache.
|
||||
*
|
||||
* @param albumUrl The url of the album art (should be a file:// url)
|
||||
* @param payload The payload input stream
|
||||
*/
|
||||
static void payloadToDiskCache(String albumUrl, NetworkPacket.Payload payload) {
|
||||
//We need the disk cache for this
|
||||
if (payload == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (diskCache == null) {
|
||||
Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!");
|
||||
payload.close();
|
||||
return;
|
||||
}
|
||||
|
||||
URL url;
|
||||
try {
|
||||
url = new URL(albumUrl);
|
||||
} catch (MalformedURLException e) {
|
||||
//Shouldn't happen (checked on receival of the url), but just to be sure
|
||||
payload.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!"file".equals(url.getProtocol())) {
|
||||
//Shouldn't happen (otherwise we wouldn't have asked for the payload), but just to be sure
|
||||
payload.close();
|
||||
return;
|
||||
}
|
||||
|
||||
//Only fetch the URL if we're not fetching it already
|
||||
if (isFetchingList.contains(url)) {
|
||||
payload.close();
|
||||
return;
|
||||
}
|
||||
|
||||
//Check if we already have this art
|
||||
try {
|
||||
if (memoryCache.get(albumUrl) != null || diskCache.get(urlToDiskCacheKey(albumUrl)) != null) {
|
||||
payload.close();
|
||||
return;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.e("KDE/Mpris/AlbumArtCache", "Disk cache problem!", e);
|
||||
payload.close();
|
||||
return;
|
||||
}
|
||||
|
||||
//Add it to the currently-fetching list
|
||||
isFetchingList.add(url);
|
||||
++numFetching;
|
||||
|
||||
try {
|
||||
DiskLruCache.Editor cacheItem = diskCache.edit(urlToDiskCacheKey(url.toString()));
|
||||
if (cacheItem == null) {
|
||||
Log.e("KDE/Mpris/AlbumArtCache",
|
||||
"Two disk cache edits happened at the same time, should be impossible!");
|
||||
--numFetching;
|
||||
payload.close();
|
||||
return;
|
||||
}
|
||||
|
||||
//Do the actual fetch in the background
|
||||
new FetchURLTask(url, payload, cacheItem).execute();
|
||||
} catch (IOException e) {
|
||||
Log.e("KDE/Mpris/AlbumArtCache", "Problems with the disk cache", e);
|
||||
--numFetching;
|
||||
}
|
||||
}
|
||||
}
|
451
src/org/kde/kdeconnect/Plugins/MprisPlugin/AlbumArtCache.kt
Normal file
451
src/org/kde/kdeconnect/Plugins/MprisPlugin/AlbumArtCache.kt
Normal file
@ -0,0 +1,451 @@
|
||||
/*
|
||||
* Copyright 2017 Matthijs Tijink <matthijstijink@gmail.com>
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License as
|
||||
* published by the Free Software Foundation; either version 2 of
|
||||
* the License or (at your option) version 3 or any later version
|
||||
* accepted by the membership of KDE e.V. (or its successor approved
|
||||
* by the membership of KDE e.V.), which shall act as a proxy
|
||||
* defined in Section 14 of version 3 of the license.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.kde.kdeconnect.Plugins.MprisPlugin
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager.NameNotFoundException
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.ConnectivityManager
|
||||
import android.util.Log
|
||||
import androidx.collection.LruCache
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.net.ConnectivityManagerCompat
|
||||
import com.jakewharton.disklrucache.DiskLruCache
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.kde.kdeconnect.NetworkPacket.Payload
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.MalformedURLException
|
||||
import java.net.URL
|
||||
import java.net.URLDecoder
|
||||
import java.security.MessageDigest
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Handles the cache for album art
|
||||
*/
|
||||
internal object AlbumArtCache {
|
||||
/**
|
||||
* An in-memory cache for album art bitmaps. Holds at most 10 entries (to prevent too much memory usage)
|
||||
* Also remembers failure to fetch urls.
|
||||
*/
|
||||
private val memoryCache = LruCache<String, MemoryCacheItem>(10)
|
||||
|
||||
/**
|
||||
* An on-disk cache for album art bitmaps.
|
||||
*/
|
||||
private lateinit var diskCache: DiskLruCache
|
||||
|
||||
/**
|
||||
* Used to check if the connection is metered
|
||||
*/
|
||||
private lateinit var connectivityManager: ConnectivityManager
|
||||
|
||||
/**
|
||||
* A list of urls yet to be fetched.
|
||||
*/
|
||||
private val fetchUrlList = ArrayList<URL>()
|
||||
|
||||
/**
|
||||
* A list of urls currently being fetched
|
||||
*/
|
||||
private val isFetchingList = ArrayList<URL>()
|
||||
|
||||
/**
|
||||
* A integer indicating how many fetches are in progress.
|
||||
*/
|
||||
private var numFetching = 0
|
||||
|
||||
/**
|
||||
* A list of plugins to notify on fetched album art
|
||||
*/
|
||||
private val registeredPlugins = ArrayList<MprisPlugin>()
|
||||
|
||||
/**
|
||||
* Initializes the disk cache. Needs to be called at least once before trying to use the cache
|
||||
*
|
||||
* @param context The context
|
||||
*/
|
||||
@JvmStatic
|
||||
fun initializeDiskCache(context: Context) {
|
||||
if (this::diskCache.isInitialized) return
|
||||
val cacheDir = File(context.cacheDir, "album_art")
|
||||
val versionCode: Int
|
||||
try {
|
||||
val info = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||
versionCode = info.versionCode
|
||||
//Initialize the disk cache with a limit of 5 MB storage (fits ~830 images, taking Spotify as reference)
|
||||
diskCache = DiskLruCache.open(cacheDir, versionCode, 1, 1000 * 1000 * 5.toLong())
|
||||
} catch (e: NameNotFoundException) {
|
||||
throw AssertionError(e)
|
||||
} catch (e: IOException) {
|
||||
Log.e("KDE/Mpris/AlbumArtCache", "Could not open the album art disk cache!", e)
|
||||
}
|
||||
connectivityManager = context.applicationContext.getSystemService()!!
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a mpris plugin, such that it gets notified of fetched album art
|
||||
*
|
||||
* @param mpris The mpris plugin
|
||||
*/
|
||||
@JvmStatic
|
||||
fun registerPlugin(mpris: MprisPlugin) {
|
||||
registeredPlugins.add(mpris)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deregister a mpris plugin
|
||||
*
|
||||
* @param mpris The mpris plugin
|
||||
*/
|
||||
@JvmStatic
|
||||
fun deregisterPlugin(mpris: MprisPlugin?) {
|
||||
registeredPlugins.remove(mpris)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the album art for the given url. Currently only handles http(s) urls.
|
||||
* If it's not in the cache, will initiate a request to fetch it.
|
||||
*
|
||||
* @param albumUrl The album art url
|
||||
* @return A bitmap for the album art. Can be null if not (yet) found
|
||||
*/
|
||||
@JvmStatic
|
||||
fun getAlbumArt(albumUrl: String?, plugin: MprisPlugin, player: String?): Bitmap? {
|
||||
//If the url is invalid, return "no album art"
|
||||
if (albumUrl.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
val url = try {
|
||||
URL(albumUrl)
|
||||
} catch (e: MalformedURLException) {
|
||||
//Invalid url, so just return "no album art"
|
||||
//Shouldn't happen (checked on receival of the url), but just to be sure
|
||||
return null
|
||||
}
|
||||
|
||||
//We currently only support http(s) and file urls
|
||||
if (url.protocol !in arrayOf("http", "https", "file")) {
|
||||
return null
|
||||
}
|
||||
|
||||
//First, check the in-memory cache
|
||||
val albumItem = memoryCache[albumUrl]
|
||||
if (albumItem != null) {
|
||||
//Do not retry failed fetches
|
||||
return if (albumItem.failedFetch) {
|
||||
null
|
||||
} else {
|
||||
albumItem.albumArt
|
||||
}
|
||||
}
|
||||
|
||||
//If not found, check the disk cache
|
||||
if (!this::diskCache.isInitialized) {
|
||||
Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!")
|
||||
return null
|
||||
}
|
||||
try {
|
||||
val item = diskCache[urlToDiskCacheKey(albumUrl)]
|
||||
if (item != null) {
|
||||
val result = BitmapFactory.decodeStream(item.getInputStream(0))
|
||||
item.close()
|
||||
val memItem = MemoryCacheItem()
|
||||
if (result != null) {
|
||||
memItem.failedFetch = false
|
||||
memItem.albumArt = result
|
||||
} else {
|
||||
//Invalid bitmap, so remember it as a "failed fetch" and remove it from the disk cache
|
||||
memItem.failedFetch = true
|
||||
memItem.albumArt = null
|
||||
diskCache.remove(urlToDiskCacheKey(albumUrl))
|
||||
Log.d("KDE/Mpris/AlbumArtCache", "Invalid image: $albumUrl")
|
||||
}
|
||||
memoryCache.put(albumUrl, memItem)
|
||||
return result
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
return null
|
||||
}
|
||||
|
||||
/* If not found, we have not tried fetching it (recently), or a fetch is in-progress.
|
||||
Either way, just add it to the fetch queue and starting fetching it if no fetch is running. */
|
||||
if ("file" == url.protocol) {
|
||||
//Special-case file, since we need to fetch it from the remote
|
||||
if (url in isFetchingList) return null
|
||||
if (!plugin.askTransferAlbumArt(albumUrl, player)) {
|
||||
//It doesn't support transferring the art, so mark it as failed in the memory cache
|
||||
memoryCache.put(url.toString(), MemoryCacheItem(true))
|
||||
}
|
||||
} else {
|
||||
fetchUrl(url)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches an album art url and puts it in the cache
|
||||
*
|
||||
* @param url The url
|
||||
*/
|
||||
private fun fetchUrl(url: URL) {
|
||||
//We need the disk cache for this
|
||||
if (!this::diskCache.isInitialized) {
|
||||
Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!")
|
||||
return
|
||||
}
|
||||
if (ConnectivityManagerCompat.isActiveNetworkMetered(connectivityManager)) {
|
||||
//Only download art on unmetered networks (wifi etc.)
|
||||
return
|
||||
}
|
||||
|
||||
//Only fetch an URL if we're not fetching it already
|
||||
if (url in fetchUrlList || url in isFetchingList) {
|
||||
return
|
||||
}
|
||||
fetchUrlList.add(url)
|
||||
initiateFetch()
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the actual fetching and makes sure only not too many fetches are running at the same time
|
||||
*/
|
||||
private fun initiateFetch() {
|
||||
if (numFetching >= 2 || fetchUrlList.isEmpty()) return
|
||||
|
||||
//Fetch the last-requested url first, it will probably be needed first
|
||||
val url = fetchUrlList.last()
|
||||
//Remove the url from the to-fetch list
|
||||
fetchUrlList.remove(url)
|
||||
if ("file" == url.protocol) {
|
||||
throw AssertionError("Not file urls should be possible here!")
|
||||
}
|
||||
|
||||
//Download the album art ourselves
|
||||
++numFetching
|
||||
//Add the url to the currently-fetching list
|
||||
isFetchingList.add(url)
|
||||
try {
|
||||
val cacheItem = diskCache.edit(urlToDiskCacheKey(url.toString()))
|
||||
if (cacheItem == null) {
|
||||
Log.e("KDE/Mpris/AlbumArtCache",
|
||||
"Two disk cache edits happened at the same time, should be impossible!")
|
||||
--numFetching
|
||||
return
|
||||
}
|
||||
|
||||
//Do the actual fetch in the background
|
||||
GlobalScope.launch { fetchURL(url, null, cacheItem) }
|
||||
} catch (e: IOException) {
|
||||
Log.e("KDE/Mpris/AlbumArtCache", "Problems with the disk cache", e)
|
||||
--numFetching
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The disk cache requires mostly alphanumeric characters, and at most 64 characters.
|
||||
* So hash the url to get a valid key
|
||||
*
|
||||
* @param url The url
|
||||
* @return A valid disk cache key
|
||||
*/
|
||||
private fun urlToDiskCacheKey(url: String): String {
|
||||
return MessageDigest.getInstance("MD5").digest(url.toByteArray())
|
||||
.joinToString(separator = "") { String.format("%02x", it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfer an asked-for album art payload to the disk cache.
|
||||
*
|
||||
* @param albumUrl The url of the album art (should be a file:// url)
|
||||
* @param payload The payload input stream
|
||||
*/
|
||||
@JvmStatic
|
||||
fun payloadToDiskCache(albumUrl: String, payload: Payload?) {
|
||||
//We need the disk cache for this
|
||||
if (payload == null) {
|
||||
return
|
||||
}
|
||||
if (!this::diskCache.isInitialized) {
|
||||
Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!")
|
||||
payload.close()
|
||||
return
|
||||
}
|
||||
val url = try {
|
||||
URL(albumUrl)
|
||||
} catch (e: MalformedURLException) {
|
||||
//Shouldn't happen (checked on receival of the url), but just to be sure
|
||||
payload.close()
|
||||
return
|
||||
}
|
||||
if ("file" != url.protocol) {
|
||||
//Shouldn't happen (otherwise we wouldn't have asked for the payload), but just to be sure
|
||||
payload.close()
|
||||
return
|
||||
}
|
||||
|
||||
//Only fetch the URL if we're not fetching it already
|
||||
if (url in isFetchingList) {
|
||||
payload.close()
|
||||
return
|
||||
}
|
||||
|
||||
//Check if we already have this art
|
||||
try {
|
||||
if (memoryCache[albumUrl] != null || diskCache[urlToDiskCacheKey(albumUrl)] != null) {
|
||||
payload.close()
|
||||
return
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e("KDE/Mpris/AlbumArtCache", "Disk cache problem!", e)
|
||||
payload.close()
|
||||
return
|
||||
}
|
||||
|
||||
//Add it to the currently-fetching list
|
||||
isFetchingList.add(url)
|
||||
++numFetching
|
||||
try {
|
||||
val cacheItem = diskCache.edit(urlToDiskCacheKey(url.toString()))
|
||||
if (cacheItem == null) {
|
||||
Log.e("KDE/Mpris/AlbumArtCache",
|
||||
"Two disk cache edits happened at the same time, should be impossible!")
|
||||
--numFetching
|
||||
payload.close()
|
||||
return
|
||||
}
|
||||
|
||||
//Do the actual fetch in the background
|
||||
GlobalScope.launch { fetchURL(url, payload, cacheItem) }
|
||||
} catch (e: IOException) {
|
||||
Log.e("KDE/Mpris/AlbumArtCache", "Problems with the disk cache", e)
|
||||
--numFetching
|
||||
}
|
||||
}
|
||||
|
||||
private class MemoryCacheItem(var failedFetch: Boolean = false, var albumArt: Bitmap? = null)
|
||||
|
||||
/**
|
||||
* Initialize an url fetch
|
||||
*
|
||||
* @param url The url being fetched
|
||||
* @param payload A NetworkPacket Payload (if from the connected device). null if fetched from http(s)
|
||||
* @param cacheItem The disk cache item to edit
|
||||
*/
|
||||
private suspend fun fetchURL(url: URL, payload: Payload?, cacheItem: DiskLruCache.Editor) {
|
||||
var success = withContext(Dispatchers.IO) {
|
||||
//See if we need to open a http(s) connection here, or if we use a payload input stream
|
||||
val output = cacheItem.newOutputStream(0)
|
||||
try {
|
||||
val inputStream = payload?.inputStream ?: openHttp(url)
|
||||
val buffer = ByteArray(4096)
|
||||
var bytesRead: Int
|
||||
if (inputStream != null) {
|
||||
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
|
||||
output.write(buffer, 0, bytesRead)
|
||||
}
|
||||
}
|
||||
output.flush()
|
||||
output.close()
|
||||
return@withContext true
|
||||
} catch (e: IOException) {
|
||||
return@withContext false
|
||||
} finally {
|
||||
payload?.close()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Since commit() and abort() are blocking calls, they have to be executed in the IO
|
||||
// dispatcher.
|
||||
withContext(Dispatchers.IO) {
|
||||
if (success) {
|
||||
cacheItem.commit()
|
||||
} else {
|
||||
cacheItem.abort()
|
||||
}
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
success = false
|
||||
Log.e("KDE/Mpris/AlbumArtCache", "Problem with the disk cache", e)
|
||||
}
|
||||
if (success) {
|
||||
//Now it's in the disk cache, the getAlbumArt() function should be able to read it
|
||||
|
||||
//So notify the mpris plugins of the fetched art
|
||||
for (mpris in registeredPlugins) {
|
||||
mpris.fetchedAlbumArt(url.toString())
|
||||
}
|
||||
} else {
|
||||
//Mark the fetch as failed in the memory cache
|
||||
memoryCache.put(url.toString(), MemoryCacheItem(true))
|
||||
}
|
||||
|
||||
//Remove the url from the fetching list
|
||||
isFetchingList.remove(url)
|
||||
//Fetch the next url (if any)
|
||||
--numFetching
|
||||
initiateFetch()
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the http(s) connection
|
||||
*
|
||||
* @return True if succeeded
|
||||
*/
|
||||
private fun openHttp(url: URL): InputStream? {
|
||||
//Default android behaviour does not follow https -> http urls, so do this manually
|
||||
if (url.protocol !in arrayOf("http", "https")) {
|
||||
throw AssertionError("Invalid url: not http(s) in background album art fetch")
|
||||
}
|
||||
var currentUrl = url
|
||||
var connection: HttpURLConnection
|
||||
loop@ for (i in 0..4) {
|
||||
connection = currentUrl.openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 10000
|
||||
connection.readTimeout = 10000
|
||||
connection.instanceFollowRedirects = false
|
||||
when (connection.responseCode) {
|
||||
HttpURLConnection.HTTP_MOVED_PERM, HttpURLConnection.HTTP_MOVED_TEMP -> {
|
||||
var location = connection.getHeaderField("Location")
|
||||
location = URLDecoder.decode(location, "UTF-8")
|
||||
currentUrl = URL(currentUrl, location) // Deal with relative URLs
|
||||
//Again, only support http(s)
|
||||
if (currentUrl.protocol !in arrayOf("http", "https")) {
|
||||
return null
|
||||
}
|
||||
connection.disconnect()
|
||||
continue@loop
|
||||
}
|
||||
}
|
||||
|
||||
//Found a non-redirecting connection, so do something with it
|
||||
return connection.inputStream
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user