diff --git a/build.gradle b/build.gradle
--- a/build.gradle
+++ b/build.gradle
@@ -82,6 +82,7 @@
implementation 'com.android.support:support-v4:25.4.0'
implementation 'com.android.support:appcompat-v7:25.4.0'
implementation 'com.android.support:design:25.4.0'
+ implementation 'com.jakewharton:disklrucache:2.0.2' //For caching album art bitmaps
implementation 'org.apache.sshd:sshd-core:0.8.0' //0.9 seems to fail on Android 6 and 1.+ requires java.nio.file, which doesn't exist in Android
diff --git a/res/drawable/ic_album_art_placeholder.xml b/res/drawable/ic_album_art_placeholder.xml
new file mode 100644
--- /dev/null
+++ b/res/drawable/ic_album_art_placeholder.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/res/layout-land/activity_mpris.xml b/res/layout-land/activity_mpris.xml
new file mode 100644
--- /dev/null
+++ b/res/layout-land/activity_mpris.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
diff --git a/res/layout/activity_mpris.xml b/res/layout/activity_mpris.xml
new file mode 100644
--- /dev/null
+++ b/res/layout/activity_mpris.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
diff --git a/res/layout/mpris_control.xml b/res/layout/mpris_control.xml
--- a/res/layout/mpris_control.xml
+++ b/res/layout/mpris_control.xml
@@ -7,10 +7,7 @@
android:layout_height="match_parent"
android:id="@+id/mpris_control_view"
android:gravity="center"
- android:paddingLeft="30dip"
- android:paddingTop="5dip"
- android:paddingRight="30dip"
- android:paddingBottom="5dip">
+ android:layout_gravity="center">
+ *
+ * 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 .
+*/
+
+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.os.AsyncTask;
+import android.support.v4.util.LruCache;
+import android.util.Log;
+
+import com.jakewharton.disklrucache.DiskLruCache;
+
+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
+ */
+public 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 memoryCache = new LruCache<>(10);
+ /**
+ * An on-disk cache for album art bitmaps.
+ */
+ private static DiskLruCache diskCache;
+
+ /**
+ * A list of urls yet to be fetched.
+ */
+ private static final ArrayList fetchUrlList = 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 ArrayList registeredPlugins = new ArrayList<>();
+
+ /**
+ * Initializes the disk cache. Needs to be called at least once before trying to use the cache
+ * @param context The context
+ */
+ public 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);
+ }
+ }
+
+ /**
+ * Registers a mpris plugin, such that it gets notified of fetched album art
+ * @param mpris The mpris plugin
+ */
+ public static void registerPlugin(MprisPlugin mpris) {
+ registeredPlugins.add(mpris);
+ }
+
+ /**
+ * Deregister a mpris plugin
+ * @param mpris The mpris plugin
+ */
+ public 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
+ */
+ public static Bitmap getAlbumArt(String albumUrl) {
+ //If the url is invalid, return "no album art"
+ if (albumUrl == null || albumUrl.isEmpty()) {
+ 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) urls
+ if (!url.getProtocol().equals("http") && !url.getProtocol().equals("https")) {
+ 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) {
+ BitmapFactory.Options decodeOptions = new BitmapFactory.Options();
+ decodeOptions.inScaled = false;
+ decodeOptions.inDensity = 1;
+ decodeOptions.inTargetDensity = 1;
+ Bitmap result = BitmapFactory.decodeStream(item.getInputStream(0), null, decodeOptions);
+ 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. */
+ 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;
+ }
+
+ //Only fetch an URL if we're not fetching it already
+ if (fetchUrlList.contains(url)) {
+ return;
+ }
+
+ fetchUrlList.add(url);
+ initiateFetch();
+ }
+
+ private static final class FetchURLTask extends AsyncTask {
+ private URL url;
+ private InputStream input;
+ private DiskLruCache.Editor cacheItem;
+ private OutputStream output;
+
+ /**
+ * Initialize an url fetch
+ *
+ * @param url The url being fetched
+ * @param payloadInput A payload input stream (if from the connected device). null if fetched from http(s)
+ * @param cacheItem The disk cache item to edit
+ * @throws IOException
+ */
+ FetchURLTask(URL url, InputStream payloadInput, DiskLruCache.Editor cacheItem) throws IOException {
+ this.url = url;
+ this.input = payloadInput;
+ this.cacheItem = cacheItem;
+ output = cacheItem.newOutputStream(0);
+ }
+
+ /**
+ * Opens the http(s) connection
+ * @return True if succeeded
+ * @throws IOException
+ */
+ private boolean openHttp() throws IOException {
+ //Default android behaviour does not follow https -> http urls, so do this manually
+ 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 false;
+ }
+ connection.disconnect();
+ continue;
+ }
+
+ //Found a non-redirecting connection, so do something with it
+ input = connection.getInputStream();
+ return true;
+ }
+
+ return false;
+ }
+
+ @Override
+ protected Boolean doInBackground(Void... params) {
+ try {
+ //See if we need to open a http(s) connection here, or if we use a payload input stream
+ if (input == null) {
+ if (!openHttp()) {
+ 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;
+ }
+ }
+
+ @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 to-fetch list
+ fetchUrlList.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;
+
+ ++numFetching;
+
+ //Fetch the last-requested url first, it will probably be needed first
+ URL url = fetchUrlList.get(fetchUrlList.size() - 1);
+ 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
+ */
+ public static void payloadToDiskCache(String albumUrl, InputStream payload) {
+ //We need the disk cache for this
+ if (diskCache == null) {
+ Log.e("KDE/Mpris/AlbumArtCache", "The disk cache is not intialized!");
+ 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
+ return;
+ }
+
+ if (!"file".equals(url.getProtocol())) {
+ //Shouldn't happen (otherwise we wouldn't have asked for the payload), but just to be sure
+ return;
+ }
+
+ //Only fetch the URL if we're not fetching it already
+ if (fetchUrlList.contains(url)) {
+ return;
+ }
+
+ fetchUrlList.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;
+ 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;
+ }
+ }
+}
diff --git a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisActivity.java b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisActivity.java
--- a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisActivity.java
+++ b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisActivity.java
@@ -21,18 +21,22 @@
package org.kde.kdeconnect.Plugins.MprisPlugin;
import android.content.SharedPreferences;
+import android.graphics.Bitmap;
+import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.preference.PreferenceManager;
import android.support.annotation.NonNull;
+import android.support.v4.graphics.drawable.DrawableCompat;
import android.support.v7.app.AppCompatActivity;
import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.ImageButton;
+import android.widget.ImageView;
import android.widget.SeekBar;
import android.widget.Spinner;
import android.widget.TextView;
@@ -196,6 +200,15 @@
nowPlaying.setText(song);
}
+ Bitmap albumArt = playerStatus.getAlbumArt();
+ if (albumArt == null) {
+ Drawable placeholder_art = DrawableCompat.wrap(getResources().getDrawable(R.drawable.ic_album_art_placeholder));
+ DrawableCompat.setTint(placeholder_art, getResources().getColor(R.color.primary));
+ ((ImageView) findViewById(R.id.album_art)).setImageDrawable(placeholder_art);
+ } else {
+ ((ImageView) findViewById(R.id.album_art)).setImageBitmap(albumArt);
+ }
+
if (playerStatus.isSeekAllowed()) {
((TextView) findViewById(R.id.time_textview)).setText(milisToProgress(playerStatus.getLength()));
SeekBar positionSeek = (SeekBar)findViewById(R.id.positionSeek);
@@ -276,7 +289,7 @@
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- setContentView(R.layout.mpris_control);
+ setContentView(R.layout.activity_mpris);
final String targetPlayerName = getIntent().getStringExtra("player");
getIntent().removeExtra("player");
diff --git a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisPlugin.java b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisPlugin.java
--- a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisPlugin.java
+++ b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisPlugin.java
@@ -22,6 +22,7 @@
import android.app.Activity;
import android.content.Intent;
+import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.os.Message;
@@ -32,6 +33,8 @@
import org.kde.kdeconnect.Plugins.Plugin;
import org.kde.kdeconnect_tp.R;
+import java.net.MalformedURLException;
+import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -46,6 +49,7 @@
private String title = "";
private String artist = "";
private String album = "";
+ private String albumArtUrl = "";
private int volume = 50;
private long length = -1;
private long lastPosition = 0;
@@ -114,6 +118,18 @@
return seekAllowed && getLength() >= 0 && getPosition() >= 0 && !isSpotify();
}
+ public boolean hasAlbumArt() {
+ return !albumArtUrl.isEmpty();
+ }
+
+ /**
+ * Returns the album art (if available). Note that this can return null even if hasAlbumArt() returns true.
+ * @return The album art, or null if not available
+ */
+ public Bitmap getAlbumArt() {
+ return AlbumArtCache.getAlbumArt(albumArtUrl);
+ }
+
public boolean isSetVolumeAllowed() {
return !isSpotify();
}
@@ -217,12 +233,16 @@
//Always request the player list so the data is up-to-date
requestPlayerList();
+ AlbumArtCache.initializeDiskCache(context);
+ AlbumArtCache.registerPlugin(this);
+
return true;
}
@Override
public void onDestroy() {
players.clear();
+ AlbumArtCache.deregisterPlugin(this);
}
private void sendCommand(String player, String method, String value) {
@@ -261,6 +281,12 @@
playerStatus.goNextAllowed = np.getBoolean("canGoNext", playerStatus.goNextAllowed);
playerStatus.goPreviousAllowed = np.getBoolean("canGoPrevious", playerStatus.goPreviousAllowed);
playerStatus.seekAllowed = np.getBoolean("canSeek", playerStatus.seekAllowed);
+ String newAlbumArtUrlstring = np.getString("albumArtUrl", playerStatus.albumArtUrl);
+ try {
+ //Turn the url into canonical form (and check its validity)
+ URL newAlbumArtUrl = new URL(newAlbumArtUrlstring);
+ playerStatus.albumArtUrl = newAlbumArtUrlstring.toString();
+ } catch (MalformedURLException ignored) {}
for (String key : playerStatusUpdated.keySet()) {
try {
playerStatusUpdated.get(key).dispatchMessage(new Message());
@@ -396,4 +422,24 @@
return context.getString(R.string.open_mpris_controls);
}
+ public void fetchedAlbumArt(String url) {
+ boolean doEmitUpdate = false;
+ for (MprisPlayer player : players.values()) {
+ if (url.equals(player.albumArtUrl)) {
+ doEmitUpdate = true;
+ }
+ }
+
+ if (doEmitUpdate) {
+ for (String key : playerStatusUpdated.keySet()) {
+ try {
+ playerStatusUpdated.get(key).dispatchMessage(new Message());
+ } catch(Exception e) {
+ e.printStackTrace();
+ Log.e("MprisControl","Exception");
+ playerStatusUpdated.remove(key);
+ }
+ }
+ }
+ }
}