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); + } + } + } + } }