diff --git a/build.gradle b/build.gradle index 5ab8675a..7e09abb6 100644 --- a/build.gradle +++ b/build.gradle @@ -1,96 +1,97 @@ buildscript { repositories { jcenter() maven { url 'https://maven.google.com/' name 'Google' } } dependencies { classpath 'com.android.tools.build:gradle:3.0.1' } } apply plugin: 'com.android.application' android { buildToolsVersion '26.0.2' compileSdkVersion 25 defaultConfig { minSdkVersion 9 targetSdkVersion 25 //multiDexEnabled true //testInstrumentationRunner "com.android.test.runner.MultiDexTestRunner" } dexOptions { javaMaxHeapSize "2g" } compileOptions { // Use Java 1.7, requires minSdk 8 //SSHD requires mina when running on JDK < 7 sourceCompatibility JavaVersion.VERSION_1_7 targetCompatibility JavaVersion.VERSION_1_7 } sourceSets { main { manifest.srcFile 'AndroidManifest.xml' java.srcDirs = ['src'] resources.srcDirs = ['resources'] res.srcDirs = ['res'] assets.srcDirs = ['assets'] } androidTest { java.srcDirs = ['tests'] } } packagingOptions { pickFirst "META-INF/DEPENDENCIES" pickFirst "META-INF/LICENSE" pickFirst "META-INF/NOTICE" pickFirst "META-INF/BCKEY.SF" pickFirst "META-INF/BCKEY.DSA" pickFirst "META-INF/INDEX.LIST" pickFirst "META-INF/io.netty.versions.properties" } lintOptions { abortOnError false checkReleaseBuilds false } buildTypes { debug { minifyEnabled false useProguard false } release { //keep on 'release', set to 'all' when testing to make sure proguard is not deleting important stuff minifyEnabled true useProguard true proguardFiles getDefaultProguardFile('proguard-android.txt'),'proguard-rules.pro' } } } dependencies { repositories { jcenter() maven { url 'https://maven.google.com/' name 'Google' } } 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 implementation 'com.madgag.spongycastle:pkix:1.54.0.0' //For SSL certificate generation // Testing androidTestImplementation 'org.mockito:mockito-core:1.10.19' androidTestImplementation 'com.google.dexmaker:dexmaker-mockito:1.1'// Because mockito has some problems with dex environment androidTestImplementation 'org.skyscreamer:jsonassert:1.3.0' testImplementation 'junit:junit:4.12' } diff --git a/res/drawable/ic_album_art_placeholder.xml b/res/drawable/ic_album_art_placeholder.xml new file mode 100644 index 00000000..be5f1214 --- /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 index 00000000..b79bd807 --- /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 index 00000000..07c52ef2 --- /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 index 70d47b1a..aeb41169 100644 --- a/res/layout/mpris_control.xml +++ b/res/layout/mpris_control.xml @@ -1,184 +1,181 @@ + android:layout_gravity="center"> diff --git a/src/org/kde/kdeconnect/Plugins/MprisPlugin/AlbumArtCache.java b/src/org/kde/kdeconnect/Plugins/MprisPlugin/AlbumArtCache.java new file mode 100644 index 00000000..f15e81f3 --- /dev/null +++ b/src/org/kde/kdeconnect/Plugins/MprisPlugin/AlbumArtCache.java @@ -0,0 +1,428 @@ +/* + * Copyright 2017 Matthijs Tijink + * + * 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 index 5e5e9609..e9036e34 100644 --- a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisActivity.java +++ b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisActivity.java @@ -1,445 +1,458 @@ /* * Copyright 2014 Albert Vaca Cintora * * 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.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; import org.kde.kdeconnect.Backends.BaseLink; import org.kde.kdeconnect.Backends.BaseLinkProvider; import org.kde.kdeconnect.BackgroundService; import org.kde.kdeconnect.Device; import org.kde.kdeconnect.NetworkPackage; import org.kde.kdeconnect_tp.R; import java.util.List; public class MprisActivity extends AppCompatActivity { private String deviceId; private final Handler positionSeekUpdateHandler = new Handler(); private Runnable positionSeekUpdateRunnable = null; private MprisPlugin.MprisPlayer targetPlayer = null; private static String milisToProgress(long milis) { int length = (int)(milis / 1000); //From milis to seconds StringBuilder text = new StringBuilder(); int minutes = length / 60; if (minutes > 60) { int hours = minutes / 60; minutes = minutes % 60; text.append(hours).append(':'); if (minutes < 10) text.append('0'); } text.append(minutes).append(':'); int seconds = (length % 60); if(seconds < 10) text.append('0'); // needed to show length properly (eg 4:05 instead of 4:5) text.append(seconds); return text.toString(); } protected void connectToPlugin(final String targetPlayerName) { BackgroundService.RunCommand(this, new BackgroundService.InstanceCallback() { @Override public void onServiceStart(BackgroundService service) { final Device device = service.getDevice(deviceId); final MprisPlugin mpris = device.getPlugin(MprisPlugin.class); if (mpris == null) { Log.e("MprisActivity", "device has no mpris plugin!"); return; } targetPlayer = mpris.getPlayerStatus(targetPlayerName); mpris.setPlayerStatusUpdatedHandler("activity", new Handler() { @Override public void handleMessage(Message msg) { runOnUiThread(new Runnable() { @Override public void run() { updatePlayerStatus(mpris); } }); } }); mpris.setPlayerListUpdatedHandler("activity", new Handler() { @Override public void handleMessage(Message msg) { final List playerList = mpris.getPlayerList(); final ArrayAdapter adapter = new ArrayAdapter<>(MprisActivity.this, android.R.layout.simple_spinner_item, playerList.toArray(new String[playerList.size()]) ); adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); runOnUiThread(new Runnable() { @Override public void run() { Spinner spinner = (Spinner) findViewById(R.id.player_spinner); //String prevPlayer = (String)spinner.getSelectedItem(); spinner.setAdapter(adapter); if (playerList.isEmpty()) { findViewById(R.id.no_players).setVisibility(View.VISIBLE); spinner.setVisibility(View.GONE); ((TextView) findViewById(R.id.now_playing_textview)).setText(""); } else { findViewById(R.id.no_players).setVisibility(View.GONE); spinner.setVisibility(View.VISIBLE); } spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(AdapterView arg0, View arg1, int pos, long id) { if (pos >= playerList.size()) return; String player = playerList.get(pos); if (targetPlayer != null && player.equals(targetPlayer.getPlayer())) { return; //Player hasn't actually changed } targetPlayer = mpris.getPlayerStatus(player); updatePlayerStatus(mpris); } @Override public void onNothingSelected(AdapterView arg0) { targetPlayer = null; } }); if (targetPlayer != null) { int targetIndex = adapter.getPosition(targetPlayer.getPlayer()); if (targetIndex >= 0) { spinner.setSelection(targetIndex); } else { targetPlayer = null; } } updatePlayerStatus(mpris); } }); } }); } }); } private final BaseLinkProvider.ConnectionReceiver connectionReceiver = new BaseLinkProvider.ConnectionReceiver() { @Override public void onConnectionReceived(NetworkPackage identityPackage, BaseLink link) { connectToPlugin(null); } @Override public void onConnectionLost(BaseLink link) { } }; @Override protected void onDestroy() { super.onDestroy(); BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() { @Override public void onServiceStart(BackgroundService service) { service.removeConnectionListener(connectionReceiver); } }); } private void updatePlayerStatus(MprisPlugin mpris) { MprisPlugin.MprisPlayer playerStatus = targetPlayer; if (playerStatus == null) { //No player with that name found, just display "empty" data playerStatus = mpris.getEmptyPlayer(); } String song = playerStatus.getCurrentSong(); TextView nowPlaying = (TextView) findViewById(R.id.now_playing_textview); if (!nowPlaying.getText().toString().equals(song)) { 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); positionSeek.setMax((int)(playerStatus.getLength())); positionSeek.setProgress((int)(playerStatus.getPosition())); findViewById(R.id.progress_slider).setVisibility(View.VISIBLE); } else { findViewById(R.id.progress_slider).setVisibility(View.GONE); } int volume = playerStatus.getVolume(); ((SeekBar) findViewById(R.id.volume_seek)).setProgress(volume); boolean isPlaying = playerStatus.isPlaying(); if (isPlaying) { ((ImageButton) findViewById(R.id.play_button)).setImageResource(R.drawable.ic_pause_black); findViewById(R.id.play_button).setVisibility(playerStatus.isPauseAllowed() ? View.VISIBLE : View.GONE); } else { ((ImageButton) findViewById(R.id.play_button)).setImageResource(R.drawable.ic_play_black); findViewById(R.id.play_button).setVisibility(playerStatus.isPlayAllowed() ? View.VISIBLE : View.GONE); } findViewById(R.id.volume_layout).setVisibility(playerStatus.isSetVolumeAllowed() ? View.VISIBLE : View.INVISIBLE); findViewById(R.id.rew_button).setVisibility(playerStatus.isSeekAllowed() ? View.VISIBLE : View.GONE); findViewById(R.id.ff_button).setVisibility(playerStatus.isSeekAllowed() ? View.VISIBLE : View.GONE); findViewById(R.id.next_button).setVisibility(playerStatus.isGoNextAllowed() ? View.VISIBLE : View.GONE); findViewById(R.id.prev_button).setVisibility(playerStatus.isGoPreviousAllowed() ? View.VISIBLE : View.GONE); } /** * Change current volume with provided step. * * @param step step size volume change */ private void updateVolume(int step) { if (targetPlayer == null) { return; } final int currentVolume = targetPlayer.getVolume(); if(currentVolume < 100 || currentVolume > 0) { int newVolume = currentVolume + step; if(newVolume > 100) { newVolume = 100; } else if (newVolume <0 ) { newVolume = 0; } targetPlayer.setVolume(newVolume); } } @Override public boolean onKeyDown(int keyCode, KeyEvent event) { switch (keyCode) { case KeyEvent.KEYCODE_VOLUME_UP: updateVolume(5); return true; case KeyEvent.KEYCODE_VOLUME_DOWN: updateVolume(-5); return true; default: return super.onKeyDown(keyCode, event); } } @Override public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) { switch (keyCode) { case KeyEvent.KEYCODE_VOLUME_UP: return true; case KeyEvent.KEYCODE_VOLUME_DOWN: return true; default: return super.onKeyUp(keyCode, event); } } @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"); deviceId = getIntent().getStringExtra("deviceId"); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); String interval_time_str = prefs.getString(getString(R.string.mpris_time_key), getString(R.string.mpris_time_default)); final int interval_time = Integer.parseInt(interval_time_str); BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() { @Override public void onServiceStart(BackgroundService service) { service.addConnectionListener(connectionReceiver); } }); connectToPlugin(targetPlayerName); findViewById(R.id.play_button).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() { @Override public void onServiceStart(BackgroundService service) { if (targetPlayer == null) return; targetPlayer.playPause(); } }); } }); findViewById(R.id.prev_button).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() { @Override public void onServiceStart(BackgroundService service) { if (targetPlayer == null) return; targetPlayer.previous(); } }); } }); findViewById(R.id.rew_button).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() { @Override public void onServiceStart(BackgroundService service) { if (targetPlayer == null) return; targetPlayer.seek(interval_time * -1); } }); } }); findViewById(R.id.ff_button).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() { @Override public void onServiceStart(BackgroundService service) { if (targetPlayer == null) return; targetPlayer.seek(interval_time); } }); } }); findViewById(R.id.next_button).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() { @Override public void onServiceStart(BackgroundService service) { if (targetPlayer == null) return; targetPlayer.next(); } }); } }); ((SeekBar)findViewById(R.id.volume_seek)).setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekBar, int i, boolean b) { } @Override public void onStartTrackingTouch(SeekBar seekBar) { } @Override public void onStopTrackingTouch(final SeekBar seekBar) { BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() { @Override public void onServiceStart(BackgroundService service) { if (targetPlayer == null) return; targetPlayer.setVolume(seekBar.getProgress()); } }); } }); positionSeekUpdateRunnable = new Runnable() { @Override public void run() { final SeekBar positionSeek = (SeekBar)findViewById(R.id.positionSeek); BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() { @Override public void onServiceStart(BackgroundService service) { if (targetPlayer != null) { positionSeek.setProgress((int) (targetPlayer.getPosition())); } positionSeekUpdateHandler.removeCallbacks(positionSeekUpdateRunnable); positionSeekUpdateHandler.postDelayed(positionSeekUpdateRunnable, 1000); } }); } }; positionSeekUpdateHandler.postDelayed(positionSeekUpdateRunnable, 200); ((SeekBar)findViewById(R.id.positionSeek)).setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean byUser) { ((TextView)findViewById(R.id.progress_textview)).setText(milisToProgress(progress)); } @Override public void onStartTrackingTouch(SeekBar seekBar) { positionSeekUpdateHandler.removeCallbacks(positionSeekUpdateRunnable); } @Override public void onStopTrackingTouch(final SeekBar seekBar) { BackgroundService.RunCommand(MprisActivity.this, new BackgroundService.InstanceCallback() { @Override public void onServiceStart(BackgroundService service) { if (targetPlayer != null) { targetPlayer.setPosition(seekBar.getProgress()); } positionSeekUpdateHandler.postDelayed(positionSeekUpdateRunnable, 200); } }); } }); findViewById(R.id.now_playing_textview).setSelected(true); } @Override protected void onStart() { super.onStart(); BackgroundService.addGuiInUseCounter(this); } @Override protected void onStop() { super.onStop(); BackgroundService.removeGuiInUseCounter(this); } } diff --git a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisPlugin.java b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisPlugin.java index aa5f0a50..cabc1637 100644 --- a/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisPlugin.java +++ b/src/org/kde/kdeconnect/Plugins/MprisPlugin/MprisPlugin.java @@ -1,399 +1,445 @@ /* * Copyright 2014 Albert Vaca Cintora * * 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.app.Activity; import android.content.Intent; +import android.graphics.Bitmap; import android.graphics.drawable.Drawable; import android.os.Handler; import android.os.Message; import android.support.v4.content.ContextCompat; import android.util.Log; import org.kde.kdeconnect.NetworkPackage; 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; import java.util.Iterator; import java.util.List; public class MprisPlugin extends Plugin { public class MprisPlayer { private String player = ""; private boolean playing = false; private String currentSong = ""; private String title = ""; private String artist = ""; private String album = ""; + private String albumArtUrl = ""; private int volume = 50; private long length = -1; private long lastPosition = 0; private long lastPositionTime; private boolean playAllowed = true; private boolean pauseAllowed = true; private boolean goNextAllowed = true; private boolean goPreviousAllowed = true; private boolean seekAllowed = true; public MprisPlayer() { lastPositionTime = System.currentTimeMillis(); } public String getCurrentSong() { return currentSong; } public String getTitle() { return title; } public String getArtist() { return artist; } public String getAlbum() { return album; } public String getPlayer() { return player; } private boolean isSpotify() { return getPlayer().toLowerCase().equals("spotify"); } public int getVolume() { return volume; } public long getLength(){ return length; } public boolean isPlaying() { return playing; } public boolean isPlayAllowed() { return playAllowed; } public boolean isPauseAllowed() { return pauseAllowed; } public boolean isGoNextAllowed() { return goNextAllowed; } public boolean isGoPreviousAllowed() { return goPreviousAllowed; } public boolean isSeekAllowed() { 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(); } public long getPosition(){ if(playing) { return lastPosition + (System.currentTimeMillis() - lastPositionTime); } else { return lastPosition; } } public void playPause() { if (isPauseAllowed() || isPlayAllowed()) { MprisPlugin.this.sendCommand(getPlayer(), "action", "PlayPause"); } } public void play() { if (isPlayAllowed()) { MprisPlugin.this.sendCommand(getPlayer(), "action", "Play"); } } public void pause() { if (isPauseAllowed()) { MprisPlugin.this.sendCommand(getPlayer(), "action", "Pause"); } } public void stop() { MprisPlugin.this.sendCommand(getPlayer(), "action", "Stop"); } public void previous() { if (isGoPreviousAllowed()) { MprisPlugin.this.sendCommand(getPlayer(), "action", "Previous"); } } public void next() { if (isGoNextAllowed()) { MprisPlugin.this.sendCommand(getPlayer(), "action", "Next"); } } public void setVolume(int volume) { if (isSetVolumeAllowed()) { MprisPlugin.this.sendCommand(getPlayer(), "setVolume", volume); } } public void setPosition(int position) { if (isSeekAllowed()) { MprisPlugin.this.sendCommand(getPlayer(), "SetPosition", position); lastPosition = position; lastPositionTime = System.currentTimeMillis(); } } public void seek(int offset) { if (isSeekAllowed()) { MprisPlugin.this.sendCommand(getPlayer(), "Seek", offset); } } } public final static String PACKAGE_TYPE_MPRIS = "kdeconnect.mpris"; public final static String PACKAGE_TYPE_MPRIS_REQUEST = "kdeconnect.mpris.request"; private HashMap players = new HashMap<>(); private HashMap playerStatusUpdated = new HashMap<>(); private HashMap playerListUpdated = new HashMap<>(); @Override public String getDisplayName() { return context.getResources().getString(R.string.pref_plugin_mpris); } @Override public String getDescription() { return context.getResources().getString(R.string.pref_plugin_mpris_desc); } @Override public Drawable getIcon() { return ContextCompat.getDrawable(context, R.drawable.mpris_plugin_action); } @Override public boolean hasSettings() { return true; } @Override public boolean onCreate() { requestPlayerList(); //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) { NetworkPackage np = new NetworkPackage(PACKAGE_TYPE_MPRIS_REQUEST); np.set("player", player); np.set(method, value); device.sendPackage(np); } private void sendCommand(String player, String method, int value) { NetworkPackage np = new NetworkPackage(PACKAGE_TYPE_MPRIS_REQUEST); np.set("player", player); np.set(method, value); device.sendPackage(np); } @Override public boolean onPackageReceived(NetworkPackage np) { if (np.has("nowPlaying") || np.has("volume") || np.has("isPlaying") || np.has("length") || np.has("pos")) { MprisPlayer playerStatus = players.get(np.getString("player")); if (playerStatus != null) { playerStatus.currentSong = np.getString("nowPlaying", playerStatus.currentSong); //Note: title, artist and album will not be available for all desktop clients playerStatus.title = np.getString("title", playerStatus.title); playerStatus.artist = np.getString("artist", playerStatus.artist); playerStatus.album = np.getString("album", playerStatus.album); playerStatus.volume = np.getInt("volume", playerStatus.volume); playerStatus.length = np.getLong("length", playerStatus.length); if(np.has("pos")){ playerStatus.lastPosition = np.getLong("pos", playerStatus.lastPosition); playerStatus.lastPositionTime = System.currentTimeMillis(); } playerStatus.playing = np.getBoolean("isPlaying", playerStatus.playing); playerStatus.playAllowed = np.getBoolean("canPlay", playerStatus.playAllowed); playerStatus.pauseAllowed = np.getBoolean("canPause", playerStatus.pauseAllowed); 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()); } catch(Exception e) { e.printStackTrace(); Log.e("MprisControl","Exception"); playerStatusUpdated.remove(key); } } } } List newPlayerList = np.getStringList("playerList"); if (newPlayerList != null) { boolean equals = true; for (String newPlayer : newPlayerList) { if (!players.containsKey(newPlayer)) { equals = false; MprisPlayer player = new MprisPlayer(); player.player = newPlayer; players.put(newPlayer, player); //Immediately ask for the data of this player requestPlayerStatus(newPlayer); } } Iterator> iter = players.entrySet().iterator(); while (iter.hasNext()) { String oldPlayer = iter.next().getKey(); boolean found = false; for (String newPlayer : newPlayerList) { if (newPlayer.equals(oldPlayer)) { found = true; break; } } if (!found) { iter.remove(); equals = false; } } if (!equals) { for (String key : playerListUpdated.keySet()) { try { playerListUpdated.get(key).dispatchMessage(new Message()); } catch(Exception e) { e.printStackTrace(); Log.e("MprisControl","Exception"); playerListUpdated.remove(key); } } } } return true; } @Override public String[] getSupportedPackageTypes() { return new String[] {PACKAGE_TYPE_MPRIS}; } @Override public String[] getOutgoingPackageTypes() { return new String[] {PACKAGE_TYPE_MPRIS_REQUEST}; } public void setPlayerStatusUpdatedHandler(String id, Handler h) { playerStatusUpdated.put(id, h); h.dispatchMessage(new Message()); } public void removePlayerStatusUpdatedHandler(String id) { playerStatusUpdated.remove(id); } public void setPlayerListUpdatedHandler(String id, Handler h) { playerListUpdated.put(id,h); h.dispatchMessage(new Message()); } public void removePlayerListUpdatedHandler(String id) { playerListUpdated.remove(id); } public List getPlayerList() { List playerlist = new ArrayList<>(players.keySet()); Collections.sort(playerlist); return playerlist; } public MprisPlayer getPlayerStatus(String player) { return players.get(player); } public MprisPlayer getEmptyPlayer() { return new MprisPlayer(); } private void requestPlayerList() { NetworkPackage np = new NetworkPackage(PACKAGE_TYPE_MPRIS_REQUEST); np.set("requestPlayerList",true); device.sendPackage(np); } private void requestPlayerStatus(String player) { NetworkPackage np = new NetworkPackage(PACKAGE_TYPE_MPRIS_REQUEST); np.set("player", player); np.set("requestNowPlaying",true); np.set("requestVolume",true); device.sendPackage(np); } @Override public boolean hasMainActivity() { return true; } @Override public void startMainActivity(Activity parentActivity) { Intent intent = new Intent(parentActivity, MprisActivity.class); intent.putExtra("deviceId", device.getDeviceId()); parentActivity.startActivity(intent); } @Override public String getActionName() { 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); + } + } + } + } }