diff --git a/app/src/androidTest/java/de/test/antennapod/storage/AutoDownloadTest.java b/app/src/androidTest/java/de/test/antennapod/storage/AutoDownloadTest.java new file mode 100644 index 000000000..c6beb2d42 --- /dev/null +++ b/app/src/androidTest/java/de/test/antennapod/storage/AutoDownloadTest.java @@ -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 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; + } + } +} diff --git a/app/src/androidTest/java/de/test/antennapod/storage/DBTasksTest.java b/app/src/androidTest/java/de/test/antennapod/storage/DBTasksTest.java index 55ea16f13..cce4e5111 100644 --- a/app/src/androidTest/java/de/test/antennapod/storage/DBTasksTest.java +++ b/app/src/androidTest/java/de/test/antennapod/storage/DBTasksTest.java @@ -2,26 +2,31 @@ package de.test.antennapod.storage; 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.Arrays; import java.util.Collections; import java.util.Date; 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.FeedItem; import de.danoeh.antennapod.core.feed.FeedMedia; import de.danoeh.antennapod.core.preferences.UserPreferences; 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.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 org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -179,4 +184,82 @@ public class DBTasksTest { lastDate = item.getPubDate(); } } + + @Test + public void testAddQueueItemsInDownload_EnqueueEnabled() throws Exception { + // Setup test data / environment + UserPreferences.setEnqueueDownloadedEpisodes(true); + UserPreferences.setEnqueueLocation(UserPreferences.EnqueueLocation.BACK); + + List fis1 = createSavedFeed("Feed 1", 2).getItems(); + List 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 expectedEnqueued = Arrays.asList(fis1.get(1), fis2.get(2), fis2.get(1)); + List expectedQueue = new ArrayList<>(); + expectedQueue.addAll(DBReader.getQueue()); + expectedQueue.addAll(expectedEnqueued); + + // Run actual test and assert results + List 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 fis1 = createSavedFeed("Feed 1", 2).getItems(); + List 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 expectedEnqueued = Collections.emptyList(); + List expectedQueue = DBReader.getQueue(); + + // Run actual test and assert results + List 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 expected, List actual) { + // assert only the IDs, so that any differences are easily to spot. + List expectedIds = getIdList(expected); + List 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 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; + } + } diff --git a/app/src/androidTest/java/de/test/antennapod/storage/DBWriterTest.java b/app/src/androidTest/java/de/test/antennapod/storage/DBWriterTest.java index 42f4413d3..1e13bd5c3 100644 --- a/app/src/androidTest/java/de/test/antennapod/storage/DBWriterTest.java +++ b/app/src/androidTest/java/de/test/antennapod/storage/DBWriterTest.java @@ -4,12 +4,15 @@ import android.content.Context; import android.content.SharedPreferences; import android.database.Cursor; import android.preference.PreferenceManager; -import androidx.test.InstrumentationRegistry; -import androidx.test.filters.LargeTest; -import androidx.test.filters.MediumTest; import android.util.Log; +import androidx.test.InstrumentationRegistry; +import androidx.test.filters.MediumTest; + import org.awaitility.Awaitility; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; import java.io.File; 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.PodDBAdapter; import de.danoeh.antennapod.core.util.Consumer; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; +import de.danoeh.antennapod.core.util.FeedItemUtil; import static androidx.test.InstrumentationRegistry.getInstrumentation; import static org.junit.Assert.assertEquals; @@ -475,11 +476,12 @@ public class DBWriterTest { 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(); + UserPreferences.setEnqueueLocation(UserPreferences.EnqueueLocation.BACK); Feed feed = new Feed("url", null, "title"); 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); feed.getItems().add(item); } @@ -573,12 +575,16 @@ public class DBWriterTest { Cursor cursor = adapter.getQueueIDCursor(); assertTrue(cursor.moveToFirst()); assertTrue(cursor.getCount() == NUM_ITEMS); + List expectedIds = FeedItemUtil.getIdList(feed.getItems()); + List actualIds = new ArrayList<>(); for (int i = 0; i < NUM_ITEMS; i++) { assertTrue(cursor.moveToPosition(i)); - assertTrue(cursor.getLong(0) == feed.getItems().get(i).getId()); + actualIds.add(cursor.getLong(0)); } cursor.close(); adapter.close(); + assertEquals("Bulk add to queue: result order should be the same as the order given", + expectedIds, actualIds); } @Test diff --git a/app/src/androidTest/java/de/test/antennapod/ui/PreferencesTest.java b/app/src/androidTest/java/de/test/antennapod/ui/PreferencesTest.java index 65bc7d745..f22e4b426 100644 --- a/app/src/androidTest/java/de/test/antennapod/ui/PreferencesTest.java +++ b/app/src/androidTest/java/de/test/antennapod/ui/PreferencesTest.java @@ -4,13 +4,14 @@ import android.content.Intent; import android.content.SharedPreferences; import android.content.res.Resources; import android.preference.PreferenceManager; -import androidx.test.filters.LargeTest; +import androidx.annotation.StringRes; +import androidx.test.filters.LargeTest; import androidx.test.rule.ActivityTestRule; + import com.robotium.solo.Solo; import com.robotium.solo.Timeout; -import de.test.antennapod.EspressoTestUtils; import org.junit.Before; import org.junit.Rule; import org.junit.Test; @@ -21,6 +22,7 @@ import java.util.concurrent.TimeUnit; import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.PreferenceActivity; 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.APNullCleanupAlgorithm; 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.QueueFragment; import de.danoeh.antennapod.fragment.SubscriptionFragment; +import de.test.antennapod.EspressoTestUtils; import static androidx.test.InstrumentationRegistry.getInstrumentation; import static androidx.test.espresso.Espresso.onView; @@ -126,13 +129,19 @@ public class PreferencesTest { } @Test - public void testEnqueueAtFront() { + public void testEnqueueLocation() { clickPreference(R.string.playback_pref); - final boolean enqueueAtFront = UserPreferences.enqueueAtFront(); - clickPreference(R.string.pref_queueAddToFront_title); - assertTrue(solo.waitForCondition(() -> enqueueAtFront != UserPreferences.enqueueAtFront(), Timeout.getLargeTimeout())); - clickPreference(R.string.pref_queueAddToFront_title); - assertTrue(solo.waitForCondition(() -> enqueueAtFront == UserPreferences.enqueueAtFront(), Timeout.getLargeTimeout())); + doTestEnqueueLocation(R.string.enqueue_location_after_current, EnqueueLocation.AFTER_CURRENTLY_PLAYING); + doTestEnqueueLocation(R.string.enqueue_location_front, EnqueueLocation.FRONT); + doTestEnqueueLocation(R.string.enqueue_location_back, EnqueueLocation.BACK); + } + + 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 diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/PlaybackPreferencesFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/PlaybackPreferencesFragment.java index 1795dfc29..5d9af14bd 100644 --- a/app/src/main/java/de/danoeh/antennapod/fragment/preferences/PlaybackPreferencesFragment.java +++ b/app/src/main/java/de/danoeh/antennapod/fragment/preferences/PlaybackPreferencesFragment.java @@ -4,8 +4,15 @@ import android.app.Activity; import android.content.res.Resources; import android.os.Build; import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.collection.ArrayMap; import androidx.preference.ListPreference; +import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; + +import java.util.Map; + import de.danoeh.antennapod.R; import de.danoeh.antennapod.activity.MediaplayerActivity; 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.setEntryValues(R.array.video_background_behavior_values_without_pip); } + + buildEnqueueLocationPreference(); + } + + private void buildEnqueueLocationPreference() { + final Resources res = requireActivity().getResources(); + final Map 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 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() { diff --git a/app/src/main/java/de/danoeh/antennapod/preferences/PreferenceUpgrader.java b/app/src/main/java/de/danoeh/antennapod/preferences/PreferenceUpgrader.java index 767f71bb6..455038960 100644 --- a/app/src/main/java/de/danoeh/antennapod/preferences/PreferenceUpgrader.java +++ b/app/src/main/java/de/danoeh/antennapod/preferences/PreferenceUpgrader.java @@ -7,6 +7,7 @@ import android.preference.PreferenceManager; import de.danoeh.antennapod.BuildConfig; import de.danoeh.antennapod.R; 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.gui.NotificationUtils; @@ -75,6 +76,13 @@ public class PreferenceUpgrader { } 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); + } } } } diff --git a/app/src/main/res/xml/preferences_playback.xml b/app/src/main/res/xml/preferences_playback.xml index 2334e1b1c..b01105376 100644 --- a/app/src/main/res/xml/preferences_playback.xml +++ b/app/src/main/res/xml/preferences_playback.xml @@ -90,12 +90,13 @@ android:key="prefEnqueueDownloaded" android:summary="@string/pref_enqueue_downloaded_summary" android:title="@string/pref_enqueue_downloaded_title" /> - + enqueueFeedItemsToDownload(final Context context, + FeedItem... items) + throws InterruptedException, ExecutionException { + List 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,56 +315,53 @@ 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 queue = DBReader.getQueue(adapter); + if (itemIds.length < 1) { + return; + } - if (queue != null) { - boolean queueModified = false; - LongList markAsUnplayedIds = new LongList(); - List events = new ArrayList<>(); - List updatedItems = new ArrayList<>(); - for (int i = 0; i < itemIds.length; i++) { - if (!itemListContains(queue, itemIds[i])) { - final FeedItem item = DBReader.getFeedItem(itemIds[i]); + final PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + final List queue = DBReader.getQueue(adapter); + boolean queueModified = false; + LongList markAsUnplayedIds = new LongList(); + List events = new ArrayList<>(); + List 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) { - // 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()); + 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 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 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 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 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 getIdList(List items) { + List 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 @@ @string/episode_cleanup_never + + @string/enqueue_location_back + @string/enqueue_location_front + @string/enqueue_location_after_current + + + + + BACK + FRONT + AFTER_CURRENTLY_PLAYING + + -1 0 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 @@ Show Download Report If downloads fail, generate a report that shows the details of the failure. Android versions before 4.1 do not support expanded notifications. - Add new episodes to the front of the queue. - Enqueue at Front + Enqueue Location + Add episodes to: %1$s + Back + Front + After current episode Disabled Image Cache Size Size of the disk cache for images. 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 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 idsExpected; + + @Parameter(2) + public EnqueueLocation options; + + @Parameter(3) + public List 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 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 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 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 idsExpectedAfter101; + + @Parameter(2) + public List idsExpectedAfter102; + + @Parameter(3) + public List idsExpectedAfter103; + + @Parameter(4) + public EnqueueLocation options; + + @Parameter(5) + public List 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 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 queue, + Playable currentlyPlaying, + List idsExpected) { + int posActual = calculator.calcPosition(queue, currentlyPlaying); + queue.add(posActual, itemToAdd); + assertEquals(message, idsExpected, getIdList(queue)); + } + + static final List QUEUE_EMPTY = Collections.unmodifiableList(Arrays.asList()); + + static final List QUEUE_DEFAULT = + Collections.unmodifiableList(Arrays.asList( + createFeedItem(11), createFeedItem(12), createFeedItem(13), createFeedItem(14))); + static final List 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 List concat(T item, List list) { + List res = new ArrayList<>(list); + res.add(0, item); + return res; + } + + public static List concat(List list, T item) { + List res = new ArrayList<>(list); + res.add(item); + return res; + } + + public static List concat(List list1, List list2) { + List res = new ArrayList<>(list1); + res.addAll(list2); + return res; + } + + public static List list(T... a) { + return Arrays.asList(a); + } +}