diff options
author | Ross Harrison <rtharrison86@gmail.com> | 2015-02-01 00:36:16 -0600 |
---|---|---|
committer | Ross Harrison <rtharrison86@gmail.com> | 2015-02-01 00:36:16 -0600 |
commit | e3a78e9cd53f95d4ddf068c89d11f7fd4abba2e2 (patch) | |
tree | ea0864f5253dbb2a16a365b270596027e4381a6d /core/src/main/java | |
parent | c11edda64cc8f308aaea7db41c6751189742bda1 (diff) | |
parent | 373f28c8aea278ae50ac6ebbdc7db5d3703dcec4 (diff) | |
download | AntennaPod-e3a78e9cd53f95d4ddf068c89d11f7fd4abba2e2.zip |
Merge branch 'develop' of github.com:rharriso/AntennaPod
Diffstat (limited to 'core/src/main/java')
19 files changed, 1135 insertions, 195 deletions
diff --git a/core/src/main/java/de/danoeh/antennapod/core/ClientConfig.java b/core/src/main/java/de/danoeh/antennapod/core/ClientConfig.java index e5e609f5f..1a2671555 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/ClientConfig.java +++ b/core/src/main/java/de/danoeh/antennapod/core/ClientConfig.java @@ -22,4 +22,6 @@ public class ClientConfig { public static FlattrCallbacks flattrCallbacks; public static StorageCallbacks storageCallbacks; + + public static DBTasksCallbacks dbTasksCallbacks; } diff --git a/core/src/main/java/de/danoeh/antennapod/core/DBTasksCallbacks.java b/core/src/main/java/de/danoeh/antennapod/core/DBTasksCallbacks.java new file mode 100644 index 000000000..edf3e3199 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/DBTasksCallbacks.java @@ -0,0 +1,20 @@ +package de.danoeh.antennapod.core; + +import de.danoeh.antennapod.core.storage.AutomaticDownloadAlgorithm; +import de.danoeh.antennapod.core.storage.EpisodeCleanupAlgorithm; + +/** + * Callbacks for the DBTasks class of the storage module. + */ +public interface DBTasksCallbacks { + + /** + * Returns the client's implementation of the AutomaticDownloadAlgorithm interface. + */ + public AutomaticDownloadAlgorithm getAutomaticDownloadAlgorithm(); + + /** + * Returns the client's implementation of the EpisodeCacheCleanupAlgorithm interface. + */ + public EpisodeCleanupAlgorithm getEpisodeCacheCleanupAlgorithm(); +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/asynctask/DBTaskLoader.java b/core/src/main/java/de/danoeh/antennapod/core/asynctask/DBTaskLoader.java new file mode 100644 index 000000000..0f402f44a --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/asynctask/DBTaskLoader.java @@ -0,0 +1,29 @@ +package de.danoeh.antennapod.core.asynctask; + +import android.content.Context; +import android.support.v4.content.AsyncTaskLoader; + +/** + * Subclass of AsyncTaskLoader that is made for loading data with one of the DB*-classes. + * This class will provide a useful default implementation that would otherwise always be necessary when interacting + * with the DB*-classes with an AsyncTaskLoader. + */ +public abstract class DBTaskLoader<D> extends AsyncTaskLoader<D> { + + public DBTaskLoader(Context context) { + super(context); + } + + @Override + protected void onStopLoading() { + super.onStopLoading(); + cancelLoad(); + } + + @Override + protected void onStartLoading() { + super.onStartLoading(); + // according to https://code.google.com/p/android/issues/detail?id=14944, this has to be called manually + forceLoad(); + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/asynctask/PicassoProvider.java b/core/src/main/java/de/danoeh/antennapod/core/asynctask/PicassoProvider.java index 1ed29c23a..b6ece6dc8 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/asynctask/PicassoProvider.java +++ b/core/src/main/java/de/danoeh/antennapod/core/asynctask/PicassoProvider.java @@ -14,6 +14,7 @@ import com.squareup.picasso.OkHttpDownloader; import com.squareup.picasso.Picasso; import com.squareup.picasso.Request; import com.squareup.picasso.RequestHandler; +import com.squareup.picasso.Transformation; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; @@ -209,4 +210,254 @@ public class PicassoProvider { options.inJustDecodeBounds = false; } } + + public static final int BLUR_RADIUS = 1; + public static final int BLUR_IMAGE_SIZE = 100; + public static final String BLUR_KEY = "blur"; + + public static final Transformation blurTransformation = new Transformation() { + @Override + public Bitmap transform(Bitmap source) { + Bitmap result = fastblur(source, BLUR_RADIUS); + source.recycle(); + return result; + } + + @Override + public String key() { + return BLUR_KEY; + } + }; + + public static Bitmap fastblur(Bitmap sentBitmap, int radius) { + + // Stack Blur v1.0 from + // http://www.quasimondo.com/StackBlurForCanvas/StackBlurDemo.html + // + // Java Author: Mario Klingemann <mario at quasimondo.com> + // http://incubator.quasimondo.com + // created Feburary 29, 2004 + // Android port : Yahel Bouaziz <yahel at kayenko.com> + // http://www.kayenko.com + // ported april 5th, 2012 + + // This is a compromise between Gaussian Blur and Box blur + // It creates much better looking blurs than Box Blur, but is + // 7x faster than my Gaussian Blur implementation. + // + // I called it Stack Blur because this describes best how this + // filter works internally: it creates a kind of moving stack + // of colors whilst scanning through the image. Thereby it + // just has to add one new block of color to the right side + // of the stack and remove the leftmost color. The remaining + // colors on the topmost layer of the stack are either added on + // or reduced by one, depending on if they are on the right or + // on the left side of the stack. + // + // If you are using this algorithm in your code please add + // the following line: + // + // Stack Blur Algorithm by Mario Klingemann <mario@quasimondo.com> + + Bitmap bitmap = sentBitmap.copy(sentBitmap.getConfig(), true); + + if (radius < 1) { + return (null); + } + + int w = bitmap.getWidth(); + int h = bitmap.getHeight(); + + int[] pix = new int[w * h]; + Log.e("pix", w + " " + h + " " + pix.length); + bitmap.getPixels(pix, 0, w, 0, 0, w, h); + + int wm = w - 1; + int hm = h - 1; + int wh = w * h; + int div = radius + radius + 1; + + int r[] = new int[wh]; + int g[] = new int[wh]; + int b[] = new int[wh]; + int rsum, gsum, bsum, x, y, i, p, yp, yi, yw; + int vmin[] = new int[Math.max(w, h)]; + + int divsum = (div + 1) >> 1; + divsum *= divsum; + int dv[] = new int[256 * divsum]; + for (i = 0; i < 256 * divsum; i++) { + dv[i] = (i / divsum); + } + + yw = yi = 0; + + int[][] stack = new int[div][3]; + int stackpointer; + int stackstart; + int[] sir; + int rbs; + int r1 = radius + 1; + int routsum, goutsum, boutsum; + int rinsum, ginsum, binsum; + + for (y = 0; y < h; y++) { + rinsum = ginsum = binsum = routsum = goutsum = boutsum = rsum = gsum = bsum = 0; + for (i = -radius; i <= radius; i++) { + p = pix[yi + Math.min(wm, Math.max(i, 0))]; + sir = stack[i + radius]; + sir[0] = (p & 0xff0000) >> 16; + sir[1] = (p & 0x00ff00) >> 8; + sir[2] = (p & 0x0000ff); + rbs = r1 - Math.abs(i); + rsum += sir[0] * rbs; + gsum += sir[1] * rbs; + bsum += sir[2] * rbs; + if (i > 0) { + rinsum += sir[0]; + ginsum += sir[1]; + binsum += sir[2]; + } else { + routsum += sir[0]; + goutsum += sir[1]; + boutsum += sir[2]; + } + } + stackpointer = radius; + + for (x = 0; x < w; x++) { + + r[yi] = dv[rsum]; + g[yi] = dv[gsum]; + b[yi] = dv[bsum]; + + rsum -= routsum; + gsum -= goutsum; + bsum -= boutsum; + + stackstart = stackpointer - radius + div; + sir = stack[stackstart % div]; + + routsum -= sir[0]; + goutsum -= sir[1]; + boutsum -= sir[2]; + + if (y == 0) { + vmin[x] = Math.min(x + radius + 1, wm); + } + p = pix[yw + vmin[x]]; + + sir[0] = (p & 0xff0000) >> 16; + sir[1] = (p & 0x00ff00) >> 8; + sir[2] = (p & 0x0000ff); + + rinsum += sir[0]; + ginsum += sir[1]; + binsum += sir[2]; + + rsum += rinsum; + gsum += ginsum; + bsum += binsum; + + stackpointer = (stackpointer + 1) % div; + sir = stack[(stackpointer) % div]; + + routsum += sir[0]; + goutsum += sir[1]; + boutsum += sir[2]; + + rinsum -= sir[0]; + ginsum -= sir[1]; + binsum -= sir[2]; + + yi++; + } + yw += w; + } + for (x = 0; x < w; x++) { + rinsum = ginsum = binsum = routsum = goutsum = boutsum = rsum = gsum = bsum = 0; + yp = -radius * w; + for (i = -radius; i <= radius; i++) { + yi = Math.max(0, yp) + x; + + sir = stack[i + radius]; + + sir[0] = r[yi]; + sir[1] = g[yi]; + sir[2] = b[yi]; + + rbs = r1 - Math.abs(i); + + rsum += r[yi] * rbs; + gsum += g[yi] * rbs; + bsum += b[yi] * rbs; + + if (i > 0) { + rinsum += sir[0]; + ginsum += sir[1]; + binsum += sir[2]; + } else { + routsum += sir[0]; + goutsum += sir[1]; + boutsum += sir[2]; + } + + if (i < hm) { + yp += w; + } + } + yi = x; + stackpointer = radius; + for (y = 0; y < h; y++) { + // Preserve alpha channel: ( 0xff000000 & pix[yi] ) + pix[yi] = (0xff000000 & pix[yi]) | (dv[rsum] << 16) | (dv[gsum] << 8) | dv[bsum]; + + rsum -= routsum; + gsum -= goutsum; + bsum -= boutsum; + + stackstart = stackpointer - radius + div; + sir = stack[stackstart % div]; + + routsum -= sir[0]; + goutsum -= sir[1]; + boutsum -= sir[2]; + + if (x == 0) { + vmin[y] = Math.min(y + r1, hm) * w; + } + p = x + vmin[y]; + + sir[0] = r[p]; + sir[1] = g[p]; + sir[2] = b[p]; + + rinsum += sir[0]; + ginsum += sir[1]; + binsum += sir[2]; + + rsum += rinsum; + gsum += ginsum; + bsum += binsum; + + stackpointer = (stackpointer + 1) % div; + sir = stack[stackpointer]; + + routsum += sir[0]; + goutsum += sir[1]; + boutsum += sir[2]; + + rinsum -= sir[0]; + ginsum -= sir[1]; + binsum -= sir[2]; + + yi += w; + } + } + + Log.e("pix", w + " " + h + " " + pix.length); + bitmap.setPixels(pix, 0, w, 0, 0, w, h); + + return (bitmap); + } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java index 42e4191f6..c63b61f55 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java @@ -143,7 +143,7 @@ public class FeedItem extends FeedComponent implements ShownotesProvider, Flattr paymentLink = other.paymentLink; } if (other.chapters != null) { - if (chapters == null) { + if (!hasChapters) { chapters = other.chapters; } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/menuhandler/MenuItemUtils.java b/core/src/main/java/de/danoeh/antennapod/core/menuhandler/MenuItemUtils.java new file mode 100644 index 000000000..b8d17bcce --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/menuhandler/MenuItemUtils.java @@ -0,0 +1,38 @@ +package de.danoeh.antennapod.core.menuhandler; + +import android.support.v4.view.MenuItemCompat; +import android.view.Menu; +import android.view.MenuItem; + +import de.danoeh.antennapod.core.R; + +/** + * Utilities for menu items + */ +public class MenuItemUtils { + + /** + * Changes the appearance of a MenuItem depending on whether the given UpdateRefreshMenuItemChecker + * is refreshing or not. If it returns true, the menu item will be replaced by an indeterminate progress + * bar, otherwise nothing will happen. + * + * @param menu The menu that the MenuItem belongs to + * @param resId The id of the MenuItem + * @param checker Is used for checking whether to show the progress indicator or not. + * @return The returned value of the UpdateRefreshMenuItemChecker's isRefreshing() method. + */ + public static boolean updateRefreshMenuItem(Menu menu, int resId, UpdateRefreshMenuItemChecker checker) { + // expand actionview if feeds are being downloaded, collapse otherwise + if (checker.isRefreshing()) { + MenuItem refreshItem = menu.findItem(resId); + MenuItemCompat.setActionView(refreshItem, R.layout.refresh_action_view); + return true; + } else { + return false; + } + } + + public static interface UpdateRefreshMenuItemChecker { + public boolean isRefreshing(); + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java index f18028e8f..a3b9f6049 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java +++ b/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java @@ -38,6 +38,7 @@ public class UserPreferences implements private static final String TAG = "UserPreferences"; public static final String PREF_PAUSE_ON_HEADSET_DISCONNECT = "prefPauseOnHeadsetDisconnect"; + public static final String PREF_UNPAUSE_ON_HEADSET_RECONNECT = "prefUnpauseOnHeadsetReconnect"; public static final String PREF_FOLLOW_QUEUE = "prefFollowQueue"; public static final String PREF_DOWNLOAD_MEDIA_ON_WIFI_ONLY = "prefDownloadMediaOnWifiOnly"; public static final String PREF_UPDATE_INTERVAL = "prefAutoUpdateIntervall"; @@ -50,6 +51,7 @@ public class UserPreferences implements public static final String PREF_DATA_FOLDER = "prefDataFolder"; public static final String PREF_ENABLE_AUTODL = "prefEnableAutoDl"; public static final String PREF_ENABLE_AUTODL_WIFI_FILTER = "prefEnableAutoDownloadWifiFilter"; + public static final String PREF_ENABLE_AUTODL_ON_BATTERY = "prefEnableAutoDownloadOnBattery"; private static final String PREF_AUTODL_SELECTED_NETWORKS = "prefAutodownloadSelectedNetworks"; public static final String PREF_EPISODE_CACHE_SIZE = "prefEpisodeCacheSize"; private static final String PREF_PLAYBACK_SPEED = "prefPlaybackSpeed"; @@ -69,6 +71,7 @@ public class UserPreferences implements // Preferences private boolean pauseOnHeadsetDisconnect; + private boolean unpauseOnHeadsetReconnect; private boolean followQueue; private boolean downloadMediaOnWifiOnly; private long updateInterval; @@ -80,6 +83,7 @@ public class UserPreferences implements private int theme; private boolean enableAutodownload; private boolean enableAutodownloadWifiFilter; + private boolean enableAutodownloadOnBattery; private String[] autodownloadSelectedNetworks; private int episodeCacheSize; private String playbackSpeed; @@ -121,6 +125,8 @@ public class UserPreferences implements R.integer.episode_cache_size_unlimited); pauseOnHeadsetDisconnect = sp.getBoolean( PREF_PAUSE_ON_HEADSET_DISCONNECT, true); + unpauseOnHeadsetReconnect = sp.getBoolean( + PREF_UNPAUSE_ON_HEADSET_RECONNECT, true); followQueue = sp.getBoolean(PREF_FOLLOW_QUEUE, false); downloadMediaOnWifiOnly = sp.getBoolean( PREF_DOWNLOAD_MEDIA_ON_WIFI_ONLY, true); @@ -140,6 +146,7 @@ public class UserPreferences implements episodeCacheSize = readEpisodeCacheSizeInternal(sp.getString( PREF_EPISODE_CACHE_SIZE, "20")); enableAutodownload = sp.getBoolean(PREF_ENABLE_AUTODL, false); + enableAutodownloadOnBattery = sp.getBoolean(PREF_ENABLE_AUTODL_ON_BATTERY, true); playbackSpeed = sp.getString(PREF_PLAYBACK_SPEED, "1.0"); playbackSpeedArray = readPlaybackSpeedArray(sp.getString( PREF_PLAYBACK_SPEED_ARRAY, null)); @@ -221,6 +228,11 @@ public class UserPreferences implements return instance.pauseOnHeadsetDisconnect; } + public static boolean isUnpauseOnHeadsetReconnect() { + instanceAvailable(); + return instance.unpauseOnHeadsetReconnect; + } + public static boolean isFollowQueue() { instanceAvailable(); return instance.followQueue; @@ -282,6 +294,15 @@ public class UserPreferences implements return instance.theme; } + public static int getNoTitleTheme() { + int theme = getTheme(); + if (theme == R.style.Theme_AntennaPod_Dark) { + return R.style.Theme_AntennaPod_Dark_NoTitle; + } else { + return R.style.Theme_AntennaPod_Light_NoTitle; + } + } + public static boolean isEnableAutodownloadWifiFilter() { instanceAvailable(); return instance.enableAutodownloadWifiFilter; @@ -326,6 +347,11 @@ public class UserPreferences implements return instance.enableAutodownload; } + public static boolean isEnableAutodownloadOnBattery() { + instanceAvailable(); + return instance.enableAutodownloadOnBattery; + } + public static boolean shouldPauseForFocusLoss() { instanceAvailable(); return instance.pauseForFocusLoss; @@ -377,6 +403,8 @@ public class UserPreferences implements PREF_EPISODE_CACHE_SIZE, "20")); } else if (key.equals(PREF_ENABLE_AUTODL)) { enableAutodownload = sp.getBoolean(PREF_ENABLE_AUTODL, false); + } else if (key.equals(PREF_ENABLE_AUTODL_ON_BATTERY)) { + enableAutodownloadOnBattery = sp.getBoolean(PREF_ENABLE_AUTODL_ON_BATTERY, true); } else if (key.equals(PREF_PLAYBACK_SPEED)) { playbackSpeed = sp.getString(PREF_PLAYBACK_SPEED, "1.0"); } else if (key.equals(PREF_PLAYBACK_SPEED_ARRAY)) { @@ -388,6 +416,8 @@ public class UserPreferences implements seekDeltaSecs = Integer.valueOf(sp.getString(PREF_SEEK_DELTA_SECS, "30")); } else if (key.equals(PREF_PAUSE_ON_HEADSET_DISCONNECT)) { pauseOnHeadsetDisconnect = sp.getBoolean(PREF_PAUSE_ON_HEADSET_DISCONNECT, true); + } else if (key.equals(PREF_UNPAUSE_ON_HEADSET_RECONNECT)) { + unpauseOnHeadsetReconnect = sp.getBoolean(PREF_UNPAUSE_ON_HEADSET_RECONNECT, true); } else if (key.equals(PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD)) { autoFlattrPlayedDurationThreshold = sp.getFloat(PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD, PREF_AUTO_FLATTR_PLAYED_DURATION_THRESHOLD_DEFAULT); diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java index a5560e3fb..866f1cba3 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java @@ -144,6 +144,10 @@ public class PlaybackService extends Service { * Is true if service has received a valid start command. */ public static boolean started = false; + /** + * Is true if the service was running, but paused due to headphone disconnect + */ + public static boolean transientPause = false; private static final int NOTIFICATION_ID = 1; @@ -206,6 +210,8 @@ public class PlaybackService extends Service { Intent.ACTION_HEADSET_PLUG)); registerReceiver(shutdownReceiver, new IntentFilter( ACTION_SHUTDOWN_PLAYBACK_SERVICE)); + registerReceiver(bluetoothStateUpdated, new IntentFilter( + AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)); registerReceiver(audioBecomingNoisy, new IntentFilter( AudioManager.ACTION_AUDIO_BECOMING_NOISY)); registerReceiver(skipCurrentEpisodeReceiver, new IntentFilter( @@ -228,6 +234,7 @@ public class PlaybackService extends Service { unregisterReceiver(headsetDisconnected); unregisterReceiver(shutdownReceiver); + unregisterReceiver(bluetoothStateUpdated); unregisterReceiver(audioBecomingNoisy); unregisterReceiver(skipCurrentEpisodeReceiver); mediaPlayer.shutdown(); @@ -284,7 +291,6 @@ public class PlaybackService extends Service { private void handleKeycode(int keycode) { if (BuildConfig.DEBUG) Log.d(TAG, "Handling keycode: " + keycode); - final PlaybackServiceMediaPlayer.PSMPInfo info = mediaPlayer.getPSMPInfo(); final PlayerStatus status = info.playerStatus; switch (keycode) { @@ -315,12 +321,14 @@ public class PlaybackService extends Service { break; case KeyEvent.KEYCODE_MEDIA_PAUSE: if (status == PlayerStatus.PLAYING) { - if (UserPreferences.isPersistNotify()) { - mediaPlayer.pause(false, true); - } else { - mediaPlayer.pause(true, true); - } + mediaPlayer.pause(false, true); + } + if (UserPreferences.isPersistNotify()) { + mediaPlayer.pause(false, true); + } else { + mediaPlayer.pause(true, true); } + break; case KeyEvent.KEYCODE_MEDIA_NEXT: case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD: @@ -333,7 +341,9 @@ public class PlaybackService extends Service { case KeyEvent.KEYCODE_MEDIA_STOP: if (status == PlayerStatus.PLAYING) { mediaPlayer.pause(true, true); + started = false; } + stopForeground(true); // gets rid of persistent notification break; default: @@ -411,10 +421,13 @@ public class PlaybackService extends Service { taskManager.cancelWidgetUpdater(); if (UserPreferences.isPersistNotify() && android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { // do not remove notification on pause based on user pref and whether android version supports expanded notifications - } else { + // Change [Play] button to [Pause] + setupNotification(newInfo); + } else if (!UserPreferences.isPersistNotify()) { // remove notifcation on pause stopForeground(true); } + break; case STOPPED: @@ -431,6 +444,7 @@ public class PlaybackService extends Service { taskManager.startPositionSaver(); taskManager.startWidgetUpdater(); setupNotification(newInfo); + started = true; break; case ERROR: writePlaybackPreferencesNoMediaPlaying(); @@ -734,8 +748,9 @@ public class PlaybackService extends Service { PlaybackServiceMediaPlayer.PSMPInfo newInfo = mediaPlayer.getPSMPInfo(); final int smallIcon = ClientConfig.playbackServiceCallbacks.getNotificationIconResource(getApplicationContext()); - if (!isCancelled() && info.playerStatus == PlayerStatus.PLAYING - && info.playable != null) { + if (!isCancelled() && + started && + info.playable != null) { String contentText = info.playable.getFeedTitle(); String contentTitle = info.playable.getEpisodeTitle(); Notification notification = null; @@ -775,16 +790,30 @@ public class PlaybackService extends Service { .setContentIntent(pIntent) .setLargeIcon(icon) .setSmallIcon(smallIcon) - .setPriority(UserPreferences.getNotifyPriority()) // set notification priority - .addAction(android.R.drawable.ic_media_play, //play action - getString(R.string.play_label), - playButtonPendingIntent) - .addAction(android.R.drawable.ic_media_pause, //pause action - getString(R.string.pause_label), - pauseButtonPendingIntent) - .addAction(android.R.drawable.ic_menu_close_clear_cancel, // stop action - getString(R.string.stop_label), - stopButtonPendingIntent); + .setPriority(UserPreferences.getNotifyPriority()); // set notification priority + if (newInfo.playerStatus == PlayerStatus.PLAYING) { + notificationBuilder.addAction(android.R.drawable.ic_media_pause, //pause action + getString(R.string.pause_label), + pauseButtonPendingIntent); + } else { + notificationBuilder.addAction(android.R.drawable.ic_media_play, //play action + getString(R.string.play_label), + playButtonPendingIntent); + } + if (UserPreferences.isPersistNotify()) { + notificationBuilder.addAction(android.R.drawable.ic_menu_close_clear_cancel, // stop action + getString(R.string.stop_label), + stopButtonPendingIntent); + } + + if (Build.VERSION.SDK_INT >= 21) { + notificationBuilder.setStyle(new Notification.MediaStyle() + .setMediaSession((android.media.session.MediaSession.Token) mediaPlayer.getSessionToken().getToken()) + .setShowActionsInCompactView(0)) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setColor(Notification.COLOR_DEFAULT); + } + notification = notificationBuilder.build(); } else { NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder( @@ -793,11 +822,9 @@ public class PlaybackService extends Service { .setContentText(contentText).setOngoing(true) .setContentIntent(pIntent).setLargeIcon(icon) .setSmallIcon(smallIcon); - notification = notificationBuilder.getNotification(); - } - if (newInfo.playerStatus == PlayerStatus.PLAYING) { - startForeground(NOTIFICATION_ID, notification); + notification = notificationBuilder.build(); } + startForeground(NOTIFICATION_ID, notification); if (BuildConfig.DEBUG) Log.d(TAG, "Notification set up"); } @@ -966,6 +993,7 @@ public class PlaybackService extends Service { private BroadcastReceiver headsetDisconnected = new BroadcastReceiver() { private static final String TAG = "headsetDisconnected"; private static final int UNPLUGGED = 0; + private static final int PLUGGED = 1; @Override public void onReceive(Context context, Intent intent) { @@ -978,6 +1006,10 @@ public class PlaybackService extends Service { if (BuildConfig.DEBUG) Log.d(TAG, "Headset was unplugged during playback."); pauseIfPauseOnDisconnect(); + } else if (state == PLUGGED) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Headset was plugged in during playback."); + unpauseIfPauseOnDisconnect(); } } else { Log.e(TAG, "Received invalid ACTION_HEADSET_PLUG intent"); @@ -986,6 +1018,21 @@ public class PlaybackService extends Service { } }; + private BroadcastReceiver bluetoothStateUpdated = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (StringUtils.equals(intent.getAction(), AudioManager.ACTION_SCO_AUDIO_STATE_UPDATED)) { + int state = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_STATE, -1); + int prevState = intent.getIntExtra(AudioManager.EXTRA_SCO_AUDIO_PREVIOUS_STATE, -1); + if (state == AudioManager.SCO_AUDIO_STATE_CONNECTED) { + if (BuildConfig.DEBUG) + Log.d(TAG, "Received bluetooth connection intent"); + unpauseIfPauseOnDisconnect(); + } + } + } + }; + private BroadcastReceiver audioBecomingNoisy = new BroadcastReceiver() { @Override @@ -1003,6 +1050,9 @@ public class PlaybackService extends Service { */ private void pauseIfPauseOnDisconnect() { if (UserPreferences.isPauseOnHeadsetDisconnect()) { + if (mediaPlayer.getPlayerStatus() == PlayerStatus.PLAYING) { + transientPause = true; + } if (UserPreferences.isPersistNotify()) { mediaPlayer.pause(false, true); } else { @@ -1011,6 +1061,15 @@ public class PlaybackService extends Service { } } + private void unpauseIfPauseOnDisconnect() { + if (transientPause) { + transientPause = false; + if (UserPreferences.isPauseOnHeadsetDisconnect() && UserPreferences.isUnpauseOnHeadsetReconnect()) { + mediaPlayer.resume(); + } + } + } + private BroadcastReceiver shutdownReceiver = new BroadcastReceiver() { @Override diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceMediaPlayer.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceMediaPlayer.java index dbf870eac..c143d7f2c 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceMediaPlayer.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceMediaPlayer.java @@ -6,6 +6,9 @@ import android.media.AudioManager; import android.media.RemoteControlClient; import android.net.wifi.WifiManager; import android.os.PowerManager; +import android.support.v4.media.MediaMetadataCompat; +import android.support.v4.media.session.MediaSessionCompat; +import android.support.v4.media.session.PlaybackStateCompat; import android.telephony.TelephonyManager; import android.util.Log; import android.util.Pair; @@ -48,6 +51,10 @@ public class PlaybackServiceMediaPlayer { private volatile PlayerStatus statusBeforeSeeking; private volatile IPlayer mediaPlayer; private volatile Playable media; + /** + * Only used for Lollipop notifications. + */ + private final MediaSessionCompat mediaSession; private volatile boolean stream; private volatile MediaType mediaType; @@ -89,6 +96,10 @@ public class PlaybackServiceMediaPlayer { } ); + mediaSession = new MediaSessionCompat(context, TAG); + mediaSession.setCallback(sessionCallback); + mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS); + mediaPlayer = null; statusBeforeSeeking = null; pausedBecauseOfTransientAudiofocusLoss = false; @@ -181,6 +192,7 @@ public class PlaybackServiceMediaPlayer { setPlayerStatus(PlayerStatus.INITIALIZING, media); try { media.loadMetadata(); + mediaSession.setMetadata(getMediaSessionMetadata(media)); if (stream) { mediaPlayer.setDataSource(media.getStreamUrl()); } else { @@ -211,6 +223,13 @@ public class PlaybackServiceMediaPlayer { } } + private MediaMetadataCompat getMediaSessionMetadata(Playable p) { + MediaMetadataCompat.Builder builder = new MediaMetadataCompat.Builder(); + builder.putString(MediaMetadataCompat.METADATA_KEY_TITLE, p.getEpisodeTitle()); + builder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, p.getFeedTitle()); + return builder.build(); + } + /** * Resumes playback if the PSMP object is in PREPARED or PAUSED state. If the PSMP object is in an invalid state. @@ -586,6 +605,10 @@ public class PlaybackServiceMediaPlayer { return mediaType; } + public PlayerStatus getPlayerStatus() { + return playerStatus; + } + public boolean isStreaming() { return stream; } @@ -599,6 +622,9 @@ public class PlaybackServiceMediaPlayer { if (mediaPlayer != null) { mediaPlayer.release(); } + if (mediaSession != null) { + mediaSession.release(); + } releaseWifiLockIfNecessary(); } @@ -663,6 +689,16 @@ public class PlaybackServiceMediaPlayer { } /** + * Returns a token to this object's MediaSession. The MediaSession should only be used for notifications + * at the moment. + * + * @return The MediaSessionCompat.Token object. + */ + public MediaSessionCompat.Token getSessionToken() { + return mediaSession.getSessionToken(); + } + + /** * Sets the player status of the PSMP object. PlayerStatus and media attributes have to be set at the same time * so that getPSMPInfo can't return an invalid state (e.g. status is PLAYING, but media is null). * <p/> @@ -679,6 +715,45 @@ public class PlaybackServiceMediaPlayer { this.playerStatus = newStatus; this.media = newMedia; + + PlaybackStateCompat.Builder sessionState = new PlaybackStateCompat.Builder(); + + int state; + if (playerStatus != null) { + switch (playerStatus) { + case PLAYING: + state = PlaybackStateCompat.STATE_PLAYING; + break; + case PREPARED: + case PAUSED: + state = PlaybackStateCompat.STATE_PAUSED; + break; + case STOPPED: + state = PlaybackStateCompat.STATE_STOPPED; + break; + case SEEKING: + state = PlaybackStateCompat.STATE_FAST_FORWARDING; + break; + case PREPARING: + case INITIALIZING: + state = PlaybackStateCompat.STATE_CONNECTING; + break; + case INITIALIZED: + case INDETERMINATE: + state = PlaybackStateCompat.STATE_NONE; + break; + case ERROR: + state = PlaybackStateCompat.STATE_ERROR; + break; + default: + state = PlaybackStateCompat.STATE_NONE; + break; + } + } else { + state = PlaybackStateCompat.STATE_NONE; + } + sessionState.setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, getPlaybackSpeed()); + callback.statusChanged(new PSMPInfo(playerStatus, media)); } @@ -976,4 +1051,54 @@ public class PlaybackServiceMediaPlayer { } }); } + + private final MediaSessionCompat.Callback sessionCallback = new MediaSessionCompat.Callback() { + + @Override + public void onPlay() { + if (playerStatus == PlayerStatus.PAUSED || playerStatus == PlayerStatus.PREPARED) { + resume(); + } else if (playerStatus == PlayerStatus.INITIALIZED) { + setStartWhenPrepared(true); + prepare(); + } + } + + @Override + public void onPause() { + super.onPause(); + if (playerStatus == PlayerStatus.PLAYING) { + pause(false, true); + } + if (UserPreferences.isPersistNotify()) { + pause(false, true); + } else { + pause(true, true); + } + } + + @Override + public void onSkipToNext() { + super.onSkipToNext(); + endPlayback(); + } + + @Override + public void onFastForward() { + super.onFastForward(); + seekDelta(UserPreferences.getSeekDeltaMs()); + } + + @Override + public void onRewind() { + super.onRewind(); + seekDelta(-UserPreferences.getSeekDeltaMs()); + } + + @Override + public void onSeekTo(long pos) { + super.onSeekTo(pos); + seekTo((int) pos); + } + }; } diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/APCleanupAlgorithm.java b/core/src/main/java/de/danoeh/antennapod/core/storage/APCleanupAlgorithm.java new file mode 100644 index 000000000..499fddf74 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/APCleanupAlgorithm.java @@ -0,0 +1,103 @@ +package de.danoeh.antennapod.core.storage; + +import android.content.Context; +import android.util.Log; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.concurrent.ExecutionException; + +import de.danoeh.antennapod.core.feed.FeedItem; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.util.QueueAccess; + +/** + * Implementation of the EpisodeCleanupAlgorithm interface used by AntennaPod. + */ +public class APCleanupAlgorithm implements EpisodeCleanupAlgorithm<Integer> { + private static final String TAG = "APCleanupAlgorithm"; + + @Override + public int performCleanup(Context context, Integer episodeNumber) { + List<FeedItem> candidates = new ArrayList<FeedItem>(); + List<FeedItem> downloadedItems = DBReader.getDownloadedItems(context); + QueueAccess queue = QueueAccess.IDListAccess(DBReader.getQueueIDList(context)); + List<FeedItem> delete; + for (FeedItem item : downloadedItems) { + if (item.hasMedia() && item.getMedia().isDownloaded() + && !queue.contains(item.getId()) && item.isRead()) { + candidates.add(item); + } + + } + + Collections.sort(candidates, new Comparator<FeedItem>() { + @Override + public int compare(FeedItem lhs, FeedItem rhs) { + Date l = lhs.getMedia().getPlaybackCompletionDate(); + Date r = rhs.getMedia().getPlaybackCompletionDate(); + + if (l == null) { + l = new Date(0); + } + if (r == null) { + r = new Date(0); + } + return l.compareTo(r); + } + }); + + if (candidates.size() > episodeNumber) { + delete = candidates.subList(0, episodeNumber); + } else { + delete = candidates; + } + + for (FeedItem item : delete) { + try { + DBWriter.deleteFeedMediaOfItem(context, item.getMedia().getId()).get(); + } catch (InterruptedException | ExecutionException e) { + e.printStackTrace(); + } + } + + int counter = delete.size(); + + + Log.i(TAG, String.format( + "Auto-delete deleted %d episodes (%d requested)", counter, + episodeNumber)); + + return counter; + } + + @Override + public Integer getDefaultCleanupParameter(Context context) { + return 0; + } + + @Override + public Integer getPerformCleanupParameter(Context context, List<FeedItem> items) { + return getPerformAutoCleanupArgs(context, items.size()); + } + + static int getPerformAutoCleanupArgs(Context context, + final int episodeNumber) { + if (episodeNumber >= 0 + && UserPreferences.getEpisodeCacheSize() != UserPreferences + .getEpisodeCacheSizeUnlimited()) { + int downloadedEpisodes = DBReader + .getNumberOfDownloadedEpisodes(context); + if (downloadedEpisodes + episodeNumber >= UserPreferences + .getEpisodeCacheSize()) { + + return downloadedEpisodes + episodeNumber + - UserPreferences.getEpisodeCacheSize(); + } + } + return 0; + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/APDownloadAlgorithm.java b/core/src/main/java/de/danoeh/antennapod/core/storage/APDownloadAlgorithm.java new file mode 100644 index 000000000..c5f871f48 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/APDownloadAlgorithm.java @@ -0,0 +1,133 @@ +package de.danoeh.antennapod.core.storage; + +import android.content.Context; +import android.util.Log; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.feed.FeedItem; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.util.NetworkUtils; +import de.danoeh.antennapod.core.util.PowerUtils; + +/** + * Implements the automatic download algorithm used by AntennaPod. This class assumes that + * the client uses the APEpisodeCleanupAlgorithm. + */ +public class APDownloadAlgorithm implements AutomaticDownloadAlgorithm { + private static final String TAG = "APDownloadAlgorithm"; + + private final APCleanupAlgorithm cleanupAlgorithm = new APCleanupAlgorithm(); + + /** + * Looks for undownloaded episodes in the queue or list of unread items and request a download if + * 1. Network is available + * 2. The device is charging or the user allows auto download on battery + * 3. There is free space in the episode cache + * This method is executed on an internal single thread executor. + * + * @param context Used for accessing the DB. + * @param mediaIds If this list is not empty, the method will only download a candidate for automatic downloading if + * its media ID is in the mediaIds list. + * @return A Runnable that will be submitted to an ExecutorService. + */ + @Override + public Runnable autoDownloadUndownloadedItems(final Context context, final long... mediaIds) { + return new Runnable() { + @Override + public void run() { + + // true if we should auto download based on network status + boolean networkShouldAutoDl = NetworkUtils.autodownloadNetworkAvailable(context) + && UserPreferences.isEnableAutodownload(); + + // true if we should auto download based on power status + boolean powerShouldAutoDl = PowerUtils.deviceCharging(context) + || UserPreferences.isEnableAutodownloadOnBattery(); + + // we should only auto download if both network AND power are happy + if (networkShouldAutoDl && powerShouldAutoDl) { + + Log.d(TAG, "Performing auto-dl of undownloaded episodes"); + + final List<FeedItem> queue = DBReader.getQueue(context); + final List<FeedItem> unreadItems = DBReader + .getUnreadItemsList(context); + + int undownloadedEpisodes = DBTasks.getNumberOfUndownloadedEpisodes(queue, + unreadItems); + int downloadedEpisodes = DBReader + .getNumberOfDownloadedEpisodes(context); + int deletedEpisodes = cleanupAlgorithm.performCleanup(context, + APCleanupAlgorithm.getPerformAutoCleanupArgs(context, undownloadedEpisodes)); + int episodeSpaceLeft = undownloadedEpisodes; + boolean cacheIsUnlimited = UserPreferences.getEpisodeCacheSize() == UserPreferences + .getEpisodeCacheSizeUnlimited(); + + if (!cacheIsUnlimited + && UserPreferences.getEpisodeCacheSize() < downloadedEpisodes + + undownloadedEpisodes) { + episodeSpaceLeft = UserPreferences.getEpisodeCacheSize() + - (downloadedEpisodes - deletedEpisodes); + } + + Arrays.sort(mediaIds); // sort for binary search + final boolean ignoreMediaIds = mediaIds.length == 0; + List<FeedItem> itemsToDownload = new ArrayList<FeedItem>(); + + if (episodeSpaceLeft > 0 && undownloadedEpisodes > 0) { + for (int i = 0; i < queue.size(); i++) { // ignore playing item + FeedItem item = queue.get(i); + long mediaId = (item.hasMedia()) ? item.getMedia().getId() : -1; + if ((ignoreMediaIds || Arrays.binarySearch(mediaIds, mediaId) >= 0) + && item.hasMedia() + && !item.getMedia().isDownloaded() + && !item.getMedia().isPlaying() + && item.getFeed().getPreferences().getAutoDownload()) { + itemsToDownload.add(item); + episodeSpaceLeft--; + undownloadedEpisodes--; + if (episodeSpaceLeft == 0 || undownloadedEpisodes == 0) { + break; + } + } + } + } + + if (episodeSpaceLeft > 0 && undownloadedEpisodes > 0) { + for (FeedItem item : unreadItems) { + long mediaId = (item.hasMedia()) ? item.getMedia().getId() : -1; + if ((ignoreMediaIds || Arrays.binarySearch(mediaIds, mediaId) >= 0) + && item.hasMedia() + && !item.getMedia().isDownloaded() + && item.getFeed().getPreferences().getAutoDownload()) { + itemsToDownload.add(item); + episodeSpaceLeft--; + undownloadedEpisodes--; + if (episodeSpaceLeft == 0 || undownloadedEpisodes == 0) { + break; + } + } + } + } + if (BuildConfig.DEBUG) + Log.d(TAG, "Enqueueing " + itemsToDownload.size() + + " items for download"); + + try { + DBTasks.downloadFeedItems(false, context, + itemsToDownload.toArray(new FeedItem[itemsToDownload + .size()]) + ); + } catch (DownloadRequestException e) { + e.printStackTrace(); + } + + } + } + }; + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/APSPCleanupAlgorithm.java b/core/src/main/java/de/danoeh/antennapod/core/storage/APSPCleanupAlgorithm.java new file mode 100644 index 000000000..420bbc09d --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/APSPCleanupAlgorithm.java @@ -0,0 +1,139 @@ +package de.danoeh.antennapod.core.storage; + +import android.content.Context; +import android.util.Log; + +import org.apache.commons.io.FileUtils; + +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.ExecutionException; + +import de.danoeh.antennapod.core.feed.FeedItem; + +/** + * Implementation of the EpisodeCleanupAlgorithm interface used by AntennaPodSP apps. + */ +public class APSPCleanupAlgorithm implements EpisodeCleanupAlgorithm<Integer> { + private static final String TAG = "APSPCleanupAlgorithm"; + + final int numberOfNewAutomaticallyDownloadedEpisodes; + + public APSPCleanupAlgorithm(int numberOfNewAutomaticallyDownloadedEpisodes) { + this.numberOfNewAutomaticallyDownloadedEpisodes = numberOfNewAutomaticallyDownloadedEpisodes; + } + + /** + * Performs an automatic cleanup. Episodes that have been downloaded first will also be deleted first. + * The episode that is currently playing as well as the n most recent episodes (the exact value is determined + * by AppPreferences.numberOfNewAutomaticallyDownloadedEpisodes) will never be deleted. + * + * @param context + * @param episodeSize The maximum amount of space that should be freed by this method + * @return The number of episodes that have been deleted + */ + @Override + public int performCleanup(Context context, Integer episodeSize) { + Log.i(TAG, String.format("performAutoCleanup(%d)", episodeSize)); + if (episodeSize <= 0) { + return 0; + } + + List<FeedItem> candidates = getAutoCleanupCandidates(context); + List<FeedItem> deleteList = new ArrayList<FeedItem>(); + long deletedEpisodesSize = 0; + Collections.sort(candidates, new Comparator<FeedItem>() { + @Override + public int compare(FeedItem lhs, FeedItem rhs) { + File lFile = new File(lhs.getMedia().getFile_url()); + File rFile = new File(rhs.getMedia().getFile_url()); + if (!lFile.exists() || !rFile.exists()) { + return 0; + } + if (FileUtils.isFileOlder(lFile, rFile)) { + return -1; + } else { + return 1; + } + } + }); + // listened episodes will be deleted first + Iterator<FeedItem> it = candidates.iterator(); + if (it.hasNext()) { + for (FeedItem i = it.next(); it.hasNext() && deletedEpisodesSize <= episodeSize; i = it.next()) { + if (!i.getMedia().isPlaying() && i.getMedia().getPlaybackCompletionDate() != null) { + it.remove(); + deleteList.add(i); + deletedEpisodesSize += i.getMedia().getSize(); + } + } + } + + // delete unlistened old episodes if necessary + it = candidates.iterator(); + if (it.hasNext()) { + for (FeedItem i = it.next(); it.hasNext() && deletedEpisodesSize <= episodeSize; i = it.next()) { + if (!i.getMedia().isPlaying()) { + it.remove(); + deleteList.add(i); + deletedEpisodesSize += i.getMedia().getSize(); + } + } + } + for (FeedItem item : deleteList) { + try { + DBWriter.deleteFeedMediaOfItem(context, item.getMedia().getId()).get(); + } catch (InterruptedException e) { + e.printStackTrace(); + } catch (ExecutionException e) { + e.printStackTrace(); + } + } + Log.i(TAG, String.format("performAutoCleanup(%d) deleted %d episodes and freed %d bytes of memory", + episodeSize, deleteList.size(), deletedEpisodesSize)); + return deleteList.size(); + } + + @Override + public Integer getDefaultCleanupParameter(Context context) { + return 0; + } + + @Override + public Integer getPerformCleanupParameter(Context context, List<FeedItem> items) { + int episodeSize = 0; + for (FeedItem item : items) { + if (item.hasMedia() && !item.getMedia().isDownloaded()) { + episodeSize += item.getMedia().getSize(); + } + } + return episodeSize; + } + + /** + * Returns list of FeedItems that have been downloaded, but are not one of the + * [numberOfNewAutomaticallyDownloadedEpisodes] most recent items. + */ + private List<FeedItem> getAutoCleanupCandidates(Context context) { + List<FeedItem> downloaded = new ArrayList<FeedItem>(DBReader.getDownloadedItems(context)); + List<FeedItem> recent = new ArrayList<FeedItem>(DBReader.getRecentlyPublishedEpisodes(context, + numberOfNewAutomaticallyDownloadedEpisodes)); + for (FeedItem r : recent) { + if (r.hasMedia() && r.getMedia().isDownloaded()) { + for (int i = 0; i < downloaded.size(); i++) { + if (downloaded.get(i).getId() == r.getId()) { + downloaded.remove(i); + break; + } + } + } + } + + return downloaded; + + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/APSPDownloadAlgorithm.java b/core/src/main/java/de/danoeh/antennapod/core/storage/APSPDownloadAlgorithm.java new file mode 100644 index 000000000..f760ec0ce --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/APSPDownloadAlgorithm.java @@ -0,0 +1,72 @@ +package de.danoeh.antennapod.core.storage; + +import android.content.Context; +import android.util.Log; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.List; + +import de.danoeh.antennapod.core.BuildConfig; +import de.danoeh.antennapod.core.feed.FeedItem; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.util.NetworkUtils; + +/** + * Implements the automatic download algorithm used by AntennaPodSP apps. + */ +public class APSPDownloadAlgorithm implements AutomaticDownloadAlgorithm { + private static final String TAG = "APSPDownloadAlgorithm"; + + private final int numberOfNewAutomaticallyDownloadedEpisodes; + + public APSPDownloadAlgorithm(int numberOfNewAutomaticallyDownloadedEpisodes) { + this.numberOfNewAutomaticallyDownloadedEpisodes = numberOfNewAutomaticallyDownloadedEpisodes; + } + + /** + * Downloads the most recent episodes automatically. The exact number of + * episodes that will be downloaded can be set in the AppPreferences. + * + * @param context Used for accessing the DB. + * @return A Runnable that will be submitted to an ExecutorService. + */ + @Override + public Runnable autoDownloadUndownloadedItems(final Context context, final long... mediaIds) { + return new Runnable() { + @Override + public void run() { + if (BuildConfig.DEBUG) + Log.d(TAG, "Performing auto-dl of undownloaded episodes"); + if (NetworkUtils.autodownloadNetworkAvailable(context) + && UserPreferences.isEnableAutodownload()) { + + Arrays.sort(mediaIds); + List<FeedItem> itemsToDownload = DBReader.getRecentlyPublishedEpisodes(context, + numberOfNewAutomaticallyDownloadedEpisodes); + Iterator<FeedItem> it = itemsToDownload.iterator(); + + for (FeedItem item = it.next(); it.hasNext(); item = it.next()) { + if (!item.hasMedia() + || item.getMedia().isDownloaded() + || Arrays.binarySearch(mediaIds, item.getMedia().getId()) < 0) { + it.remove(); + } + } + if (BuildConfig.DEBUG) + Log.d(TAG, "Enqueueing " + itemsToDownload.size() + + " items for automatic download"); + if (!itemsToDownload.isEmpty()) { + try { + DBTasks.downloadFeedItems(false, context, + itemsToDownload.toArray(new FeedItem[itemsToDownload + .size()])); + } catch (DownloadRequestException e) { + e.printStackTrace(); + } + } + } + } + }; + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/AutomaticDownloadAlgorithm.java b/core/src/main/java/de/danoeh/antennapod/core/storage/AutomaticDownloadAlgorithm.java new file mode 100644 index 000000000..9ca9620a7 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/AutomaticDownloadAlgorithm.java @@ -0,0 +1,20 @@ +package de.danoeh.antennapod.core.storage; + +import android.content.Context; + +public interface AutomaticDownloadAlgorithm { + + /** + * Looks for undownloaded episodes and request a download if + * 1. Network is available + * 2. The device is charging or the user allows auto download on battery + * 3. There is free space in the episode cache + * This method is executed on an internal single thread executor. + * + * @param context Used for accessing the DB. + * @param mediaIds If this list is not empty, the method will only download a candidate for automatic downloading if + * its media ID is in the mediaIds list. + * @return A Runnable that will be submitted to an ExecutorService. + */ + public Runnable autoDownloadUndownloadedItems(Context context, long... mediaIds); +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java index b1aff5594..e73f9599d 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java @@ -8,7 +8,6 @@ import android.util.Log; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.Comparator; import java.util.Date; import java.util.Iterator; import java.util.List; @@ -35,7 +34,6 @@ import de.danoeh.antennapod.core.service.GpodnetSyncService; import de.danoeh.antennapod.core.service.download.DownloadStatus; import de.danoeh.antennapod.core.service.playback.PlaybackService; import de.danoeh.antennapod.core.util.DownloadError; -import de.danoeh.antennapod.core.util.NetworkUtils; import de.danoeh.antennapod.core.util.QueueAccess; import de.danoeh.antennapod.core.util.comparator.FeedItemPubdateComparator; import de.danoeh.antennapod.core.util.exception.MediaFileNotFoundException; @@ -386,8 +384,8 @@ public final class DBTasks { downloadFeedItems(true, context, items); } - private static void downloadFeedItems(boolean performAutoCleanup, - final Context context, final FeedItem... items) + static void downloadFeedItems(boolean performAutoCleanup, + final Context context, final FeedItem... items) throws DownloadRequestException { final DownloadRequester requester = DownloadRequester.getInstance(); @@ -396,8 +394,10 @@ public final class DBTasks { @Override public void run() { - performAutoCleanup(context, - getPerformAutoCleanupArgs(context, items.length)); + ClientConfig.dbTasksCallbacks.getEpisodeCacheCleanupAlgorithm() + .performCleanup(context, + ClientConfig.dbTasksCallbacks.getEpisodeCacheCleanupAlgorithm() + .getPerformCleanupParameter(context, Arrays.asList(items))); } }.start(); @@ -427,7 +427,7 @@ public final class DBTasks { } } - private static int getNumberOfUndownloadedEpisodes( + static int getNumberOfUndownloadedEpisodes( final List<FeedItem> queue, final List<FeedItem> unreadItems) { int counter = 0; for (FeedItem item : queue) { @@ -449,7 +449,8 @@ public final class DBTasks { /** * Looks for undownloaded episodes in the queue or list of unread items and request a download if * 1. Network is available - * 2. There is free space in the episode cache + * 2. The device is charging or the user allows auto download on battery + * 3. There is free space in the episode cache * This method is executed on an internal single thread executor. * * @param context Used for accessing the DB. @@ -458,107 +459,9 @@ public final class DBTasks { * @return A Future that can be used for waiting for the methods completion. */ public static Future<?> autodownloadUndownloadedItems(final Context context, final long... mediaIds) { - return autodownloadExec.submit(new Runnable() { - @Override - public void run() { - if (BuildConfig.DEBUG) - Log.d(TAG, "Performing auto-dl of undownloaded episodes"); - if (NetworkUtils.autodownloadNetworkAvailable(context) - && UserPreferences.isEnableAutodownload()) { - final List<FeedItem> queue = DBReader.getQueue(context); - final List<FeedItem> unreadItems = DBReader - .getUnreadItemsList(context); - - int undownloadedEpisodes = getNumberOfUndownloadedEpisodes(queue, - unreadItems); - int downloadedEpisodes = DBReader - .getNumberOfDownloadedEpisodes(context); - int deletedEpisodes = performAutoCleanup(context, - getPerformAutoCleanupArgs(context, undownloadedEpisodes)); - int episodeSpaceLeft = undownloadedEpisodes; - boolean cacheIsUnlimited = UserPreferences.getEpisodeCacheSize() == UserPreferences - .getEpisodeCacheSizeUnlimited(); - - if (!cacheIsUnlimited - && UserPreferences.getEpisodeCacheSize() < downloadedEpisodes - + undownloadedEpisodes) { - episodeSpaceLeft = UserPreferences.getEpisodeCacheSize() - - (downloadedEpisodes - deletedEpisodes); - } - - Arrays.sort(mediaIds); // sort for binary search - final boolean ignoreMediaIds = mediaIds.length == 0; - List<FeedItem> itemsToDownload = new ArrayList<FeedItem>(); - - if (episodeSpaceLeft > 0 && undownloadedEpisodes > 0) { - for (int i = 0; i < queue.size(); i++) { // ignore playing item - FeedItem item = queue.get(i); - long mediaId = (item.hasMedia()) ? item.getMedia().getId() : -1; - if ((ignoreMediaIds || Arrays.binarySearch(mediaIds, mediaId) >= 0) - && item.hasMedia() - && !item.getMedia().isDownloaded() - && !item.getMedia().isPlaying() - && item.getFeed().getPreferences().getAutoDownload()) { - itemsToDownload.add(item); - episodeSpaceLeft--; - undownloadedEpisodes--; - if (episodeSpaceLeft == 0 || undownloadedEpisodes == 0) { - break; - } - } - } - } - - if (episodeSpaceLeft > 0 && undownloadedEpisodes > 0) { - for (FeedItem item : unreadItems) { - long mediaId = (item.hasMedia()) ? item.getMedia().getId() : -1; - if ((ignoreMediaIds || Arrays.binarySearch(mediaIds, mediaId) >= 0) - && item.hasMedia() - && !item.getMedia().isDownloaded() - && item.getFeed().getPreferences().getAutoDownload()) { - itemsToDownload.add(item); - episodeSpaceLeft--; - undownloadedEpisodes--; - if (episodeSpaceLeft == 0 || undownloadedEpisodes == 0) { - break; - } - } - } - } - if (BuildConfig.DEBUG) - Log.d(TAG, "Enqueueing " + itemsToDownload.size() - + " items for download"); - - try { - downloadFeedItems(false, context, - itemsToDownload.toArray(new FeedItem[itemsToDownload - .size()]) - ); - } catch (DownloadRequestException e) { - e.printStackTrace(); - } + return autodownloadExec.submit(ClientConfig.dbTasksCallbacks.getAutomaticDownloadAlgorithm() + .autoDownloadUndownloadedItems(context, mediaIds)); - } - } - }); - - } - - private static int getPerformAutoCleanupArgs(Context context, - final int episodeNumber) { - if (episodeNumber >= 0 - && UserPreferences.getEpisodeCacheSize() != UserPreferences - .getEpisodeCacheSizeUnlimited()) { - int downloadedEpisodes = DBReader - .getNumberOfDownloadedEpisodes(context); - if (downloadedEpisodes + episodeNumber >= UserPreferences - .getEpisodeCacheSize()) { - - return downloadedEpisodes + episodeNumber - - UserPreferences.getEpisodeCacheSize(); - } - } - return 0; } /** @@ -570,63 +473,8 @@ public final class DBTasks { * @param context Used for accessing the DB. */ public static void performAutoCleanup(final Context context) { - performAutoCleanup(context, getPerformAutoCleanupArgs(context, 0)); - } - - private static int performAutoCleanup(final Context context, - final int episodeNumber) { - List<FeedItem> candidates = new ArrayList<FeedItem>(); - List<FeedItem> downloadedItems = DBReader.getDownloadedItems(context); - QueueAccess queue = QueueAccess.IDListAccess(DBReader.getQueueIDList(context)); - List<FeedItem> delete; - for (FeedItem item : downloadedItems) { - if (item.hasMedia() && item.getMedia().isDownloaded() - && !queue.contains(item.getId()) && item.isRead()) { - candidates.add(item); - } - - } - - Collections.sort(candidates, new Comparator<FeedItem>() { - @Override - public int compare(FeedItem lhs, FeedItem rhs) { - Date l = lhs.getMedia().getPlaybackCompletionDate(); - Date r = rhs.getMedia().getPlaybackCompletionDate(); - - if (l == null) { - l = new Date(0); - } - if (r == null) { - r = new Date(0); - } - return l.compareTo(r); - } - }); - - if (candidates.size() > episodeNumber) { - delete = candidates.subList(0, episodeNumber); - } else { - delete = candidates; - } - - for (FeedItem item : delete) { - try { - DBWriter.deleteFeedMediaOfItem(context, item.getMedia().getId()).get(); - } catch (InterruptedException e) { - e.printStackTrace(); - } catch (ExecutionException e) { - e.printStackTrace(); - } - } - - int counter = delete.size(); - - if (BuildConfig.DEBUG) - Log.d(TAG, String.format( - "Auto-delete deleted %d episodes (%d requested)", counter, - episodeNumber)); - - return counter; + ClientConfig.dbTasksCallbacks.getEpisodeCacheCleanupAlgorithm().performCleanup(context, + ClientConfig.dbTasksCallbacks.getEpisodeCacheCleanupAlgorithm().getDefaultCleanupParameter(context)); } /** diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java index f5ee9e28c..d0cdad649 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java @@ -92,8 +92,9 @@ public class DownloadRequester { private void download(Context context, FeedFile item, FeedFile container, File dest, boolean overwriteIfExists, String username, String password, boolean deleteOnFailure, Bundle arguments) { + final boolean partiallyDownloadedFileExists = item.getFile_url() != null; if (!isDownloadingFile(item)) { - if (!isFilenameAvailable(dest.toString()) || (deleteOnFailure && dest.exists())) { + if (!isFilenameAvailable(dest.toString()) || (!partiallyDownloadedFileExists && dest.exists())) { if (BuildConfig.DEBUG) Log.d(TAG, "Filename already used."); if (isFilenameAvailable(dest.toString()) && overwriteIfExists) { @@ -254,8 +255,7 @@ public class DownloadRequester { * Cancels all running downloads */ public synchronized void cancelAllDownloads(Context context) { - if (BuildConfig.DEBUG) - Log.d(TAG, "Cancelling all running downloads"); + Log.d(TAG, "Cancelling all running downloads"); context.sendBroadcast(new Intent( DownloadService.ACTION_CANCEL_ALL_DOWNLOADS)); } @@ -377,10 +377,13 @@ public class DownloadRequester { String URLBaseFilename = URLUtil.guessFileName(media.getDownload_url(), null, media.getMime_type()); - ; - if (titleBaseFilename != "") { + if (!titleBaseFilename.equals("")) { // Append extension + final int FILENAME_MAX_LENGTH = 220; + if (titleBaseFilename.length() > FILENAME_MAX_LENGTH) { + titleBaseFilename = titleBaseFilename.substring(0, FILENAME_MAX_LENGTH); + } filename = titleBaseFilename + FilenameUtils.EXTENSION_SEPARATOR + FilenameUtils.getExtension(URLBaseFilename); } else { diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/EpisodeCleanupAlgorithm.java b/core/src/main/java/de/danoeh/antennapod/core/storage/EpisodeCleanupAlgorithm.java new file mode 100644 index 000000000..6a8b4a441 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/EpisodeCleanupAlgorithm.java @@ -0,0 +1,36 @@ +package de.danoeh.antennapod.core.storage; + +import android.content.Context; + +import java.util.List; + +import de.danoeh.antennapod.core.feed.FeedItem; + +public interface EpisodeCleanupAlgorithm<T> { + + /** + * Deletes downloaded episodes that are no longer needed. What episodes are deleted and how many + * of them depends on the implementation. + * + * @param context Can be used for accessing the database + * @param parameter An additional parameter. This parameter is either returned by getDefaultCleanupParameter + * or getPerformCleanupParameter. + * @return The number of episodes that were deleted. + */ + public int performCleanup(Context context, T parameter); + + /** + * Returns a parameter for performCleanup. The implementation of this interface should decide how much + * space to free to satisfy the episode cache conditions. If the conditions are already satisfied, this + * method should not have any effects. + */ + public T getDefaultCleanupParameter(Context context); + + /** + * Returns a parameter for performCleanup. + * + * @param items A list of FeedItems that are about to be downloaded. The implementation of this interface + * should decide how much space to free to satisfy the episode cache conditions. + */ + public T getPerformCleanupParameter(Context context, List<FeedItem> items); +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/PowerUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/PowerUtils.java new file mode 100644 index 000000000..39deea36a --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/PowerUtils.java @@ -0,0 +1,32 @@ +package de.danoeh.antennapod.core.util; + +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.BatteryManager; + +/** + * Created by Tom on 1/5/15. + */ +public class PowerUtils { + + private static final String TAG = "PowerUtils"; + + private PowerUtils() { + + } + + /** + * @return true if the device is charging + */ + public static boolean deviceCharging(Context context) { + // from http://developer.android.com/training/monitoring-device-state/battery-monitoring.html + IntentFilter iFilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED); + Intent batteryStatus = context.registerReceiver(null, iFilter); + + int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1); + return (status == BatteryManager.BATTERY_STATUS_CHARGING || + status == BatteryManager.BATTERY_STATUS_FULL); + + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/Timeline.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/Timeline.java index 443ff0ad1..f31297b41 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/Timeline.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/Timeline.java @@ -28,7 +28,7 @@ import de.danoeh.antennapod.core.util.ShownotesProvider; public class Timeline { private static final String TAG = "Timeline"; - private static final String WEBVIEW_STYLE = "@font-face { font-family: 'Roboto-Light'; src: url('file:///android_asset/Roboto-Light.ttf'); } * { color: %s; font-family: roboto-Light; font-size: 11pt; } a { font-style: normal; text-decoration: none; font-weight: normal; color: #00A8DF; } a.timecode { color: #669900; } img { display: block; margin: 10 auto; max-width: %s; height: auto; } body { margin: %dpx %dpx %dpx %dpx; }"; + private static final String WEBVIEW_STYLE = "@font-face { font-family: 'Roboto-Light'; src: url('file:///android_asset/Roboto-Light.ttf'); } * { color: %s; font-family: roboto-Light; font-size: 13pt; } a { font-style: normal; text-decoration: none; font-weight: normal; color: #00A8DF; } a.timecode { color: #669900; } img { display: block; margin: 10 auto; max-width: %s; height: auto; } body { margin: %dpx %dpx %dpx %dpx; }"; private ShownotesProvider shownotesProvider; |