mirror of
https://github.com/KDE/kdeconnect-android
synced 2025-08-23 10:27:57 +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