summaryrefslogtreecommitdiff
path: root/core
diff options
context:
space:
mode:
Diffstat (limited to 'core')
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java48
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java13
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java31
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java98
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java7
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/DownloadStateProvider.java16
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/ItemEnqueuePositionCalculator.java97
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/FeedItemUtil.java12
-rw-r--r--core/src/main/res/values/arrays.xml13
-rw-r--r--core/src/main/res/values/strings.xml7
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/feed/FeedMother.java2
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/storage/ItemEnqueuePositionCalculatorTest.java293
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/util/CollectionTestUtil.java30
13 files changed, 595 insertions, 72 deletions
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 86cae3247..a3d3b56e0 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
@@ -4,11 +4,13 @@ import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Configuration;
import android.preference.PreferenceManager;
+import android.text.TextUtils;
+import android.util.Log;
+
import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
import androidx.core.app.NotificationCompat;
-import android.text.TextUtils;
-import android.util.Log;
import org.json.JSONArray;
import org.json.JSONException;
@@ -23,8 +25,8 @@ import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
-import de.danoeh.antennapod.core.feed.MediaType;
import de.danoeh.antennapod.core.R;
+import de.danoeh.antennapod.core.feed.MediaType;
import de.danoeh.antennapod.core.service.download.ProxyConfig;
import de.danoeh.antennapod.core.storage.APCleanupAlgorithm;
import de.danoeh.antennapod.core.storage.APNullCleanupAlgorithm;
@@ -61,8 +63,6 @@ public class UserPreferences {
public static final String PREF_BACK_BUTTON_BEHAVIOR = "prefBackButtonBehavior";
private static final String PREF_BACK_BUTTON_GO_TO_PAGE = "prefBackButtonGoToPage";
- // Queue
- private static final String PREF_QUEUE_ADD_TO_FRONT = "prefQueueAddToFront";
public static final String PREF_QUEUE_KEEP_SORTED = "prefQueueKeepSorted";
public static final String PREF_QUEUE_KEEP_SORTED_ORDER = "prefQueueKeepSortedOrder";
@@ -86,6 +86,7 @@ public class UserPreferences {
// Network
private static final String PREF_ENQUEUE_DOWNLOADED = "prefEnqueueDownloaded";
+ public static final String PREF_ENQUEUE_LOCATION = "prefEnqueueLocation";
public static final String PREF_UPDATE_INTERVAL = "prefAutoUpdateIntervall";
private static final String PREF_MOBILE_UPDATE = "prefMobileUpdateTypes";
public static final String PREF_EPISODE_CLEANUP = "prefEpisodeCleanup";
@@ -295,8 +296,33 @@ public class UserPreferences {
return prefs.getBoolean(PREF_ENQUEUE_DOWNLOADED, true);
}
- public static boolean enqueueAtFront() {
- return prefs.getBoolean(PREF_QUEUE_ADD_TO_FRONT, false);
+ @VisibleForTesting
+ public static void setEnqueueDownloadedEpisodes(boolean enqueueDownloadedEpisodes) {
+ prefs.edit()
+ .putBoolean(PREF_ENQUEUE_DOWNLOADED, enqueueDownloadedEpisodes)
+ .apply();
+ }
+
+ public enum EnqueueLocation {
+ BACK, FRONT, AFTER_CURRENTLY_PLAYING;
+ }
+
+ @NonNull
+ public static EnqueueLocation getEnqueueLocation() {
+ String valStr = prefs.getString(PREF_ENQUEUE_LOCATION, EnqueueLocation.BACK.name());
+ try {
+ return EnqueueLocation.valueOf(valStr);
+ } catch (Throwable t) {
+ // should never happen but just in case
+ Log.e(TAG, "getEnqueueLocation: invalid value '" + valStr + "' Use default.", t);
+ return EnqueueLocation.BACK;
+ }
+ }
+
+ public static void setEnqueueLocation(@NonNull EnqueueLocation location) {
+ prefs.edit()
+ .putString(PREF_ENQUEUE_LOCATION, location.name())
+ .apply();
}
public static boolean isPauseOnHeadsetDisconnect() {
@@ -324,6 +350,14 @@ public class UserPreferences {
return prefs.getBoolean(PREF_FOLLOW_QUEUE, true);
}
+ /**
+ * Set to true to enable Continuous Playback
+ */
+ @VisibleForTesting
+ public static void setFollowQueue(boolean value) {
+ prefs.edit().putBoolean(UserPreferences.PREF_FOLLOW_QUEUE, value).apply();
+ }
+
public static boolean shouldSkipKeepEpisode() { return prefs.getBoolean(PREF_SKIP_KEEPS_EPISODE, true); }
public static boolean shouldFavoriteKeepEpisode() {
diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java
index cf5a84eea..7465b5b38 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java
@@ -3,22 +3,22 @@ package de.danoeh.antennapod.core.service.download.handler;
import android.content.Context;
import android.media.MediaMetadataRetriever;
import android.util.Log;
+
import androidx.annotation.NonNull;
+
+import java.util.concurrent.ExecutionException;
+
import de.danoeh.antennapod.core.feed.FeedItem;
import de.danoeh.antennapod.core.feed.FeedMedia;
import de.danoeh.antennapod.core.gpoddernet.model.GpodnetEpisodeAction;
import de.danoeh.antennapod.core.preferences.GpodnetPreferences;
-import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.service.download.DownloadRequest;
import de.danoeh.antennapod.core.service.download.DownloadStatus;
import de.danoeh.antennapod.core.storage.DBReader;
-import de.danoeh.antennapod.core.storage.DBTasks;
import de.danoeh.antennapod.core.storage.DBWriter;
import de.danoeh.antennapod.core.util.ChapterUtils;
import de.danoeh.antennapod.core.util.DownloadError;
-import java.util.concurrent.ExecutionException;
-
/**
* Handles a completed media download.
*/
@@ -82,11 +82,6 @@ public class MediaDownloadedHandler implements Runnable {
// to ensure subscribers will get the updated FeedMedia as well
DBWriter.setFeedItem(item).get();
}
-
- if (item != null && UserPreferences.enqueueDownloadedEpisodes()
- && !DBTasks.isInQueue(context, item.getId())) {
- DBWriter.addQueueItem(context, item).get();
- }
} catch (InterruptedException e) {
Log.e(TAG, "MediaHandlerThread was interrupted");
} catch (ExecutionException e) {
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 9d37a5f2a..7dc53f8b3 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
@@ -7,6 +7,8 @@ import android.os.Looper;
import android.text.TextUtils;
import android.util.Log;
+import androidx.annotation.VisibleForTesting;
+
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
@@ -26,6 +28,7 @@ import de.danoeh.antennapod.core.feed.Feed;
import de.danoeh.antennapod.core.feed.FeedItem;
import de.danoeh.antennapod.core.feed.FeedMedia;
import de.danoeh.antennapod.core.feed.FeedPreferences;
+import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.service.GpodnetSyncService;
import de.danoeh.antennapod.core.service.download.DownloadStatus;
import de.danoeh.antennapod.core.service.playback.PlaybackService;
@@ -329,6 +332,15 @@ public final class DBTasks {
}.start();
}
+ // #2448: First, add to-download items to the queue before actual download
+ // so that the resulting queue order is the same as when download is clicked
+ try {
+ enqueueFeedItemsToDownload(context, items);
+ } catch (Throwable t) {
+ throw new DownloadRequestException("Unexpected exception during enqueue before downloads", t);
+ }
+
+ // Then, download them
for (FeedItem item : items) {
if (item.getMedia() != null
&& !requester.isDownloadingFile(item.getMedia())
@@ -354,6 +366,25 @@ public final class DBTasks {
}
}
+ @VisibleForTesting
+ public static List<? extends FeedItem> enqueueFeedItemsToDownload(final Context context,
+ FeedItem... items)
+ throws InterruptedException, ExecutionException {
+ List<FeedItem> itemsToEnqueue = new ArrayList<>();
+ if (UserPreferences.enqueueDownloadedEpisodes()) {
+ LongList queueIDList = DBReader.getQueueIDList();
+ for (FeedItem item : items) {
+ if (!queueIDList.contains(item.getId())) {
+ itemsToEnqueue.add(item);
+ }
+ }
+ DBWriter.addQueueItem(context,
+ itemsToEnqueue.toArray(new FeedItem[0]))
+ .get();
+ }
+ return itemsToEnqueue;
+ }
+
/**
* Looks for undownloaded episodes in the queue or list of unread items and request a download if
* 1. Network is available
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java
index 8f0626c5c..23d14fe87 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java
@@ -7,10 +7,6 @@ import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
-import de.danoeh.antennapod.core.event.DownloadLogEvent;
-import de.danoeh.antennapod.core.event.FeedListUpdateEvent;
-import de.danoeh.antennapod.core.event.PlaybackHistoryEvent;
-import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent;
import org.greenrobot.eventbus.EventBus;
import java.io.File;
@@ -25,10 +21,14 @@ import java.util.concurrent.Future;
import de.danoeh.antennapod.core.ClientConfig;
import de.danoeh.antennapod.core.R;
+import de.danoeh.antennapod.core.event.DownloadLogEvent;
import de.danoeh.antennapod.core.event.FavoritesEvent;
import de.danoeh.antennapod.core.event.FeedItemEvent;
+import de.danoeh.antennapod.core.event.FeedListUpdateEvent;
import de.danoeh.antennapod.core.event.MessageEvent;
+import de.danoeh.antennapod.core.event.PlaybackHistoryEvent;
import de.danoeh.antennapod.core.event.QueueEvent;
+import de.danoeh.antennapod.core.event.UnreadItemsUpdateEvent;
import de.danoeh.antennapod.core.feed.Feed;
import de.danoeh.antennapod.core.feed.FeedEvent;
import de.danoeh.antennapod.core.feed.FeedItem;
@@ -45,6 +45,7 @@ import de.danoeh.antennapod.core.util.IntentUtils;
import de.danoeh.antennapod.core.util.LongList;
import de.danoeh.antennapod.core.util.Permutor;
import de.danoeh.antennapod.core.util.SortOrder;
+import de.danoeh.antennapod.core.util.playback.Playable;
/**
* Provides methods for writing data to AntennaPod's database.
@@ -314,57 +315,54 @@ public class DBWriter {
public static Future<?> addQueueItem(final Context context, final boolean performAutoDownload,
final long... itemIds) {
return dbExec.submit(() -> {
- if (itemIds.length > 0) {
- final PodDBAdapter adapter = PodDBAdapter.getInstance();
- adapter.open();
- final List<FeedItem> queue = DBReader.getQueue(adapter);
-
- if (queue != null) {
- boolean queueModified = false;
- LongList markAsUnplayedIds = new LongList();
- List<QueueEvent> events = new ArrayList<>();
- List<FeedItem> updatedItems = new ArrayList<>();
- for (int i = 0; i < itemIds.length; i++) {
- if (!itemListContains(queue, itemIds[i])) {
- final FeedItem item = DBReader.getFeedItem(itemIds[i]);
-
-
- if (item != null) {
- // add item to either front ot back of queue
- boolean addToFront = UserPreferences.enqueueAtFront();
- if (addToFront) {
- queue.add(i, item);
- events.add(QueueEvent.added(item, i));
- } else {
- queue.add(item);
- events.add(QueueEvent.added(item, queue.size() - 1));
- }
- item.addTag(FeedItem.TAG_QUEUE);
- updatedItems.add(item);
- queueModified = true;
- if (item.isNew()) {
- markAsUnplayedIds.add(item.getId());
- }
- }
- }
- }
- if (queueModified) {
- applySortOrder(queue, events);
- adapter.setQueue(queue);
- for (QueueEvent event : events) {
- EventBus.getDefault().post(event);
- }
- EventBus.getDefault().post(FeedItemEvent.updated(updatedItems));
- if (markAsUnplayedIds.size() > 0) {
- DBWriter.markItemPlayed(FeedItem.UNPLAYED, markAsUnplayedIds.toArray());
+ if (itemIds.length < 1) {
+ return;
+ }
+
+ final PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ final List<FeedItem> queue = DBReader.getQueue(adapter);
+
+ boolean queueModified = false;
+ LongList markAsUnplayedIds = new LongList();
+ List<QueueEvent> events = new ArrayList<>();
+ List<FeedItem> updatedItems = new ArrayList<>();
+ ItemEnqueuePositionCalculator positionCalculator =
+ new ItemEnqueuePositionCalculator(UserPreferences.getEnqueueLocation());
+ Playable currentlyPlaying = Playable.PlayableUtils.createInstanceFromPreferences(context);
+ int insertPosition = positionCalculator.calcPosition(queue, currentlyPlaying);
+ for (long itemId : itemIds) {
+ if (!itemListContains(queue, itemId)) {
+ final FeedItem item = DBReader.getFeedItem(itemId);
+ if (item != null) {
+ queue.add(insertPosition, item);
+ events.add(QueueEvent.added(item, insertPosition));
+
+ item.addTag(FeedItem.TAG_QUEUE);
+ updatedItems.add(item);
+ queueModified = true;
+ if (item.isNew()) {
+ markAsUnplayedIds.add(item.getId());
}
+ insertPosition++;
}
}
- adapter.close();
- if (performAutoDownload) {
- DBTasks.autodownloadUndownloadedItems(context);
+ }
+ if (queueModified) {
+ applySortOrder(queue, events);
+ adapter.setQueue(queue);
+ for (QueueEvent event : events) {
+ EventBus.getDefault().post(event);
+ }
+ EventBus.getDefault().post(FeedItemEvent.updated(updatedItems));
+ if (markAsUnplayedIds.size() > 0) {
+ DBWriter.markItemPlayed(FeedItem.UNPLAYED, markAsUnplayedIds.toArray());
}
}
+ adapter.close();
+ if (performAutoDownload) {
+ DBTasks.autodownloadUndownloadedItems(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 71f6845c5..c61abc168 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
@@ -3,12 +3,13 @@ package de.danoeh.antennapod.core.storage;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
-import androidx.annotation.NonNull;
-import androidx.core.content.ContextCompat;
import android.text.TextUtils;
import android.util.Log;
import android.webkit.URLUtil;
+import androidx.annotation.NonNull;
+import androidx.core.content.ContextCompat;
+
import org.apache.commons.io.FilenameUtils;
import java.io.File;
@@ -31,7 +32,7 @@ import de.danoeh.antennapod.core.util.URLChecker;
* Sends download requests to the DownloadService. This class should always be used for starting downloads,
* otherwise they won't work correctly.
*/
-public class DownloadRequester {
+public class DownloadRequester implements DownloadStateProvider {
private static final String TAG = "DownloadRequester";
private static final String FEED_DOWNLOADPATH = "cache/";
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadStateProvider.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadStateProvider.java
new file mode 100644
index 000000000..ece40353f
--- /dev/null
+++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadStateProvider.java
@@ -0,0 +1,16 @@
+package de.danoeh.antennapod.core.storage;
+
+import androidx.annotation.NonNull;
+
+import de.danoeh.antennapod.core.feed.FeedFile;
+
+/**
+ * Allow callers to query the states of downloads, but not affect them.
+ */
+public interface DownloadStateProvider {
+ /**
+ * @return {@code true} if the named feedfile is in the downloads list
+ */
+ boolean isDownloadingFile(@NonNull FeedFile item);
+
+}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/ItemEnqueuePositionCalculator.java b/core/src/main/java/de/danoeh/antennapod/core/storage/ItemEnqueuePositionCalculator.java
new file mode 100644
index 000000000..4b28d36b5
--- /dev/null
+++ b/core/src/main/java/de/danoeh/antennapod/core/storage/ItemEnqueuePositionCalculator.java
@@ -0,0 +1,97 @@
+package de.danoeh.antennapod.core.storage;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+
+import java.util.List;
+
+import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.feed.FeedMedia;
+import de.danoeh.antennapod.core.preferences.UserPreferences.EnqueueLocation;
+import de.danoeh.antennapod.core.util.playback.Playable;
+
+/**
+ * @see DBWriter#addQueueItem(Context, boolean, long...) it uses the class to determine
+ * the positions of the {@link FeedItem} in the queue.
+ */
+class ItemEnqueuePositionCalculator {
+
+ @NonNull
+ private final EnqueueLocation enqueueLocation;
+
+ @VisibleForTesting
+ DownloadStateProvider downloadStateProvider = DownloadRequester.getInstance();
+
+ public ItemEnqueuePositionCalculator(@NonNull EnqueueLocation enqueueLocation) {
+ this.enqueueLocation = enqueueLocation;
+ }
+
+ /**
+ * Determine the position (0-based) that the item(s) should be inserted to the named queue.
+ *
+ * @param curQueue the queue to which the item is to be inserted
+ * @param currentPlaying the currently playing media
+ */
+ public int calcPosition(@NonNull List<FeedItem> curQueue, @Nullable Playable currentPlaying) {
+ switch (enqueueLocation) {
+ case BACK:
+ return curQueue.size();
+ case FRONT:
+ // Return not necessarily 0, so that when a list of items are downloaded and enqueued
+ // in succession of calls (e.g., users manually tapping download one by one),
+ // the items enqueued are kept the same order.
+ // Simply returning 0 will reverse the order.
+ return getPositionOfFirstNonDownloadingItem(0, curQueue);
+ case AFTER_CURRENTLY_PLAYING:
+ int currentlyPlayingPosition = getCurrentlyPlayingPosition(curQueue, currentPlaying);
+ return getPositionOfFirstNonDownloadingItem(
+ currentlyPlayingPosition + 1, curQueue);
+ default:
+ throw new AssertionError("calcPosition() : unrecognized enqueueLocation option: " + enqueueLocation);
+ }
+ }
+
+ private int getPositionOfFirstNonDownloadingItem(int startPosition, List<FeedItem> curQueue) {
+ final int curQueueSize = curQueue.size();
+ for (int i = startPosition; i < curQueueSize; i++) {
+ if (!isItemAtPositionDownloading(i, curQueue)) {
+ return i;
+ } // else continue to search;
+ }
+ return curQueueSize;
+ }
+
+ private boolean isItemAtPositionDownloading(int position, List<FeedItem> curQueue) {
+ FeedItem curItem;
+ try {
+ curItem = curQueue.get(position);
+ } catch (IndexOutOfBoundsException e) {
+ curItem = null;
+ }
+
+ if (curItem != null
+ && curItem.getMedia() != null
+ && downloadStateProvider.isDownloadingFile(curItem.getMedia())) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ private static int getCurrentlyPlayingPosition(@NonNull List<FeedItem> curQueue,
+ @Nullable Playable currentPlaying) {
+ if (!(currentPlaying instanceof FeedMedia)) {
+ return -1;
+ }
+ final long curPlayingItemId = ((FeedMedia) currentPlaying).getItem().getId();
+ for (int i = 0; i < curQueue.size(); i++) {
+ if (curPlayingItemId == curQueue.get(i).getId()) {
+ return i;
+ }
+ }
+ return -1;
+ }
+}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/FeedItemUtil.java b/core/src/main/java/de/danoeh/antennapod/core/util/FeedItemUtil.java
index 8d77f0f24..5ae8dbcc7 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/FeedItemUtil.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/FeedItemUtil.java
@@ -1,7 +1,10 @@
package de.danoeh.antennapod.core.util;
+import androidx.annotation.NonNull;
+
import org.apache.commons.lang3.StringUtils;
+import java.util.ArrayList;
import java.util.List;
import de.danoeh.antennapod.core.feed.FeedItem;
@@ -40,6 +43,15 @@ public class FeedItemUtil {
return result;
}
+ @NonNull
+ public static List<Long> getIdList(List<? extends FeedItem> items) {
+ List<Long> result = new ArrayList<>();
+ for (FeedItem item : items) {
+ result.add(item.getId());
+ }
+ return result;
+ }
+
/**
* Get the link for the feed item for the purpose of Share. It fallbacks to
* use the feed's link if the named feed item has no link.
diff --git a/core/src/main/res/values/arrays.xml b/core/src/main/res/values/arrays.xml
index 2dd985d6a..3a6a45e6d 100644
--- a/core/src/main/res/values/arrays.xml
+++ b/core/src/main/res/values/arrays.xml
@@ -93,6 +93,19 @@
<item>@string/episode_cleanup_never</item>
</string-array>
+ <string-array name="enqueue_location_options">
+ <item>@string/enqueue_location_back</item>
+ <item>@string/enqueue_location_front</item>
+ <item>@string/enqueue_location_after_current</item>
+ </string-array>
+
+ <string-array name="enqueue_location_values">
+ <!-- MUST be the same as UserPreferences.EnqueueLocation enum -->
+ <item>BACK</item>
+ <item>FRONT</item>
+ <item>AFTER_CURRENTLY_PLAYING</item>
+ </string-array>
+
<string-array name="episode_cleanup_values">
<item>-1</item>
<item>0</item>
diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml
index 6541524fb..b8187a404 100644
--- a/core/src/main/res/values/strings.xml
+++ b/core/src/main/res/values/strings.xml
@@ -454,8 +454,11 @@
<string name="pref_showDownloadReport_title">Show Download Report</string>
<string name="pref_showDownloadReport_sum">If downloads fail, generate a report that shows the details of the failure.</string>
<string name="pref_expand_notify_unsupport_toast">Android versions before 4.1 do not support expanded notifications.</string>
- <string name="pref_queueAddToFront_sum">Add new episodes to the front of the queue.</string>
- <string name="pref_queueAddToFront_title">Enqueue at Front</string>
+ <string name="pref_enqueue_location_title">Enqueue Location</string>
+ <string name="pref_enqueue_location_sum">Add episodes to: %1$s</string>
+ <string name="enqueue_location_back">Back</string>
+ <string name="enqueue_location_front">Front</string>
+ <string name="enqueue_location_after_current">After current episode</string>
<string name="pref_smart_mark_as_played_disabled">Disabled</string>
<string name="pref_image_cache_size_title">Image Cache Size</string>
<string name="pref_image_cache_size_sum">Size of the disk cache for images.</string>
diff --git a/core/src/test/java/de/danoeh/antennapod/core/feed/FeedMother.java b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedMother.java
index f46797d28..991495a3f 100644
--- a/core/src/test/java/de/danoeh/antennapod/core/feed/FeedMother.java
+++ b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedMother.java
@@ -1,6 +1,6 @@
package de.danoeh.antennapod.core.feed;
-class FeedMother {
+public class FeedMother {
public static final String IMAGE_URL = "http://example.com/image";
public static Feed anyFeed() {
diff --git a/core/src/test/java/de/danoeh/antennapod/core/storage/ItemEnqueuePositionCalculatorTest.java b/core/src/test/java/de/danoeh/antennapod/core/storage/ItemEnqueuePositionCalculatorTest.java
new file mode 100644
index 000000000..17b88bdd2
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/storage/ItemEnqueuePositionCalculatorTest.java
@@ -0,0 +1,293 @@
+package de.danoeh.antennapod.core.storage;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.feed.FeedMedia;
+import de.danoeh.antennapod.core.feed.FeedMother;
+import de.danoeh.antennapod.core.feed.MediaType;
+import de.danoeh.antennapod.core.preferences.UserPreferences.EnqueueLocation;
+import de.danoeh.antennapod.core.util.playback.ExternalMedia;
+import de.danoeh.antennapod.core.util.playback.Playable;
+
+import static de.danoeh.antennapod.core.preferences.UserPreferences.EnqueueLocation.AFTER_CURRENTLY_PLAYING;
+import static de.danoeh.antennapod.core.preferences.UserPreferences.EnqueueLocation.BACK;
+import static de.danoeh.antennapod.core.preferences.UserPreferences.EnqueueLocation.FRONT;
+import static de.danoeh.antennapod.core.util.CollectionTestUtil.concat;
+import static de.danoeh.antennapod.core.util.CollectionTestUtil.list;
+import static de.danoeh.antennapod.core.util.FeedItemUtil.getIdList;
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.stub;
+
+public class ItemEnqueuePositionCalculatorTest {
+
+ @RunWith(Parameterized.class)
+ public static class BasicTest {
+ @Parameters(name = "{index}: case<{0}>, expected:{1}")
+ public static Iterable<Object[]> data() {
+ return Arrays.asList(new Object[][]{
+ {"case default, i.e., add to the end",
+ concat(QUEUE_DEFAULT_IDS, TFI_ID),
+ BACK, QUEUE_DEFAULT},
+ {"case option enqueue at front",
+ concat(TFI_ID, QUEUE_DEFAULT_IDS),
+ FRONT, QUEUE_DEFAULT},
+ {"case empty queue, option default",
+ list(TFI_ID),
+ BACK, QUEUE_EMPTY},
+ {"case empty queue, option enqueue at front",
+ list(TFI_ID),
+ FRONT, QUEUE_EMPTY},
+ });
+ }
+
+ @Parameter
+ public String message;
+
+ @Parameter(1)
+ public List<Long> idsExpected;
+
+ @Parameter(2)
+ public EnqueueLocation options;
+
+ @Parameter(3)
+ public List<FeedItem> curQueue;
+
+ public static final long TFI_ID = 101;
+
+ /**
+ * Add a FeedItem with ID {@link #TFI_ID} with the setup
+ */
+ @Test
+ public void test() {
+ ItemEnqueuePositionCalculator calculator = new ItemEnqueuePositionCalculator(options);
+
+ // shallow copy to which the test will add items
+ List<FeedItem> queue = new ArrayList<>(curQueue);
+ FeedItem tFI = createFeedItem(TFI_ID);
+ doAddToQueueAndAssertResult(message,
+ calculator, tFI, queue, getCurrentlyPlaying(),
+ idsExpected);
+ }
+
+ Playable getCurrentlyPlaying() { return null; }
+ }
+
+ @RunWith(Parameterized.class)
+ public static class AfterCurrentlyPlayingTest extends BasicTest {
+ @Parameters(name = "{index}: case<{0}>, expected:{1}")
+ public static Iterable<Object[]> data() {
+ return Arrays.asList(new Object[][]{
+ {"case option after currently playing",
+ list(11L, TFI_ID, 12L, 13L, 14L),
+ AFTER_CURRENTLY_PLAYING, QUEUE_DEFAULT, 11L},
+ {"case option after currently playing, currently playing in the middle of the queue",
+ list(11L, 12L, 13L, TFI_ID, 14L),
+ AFTER_CURRENTLY_PLAYING, QUEUE_DEFAULT, 13L},
+ {"case option after currently playing, currently playing is not in queue",
+ concat(TFI_ID, QUEUE_DEFAULT_IDS),
+ AFTER_CURRENTLY_PLAYING, QUEUE_DEFAULT, 99L},
+ {"case option after currently playing, no currentlyPlaying is null",
+ concat(TFI_ID, QUEUE_DEFAULT_IDS),
+ AFTER_CURRENTLY_PLAYING, QUEUE_DEFAULT, ID_CURRENTLY_PLAYING_NULL},
+ {"case option after currently playing, currentlyPlaying is externalMedia",
+ concat(TFI_ID, QUEUE_DEFAULT_IDS),
+ AFTER_CURRENTLY_PLAYING, QUEUE_DEFAULT, ID_CURRENTLY_PLAYING_NOT_FEEDMEDIA},
+ {"case empty queue, option after currently playing",
+ list(TFI_ID),
+ AFTER_CURRENTLY_PLAYING, QUEUE_EMPTY, ID_CURRENTLY_PLAYING_NULL},
+ });
+ }
+
+ @Parameter(4)
+ public long idCurrentlyPlaying;
+
+ @Override
+ Playable getCurrentlyPlaying() {
+ return ItemEnqueuePositionCalculatorTest.getCurrentlyPlaying(idCurrentlyPlaying);
+ }
+
+ private static Playable externalMedia() {
+ return new ExternalMedia("http://example.com/episode.mp3", MediaType.AUDIO);
+ }
+
+ private static final long ID_CURRENTLY_PLAYING_NULL = -1L;
+ private static final long ID_CURRENTLY_PLAYING_NOT_FEEDMEDIA = -9999L;
+
+ }
+
+ @RunWith(Parameterized.class)
+ public static class ItemEnqueuePositionCalculatorPreserveDownloadOrderTest {
+
+ /**
+ * The test covers the use case that when user initiates multiple downloads in succession,
+ * resulting in multiple addQueueItem() calls in succession.
+ * the items in the queue will be in the same order as the the order user taps to download
+ */
+ @Parameters(name = "{index}: case<{0}>")
+ public static Iterable<Object[]> data() {
+ // Attempts to make test more readable by showing the expected list of ids
+ // (rather than the expected positions)
+ return Arrays.asList(new Object[][] {
+ {"download order test, enqueue default",
+ concat(QUEUE_DEFAULT_IDS, 101L),
+ concat(QUEUE_DEFAULT_IDS, list(101L, 102L)),
+ concat(QUEUE_DEFAULT_IDS, list(101L, 102L, 103L)),
+ BACK, QUEUE_DEFAULT, ID_CURRENTLY_PLAYING_NULL},
+ {"download order test, enqueue at front (currently playing has no effect)",
+ concat(101L, QUEUE_DEFAULT_IDS),
+ concat(list(101L, 102L), QUEUE_DEFAULT_IDS),
+ concat(list(101L, 103L, 102L), QUEUE_DEFAULT_IDS),
+ // ^ 103 is put ahead of 102, after 102 failed.
+ // It is a limitation as the logic can't tell 102 download has failed
+ // (as opposed to simply being enqueued)
+ FRONT, QUEUE_DEFAULT, 11L}, // 11 is at the front, currently playing
+ {"download order test, enqueue after currently playing",
+ list(11L, 101L, 12L, 13L, 14L),
+ list(11L, 101L, 102L, 12L, 13L, 14L),
+ list(11L, 101L, 103L, 102L, 12L, 13L, 14L),
+ AFTER_CURRENTLY_PLAYING, QUEUE_DEFAULT, 11L} // 11 is at the front, currently playing
+ });
+ }
+
+ @Parameter
+ public String message;
+
+ @Parameter(1)
+ public List<Long> idsExpectedAfter101;
+
+ @Parameter(2)
+ public List<Long> idsExpectedAfter102;
+
+ @Parameter(3)
+ public List<Long> idsExpectedAfter103;
+
+ @Parameter(4)
+ public EnqueueLocation options;
+
+ @Parameter(5)
+ public List<FeedItem> queueInitial;
+
+ @Parameter(6)
+ public long idCurrentlyPlaying;
+
+ @Test
+ public void testQueueOrderWhenDownloading2Items() {
+
+ // Setup class under test
+ //
+ ItemEnqueuePositionCalculator calculator = new ItemEnqueuePositionCalculator(options);
+ DownloadStateProvider stubDownloadStateProvider = mock(DownloadStateProvider.class);
+ stub(stubDownloadStateProvider.isDownloadingFile(any(FeedMedia.class))).toReturn(false);
+ calculator.downloadStateProvider = stubDownloadStateProvider;
+
+ // Setup initial data
+ // A shallow copy, as the test code will manipulate the queue
+ List<FeedItem> queue = new ArrayList<>(queueInitial);
+
+ // Test body
+ Playable currentlyPlaying = getCurrentlyPlaying(idCurrentlyPlaying);
+ // User clicks download on feed item 101
+ FeedItem tFI101 = setAsDownloading(101, stubDownloadStateProvider, true);
+ doAddToQueueAndAssertResult(message + " (1st download)",
+ calculator, tFI101, queue, currentlyPlaying,
+ idsExpectedAfter101);
+ // Then user clicks download on feed item 102
+ FeedItem tFI102 = setAsDownloading(102, stubDownloadStateProvider, true);
+ doAddToQueueAndAssertResult(message + " (2nd download, it should preserve order of download)",
+ calculator, tFI102, queue, currentlyPlaying,
+ idsExpectedAfter102);
+ // simulate download failure case for 102
+ setAsDownloading(tFI102, stubDownloadStateProvider, false);
+ // Then user clicks download on feed item 103
+ FeedItem tFI103 = setAsDownloading(103, stubDownloadStateProvider, true);
+ doAddToQueueAndAssertResult(message
+ + " (3rd download, with 2nd download failed; "
+ + "it should be behind 1st download (unless enqueueLocation is BACK)",
+ calculator, tFI103, queue, currentlyPlaying,
+ idsExpectedAfter103);
+
+ }
+
+
+ private static FeedItem setAsDownloading(int id, DownloadStateProvider stubDownloadStateProvider,
+ boolean isDownloading) {
+ FeedItem item = createFeedItem(id);
+ FeedMedia media =
+ new FeedMedia(item, "http://download.url.net/" + id
+ , 100000 + id, "audio/mp3");
+ media.setId(item.getId());
+ item.setMedia(media);
+ return setAsDownloading(item, stubDownloadStateProvider, isDownloading);
+ }
+
+ private static FeedItem setAsDownloading(FeedItem item, DownloadStateProvider stubDownloadStateProvider,
+ boolean isDownloading) {
+ stub(stubDownloadStateProvider.isDownloadingFile(item.getMedia())).toReturn(isDownloading);
+ return item;
+ }
+
+ }
+
+
+ static void doAddToQueueAndAssertResult(String message,
+ ItemEnqueuePositionCalculator calculator,
+ FeedItem itemToAdd,
+ List<FeedItem> queue,
+ Playable currentlyPlaying,
+ List<Long> idsExpected) {
+ int posActual = calculator.calcPosition(queue, currentlyPlaying);
+ queue.add(posActual, itemToAdd);
+ assertEquals(message, idsExpected, getIdList(queue));
+ }
+
+ static final List<FeedItem> QUEUE_EMPTY = Collections.unmodifiableList(Arrays.asList());
+
+ static final List<FeedItem> QUEUE_DEFAULT =
+ Collections.unmodifiableList(Arrays.asList(
+ createFeedItem(11), createFeedItem(12), createFeedItem(13), createFeedItem(14)));
+ static final List<Long> QUEUE_DEFAULT_IDS =
+ QUEUE_DEFAULT.stream().map(fi -> fi.getId()).collect(Collectors.toList());
+
+
+ static Playable getCurrentlyPlaying(long idCurrentlyPlaying) {
+ if (ID_CURRENTLY_PLAYING_NOT_FEEDMEDIA == idCurrentlyPlaying) {
+ return externalMedia();
+ }
+ if (ID_CURRENTLY_PLAYING_NULL == idCurrentlyPlaying) {
+ return null;
+ }
+ return createFeedItem(idCurrentlyPlaying).getMedia();
+ }
+
+ static Playable externalMedia() {
+ return new ExternalMedia("http://example.com/episode.mp3", MediaType.AUDIO);
+ }
+
+ static final long ID_CURRENTLY_PLAYING_NULL = -1L;
+ static final long ID_CURRENTLY_PLAYING_NOT_FEEDMEDIA = -9999L;
+
+
+ static FeedItem createFeedItem(long id) {
+ FeedItem item = new FeedItem(id, "Item" + id, "ItemId" + id, "url",
+ new Date(), FeedItem.PLAYED, FeedMother.anyFeed());
+ FeedMedia media = new FeedMedia(item, "download_url", 1234567, "audio/mpeg");
+ media.setId(item.getId());
+ item.setMedia(media);
+ return item;
+ }
+
+}
diff --git a/core/src/test/java/de/danoeh/antennapod/core/util/CollectionTestUtil.java b/core/src/test/java/de/danoeh/antennapod/core/util/CollectionTestUtil.java
new file mode 100644
index 000000000..21f1ef5d4
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/util/CollectionTestUtil.java
@@ -0,0 +1,30 @@
+package de.danoeh.antennapod.core.util;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public class CollectionTestUtil {
+
+ public static <T> List<? extends T> concat(T item, List<? extends T> list) {
+ List<T> res = new ArrayList<>(list);
+ res.add(0, item);
+ return res;
+ }
+
+ public static <T> List<? extends T> concat(List<? extends T> list, T item) {
+ List<T> res = new ArrayList<>(list);
+ res.add(item);
+ return res;
+ }
+
+ public static <T> List<? extends T> concat(List<? extends T> list1, List<? extends T> list2) {
+ List<T> res = new ArrayList<>(list1);
+ res.addAll(list2);
+ return res;
+ }
+
+ public static <T> List<T> list(T... a) {
+ return Arrays.asList(a);
+ }
+}