Merge pull request #2714 from orionlee/enqueue_keep_inprogress_front_2652_respect_download_start_order_2448

Enqueue fixes: keep inprogress front, respect download start order
This commit is contained in:
H. Lehmann 2019-11-05 23:29:52 +01:00 committed by GitHub
commit 808f273c09
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 942 additions and 100 deletions

View File

@ -0,0 +1,168 @@
package de.test.antennapod.storage;
import android.content.Context;
import android.content.Intent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.test.core.app.ApplicationProvider;
import org.awaitility.Awaitility;
import org.awaitility.core.ConditionTimeoutException;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.util.List;
import de.danoeh.antennapod.core.ClientConfig;
import de.danoeh.antennapod.core.DBTasksCallbacks;
import de.danoeh.antennapod.core.feed.FeedItem;
import de.danoeh.antennapod.core.feed.FeedMedia;
import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.service.playback.PlaybackService;
import de.danoeh.antennapod.core.storage.AutomaticDownloadAlgorithm;
import de.danoeh.antennapod.core.storage.DBReader;
import de.danoeh.antennapod.core.storage.DBTasks;
import de.danoeh.antennapod.core.storage.EpisodeCleanupAlgorithm;
import de.danoeh.antennapod.core.storage.PodDBAdapter;
import de.danoeh.antennapod.core.util.playback.Playable;
import de.test.antennapod.ui.UITestUtils;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
public class AutoDownloadTest {
private Context context;
private UITestUtils stubFeedsServer;
private DBTasksCallbacks dbTasksCallbacksOrig;
@Before
public void setUp() throws Exception {
context = ApplicationProvider.getApplicationContext();
stubFeedsServer = new UITestUtils(context);;
stubFeedsServer.setup();
dbTasksCallbacksOrig = ClientConfig.dbTasksCallbacks;
// create new database
PodDBAdapter.init(context);
PodDBAdapter.deleteDatabase();
PodDBAdapter adapter = PodDBAdapter.getInstance();
adapter.open();
adapter.close();
}
@After
public void tearDown() throws Exception {
stubFeedsServer.tearDown();
context.sendBroadcast(new Intent(PlaybackService.ACTION_SHUTDOWN_PLAYBACK_SERVICE));
Awaitility.await().until(() -> !PlaybackService.isRunning);
ClientConfig.dbTasksCallbacks = dbTasksCallbacksOrig;
}
/**
* A cross-functional test, ensuring playback's behavior works with Auto Download in boundary condition.
*
* Scenario:
* - For setting enqueue location AFTER_CURRENTLY_PLAYING
* - when playback of an episode is complete and the app advances to the next episode (continuous playback on)
* - when automatic download kicks in,
* - ensure the next episode is the current playing one, needed for AFTER_CURRENTLY_PLAYING enqueue location.
*/
@Test
public void downloadsEnqueuedToAfterCurrent_CurrentAdvancedToNextOnPlaybackComplete() throws Exception {
UserPreferences.setFollowQueue(true); // continuous playback
// Setup: feeds and queue
// downloads 3 of them, leave some in new state (auto-downloadable)
stubFeedsServer.addLocalFeedData(false);
List<FeedItem> queue = DBReader.getQueue();
assertTrue(queue.size() > 1);
FeedItem item0 = queue.get(0);
FeedItem item1 = queue.get(1);
// Setup: enable automatic download
// it is not needed, as the actual automatic download is stubbed.
StubDownloadAlgorithm stubDownloadAlgorithm = new StubDownloadAlgorithm();
useDownloadAlgorithm(stubDownloadAlgorithm);
// Actual test
// Play the first one in the queue
playEpisode(item0);
try {
// when playback is complete, advances to the next one, and auto download kicks in,
// ensure that currently playing has been advanced to the next one by this point.
Awaitility.await("advanced to the next episode")
.atMost(6000, MILLISECONDS) // the test mp3 media is 3-second long. twice should be enough
.until(() -> item1.equals(stubDownloadAlgorithm.getCurrentlyPlayingAtDownload()));
} catch (ConditionTimeoutException cte) {
FeedItem actual = stubDownloadAlgorithm.getCurrentlyPlayingAtDownload();
fail("when auto download is triggered, the next episode should be playing: ("
+ item1.getId() + ", " + item1.getTitle() + ") . "
+ "Actual playing: ("
+ (actual == null ? "" : actual.getId() + ", " + actual.getTitle()) + ")"
);
}
}
private void playEpisode(@NonNull FeedItem item) {
FeedMedia media = item.getMedia();
DBTasks.playMedia(context, media, false, true, true);
Awaitility.await("episode is playing")
.atMost(1000, MILLISECONDS)
.until(() -> item.equals(getCurrentlyPlaying()));
}
private FeedItem getCurrentlyPlaying() {
Playable playable = Playable.PlayableUtils.createInstanceFromPreferences(context);
if (playable == null) {
return null;
}
return ((FeedMedia)playable).getItem();
}
private void useDownloadAlgorithm(final AutomaticDownloadAlgorithm downloadAlgorithm) {
DBTasksCallbacks dbTasksCallbacksStub = new DBTasksCallbacks() {
@Override
public AutomaticDownloadAlgorithm getAutomaticDownloadAlgorithm() {
return downloadAlgorithm;
}
@Override
public EpisodeCleanupAlgorithm getEpisodeCacheCleanupAlgorithm() {
return dbTasksCallbacksOrig.getEpisodeCacheCleanupAlgorithm();
}
};
ClientConfig.dbTasksCallbacks = dbTasksCallbacksStub;
}
private class StubDownloadAlgorithm implements AutomaticDownloadAlgorithm {
@Nullable
private FeedItem currentlyPlaying;
@Override
public Runnable autoDownloadUndownloadedItems(Context context) {
return () -> {
if (currentlyPlaying == null) {
currentlyPlaying = getCurrentlyPlaying();
} else {
throw new AssertionError("Stub automatic download should be invoked once and only once");
}
};
}
@Nullable
FeedItem getCurrentlyPlayingAtDownload() {
return currentlyPlaying;
}
}
}

View File

@ -2,26 +2,31 @@ package de.test.antennapod.storage;
import android.content.Context; import android.content.Context;
import androidx.test.InstrumentationRegistry;
import androidx.test.filters.SmallTest;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import androidx.test.InstrumentationRegistry;
import androidx.test.filters.LargeTest;
import androidx.test.filters.SmallTest;
import de.danoeh.antennapod.core.feed.Feed; import de.danoeh.antennapod.core.feed.Feed;
import de.danoeh.antennapod.core.feed.FeedItem; import de.danoeh.antennapod.core.feed.FeedItem;
import de.danoeh.antennapod.core.feed.FeedMedia; import de.danoeh.antennapod.core.feed.FeedMedia;
import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.core.storage.DBReader;
import de.danoeh.antennapod.core.storage.DBTasks; import de.danoeh.antennapod.core.storage.DBTasks;
import de.danoeh.antennapod.core.storage.DBWriter;
import de.danoeh.antennapod.core.storage.PodDBAdapter; import de.danoeh.antennapod.core.storage.PodDBAdapter;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import static de.danoeh.antennapod.core.util.FeedItemUtil.getIdList;
import static java.util.Collections.singletonList; import static java.util.Collections.singletonList;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertTrue;
@ -179,4 +184,82 @@ public class DBTasksTest {
lastDate = item.getPubDate(); lastDate = item.getPubDate();
} }
} }
@Test
public void testAddQueueItemsInDownload_EnqueueEnabled() throws Exception {
// Setup test data / environment
UserPreferences.setEnqueueDownloadedEpisodes(true);
UserPreferences.setEnqueueLocation(UserPreferences.EnqueueLocation.BACK);
List<FeedItem> fis1 = createSavedFeed("Feed 1", 2).getItems();
List<FeedItem> fis2 = createSavedFeed("Feed 2", 3).getItems();
DBWriter.addQueueItem(context, fis1.get(0), fis2.get(0)).get();
// the first item fis1.get(0) is already in the queue
FeedItem[] itemsToDownload = new FeedItem[]{ fis1.get(0), fis1.get(1), fis2.get(2), fis2.get(1) };
// Expectations:
List<FeedItem> expectedEnqueued = Arrays.asList(fis1.get(1), fis2.get(2), fis2.get(1));
List<FeedItem> expectedQueue = new ArrayList<>();
expectedQueue.addAll(DBReader.getQueue());
expectedQueue.addAll(expectedEnqueued);
// Run actual test and assert results
List<? extends FeedItem> actualEnqueued =
DBTasks.enqueueFeedItemsToDownload(context, itemsToDownload);
assertEqualsByIds("Only items not in the queue are enqueued", expectedEnqueued, actualEnqueued);
assertEqualsByIds("Queue has new items appended", expectedQueue, DBReader.getQueue());
}
@Test
public void testAddQueueItemsInDownload_EnqueueDisabled() throws Exception {
// Setup test data / environment
UserPreferences.setEnqueueDownloadedEpisodes(false);
List<FeedItem> fis1 = createSavedFeed("Feed 1", 2).getItems();
List<FeedItem> fis2 = createSavedFeed("Feed 2", 3).getItems();
DBWriter.addQueueItem(context, fis1.get(0), fis2.get(0)).get();
FeedItem[] itemsToDownload = new FeedItem[]{ fis1.get(0), fis1.get(1), fis2.get(2), fis2.get(1) };
// Expectations:
List<FeedItem> expectedEnqueued = Collections.emptyList();
List<FeedItem> expectedQueue = DBReader.getQueue();
// Run actual test and assert results
List<? extends FeedItem> actualEnqueued =
DBTasks.enqueueFeedItemsToDownload(context, itemsToDownload);
assertEqualsByIds("No item is enqueued", expectedEnqueued, actualEnqueued);
assertEqualsByIds("Queue is unchanged", expectedQueue, DBReader.getQueue());
}
private void assertEqualsByIds(String msg, List<? extends FeedItem> expected, List<? extends FeedItem> actual) {
// assert only the IDs, so that any differences are easily to spot.
List<Long> expectedIds = getIdList(expected);
List<Long> actualIds = getIdList(actual);
assertEquals(msg, expectedIds, actualIds);
}
private Feed createSavedFeed(String title, int numFeedItems) {
final Feed feed = new Feed("url", null, title);
if (numFeedItems > 0) {
List<FeedItem> items = new ArrayList<>(numFeedItems);
for (int i = 1; i <= numFeedItems; i++) {
FeedItem item = new FeedItem(0, "item " + i + " of " + title, "id", "link",
new Date(), FeedItem.UNPLAYED, feed);
items.add(item);
}
feed.setItems(items);
}
PodDBAdapter adapter = PodDBAdapter.getInstance();
adapter.open();
adapter.setCompleteFeed(feed);
adapter.close();
return feed;
}
} }

View File

@ -4,12 +4,15 @@ import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.database.Cursor; import android.database.Cursor;
import android.preference.PreferenceManager; import android.preference.PreferenceManager;
import androidx.test.InstrumentationRegistry;
import androidx.test.filters.LargeTest;
import androidx.test.filters.MediumTest;
import android.util.Log; import android.util.Log;
import androidx.test.InstrumentationRegistry;
import androidx.test.filters.MediumTest;
import org.awaitility.Awaitility; import org.awaitility.Awaitility;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
@ -29,9 +32,7 @@ import de.danoeh.antennapod.core.storage.DBReader;
import de.danoeh.antennapod.core.storage.DBWriter; import de.danoeh.antennapod.core.storage.DBWriter;
import de.danoeh.antennapod.core.storage.PodDBAdapter; import de.danoeh.antennapod.core.storage.PodDBAdapter;
import de.danoeh.antennapod.core.util.Consumer; import de.danoeh.antennapod.core.util.Consumer;
import org.junit.After; import de.danoeh.antennapod.core.util.FeedItemUtil;
import org.junit.Before;
import org.junit.Test;
import static androidx.test.InstrumentationRegistry.getInstrumentation; import static androidx.test.InstrumentationRegistry.getInstrumentation;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@ -475,11 +476,12 @@ public class DBWriterTest {
assertFalse(OLD_DATE == media.getPlaybackCompletionDate().getTime()); assertFalse(OLD_DATE == media.getPlaybackCompletionDate().getTime());
} }
private Feed queueTestSetupMultipleItems(final int NUM_ITEMS) throws InterruptedException, ExecutionException, TimeoutException { private Feed queueTestSetupMultipleItems(final int numItems) throws InterruptedException, ExecutionException, TimeoutException {
final Context context = getInstrumentation().getTargetContext(); final Context context = getInstrumentation().getTargetContext();
UserPreferences.setEnqueueLocation(UserPreferences.EnqueueLocation.BACK);
Feed feed = new Feed("url", null, "title"); Feed feed = new Feed("url", null, "title");
feed.setItems(new ArrayList<>()); feed.setItems(new ArrayList<>());
for (int i = 0; i < NUM_ITEMS; i++) { for (int i = 0; i < numItems; i++) {
FeedItem item = new FeedItem(0, "title " + i, "id " + i, "link " + i, new Date(), FeedItem.PLAYED, feed); FeedItem item = new FeedItem(0, "title " + i, "id " + i, "link " + i, new Date(), FeedItem.PLAYED, feed);
feed.getItems().add(item); feed.getItems().add(item);
} }
@ -573,12 +575,16 @@ public class DBWriterTest {
Cursor cursor = adapter.getQueueIDCursor(); Cursor cursor = adapter.getQueueIDCursor();
assertTrue(cursor.moveToFirst()); assertTrue(cursor.moveToFirst());
assertTrue(cursor.getCount() == NUM_ITEMS); assertTrue(cursor.getCount() == NUM_ITEMS);
List<Long> expectedIds = FeedItemUtil.getIdList(feed.getItems());
List<Long> actualIds = new ArrayList<>();
for (int i = 0; i < NUM_ITEMS; i++) { for (int i = 0; i < NUM_ITEMS; i++) {
assertTrue(cursor.moveToPosition(i)); assertTrue(cursor.moveToPosition(i));
assertTrue(cursor.getLong(0) == feed.getItems().get(i).getId()); actualIds.add(cursor.getLong(0));
} }
cursor.close(); cursor.close();
adapter.close(); adapter.close();
assertEquals("Bulk add to queue: result order should be the same as the order given",
expectedIds, actualIds);
} }
@Test @Test

View File

@ -4,13 +4,14 @@ import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.content.res.Resources; import android.content.res.Resources;
import android.preference.PreferenceManager; import android.preference.PreferenceManager;
import androidx.test.filters.LargeTest;
import androidx.annotation.StringRes;
import androidx.test.filters.LargeTest;
import androidx.test.rule.ActivityTestRule; import androidx.test.rule.ActivityTestRule;
import com.robotium.solo.Solo; import com.robotium.solo.Solo;
import com.robotium.solo.Timeout; import com.robotium.solo.Timeout;
import de.test.antennapod.EspressoTestUtils;
import org.junit.Before; import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
@ -21,6 +22,7 @@ import java.util.concurrent.TimeUnit;
import de.danoeh.antennapod.R; import de.danoeh.antennapod.R;
import de.danoeh.antennapod.activity.PreferenceActivity; import de.danoeh.antennapod.activity.PreferenceActivity;
import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.preferences.UserPreferences.EnqueueLocation;
import de.danoeh.antennapod.core.storage.APCleanupAlgorithm; import de.danoeh.antennapod.core.storage.APCleanupAlgorithm;
import de.danoeh.antennapod.core.storage.APNullCleanupAlgorithm; import de.danoeh.antennapod.core.storage.APNullCleanupAlgorithm;
import de.danoeh.antennapod.core.storage.APQueueCleanupAlgorithm; import de.danoeh.antennapod.core.storage.APQueueCleanupAlgorithm;
@ -28,6 +30,7 @@ import de.danoeh.antennapod.core.storage.EpisodeCleanupAlgorithm;
import de.danoeh.antennapod.fragment.EpisodesFragment; import de.danoeh.antennapod.fragment.EpisodesFragment;
import de.danoeh.antennapod.fragment.QueueFragment; import de.danoeh.antennapod.fragment.QueueFragment;
import de.danoeh.antennapod.fragment.SubscriptionFragment; import de.danoeh.antennapod.fragment.SubscriptionFragment;
import de.test.antennapod.EspressoTestUtils;
import static androidx.test.InstrumentationRegistry.getInstrumentation; import static androidx.test.InstrumentationRegistry.getInstrumentation;
import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.Espresso.onView;
@ -126,13 +129,19 @@ public class PreferencesTest {
} }
@Test @Test
public void testEnqueueAtFront() { public void testEnqueueLocation() {
clickPreference(R.string.playback_pref); clickPreference(R.string.playback_pref);
final boolean enqueueAtFront = UserPreferences.enqueueAtFront(); doTestEnqueueLocation(R.string.enqueue_location_after_current, EnqueueLocation.AFTER_CURRENTLY_PLAYING);
clickPreference(R.string.pref_queueAddToFront_title); doTestEnqueueLocation(R.string.enqueue_location_front, EnqueueLocation.FRONT);
assertTrue(solo.waitForCondition(() -> enqueueAtFront != UserPreferences.enqueueAtFront(), Timeout.getLargeTimeout())); doTestEnqueueLocation(R.string.enqueue_location_back, EnqueueLocation.BACK);
clickPreference(R.string.pref_queueAddToFront_title); }
assertTrue(solo.waitForCondition(() -> enqueueAtFront == UserPreferences.enqueueAtFront(), Timeout.getLargeTimeout()));
private void doTestEnqueueLocation(@StringRes int optionResId, EnqueueLocation expected) {
clickPreference(R.string.pref_enqueue_location_title);
onView(withText(optionResId)).perform(click());
assertTrue(solo.waitForCondition(
() -> expected == UserPreferences.getEnqueueLocation(),
Timeout.getLargeTimeout()));
} }
@Test @Test

View File

@ -4,8 +4,15 @@ import android.app.Activity;
import android.content.res.Resources; import android.content.res.Resources;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.collection.ArrayMap;
import androidx.preference.ListPreference; import androidx.preference.ListPreference;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceFragmentCompat;
import java.util.Map;
import de.danoeh.antennapod.R; import de.danoeh.antennapod.R;
import de.danoeh.antennapod.activity.MediaplayerActivity; import de.danoeh.antennapod.activity.MediaplayerActivity;
import de.danoeh.antennapod.activity.PreferenceActivity; import de.danoeh.antennapod.activity.PreferenceActivity;
@ -63,6 +70,43 @@ public class PlaybackPreferencesFragment extends PreferenceFragmentCompat {
behaviour.setEntries(R.array.video_background_behavior_options_without_pip); behaviour.setEntries(R.array.video_background_behavior_options_without_pip);
behaviour.setEntryValues(R.array.video_background_behavior_values_without_pip); behaviour.setEntryValues(R.array.video_background_behavior_values_without_pip);
} }
buildEnqueueLocationPreference();
}
private void buildEnqueueLocationPreference() {
final Resources res = requireActivity().getResources();
final Map<String, String> options = new ArrayMap<>();
{
String[] keys = res.getStringArray(R.array.enqueue_location_values);
String[] values = res.getStringArray(R.array.enqueue_location_options);
for (int i = 0; i < keys.length; i++) {
options.put(keys[i], values[i]);
}
}
ListPreference pref = requirePreference(UserPreferences.PREF_ENQUEUE_LOCATION);
pref.setSummary(res.getString(R.string.pref_enqueue_location_sum, options.get(pref.getValue())));
pref.setOnPreferenceChangeListener((preference, newValue) -> {
if (!(newValue instanceof String)) {
return false;
}
String newValStr = (String)newValue;
pref.setSummary(res.getString(R.string.pref_enqueue_location_sum, options.get(newValStr)));
return true;
});
}
@NonNull
private <T extends Preference> T requirePreference(@NonNull CharSequence key) {
// Possibly put it to a common method in abstract base class
T result = findPreference(key);
if (result == null) {
throw new IllegalArgumentException("Preference with key '" + key + "' is not found");
}
return result;
} }
private void buildSmartMarkAsPlayedPreference() { private void buildSmartMarkAsPlayedPreference() {

View File

@ -7,6 +7,7 @@ import android.preference.PreferenceManager;
import de.danoeh.antennapod.BuildConfig; import de.danoeh.antennapod.BuildConfig;
import de.danoeh.antennapod.R; import de.danoeh.antennapod.R;
import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.preferences.UserPreferences.EnqueueLocation;
import de.danoeh.antennapod.core.util.download.AutoUpdateManager; import de.danoeh.antennapod.core.util.download.AutoUpdateManager;
import de.danoeh.antennapod.core.util.gui.NotificationUtils; import de.danoeh.antennapod.core.util.gui.NotificationUtils;
@ -75,6 +76,13 @@ public class PreferenceUpgrader {
} }
UserPreferences.setQueueLocked(false); UserPreferences.setQueueLocked(false);
if (!prefs.contains(UserPreferences.PREF_ENQUEUE_LOCATION)) {
final String keyOldPrefEnqueueFront = "prefQueueAddToFront";
boolean enqueueAtFront = prefs.getBoolean(keyOldPrefEnqueueFront, false);
EnqueueLocation enqueueLocation = enqueueAtFront ? EnqueueLocation.FRONT : EnqueueLocation.BACK;
UserPreferences.setEnqueueLocation(enqueueLocation);
}
} }
} }
} }

View File

@ -90,12 +90,13 @@
android:key="prefEnqueueDownloaded" android:key="prefEnqueueDownloaded"
android:summary="@string/pref_enqueue_downloaded_summary" android:summary="@string/pref_enqueue_downloaded_summary"
android:title="@string/pref_enqueue_downloaded_title" /> android:title="@string/pref_enqueue_downloaded_title" />
<SwitchPreference <ListPreference
android:defaultValue="false" android:defaultValue="BACK"
android:enabled="true" android:entries="@array/enqueue_location_options"
android:key="prefQueueAddToFront" android:entryValues="@array/enqueue_location_values"
android:summary="@string/pref_queueAddToFront_sum" android:key="prefEnqueueLocation"
android:title="@string/pref_queueAddToFront_title"/> android:title="@string/pref_enqueue_location_title"
app:useStockLayout="true"/>
<SwitchPreference <SwitchPreference
android:defaultValue="true" android:defaultValue="true"
android:enabled="true" android:enabled="true"

View File

@ -46,7 +46,7 @@ project.ext {
workManagerVersion = "1.0.1" workManagerVersion = "1.0.1"
espressoVersion = "3.2.0" espressoVersion = "3.2.0"
awaitilityVersion = "3.1.2" awaitilityVersion = "3.1.6"
commonsioVersion = "2.5" commonsioVersion = "2.5"
commonslangVersion = "3.6" commonslangVersion = "3.6"
commonstextVersion = "1.3" commonstextVersion = "1.3"

View File

@ -4,12 +4,14 @@ import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import android.content.res.Configuration; import android.content.res.Configuration;
import android.preference.PreferenceManager; import android.preference.PreferenceManager;
import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
import androidx.annotation.VisibleForTesting;
import androidx.core.app.NotificationCompat;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONException; import org.json.JSONException;
@ -23,8 +25,8 @@ import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import de.danoeh.antennapod.core.feed.MediaType;
import de.danoeh.antennapod.core.R; 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.service.download.ProxyConfig;
import de.danoeh.antennapod.core.storage.APCleanupAlgorithm; import de.danoeh.antennapod.core.storage.APCleanupAlgorithm;
import de.danoeh.antennapod.core.storage.APNullCleanupAlgorithm; import de.danoeh.antennapod.core.storage.APNullCleanupAlgorithm;
@ -61,8 +63,6 @@ public class UserPreferences {
public static final String PREF_BACK_BUTTON_BEHAVIOR = "prefBackButtonBehavior"; public static final String PREF_BACK_BUTTON_BEHAVIOR = "prefBackButtonBehavior";
private static final String PREF_BACK_BUTTON_GO_TO_PAGE = "prefBackButtonGoToPage"; 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 = "prefQueueKeepSorted";
public static final String PREF_QUEUE_KEEP_SORTED_ORDER = "prefQueueKeepSortedOrder"; public static final String PREF_QUEUE_KEEP_SORTED_ORDER = "prefQueueKeepSortedOrder";
@ -86,6 +86,7 @@ public class UserPreferences {
// Network // Network
private static final String PREF_ENQUEUE_DOWNLOADED = "prefEnqueueDownloaded"; private static final String PREF_ENQUEUE_DOWNLOADED = "prefEnqueueDownloaded";
public static final String PREF_ENQUEUE_LOCATION = "prefEnqueueLocation";
public static final String PREF_UPDATE_INTERVAL = "prefAutoUpdateIntervall"; public static final String PREF_UPDATE_INTERVAL = "prefAutoUpdateIntervall";
private static final String PREF_MOBILE_UPDATE = "prefMobileUpdateTypes"; private static final String PREF_MOBILE_UPDATE = "prefMobileUpdateTypes";
public static final String PREF_EPISODE_CLEANUP = "prefEpisodeCleanup"; public static final String PREF_EPISODE_CLEANUP = "prefEpisodeCleanup";
@ -284,8 +285,33 @@ public class UserPreferences {
return prefs.getBoolean(PREF_ENQUEUE_DOWNLOADED, true); return prefs.getBoolean(PREF_ENQUEUE_DOWNLOADED, true);
} }
public static boolean enqueueAtFront() { @VisibleForTesting
return prefs.getBoolean(PREF_QUEUE_ADD_TO_FRONT, false); 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() { public static boolean isPauseOnHeadsetDisconnect() {
@ -313,6 +339,14 @@ public class UserPreferences {
return prefs.getBoolean(PREF_FOLLOW_QUEUE, true); 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 shouldSkipKeepEpisode() { return prefs.getBoolean(PREF_SKIP_KEEPS_EPISODE, true); }
public static boolean shouldFavoriteKeepEpisode() { public static boolean shouldFavoriteKeepEpisode() {

View File

@ -3,22 +3,22 @@ package de.danoeh.antennapod.core.service.download.handler;
import android.content.Context; import android.content.Context;
import android.media.MediaMetadataRetriever; import android.media.MediaMetadataRetriever;
import android.util.Log; import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import java.util.concurrent.ExecutionException;
import de.danoeh.antennapod.core.feed.FeedItem; import de.danoeh.antennapod.core.feed.FeedItem;
import de.danoeh.antennapod.core.feed.FeedMedia; import de.danoeh.antennapod.core.feed.FeedMedia;
import de.danoeh.antennapod.core.gpoddernet.model.GpodnetEpisodeAction; import de.danoeh.antennapod.core.gpoddernet.model.GpodnetEpisodeAction;
import de.danoeh.antennapod.core.preferences.GpodnetPreferences; 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.DownloadRequest;
import de.danoeh.antennapod.core.service.download.DownloadStatus; import de.danoeh.antennapod.core.service.download.DownloadStatus;
import de.danoeh.antennapod.core.storage.DBReader; 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.storage.DBWriter;
import de.danoeh.antennapod.core.util.ChapterUtils; import de.danoeh.antennapod.core.util.ChapterUtils;
import de.danoeh.antennapod.core.util.DownloadError; import de.danoeh.antennapod.core.util.DownloadError;
import java.util.concurrent.ExecutionException;
/** /**
* Handles a completed media download. * Handles a completed media download.
*/ */
@ -82,11 +82,6 @@ public class MediaDownloadedHandler implements Runnable {
// to ensure subscribers will get the updated FeedMedia as well // to ensure subscribers will get the updated FeedMedia as well
DBWriter.setFeedItem(item).get(); DBWriter.setFeedItem(item).get();
} }
if (item != null && UserPreferences.enqueueDownloadedEpisodes()
&& !DBTasks.isInQueue(context, item.getId())) {
DBWriter.addQueueItem(context, item).get();
}
} catch (InterruptedException e) { } catch (InterruptedException e) {
Log.e(TAG, "MediaHandlerThread was interrupted"); Log.e(TAG, "MediaHandlerThread was interrupted");
} catch (ExecutionException e) { } catch (ExecutionException e) {

View File

@ -7,6 +7,8 @@ import android.os.Looper;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
import androidx.annotation.VisibleForTesting;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Date; 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.FeedItem;
import de.danoeh.antennapod.core.feed.FeedMedia; import de.danoeh.antennapod.core.feed.FeedMedia;
import de.danoeh.antennapod.core.feed.FeedPreferences; 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.GpodnetSyncService;
import de.danoeh.antennapod.core.service.download.DownloadStatus; import de.danoeh.antennapod.core.service.download.DownloadStatus;
import de.danoeh.antennapod.core.service.playback.PlaybackService; import de.danoeh.antennapod.core.service.playback.PlaybackService;
@ -329,6 +332,15 @@ public final class DBTasks {
}.start(); }.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) { for (FeedItem item : items) {
if (item.getMedia() != null if (item.getMedia() != null
&& !requester.isDownloadingFile(item.getMedia()) && !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 * Looks for undownloaded episodes in the queue or list of unread items and request a download if
* 1. Network is available * 1. Network is available

View File

@ -7,10 +7,6 @@ import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; 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 org.greenrobot.eventbus.EventBus;
import java.io.File; import java.io.File;
@ -25,10 +21,14 @@ import java.util.concurrent.Future;
import de.danoeh.antennapod.core.ClientConfig; import de.danoeh.antennapod.core.ClientConfig;
import de.danoeh.antennapod.core.R; 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.FavoritesEvent;
import de.danoeh.antennapod.core.event.FeedItemEvent; 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.MessageEvent;
import de.danoeh.antennapod.core.event.PlaybackHistoryEvent;
import de.danoeh.antennapod.core.event.QueueEvent; 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.Feed;
import de.danoeh.antennapod.core.feed.FeedEvent; import de.danoeh.antennapod.core.feed.FeedEvent;
import de.danoeh.antennapod.core.feed.FeedItem; 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.LongList;
import de.danoeh.antennapod.core.util.Permutor; import de.danoeh.antennapod.core.util.Permutor;
import de.danoeh.antennapod.core.util.SortOrder; import de.danoeh.antennapod.core.util.SortOrder;
import de.danoeh.antennapod.core.util.playback.Playable;
/** /**
* Provides methods for writing data to AntennaPod's database. * Provides methods for writing data to AntennaPod's database.
@ -314,56 +315,53 @@ public class DBWriter {
public static Future<?> addQueueItem(final Context context, final boolean performAutoDownload, public static Future<?> addQueueItem(final Context context, final boolean performAutoDownload,
final long... itemIds) { final long... itemIds) {
return dbExec.submit(() -> { return dbExec.submit(() -> {
if (itemIds.length > 0) { if (itemIds.length < 1) {
final PodDBAdapter adapter = PodDBAdapter.getInstance(); return;
adapter.open(); }
final List<FeedItem> queue = DBReader.getQueue(adapter);
if (queue != null) { final PodDBAdapter adapter = PodDBAdapter.getInstance();
boolean queueModified = false; adapter.open();
LongList markAsUnplayedIds = new LongList(); final List<FeedItem> queue = DBReader.getQueue(adapter);
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]);
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));
if (item != null) { item.addTag(FeedItem.TAG_QUEUE);
// add item to either front ot back of queue updatedItems.add(item);
boolean addToFront = UserPreferences.enqueueAtFront(); queueModified = true;
if (addToFront) { if (item.isNew()) {
queue.add(i, item); markAsUnplayedIds.add(item.getId());
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());
} }
insertPosition++;
} }
} }
adapter.close(); }
if (performAutoDownload) { if (queueModified) {
DBTasks.autodownloadUndownloadedItems(context); 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);
} }
}); });
} }

View File

@ -3,12 +3,13 @@ package de.danoeh.antennapod.core.storage;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
import android.webkit.URLUtil; import android.webkit.URLUtil;
import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import org.apache.commons.io.FilenameUtils; import org.apache.commons.io.FilenameUtils;
import java.io.File; 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, * Sends download requests to the DownloadService. This class should always be used for starting downloads,
* otherwise they won't work correctly. * otherwise they won't work correctly.
*/ */
public class DownloadRequester { public class DownloadRequester implements DownloadStateProvider {
private static final String TAG = "DownloadRequester"; private static final String TAG = "DownloadRequester";
private static final String FEED_DOWNLOADPATH = "cache/"; private static final String FEED_DOWNLOADPATH = "cache/";

View File

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

View File

@ -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;
}
}

View File

@ -1,7 +1,10 @@
package de.danoeh.antennapod.core.util; package de.danoeh.antennapod.core.util;
import androidx.annotation.NonNull;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import de.danoeh.antennapod.core.feed.FeedItem; import de.danoeh.antennapod.core.feed.FeedItem;
@ -40,6 +43,15 @@ public class FeedItemUtil {
return result; 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 * 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. * use the feed's link if the named feed item has no link.

View File

@ -93,6 +93,19 @@
<item>@string/episode_cleanup_never</item> <item>@string/episode_cleanup_never</item>
</string-array> </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"> <string-array name="episode_cleanup_values">
<item>-1</item> <item>-1</item>
<item>0</item> <item>0</item>

View File

@ -454,8 +454,11 @@
<string name="pref_showDownloadReport_title">Show Download Report</string> <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_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_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_enqueue_location_title">Enqueue Location</string>
<string name="pref_queueAddToFront_title">Enqueue at Front</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_smart_mark_as_played_disabled">Disabled</string>
<string name="pref_image_cache_size_title">Image Cache Size</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> <string name="pref_image_cache_size_sum">Size of the disk cache for images.</string>

View File

@ -1,6 +1,6 @@
package de.danoeh.antennapod.core.feed; package de.danoeh.antennapod.core.feed;
class FeedMother { public class FeedMother {
public static final String IMAGE_URL = "http://example.com/image"; public static final String IMAGE_URL = "http://example.com/image";
public static Feed anyFeed() { public static Feed anyFeed() {

View File

@ -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;
}
}

View File

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