summaryrefslogtreecommitdiff
path: root/storage/database
diff options
context:
space:
mode:
Diffstat (limited to 'storage/database')
-rw-r--r--storage/database/build.gradle11
-rw-r--r--storage/database/src/main/java/de/danoeh/antennapod/storage/database/DBWriter.java1055
-rw-r--r--storage/database/src/main/java/de/danoeh/antennapod/storage/database/FeedDatabaseWriter.java259
-rw-r--r--storage/database/src/main/java/de/danoeh/antennapod/storage/database/FeedItemDuplicateGuesser.java83
-rw-r--r--storage/database/src/main/java/de/danoeh/antennapod/storage/database/FeedItemPubdateComparator.java27
-rw-r--r--storage/database/src/main/java/de/danoeh/antennapod/storage/database/ItemEnqueuePositionCalculator.java94
6 files changed, 1529 insertions, 0 deletions
diff --git a/storage/database/build.gradle b/storage/database/build.gradle
index 0f3aed252..63f9eeaec 100644
--- a/storage/database/build.gradle
+++ b/storage/database/build.gradle
@@ -2,6 +2,7 @@ plugins {
id("com.android.library")
}
apply from: "../../common.gradle"
+apply from: "../../playFlavor.gradle"
android {
namespace "de.danoeh.antennapod.storage.database"
@@ -12,11 +13,21 @@ android {
}
dependencies {
+ implementation project(':event')
implementation project(':model')
+ implementation project(':net:download:service-interface')
+ implementation project(':net:sync:model')
+ implementation project(':net:sync:service-interface')
+ implementation project(':storage:preferences')
+ implementation project(':ui:app-start-intent')
annotationProcessor "androidx.annotation:annotation:$annotationVersion"
implementation "androidx.core:core:$coreVersion"
+ implementation 'androidx.documentfile:documentfile:1.0.1'
+
implementation "commons-io:commons-io:$commonsioVersion"
+ implementation "org.greenrobot:eventbus:$eventbusVersion"
+ implementation "com.google.guava:guava:31.0.1-android"
testImplementation "junit:junit:$junitVersion"
}
diff --git a/storage/database/src/main/java/de/danoeh/antennapod/storage/database/DBWriter.java b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/DBWriter.java
new file mode 100644
index 000000000..0d7cf5cb1
--- /dev/null
+++ b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/DBWriter.java
@@ -0,0 +1,1055 @@
+package de.danoeh.antennapod.storage.database;
+
+import android.app.backup.BackupManager;
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.util.Log;
+
+import android.view.KeyEvent;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.documentfile.provider.DocumentFile;
+
+import com.google.common.util.concurrent.Futures;
+import de.danoeh.antennapod.event.DownloadLogEvent;
+
+import de.danoeh.antennapod.net.download.serviceinterface.AutoDownloadManager;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadServiceInterface;
+import de.danoeh.antennapod.net.download.serviceinterface.FeedUpdateManager;
+import de.danoeh.antennapod.net.sync.serviceinterface.SynchronizationQueueSink;
+import de.danoeh.antennapod.ui.appstartintent.MediaButtonStarter;
+import org.greenrobot.eventbus.EventBus;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import de.danoeh.antennapod.event.FavoritesEvent;
+import de.danoeh.antennapod.event.FeedItemEvent;
+import de.danoeh.antennapod.event.FeedListUpdateEvent;
+import de.danoeh.antennapod.event.MessageEvent;
+import de.danoeh.antennapod.event.playback.PlaybackHistoryEvent;
+import de.danoeh.antennapod.event.QueueEvent;
+import de.danoeh.antennapod.event.UnreadItemsUpdateEvent;
+import de.danoeh.antennapod.event.FeedEvent;
+import de.danoeh.antennapod.storage.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import de.danoeh.antennapod.model.download.DownloadResult;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.model.feed.FeedPreferences;
+import de.danoeh.antennapod.model.feed.SortOrder;
+import de.danoeh.antennapod.model.playback.Playable;
+import de.danoeh.antennapod.net.sync.model.EpisodeAction;
+
+/**
+ * Provides methods for writing data to AntennaPod's database.
+ * In general, DBWriter-methods will be executed on an internal ExecutorService.
+ * Some methods return a Future-object which the caller can use for waiting for the method's completion. The returned Future's
+ * will NOT contain any results.
+ */
+public class DBWriter {
+
+ private static final String TAG = "DBWriter";
+
+ private static final ExecutorService dbExec;
+
+ static {
+ dbExec = Executors.newSingleThreadExecutor(r -> {
+ Thread t = new Thread(r);
+ t.setName("DatabaseExecutor");
+ t.setPriority(Thread.MIN_PRIORITY);
+ return t;
+ });
+ }
+
+ private DBWriter() {
+ }
+
+ /**
+ * Wait until all threads are finished to avoid the "Illegal connection pointer" error of
+ * Robolectric. Call this method only for unit tests.
+ */
+ public static void tearDownTests() {
+ try {
+ dbExec.awaitTermination(1, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ // ignore error
+ }
+ }
+
+ /**
+ * Deletes a downloaded FeedMedia file from the storage device.
+ *
+ * @param context A context that is used for opening a database connection.
+ */
+ public static Future<?> deleteFeedMediaOfItem(@NonNull final Context context,
+ final FeedMedia media) {
+ return runOnDbThread(() -> {
+ if (media == null) {
+ return;
+ }
+ boolean result = deleteFeedMediaSynchronous(context, media);
+ if (result && UserPreferences.shouldDeleteRemoveFromQueue()) {
+ DBWriter.removeQueueItemSynchronous(context, false, media.getItemId());
+ }
+ });
+ }
+
+ private static boolean deleteFeedMediaSynchronous(@NonNull Context context, @NonNull FeedMedia media) {
+ Log.i(TAG, String.format(Locale.US, "Requested to delete FeedMedia [id=%d, title=%s, downloaded=%s",
+ media.getId(), media.getEpisodeTitle(), media.isDownloaded()));
+ boolean localDelete = false;
+ if (media.getLocalFileUrl() != null && media.getLocalFileUrl().startsWith("content://")) {
+ // Local feed
+ DocumentFile documentFile = DocumentFile.fromSingleUri(context, Uri.parse(media.getLocalFileUrl()));
+ if (documentFile == null || !documentFile.exists() || !documentFile.delete()) {
+ EventBus.getDefault().post(new MessageEvent(context.getString(R.string.delete_local_failed)));
+ return false;
+ }
+ media.setLocalFileUrl(null);
+ localDelete = true;
+ } else if (media.getLocalFileUrl() != null) {
+ // delete downloaded media file
+ File mediaFile = new File(media.getLocalFileUrl());
+ if (mediaFile.exists() && !mediaFile.delete()) {
+ MessageEvent evt = new MessageEvent(context.getString(R.string.delete_failed));
+ EventBus.getDefault().post(evt);
+ return false;
+ }
+ media.setDownloaded(false);
+ media.setLocalFileUrl(null);
+ media.setHasEmbeddedPicture(false);
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setMedia(media);
+ adapter.close();
+ }
+
+ if (media.getId() == PlaybackPreferences.getCurrentlyPlayingFeedMediaId()) {
+ PlaybackPreferences.writeNoMediaPlaying();
+ context.sendBroadcast(MediaButtonStarter.createIntent(context, KeyEvent.KEYCODE_MEDIA_STOP));
+ }
+
+ if (localDelete) {
+ // Do full update of this feed to get rid of the item
+ FeedUpdateManager.getInstance().runOnce(context, media.getItem().getFeed());
+ } else {
+ // Gpodder: queue delete action for synchronization
+ FeedItem item = media.getItem();
+ EpisodeAction action = new EpisodeAction.Builder(item, EpisodeAction.DELETE)
+ .currentTimestamp()
+ .build();
+ SynchronizationQueueSink.enqueueEpisodeActionIfSynchronizationIsActive(context, action);
+
+ EventBus.getDefault().post(FeedItemEvent.updated(media.getItem()));
+ }
+ return true;
+ }
+
+ /**
+ * Deletes a Feed and all downloaded files of its components like images and downloaded episodes.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param feedId ID of the Feed that should be deleted.
+ */
+ public static Future<?> deleteFeed(final Context context, final long feedId) {
+ return runOnDbThread(() -> {
+ final Feed feed = DBReader.getFeed(feedId, false);
+ if (feed == null) {
+ return;
+ }
+
+ deleteFeedItemsSynchronous(context, feed.getItems());
+
+ // delete feed
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.removeFeed(feed);
+ adapter.close();
+
+ if (!feed.isLocalFeed()) {
+ SynchronizationQueueSink.enqueueFeedRemovedIfSynchronizationIsActive(context, feed.getDownloadUrl());
+ }
+ EventBus.getDefault().post(new FeedListUpdateEvent(feed));
+ });
+ }
+
+ /**
+ * Remove the listed items and their FeedMedia entries.
+ * Deleting media also removes the download log entries.
+ */
+ @NonNull
+ public static Future<?> deleteFeedItems(@NonNull Context context, @NonNull List<FeedItem> items) {
+ return runOnDbThread(() -> deleteFeedItemsSynchronous(context, items));
+ }
+
+ /**
+ * Remove the listed items and their FeedMedia entries.
+ * Deleting media also removes the download log entries.
+ */
+ private static void deleteFeedItemsSynchronous(@NonNull Context context, @NonNull List<FeedItem> items) {
+ List<FeedItem> queue = DBReader.getQueue();
+ List<FeedItem> removedFromQueue = new ArrayList<>();
+ for (FeedItem item : items) {
+ if (queue.remove(item)) {
+ removedFromQueue.add(item);
+ }
+ if (item.getMedia() != null) {
+ if (item.getMedia().getId() == PlaybackPreferences.getCurrentlyPlayingFeedMediaId()) {
+ // Applies to both downloaded and streamed media
+ PlaybackPreferences.writeNoMediaPlaying();
+ context.sendBroadcast(MediaButtonStarter.createIntent(context, KeyEvent.KEYCODE_MEDIA_STOP));
+ }
+ if (!item.getFeed().isLocalFeed()) {
+ if (DownloadServiceInterface.get().isDownloadingEpisode(item.getMedia().getDownloadUrl())) {
+ DownloadServiceInterface.get().cancel(context, item.getMedia());
+ }
+ if (item.getMedia().isDownloaded()) {
+ deleteFeedMediaSynchronous(context, item.getMedia());
+ }
+ }
+ }
+ }
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ if (!removedFromQueue.isEmpty()) {
+ adapter.setQueue(queue);
+ }
+ adapter.removeFeedItems(items);
+ adapter.close();
+
+ for (FeedItem item : removedFromQueue) {
+ EventBus.getDefault().post(QueueEvent.irreversibleRemoved(item));
+ }
+
+ // we assume we also removed download log entries for the feed or its media files.
+ // especially important if download or refresh failed, as the user should not be able
+ // to retry these
+ EventBus.getDefault().post(DownloadLogEvent.listUpdated());
+
+ BackupManager backupManager = new BackupManager(context);
+ backupManager.dataChanged();
+ }
+
+ /**
+ * Deletes the entire playback history.
+ */
+ public static Future<?> clearPlaybackHistory() {
+ return runOnDbThread(() -> {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.clearPlaybackHistory();
+ adapter.close();
+ EventBus.getDefault().post(PlaybackHistoryEvent.listUpdated());
+ });
+ }
+
+ /**
+ * Deletes the entire download log.
+ */
+ public static Future<?> clearDownloadLog() {
+ return runOnDbThread(() -> {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.clearDownloadLog();
+ adapter.close();
+ EventBus.getDefault().post(DownloadLogEvent.listUpdated());
+ });
+ }
+
+ public static Future<?> deleteFromPlaybackHistory(FeedItem feedItem) {
+ return addItemToPlaybackHistory(feedItem.getMedia(), new Date(0));
+ }
+
+ /**
+ * Adds a FeedMedia object to the playback history. A FeedMedia object is in the playback history if
+ * its playback completion date is set to a non-null value. This method will set the playback completion date to the
+ * current date regardless of the current value.
+ *
+ * @param media FeedMedia that should be added to the playback history.
+ */
+ public static Future<?> addItemToPlaybackHistory(FeedMedia media) {
+ return addItemToPlaybackHistory(media, new Date());
+ }
+
+ /**
+ * Adds a FeedMedia object to the playback history. A FeedMedia object is in the playback history if
+ * its playback completion date is set to a non-null value. This method will set the playback completion date to the
+ * current date regardless of the current value.
+ *
+ * @param media FeedMedia that should be added to the playback history.
+ * @param date PlaybackCompletionDate for <code>media</code>
+ */
+ public static Future<?> addItemToPlaybackHistory(final FeedMedia media, Date date) {
+ return runOnDbThread(() -> {
+ Log.d(TAG, "Adding item to playback history");
+ media.setPlaybackCompletionDate(date);
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setFeedMediaPlaybackCompletionDate(media);
+ adapter.close();
+ EventBus.getDefault().post(PlaybackHistoryEvent.listUpdated());
+
+ });
+ }
+
+ /**
+ * Adds a Download status object to the download log.
+ *
+ * @param status The DownloadStatus object.
+ */
+ public static Future<?> addDownloadStatus(final DownloadResult status) {
+ return runOnDbThread(() -> {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setDownloadStatus(status);
+ adapter.close();
+ EventBus.getDefault().post(DownloadLogEvent.listUpdated());
+ });
+
+ }
+
+ /**
+ * Inserts a FeedItem in the queue at the specified index. The 'read'-attribute of the FeedItem will be set to
+ * true. If the FeedItem is already in the queue, the queue will not be modified.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param itemId ID of the FeedItem that should be added to the queue.
+ * @param index Destination index. Must be in range 0..queue.size()
+ * @param performAutoDownload True if an auto-download process should be started after the operation
+ * @throws IndexOutOfBoundsException if index < 0 || index >= queue.size()
+ */
+ public static Future<?> addQueueItemAt(final Context context, final long itemId,
+ final int index, final boolean performAutoDownload) {
+ return runOnDbThread(() -> {
+ final PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ final List<FeedItem> queue = DBReader.getQueue(adapter);
+ FeedItem item;
+
+ if (queue != null) {
+ if (!itemListContains(queue, itemId)) {
+ item = DBReader.getFeedItem(itemId);
+ if (item != null) {
+ queue.add(index, item);
+ adapter.setQueue(queue);
+ item.addTag(FeedItem.TAG_QUEUE);
+ EventBus.getDefault().post(QueueEvent.added(item, index));
+ EventBus.getDefault().post(FeedItemEvent.updated(item));
+ if (item.isNew()) {
+ DBWriter.markItemPlayed(FeedItem.UNPLAYED, item.getId());
+ }
+ }
+ }
+ }
+
+ adapter.close();
+ if (performAutoDownload) {
+ AutoDownloadManager.getInstance().autodownloadUndownloadedItems(context);
+ }
+
+ });
+
+ }
+
+ public static Future<?> addQueueItem(final Context context, final FeedItem... items) {
+ return addQueueItem(context, true, items);
+ }
+
+ public static Future<?> addQueueItem(final Context context, boolean markAsUnplayed, final FeedItem... items) {
+ LongList itemIds = new LongList(items.length);
+ for (FeedItem item : items) {
+ if (!item.hasMedia()) {
+ continue;
+ }
+ itemIds.add(item.getId());
+ item.addTag(FeedItem.TAG_QUEUE);
+ }
+ return addQueueItem(context, false, markAsUnplayed, itemIds.toArray());
+ }
+
+ /**
+ * Appends FeedItem objects to the end of the queue. The 'read'-attribute of all items will be set to true.
+ * If a FeedItem is already in the queue, the FeedItem will not change its position in the queue.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param performAutoDownload true if an auto-download process should be started after the operation.
+ * @param itemIds IDs of the FeedItem objects that should be added to the queue.
+ */
+ public static Future<?> addQueueItem(final Context context, final boolean performAutoDownload,
+ final long... itemIds) {
+ return addQueueItem(context, performAutoDownload, true, itemIds);
+ }
+
+ /**
+ * Appends FeedItem objects to the end of the queue. The 'read'-attribute of all items will be set to true.
+ * If a FeedItem is already in the queue, the FeedItem will not change its position in the queue.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param performAutoDownload true if an auto-download process should be started after the operation.
+ * @param markAsUnplayed true if the items should be marked as unplayed when enqueueing
+ * @param itemIds IDs of the FeedItem objects that should be added to the queue.
+ */
+ public static Future<?> addQueueItem(final Context context, final boolean performAutoDownload,
+ final boolean markAsUnplayed, final long... itemIds) {
+ return runOnDbThread(() -> {
+ if (itemIds.length < 1) {
+ return;
+ }
+
+ final PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ final List<FeedItem> queue = DBReader.getQueue(adapter);
+
+ boolean queueModified = false;
+ LongList markAsUnplayedIds = new LongList();
+ List<QueueEvent> events = new ArrayList<>();
+ List<FeedItem> updatedItems = new ArrayList<>();
+ ItemEnqueuePositionCalculator positionCalculator =
+ new ItemEnqueuePositionCalculator(UserPreferences.getEnqueueLocation());
+ Playable currentlyPlaying = DBReader.getFeedMedia(PlaybackPreferences.getCurrentlyPlayingFeedMediaId());
+ int insertPosition = positionCalculator.calcPosition(queue, currentlyPlaying);
+ for (long itemId : itemIds) {
+ if (!itemListContains(queue, itemId)) {
+ final FeedItem item = DBReader.getFeedItem(itemId);
+ if (item != null) {
+ queue.add(insertPosition, item);
+ events.add(QueueEvent.added(item, insertPosition));
+
+ item.addTag(FeedItem.TAG_QUEUE);
+ updatedItems.add(item);
+ queueModified = true;
+ if (item.isNew()) {
+ markAsUnplayedIds.add(item.getId());
+ }
+ insertPosition++;
+ }
+ }
+ }
+ if (queueModified) {
+ applySortOrder(queue, events);
+ adapter.setQueue(queue);
+ for (QueueEvent event : events) {
+ EventBus.getDefault().post(event);
+ }
+ EventBus.getDefault().post(FeedItemEvent.updated(updatedItems));
+ if (markAsUnplayed && markAsUnplayedIds.size() > 0) {
+ DBWriter.markItemPlayed(FeedItem.UNPLAYED, markAsUnplayedIds.toArray());
+ }
+ }
+ adapter.close();
+ if (performAutoDownload) {
+ AutoDownloadManager.getInstance().autodownloadUndownloadedItems(context);
+ }
+ });
+ }
+
+ /**
+ * Sorts the queue depending on the configured sort order.
+ * If the queue is not in keep sorted mode, nothing happens.
+ *
+ * @param queue The queue to be sorted.
+ * @param events Replaces the events by a single SORT event if the list has to be sorted automatically.
+ */
+ private static void applySortOrder(List<FeedItem> queue, List<QueueEvent> events) {
+ if (!UserPreferences.isQueueKeepSorted()) {
+ // queue is not in keep sorted mode, there's nothing to do
+ return;
+ }
+
+ // Sort queue by configured sort order
+ SortOrder sortOrder = UserPreferences.getQueueKeepSortedOrder();
+ if (sortOrder == SortOrder.RANDOM) {
+ // do not shuffle the list on every change
+ return;
+ }
+ Permutor<FeedItem> permutor = FeedItemPermutors.getPermutor(sortOrder);
+ permutor.reorder(queue);
+
+ // Replace ADDED events by a single SORTED event
+ events.clear();
+ events.add(QueueEvent.sorted(queue));
+ }
+
+ /**
+ * Removes all FeedItem objects from the queue.
+ */
+ public static Future<?> clearQueue() {
+ return runOnDbThread(() -> {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.clearQueue();
+ adapter.close();
+
+ EventBus.getDefault().post(QueueEvent.cleared());
+ });
+ }
+
+ /**
+ * Removes a FeedItem object from the queue.
+ *
+ * @param context A context that is used for opening a database connection.
+ * @param performAutoDownload true if an auto-download process should be started after the operation.
+ * @param item FeedItem that should be removed.
+ */
+ public static Future<?> removeQueueItem(final Context context,
+ final boolean performAutoDownload, final FeedItem item) {
+ return runOnDbThread(() -> removeQueueItemSynchronous(context, performAutoDownload, item.getId()));
+ }
+
+ public static Future<?> removeQueueItem(final Context context, final boolean performAutoDownload,
+ final long... itemIds) {
+ return runOnDbThread(() -> removeQueueItemSynchronous(context, performAutoDownload, itemIds));
+ }
+
+ private static void removeQueueItemSynchronous(final Context context,
+ final boolean performAutoDownload,
+ final long... itemIds) {
+ if (itemIds.length < 1) {
+ return;
+ }
+ final PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ final List<FeedItem> queue = DBReader.getQueue(adapter);
+
+ if (queue != null) {
+ boolean queueModified = false;
+ List<QueueEvent> events = new ArrayList<>();
+ List<FeedItem> updatedItems = new ArrayList<>();
+ for (long itemId : itemIds) {
+ int position = indexInItemList(queue, itemId);
+ if (position >= 0) {
+ final FeedItem item = DBReader.getFeedItem(itemId);
+ if (item == null) {
+ Log.e(TAG, "removeQueueItem - item in queue but somehow cannot be loaded." +
+ " Item ignored. It should never happen. id:" + itemId);
+ continue;
+ }
+ queue.remove(position);
+ item.removeTag(FeedItem.TAG_QUEUE);
+ events.add(QueueEvent.removed(item));
+ updatedItems.add(item);
+ queueModified = true;
+ } else {
+ Log.v(TAG, "removeQueueItem - item not in queue:" + itemId);
+ }
+ }
+ if (queueModified) {
+ adapter.setQueue(queue);
+ for (QueueEvent event : events) {
+ EventBus.getDefault().post(event);
+ }
+ EventBus.getDefault().post(FeedItemEvent.updated(updatedItems));
+ } else {
+ Log.w(TAG, "Queue was not modified by call to removeQueueItem");
+ }
+ } else {
+ Log.e(TAG, "removeQueueItem: Could not load queue");
+ }
+ adapter.close();
+ if (performAutoDownload) {
+ AutoDownloadManager.getInstance().autodownloadUndownloadedItems(context);
+ }
+ }
+
+ public static Future<?> toggleFavoriteItem(final FeedItem item) {
+ if (item.isTagged(FeedItem.TAG_FAVORITE)) {
+ return removeFavoriteItem(item);
+ } else {
+ return addFavoriteItem(item);
+ }
+ }
+
+ public static Future<?> addFavoriteItem(final FeedItem item) {
+ return runOnDbThread(() -> {
+ final PodDBAdapter adapter = PodDBAdapter.getInstance().open();
+ adapter.addFavoriteItem(item);
+ adapter.close();
+ item.addTag(FeedItem.TAG_FAVORITE);
+ EventBus.getDefault().post(new FavoritesEvent());
+ EventBus.getDefault().post(FeedItemEvent.updated(item));
+ });
+ }
+
+ public static Future<?> removeFavoriteItem(final FeedItem item) {
+ return runOnDbThread(() -> {
+ final PodDBAdapter adapter = PodDBAdapter.getInstance().open();
+ adapter.removeFavoriteItem(item);
+ adapter.close();
+ item.removeTag(FeedItem.TAG_FAVORITE);
+ EventBus.getDefault().post(new FavoritesEvent());
+ EventBus.getDefault().post(FeedItemEvent.updated(item));
+ });
+ }
+
+ /**
+ * Moves the specified item to the top of the queue.
+ *
+ * @param itemId The item to move to the top of the queue
+ * @param broadcastUpdate true if this operation should trigger a QueueUpdateBroadcast. This option should be set to
+ */
+ public static Future<?> moveQueueItemToTop(final long itemId, final boolean broadcastUpdate) {
+ return runOnDbThread(() -> {
+ LongList queueIdList = DBReader.getQueueIDList();
+ int index = queueIdList.indexOf(itemId);
+ if (index >= 0) {
+ moveQueueItemHelper(index, 0, broadcastUpdate);
+ } else {
+ Log.e(TAG, "moveQueueItemToTop: item not found");
+ }
+ });
+ }
+
+ /**
+ * Moves the specified item to the bottom of the queue.
+ *
+ * @param itemId The item to move to the bottom of the queue
+ * @param broadcastUpdate true if this operation should trigger a QueueUpdateBroadcast. This option should be set to
+ */
+ public static Future<?> moveQueueItemToBottom(final long itemId,
+ final boolean broadcastUpdate) {
+ return runOnDbThread(() -> {
+ LongList queueIdList = DBReader.getQueueIDList();
+ int index = queueIdList.indexOf(itemId);
+ if (index >= 0) {
+ moveQueueItemHelper(index, queueIdList.size() - 1,
+ broadcastUpdate);
+ } else {
+ Log.e(TAG, "moveQueueItemToBottom: item not found");
+ }
+ });
+ }
+
+ /**
+ * Changes the position of a FeedItem in the queue.
+ *
+ * @param from Source index. Must be in range 0..queue.size()-1.
+ * @param to Destination index. Must be in range 0..queue.size()-1.
+ * @param broadcastUpdate true if this operation should trigger a QueueUpdateBroadcast. This option should be set to
+ * false if the caller wants to avoid unexpected updates of the GUI.
+ * @throws IndexOutOfBoundsException if (to < 0 || to >= queue.size()) || (from < 0 || from >= queue.size())
+ */
+ public static Future<?> moveQueueItem(final int from,
+ final int to, final boolean broadcastUpdate) {
+ return runOnDbThread(() -> moveQueueItemHelper(from, to, broadcastUpdate));
+ }
+
+ /**
+ * Changes the position of a FeedItem in the queue.
+ * <p/>
+ * This function must be run using the ExecutorService (dbExec).
+ *
+ * @param from Source index. Must be in range 0..queue.size()-1.
+ * @param to Destination index. Must be in range 0..queue.size()-1.
+ * @param broadcastUpdate true if this operation should trigger a QueueUpdateBroadcast. This option should be set to
+ * false if the caller wants to avoid unexpected updates of the GUI.
+ * @throws IndexOutOfBoundsException if (to < 0 || to >= queue.size()) || (from < 0 || from >= queue.size())
+ */
+ private static void moveQueueItemHelper(final int from,
+ final int to, final boolean broadcastUpdate) {
+ final PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ final List<FeedItem> queue = DBReader.getQueue(adapter);
+
+ if (queue != null) {
+ if (from >= 0 && from < queue.size() && to >= 0 && to < queue.size()) {
+ final FeedItem item = queue.remove(from);
+ queue.add(to, item);
+
+ adapter.setQueue(queue);
+ if (broadcastUpdate) {
+ EventBus.getDefault().post(QueueEvent.moved(item, to));
+ }
+ }
+ } else {
+ Log.e(TAG, "moveQueueItemHelper: Could not load queue");
+ }
+ adapter.close();
+ }
+
+ public static Future<?> resetPagedFeedPage(Feed feed) {
+ return runOnDbThread(() -> {
+ final PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.resetPagedFeedPage(feed);
+ adapter.close();
+ });
+ }
+
+ /*
+ * Sets the 'read'-attribute of all specified FeedItems
+ *
+ * @param played New value of the 'read'-attribute, one of FeedItem.PLAYED, FeedItem.NEW,
+ * FeedItem.UNPLAYED
+ * @param itemIds IDs of the FeedItems.
+ */
+ public static Future<?> markItemPlayed(final int played, final long... itemIds) {
+ return markItemPlayed(played, true, itemIds);
+ }
+
+ /*
+ * Sets the 'read'-attribute of all specified FeedItems
+ *
+ * @param played New value of the 'read'-attribute, one of FeedItem.PLAYED, FeedItem.NEW,
+ * FeedItem.UNPLAYED
+ * @param broadcastUpdate true if this operation should trigger a UnreadItemsUpdate broadcast.
+ * This option is usually set to true
+ * @param itemIds IDs of the FeedItems.
+ */
+ public static Future<?> markItemPlayed(final int played, final boolean broadcastUpdate,
+ final long... itemIds) {
+ return runOnDbThread(() -> {
+ final PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setFeedItemRead(played, itemIds);
+ adapter.close();
+ if (broadcastUpdate) {
+ EventBus.getDefault().post(new UnreadItemsUpdateEvent());
+ }
+ });
+ }
+
+ /**
+ * Sets the 'read'-attribute of a FeedItem to the specified value.
+ *
+ * @param item The FeedItem object
+ * @param played New value of the 'read'-attribute one of FeedItem.PLAYED,
+ * FeedItem.NEW, FeedItem.UNPLAYED
+ * @param resetMediaPosition true if this method should also reset the position of the FeedItem's FeedMedia object.
+ */
+ @NonNull
+ public static Future<?> markItemPlayed(FeedItem item, int played, boolean resetMediaPosition) {
+ long mediaId = (item.hasMedia()) ? item.getMedia().getId() : 0;
+ return markItemPlayed(item.getId(), played, mediaId, resetMediaPosition);
+ }
+
+ @NonNull
+ private static Future<?> markItemPlayed(final long itemId,
+ final int played,
+ final long mediaId,
+ final boolean resetMediaPosition) {
+ return runOnDbThread(() -> {
+ final PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setFeedItemRead(played, itemId, mediaId,
+ resetMediaPosition);
+ adapter.close();
+
+ EventBus.getDefault().post(new UnreadItemsUpdateEvent());
+ });
+ }
+
+ /**
+ * Sets the 'read'-attribute of all NEW FeedItems of a specific Feed to UNPLAYED.
+ *
+ * @param feedId ID of the Feed.
+ */
+ public static Future<?> removeFeedNewFlag(final long feedId) {
+ return runOnDbThread(() -> {
+ final PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setFeedItems(FeedItem.NEW, FeedItem.UNPLAYED, feedId);
+ adapter.close();
+
+ EventBus.getDefault().post(new UnreadItemsUpdateEvent());
+ });
+ }
+
+ /**
+ * Sets the 'read'-attribute of all NEW FeedItems to UNPLAYED.
+ */
+ public static Future<?> removeAllNewFlags() {
+ return runOnDbThread(() -> {
+ final PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setFeedItems(FeedItem.NEW, FeedItem.UNPLAYED);
+ adapter.close();
+
+ EventBus.getDefault().post(new UnreadItemsUpdateEvent());
+ });
+ }
+
+ static Future<?> addNewFeed(final Context context, final Feed... feeds) {
+ return runOnDbThread(() -> {
+ final PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feeds);
+ adapter.close();
+
+ for (Feed feed : feeds) {
+ if (!feed.isLocalFeed()) {
+ SynchronizationQueueSink.enqueueFeedAddedIfSynchronizationIsActive(context, feed.getDownloadUrl());
+ }
+ }
+
+ BackupManager backupManager = new BackupManager(context);
+ backupManager.dataChanged();
+ });
+ }
+
+ static Future<?> setCompleteFeed(final Feed... feeds) {
+ return runOnDbThread(() -> {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feeds);
+ adapter.close();
+ });
+ }
+
+ public static Future<?> setItemList(final List<FeedItem> items) {
+ return runOnDbThread(() -> {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.storeFeedItemlist(items);
+ adapter.close();
+ EventBus.getDefault().post(FeedItemEvent.updated(items));
+ });
+ }
+
+ /**
+ * Saves a FeedMedia object in the database. This method will save all attributes of the FeedMedia object. The
+ * contents of FeedComponent-attributes (e.g. the FeedMedia's 'item'-attribute) will not be saved.
+ *
+ * @param media The FeedMedia object.
+ */
+ public static Future<?> setFeedMedia(final FeedMedia media) {
+ return runOnDbThread(() -> {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setMedia(media);
+ adapter.close();
+ });
+ }
+
+ /**
+ * Saves the 'position', 'duration' and 'last played time' attributes of a FeedMedia object
+ *
+ * @param media The FeedMedia object.
+ */
+ public static Future<?> setFeedMediaPlaybackInformation(final FeedMedia media) {
+ return runOnDbThread(() -> {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setFeedMediaPlaybackInformation(media);
+ adapter.close();
+ });
+ }
+
+ /**
+ * Saves a FeedItem object in the database. This method will save all attributes of the FeedItem object including
+ * the content of FeedComponent-attributes.
+ *
+ * @param item The FeedItem object.
+ */
+ public static Future<?> setFeedItem(final FeedItem item) {
+ return runOnDbThread(() -> {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setSingleFeedItem(item);
+ adapter.close();
+ EventBus.getDefault().post(FeedItemEvent.updated(item));
+ });
+ }
+
+ /**
+ * Updates download URL of a feed
+ */
+ public static Future<?> updateFeedDownloadURL(final String original, final String updated) {
+ Log.d(TAG, "updateFeedDownloadURL(original: " + original + ", updated: " + updated + ")");
+ return runOnDbThread(() -> {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setFeedDownloadUrl(original, updated);
+ adapter.close();
+ });
+ }
+
+ /**
+ * Saves a FeedPreferences object in the database. The Feed ID of the FeedPreferences-object MUST NOT be 0.
+ *
+ * @param preferences The FeedPreferences object.
+ */
+ public static Future<?> setFeedPreferences(final FeedPreferences preferences) {
+ return runOnDbThread(() -> {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setFeedPreferences(preferences);
+ adapter.close();
+ EventBus.getDefault().post(new FeedListUpdateEvent(preferences.getFeedID()));
+ });
+ }
+
+ private static boolean itemListContains(List<FeedItem> items, long itemId) {
+ return indexInItemList(items, itemId) >= 0;
+ }
+
+ private static int indexInItemList(List<FeedItem> items, long itemId) {
+ for (int i = 0; i < items.size(); i++) {
+ FeedItem item = items.get(i);
+ if (item.getId() == itemId) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ /**
+ * Saves if a feed's last update failed
+ *
+ * @param lastUpdateFailed true if last update failed
+ */
+ public static Future<?> setFeedLastUpdateFailed(final long feedId,
+ final boolean lastUpdateFailed) {
+ return runOnDbThread(() -> {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setFeedLastUpdateFailed(feedId, lastUpdateFailed);
+ adapter.close();
+ EventBus.getDefault().post(new FeedListUpdateEvent(feedId));
+ });
+ }
+
+ public static Future<?> setFeedCustomTitle(Feed feed) {
+ return runOnDbThread(() -> {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setFeedCustomTitle(feed.getId(), feed.getCustomTitle());
+ adapter.close();
+ EventBus.getDefault().post(new FeedListUpdateEvent(feed));
+ });
+ }
+
+ /**
+ * Sort the FeedItems in the queue with the given the named sort order.
+ *
+ * @param broadcastUpdate <code>true</code> if this operation should trigger a
+ * QueueUpdateBroadcast. This option should be set to <code>false</code>
+ * if the caller wants to avoid unexpected updates of the GUI.
+ */
+ public static Future<?> reorderQueue(@Nullable SortOrder sortOrder, final boolean broadcastUpdate) {
+ if (sortOrder == null) {
+ Log.w(TAG, "reorderQueue() - sortOrder is null. Do nothing.");
+ return runOnDbThread(() -> { });
+ }
+ final Permutor<FeedItem> permutor = FeedItemPermutors.getPermutor(sortOrder);
+ return runOnDbThread(() -> {
+ final PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ final List<FeedItem> queue = DBReader.getQueue(adapter);
+
+ if (queue != null) {
+ permutor.reorder(queue);
+ adapter.setQueue(queue);
+ if (broadcastUpdate) {
+ EventBus.getDefault().post(QueueEvent.sorted(queue));
+ }
+ } else {
+ Log.e(TAG, "reorderQueue: Could not load queue");
+ }
+ adapter.close();
+ });
+ }
+
+ /**
+ * Set filter of the feed
+ *
+ * @param feedId The feed's ID
+ * @param filterValues Values that represent properties to filter by
+ */
+ public static Future<?> setFeedItemsFilter(final long feedId,
+ final Set<String> filterValues) {
+ Log.d(TAG, "setFeedItemsFilter() called with: " + "feedId = [" + feedId + "], filterValues = [" + filterValues + "]");
+ return runOnDbThread(() -> {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setFeedItemFilter(feedId, filterValues);
+ adapter.close();
+ EventBus.getDefault().post(new FeedEvent(FeedEvent.Action.FILTER_CHANGED, feedId));
+ });
+ }
+
+ /**
+ * Set item sort order of the feed
+ *
+ */
+ public static Future<?> setFeedItemSortOrder(long feedId, @Nullable SortOrder sortOrder) {
+ return runOnDbThread(() -> {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setFeedItemSortOrder(feedId, sortOrder);
+ adapter.close();
+ EventBus.getDefault().post(new FeedEvent(FeedEvent.Action.SORT_ORDER_CHANGED, feedId));
+ });
+ }
+
+ /**
+ * Reset the statistics in DB
+ */
+ @NonNull
+ public static Future<?> resetStatistics() {
+ return runOnDbThread(() -> {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.resetAllMediaPlayedDuration();
+ adapter.close();
+ });
+ }
+
+ /**
+ * Removes the feed with the given download url. This method should NOT be executed on the GUI thread.
+ *
+ * @param context Used for accessing the db
+ * @param downloadUrl URL of the feed.
+ */
+ public static void removeFeedWithDownloadUrl(Context context, String downloadUrl) {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ Cursor cursor = adapter.getFeedCursorDownloadUrls();
+ long feedId = 0;
+ if (cursor.moveToFirst()) {
+ do {
+ if (cursor.getString(1).equals(downloadUrl)) {
+ feedId = cursor.getLong(0);
+ }
+ } while (cursor.moveToNext());
+ }
+ cursor.close();
+ adapter.close();
+
+ if (feedId != 0) {
+ try {
+ deleteFeed(context, feedId).get();
+ } catch (InterruptedException | ExecutionException e) {
+ e.printStackTrace();
+ }
+ } else {
+ Log.w(TAG, "removeFeedWithDownloadUrl: Could not find feed with url: " + downloadUrl);
+ }
+ }
+
+ /**
+ * Submit to the DB thread only if caller is not already on the DB thread. Otherwise,
+ * just execute synchronously
+ */
+ private static Future<?> runOnDbThread(Runnable runnable) {
+ if ("DatabaseExecutor".equals(Thread.currentThread().getName())) {
+ runnable.run();
+ return Futures.immediateFuture(null);
+ } else {
+ return dbExec.submit(runnable);
+ }
+ }
+}
diff --git a/storage/database/src/main/java/de/danoeh/antennapod/storage/database/FeedDatabaseWriter.java b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/FeedDatabaseWriter.java
new file mode 100644
index 000000000..eb33f5705
--- /dev/null
+++ b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/FeedDatabaseWriter.java
@@ -0,0 +1,259 @@
+package de.danoeh.antennapod.storage.database;
+
+import android.content.Context;
+import android.text.TextUtils;
+import android.util.Log;
+import de.danoeh.antennapod.event.FeedListUpdateEvent;
+import de.danoeh.antennapod.model.download.DownloadError;
+import de.danoeh.antennapod.model.download.DownloadResult;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedPreferences;
+import de.danoeh.antennapod.net.sync.model.EpisodeAction;
+import de.danoeh.antennapod.net.sync.serviceinterface.SynchronizationQueueSink;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import org.greenrobot.eventbus.EventBus;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+
+/**
+ * Creates and updates feeds in the database.
+ */
+public abstract class FeedDatabaseWriter {
+ private static final String TAG = "FeedDbWriter";
+
+ private static Feed searchFeedByIdentifyingValueOrID(Feed feed) {
+ if (feed.getId() != 0) {
+ return DBReader.getFeed(feed.getId());
+ } else {
+ List<Feed> feeds = DBReader.getFeedList();
+ for (Feed f : feeds) {
+ if (f.getIdentifyingValue().equals(feed.getIdentifyingValue())) {
+ f.setItems(DBReader.getFeedItemList(f));
+ return f;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Get a FeedItem by its identifying value.
+ */
+ private static FeedItem searchFeedItemByIdentifyingValue(List<FeedItem> items, FeedItem searchItem) {
+ for (FeedItem item : items) {
+ if (TextUtils.equals(item.getIdentifyingValue(), searchItem.getIdentifyingValue())) {
+ return item;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Guess if one of the items could actually mean the searched item, even if it uses another identifying value.
+ * This is to work around podcasters breaking their GUIDs.
+ */
+ private static FeedItem searchFeedItemGuessDuplicate(List<FeedItem> items, FeedItem searchItem) {
+ // First, see if it is a well-behaving feed that contains an item with the same identifier
+ for (FeedItem item : items) {
+ if (FeedItemDuplicateGuesser.sameAndNotEmpty(item.getItemIdentifier(), searchItem.getItemIdentifier())) {
+ return item;
+ }
+ }
+ // Not found yet, start more expensive guessing
+ for (FeedItem item : items) {
+ if (FeedItemDuplicateGuesser.seemDuplicates(item, searchItem)) {
+ return item;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Adds new Feeds to the database or updates the old versions if they already exists. If another Feed with the same
+ * identifying value already exists, this method will add new FeedItems from the new Feed to the existing Feed.
+ * These FeedItems will be marked as unread with the exception of the most recent FeedItem.
+ *
+ * @param context Used for accessing the DB.
+ * @param newFeed The new Feed object.
+ * @param removeUnlistedItems The item list in the new Feed object is considered to be exhaustive.
+ * I.e. items are removed from the database if they are not in this item list.
+ * @return The updated Feed from the database if it already existed, or the new Feed from the parameters otherwise.
+ */
+ public static synchronized Feed updateFeed(Context context, Feed newFeed, boolean removeUnlistedItems) {
+ Feed resultFeed;
+ List<FeedItem> unlistedItems = new ArrayList<>();
+ List<FeedItem> itemsToAddToQueue = new ArrayList<>();
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+
+ // Look up feed in the feedslist
+ final Feed savedFeed = searchFeedByIdentifyingValueOrID(newFeed);
+ if (savedFeed == null) {
+ Log.d(TAG, "Found no existing Feed with title "
+ + newFeed.getTitle() + ". Adding as new one.");
+
+ resultFeed = newFeed;
+ } else {
+ Log.d(TAG, "Feed with title " + newFeed.getTitle()
+ + " already exists. Syncing new with existing one.");
+
+ Collections.sort(newFeed.getItems(), new FeedItemPubdateComparator());
+
+ if (newFeed.getPageNr() == savedFeed.getPageNr()) {
+ savedFeed.updateFromOther(newFeed);
+ savedFeed.getPreferences().updateFromOther(newFeed.getPreferences());
+ } else {
+ Log.d(TAG, "New feed has a higher page number.");
+ savedFeed.setNextPageLink(newFeed.getNextPageLink());
+ }
+
+ // get the most recent date now, before we start changing the list
+ FeedItem priorMostRecent = savedFeed.getMostRecentItem();
+ Date priorMostRecentDate = new Date();
+ if (priorMostRecent != null) {
+ priorMostRecentDate = priorMostRecent.getPubDate();
+ }
+
+ // Look for new or updated Items
+ for (int idx = 0; idx < newFeed.getItems().size(); idx++) {
+ final FeedItem item = newFeed.getItems().get(idx);
+
+ FeedItem possibleDuplicate = searchFeedItemGuessDuplicate(newFeed.getItems(), item);
+ if (!newFeed.isLocalFeed() && possibleDuplicate != null && item != possibleDuplicate) {
+ // Canonical episode is the first one returned (usually oldest)
+ DBWriter.addDownloadStatus(new DownloadResult(item.getTitle(),
+ savedFeed.getId(), Feed.FEEDFILETYPE_FEED, false,
+ DownloadError.ERROR_PARSER_EXCEPTION_DUPLICATE,
+ "The podcast host appears to have added the same episode twice. "
+ + "AntennaPod still refreshed the feed and attempted to repair it."
+ + "\n\nOriginal episode:\n" + duplicateEpisodeDetails(item)
+ + "\n\nSecond episode that is also in the feed:\n"
+ + duplicateEpisodeDetails(possibleDuplicate)));
+ continue;
+ }
+
+ FeedItem oldItem = searchFeedItemByIdentifyingValue(savedFeed.getItems(), item);
+ if (!newFeed.isLocalFeed() && oldItem == null) {
+ oldItem = searchFeedItemGuessDuplicate(savedFeed.getItems(), item);
+ if (oldItem != null) {
+ Log.d(TAG, "Repaired duplicate: " + oldItem + ", " + item);
+ DBWriter.addDownloadStatus(new DownloadResult(item.getTitle(),
+ savedFeed.getId(), Feed.FEEDFILETYPE_FEED, false,
+ DownloadError.ERROR_PARSER_EXCEPTION_DUPLICATE,
+ "The podcast host changed the ID of an existing episode instead of just "
+ + "updating the episode itself. AntennaPod still refreshed the feed and "
+ + "attempted to repair it."
+ + "\n\nOriginal episode:\n" + duplicateEpisodeDetails(oldItem)
+ + "\n\nNow the feed contains:\n" + duplicateEpisodeDetails(item)));
+ oldItem.setItemIdentifier(item.getItemIdentifier());
+
+ if (oldItem.isPlayed() && oldItem.getMedia() != null) {
+ EpisodeAction action = new EpisodeAction.Builder(oldItem, EpisodeAction.PLAY)
+ .currentTimestamp()
+ .started(oldItem.getMedia().getDuration() / 1000)
+ .position(oldItem.getMedia().getDuration() / 1000)
+ .total(oldItem.getMedia().getDuration() / 1000)
+ .build();
+ SynchronizationQueueSink.enqueueEpisodeActionIfSynchronizationIsActive(context, action);
+ }
+ }
+ }
+
+ if (oldItem != null) {
+ oldItem.updateFromOther(item);
+ } else {
+ Log.d(TAG, "Found new item: " + item.getTitle());
+ item.setFeed(savedFeed);
+
+ if (idx >= savedFeed.getItems().size()) {
+ savedFeed.getItems().add(item);
+ } else {
+ savedFeed.getItems().add(idx, item);
+ }
+
+ if (item.getPubDate() == null
+ || priorMostRecentDate == null
+ || priorMostRecentDate.before(item.getPubDate())
+ || priorMostRecentDate.equals(item.getPubDate())) {
+ Log.d(TAG, "Performing new episode action for item published on " + item.getPubDate()
+ + ", prior most recent date = " + priorMostRecentDate);
+ FeedPreferences.NewEpisodesAction action = savedFeed.getPreferences().getNewEpisodesAction();
+ if (action == FeedPreferences.NewEpisodesAction.GLOBAL) {
+ action = UserPreferences.getNewEpisodesAction();
+ }
+ switch (action) {
+ case ADD_TO_INBOX:
+ item.setNew();
+ break;
+ case ADD_TO_QUEUE:
+ itemsToAddToQueue.add(item);
+ break;
+ default:
+ break;
+ }
+ }
+ }
+ }
+
+ // identify items to be removed
+ if (removeUnlistedItems) {
+ Iterator<FeedItem> it = savedFeed.getItems().iterator();
+ while (it.hasNext()) {
+ FeedItem feedItem = it.next();
+ if (searchFeedItemByIdentifyingValue(newFeed.getItems(), feedItem) == null) {
+ unlistedItems.add(feedItem);
+ it.remove();
+ }
+ }
+ }
+
+ // update attributes
+ savedFeed.setLastModified(newFeed.getLastModified());
+ savedFeed.setType(newFeed.getType());
+ savedFeed.setLastUpdateFailed(false);
+
+ resultFeed = savedFeed;
+ }
+
+ try {
+ if (savedFeed == null) {
+ DBWriter.addNewFeed(context, newFeed).get();
+ // Update with default values that are set in database
+ resultFeed = searchFeedByIdentifyingValueOrID(newFeed);
+ } else {
+ DBWriter.setCompleteFeed(savedFeed).get();
+ }
+ if (removeUnlistedItems) {
+ DBWriter.deleteFeedItems(context, unlistedItems).get();
+ }
+ } catch (InterruptedException | ExecutionException e) {
+ e.printStackTrace();
+ }
+
+ // We need to add to queue after items are saved to database
+ DBWriter.addQueueItem(context, itemsToAddToQueue.toArray(new FeedItem[0]));
+
+ adapter.close();
+
+ if (savedFeed != null) {
+ EventBus.getDefault().post(new FeedListUpdateEvent(savedFeed));
+ } else {
+ EventBus.getDefault().post(new FeedListUpdateEvent(Collections.emptyList()));
+ }
+
+ return resultFeed;
+ }
+
+ private static String duplicateEpisodeDetails(FeedItem item) {
+ return "Title: " + item.getTitle()
+ + "\nID: " + item.getItemIdentifier()
+ + ((item.getMedia() == null) ? "" : "\nURL: " + item.getMedia().getDownloadUrl());
+ }
+}
diff --git a/storage/database/src/main/java/de/danoeh/antennapod/storage/database/FeedItemDuplicateGuesser.java b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/FeedItemDuplicateGuesser.java
new file mode 100644
index 000000000..bbaedb519
--- /dev/null
+++ b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/FeedItemDuplicateGuesser.java
@@ -0,0 +1,83 @@
+package de.danoeh.antennapod.storage.database;
+
+import android.text.TextUtils;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+
+import java.text.DateFormat;
+import java.util.Locale;
+
+/**
+ * Publishers sometimes mess up their feed by adding episodes twice or by changing the ID of existing episodes.
+ * This class tries to guess if publishers actually meant another episode,
+ * even if their feed explicitly says that the episodes are different.
+ */
+public class FeedItemDuplicateGuesser {
+ public static boolean seemDuplicates(FeedItem item1, FeedItem item2) {
+ if (sameAndNotEmpty(item1.getItemIdentifier(), item2.getItemIdentifier())) {
+ return true;
+ }
+ FeedMedia media1 = item1.getMedia();
+ FeedMedia media2 = item2.getMedia();
+ if (media1 == null || media2 == null) {
+ return false;
+ }
+ if (sameAndNotEmpty(media1.getStreamUrl(), media2.getStreamUrl())) {
+ return true;
+ }
+ return titlesLookSimilar(item1, item2)
+ && datesLookSimilar(item1, item2)
+ && durationsLookSimilar(media1, media2)
+ && mimeTypeLooksSimilar(media1, media2);
+ }
+
+ public static boolean sameAndNotEmpty(String string1, String string2) {
+ if (TextUtils.isEmpty(string1) || TextUtils.isEmpty(string2)) {
+ return false;
+ }
+ return string1.equals(string2);
+ }
+
+ private static boolean datesLookSimilar(FeedItem item1, FeedItem item2) {
+ if (item1.getPubDate() == null || item2.getPubDate() == null) {
+ return false;
+ }
+ DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.SHORT, Locale.US); // MM/DD/YY
+ String dateOriginal = dateFormat.format(item2.getPubDate());
+ String dateNew = dateFormat.format(item1.getPubDate());
+ return TextUtils.equals(dateOriginal, dateNew); // Same date; time is ignored.
+ }
+
+ private static boolean durationsLookSimilar(FeedMedia media1, FeedMedia media2) {
+ return Math.abs(media1.getDuration() - media2.getDuration()) < 10 * 60L * 1000L;
+ }
+
+ private static boolean mimeTypeLooksSimilar(FeedMedia media1, FeedMedia media2) {
+ String mimeType1 = media1.getMimeType();
+ String mimeType2 = media2.getMimeType();
+ if (mimeType1 == null || mimeType2 == null) {
+ return true;
+ }
+ if (mimeType1.contains("/") && mimeType2.contains("/")) {
+ mimeType1 = mimeType1.substring(0, mimeType1.indexOf("/"));
+ mimeType2 = mimeType2.substring(0, mimeType2.indexOf("/"));
+ }
+ return TextUtils.equals(mimeType1, mimeType2);
+ }
+
+ private static boolean titlesLookSimilar(FeedItem item1, FeedItem item2) {
+ return sameAndNotEmpty(canonicalizeTitle(item1.getTitle()), canonicalizeTitle(item2.getTitle()));
+ }
+
+ private static String canonicalizeTitle(String title) {
+ if (title == null) {
+ return "";
+ }
+ return title
+ .trim()
+ .replace('“', '"')
+ .replace('”', '"')
+ .replace('„', '"')
+ .replace('—', '-');
+ }
+}
diff --git a/storage/database/src/main/java/de/danoeh/antennapod/storage/database/FeedItemPubdateComparator.java b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/FeedItemPubdateComparator.java
new file mode 100644
index 000000000..3838a47ac
--- /dev/null
+++ b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/FeedItemPubdateComparator.java
@@ -0,0 +1,27 @@
+package de.danoeh.antennapod.storage.database;
+
+import java.util.Comparator;
+
+import de.danoeh.antennapod.model.feed.FeedItem;
+
+/**
+ * Compares the pubDate of two FeedItems for sorting.
+ */
+public class FeedItemPubdateComparator implements Comparator<FeedItem> {
+
+ /**
+ * Returns a new instance of this comparator in reverse order.
+ */
+ @Override
+ public int compare(FeedItem lhs, FeedItem rhs) {
+ if (rhs.getPubDate() == null && lhs.getPubDate() == null) {
+ return 0;
+ } else if (rhs.getPubDate() == null) {
+ return 1;
+ } else if (lhs.getPubDate() == null) {
+ return -1;
+ }
+ return rhs.getPubDate().compareTo(lhs.getPubDate());
+ }
+
+}
diff --git a/storage/database/src/main/java/de/danoeh/antennapod/storage/database/ItemEnqueuePositionCalculator.java b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/ItemEnqueuePositionCalculator.java
new file mode 100644
index 000000000..55c2b079c
--- /dev/null
+++ b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/ItemEnqueuePositionCalculator.java
@@ -0,0 +1,94 @@
+package de.danoeh.antennapod.storage.database;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.List;
+import java.util.Random;
+
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadServiceInterface;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.storage.preferences.UserPreferences.EnqueueLocation;
+import de.danoeh.antennapod.model.playback.Playable;
+
+/**
+ * @see DBWriter#addQueueItem(Context, boolean, long...) it uses the class to determine
+ * the positions of the {@link FeedItem} in the queue.
+ */
+public class ItemEnqueuePositionCalculator {
+
+ @NonNull
+ private final EnqueueLocation enqueueLocation;
+
+ 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);
+ case RANDOM:
+ Random random = new Random();
+ return random.nextInt(curQueue.size() + 1);
+ 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;
+ }
+ return curItem != null
+ && curItem.getMedia() != null
+ && DownloadServiceInterface.get().isDownloadingEpisode(curItem.getMedia().getDownloadUrl());
+ }
+
+ 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;
+ }
+}