package de.danoeh.antennapod.core.storage; import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.util.Log; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.Iterator; import java.util.List; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.FutureTask; import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicBoolean; import de.danoeh.antennapod.core.ClientConfig; import de.danoeh.antennapod.core.asynctask.FlattrClickWorker; import de.danoeh.antennapod.core.asynctask.FlattrStatusFetcher; import de.danoeh.antennapod.core.feed.EventDistributor; import de.danoeh.antennapod.core.feed.Feed; import de.danoeh.antennapod.core.feed.FeedItem; import de.danoeh.antennapod.core.feed.FeedMedia; import de.danoeh.antennapod.core.feed.FeedPreferences; import de.danoeh.antennapod.core.service.GpodnetSyncService; import de.danoeh.antennapod.core.service.download.DownloadStatus; import de.danoeh.antennapod.core.service.playback.PlaybackService; import de.danoeh.antennapod.core.util.DownloadError; import de.danoeh.antennapod.core.util.LongList; import de.danoeh.antennapod.core.util.comparator.FeedItemPubdateComparator; import de.danoeh.antennapod.core.util.exception.MediaFileNotFoundException; import de.danoeh.antennapod.core.util.flattr.FlattrUtils; /** * Provides methods for doing common tasks that use DBReader and DBWriter. */ public final class DBTasks { private static final String TAG = "DBTasks"; /** * Executor service used by the autodownloadUndownloadedEpisodes method. */ private static ExecutorService autodownloadExec; static { autodownloadExec = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r); t.setPriority(Thread.MIN_PRIORITY); return t; }); } private DBTasks() { } /** * 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 { DBWriter.deleteFeed(context, feedID).get(); } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } } else { Log.w(TAG, "removeFeedWithDownloadUrl: Could not find feed with url: " + downloadUrl); } } /** * Starts playback of a FeedMedia object's file. This method will build an Intent based on the given parameters to * start the {@link PlaybackService}. * * @param context Used for sending starting Services and Activities. * @param media The FeedMedia object. * @param showPlayer If true, starts the appropriate player activity ({@link de.danoeh.antennapod.activity.AudioplayerActivity} * or {@link de.danoeh.antennapod.activity.VideoplayerActivity} * @param startWhenPrepared Parameter for the {@link PlaybackService} start intent. If true, playback will start as * soon as the PlaybackService has finished loading the FeedMedia object's file. * @param shouldStream Parameter for the {@link PlaybackService} start intent. If true, the FeedMedia object's file * will be streamed, otherwise the downloaded file will be used. If the downloaded file cannot be * found, the PlaybackService will shutdown and the database entry of the FeedMedia object will be * corrected. */ public static void playMedia(final Context context, final FeedMedia media, boolean showPlayer, boolean startWhenPrepared, boolean shouldStream) { try { if (!shouldStream) { if (!media.fileExists()) { throw new MediaFileNotFoundException( "No episode was found at " + media.getFile_url(), media); } } // Start playback Service Intent launchIntent = new Intent(context, PlaybackService.class); launchIntent.putExtra(PlaybackService.EXTRA_PLAYABLE, media); launchIntent.putExtra(PlaybackService.EXTRA_START_WHEN_PREPARED, startWhenPrepared); launchIntent.putExtra(PlaybackService.EXTRA_SHOULD_STREAM, shouldStream); launchIntent.putExtra(PlaybackService.EXTRA_PREPARE_IMMEDIATELY, true); context.startService(launchIntent); if (showPlayer) { // Launch media player context.startActivity(PlaybackService.getPlayerActivityIntent( context, media)); } DBWriter.addQueueItemAt(context, media.getItem().getId(), 0, false); } catch (MediaFileNotFoundException e) { e.printStackTrace(); if (media.isPlaying()) { context.sendBroadcast(new Intent( PlaybackService.ACTION_SHUTDOWN_PLAYBACK_SERVICE)); } notifyMissingFeedMediaFile(context, media); } } private static AtomicBoolean isRefreshing = new AtomicBoolean(false); /** * Refreshes a given list of Feeds in a separate Thread. This method might ignore subsequent calls if it is still * enqueuing Feeds for download from a previous call * * @param context Might be used for accessing the database * @param feeds List of Feeds that should be refreshed. */ public static void refreshAllFeeds(final Context context, final List<Feed> feeds) { if (isRefreshing.compareAndSet(false, true)) { new Thread() { public void run() { if (feeds != null) { refreshFeeds(context, feeds); } else { refreshFeeds(context, DBReader.getFeedList()); } isRefreshing.set(false); if (FlattrUtils.hasToken()) { Log.d(TAG, "Flattring all pending things."); new FlattrClickWorker(context).executeAsync(); // flattr pending things Log.d(TAG, "Fetching flattr status."); new FlattrStatusFetcher(context).start(); } if (ClientConfig.gpodnetCallbacks.gpodnetEnabled()) { GpodnetSyncService.sendSyncIntent(context); } Log.d(TAG, "refreshAllFeeds autodownload"); autodownloadUndownloadedItems(context); } }.start(); } else { Log.d(TAG, "Ignoring request to refresh all feeds: Refresh lock is locked"); } } /** * @param context * @param feedList the list of feeds to refresh */ private static void refreshFeeds(final Context context, final List<Feed> feedList) { for (Feed feed : feedList) { FeedPreferences prefs = feed.getPreferences(); // feeds with !getKeepUpdated can only be refreshed // directly from the FeedActivity if (prefs.getKeepUpdated()) { try { refreshFeed(context, feed); } catch (DownloadRequestException e) { e.printStackTrace(); DBWriter.addDownloadStatus( new DownloadStatus(feed, feed .getHumanReadableIdentifier(), DownloadError.ERROR_REQUEST_ERROR, false, e .getMessage() ) ); } } } } /** * Downloads all pages of the given feed. * * @param context Used for requesting the download. * @param feed The Feed object. */ public static void refreshCompleteFeed(final Context context, final Feed feed) { try { refreshFeed(context, feed, true, false); } catch (DownloadRequestException e) { e.printStackTrace(); DBWriter.addDownloadStatus( new DownloadStatus(feed, feed .getHumanReadableIdentifier(), DownloadError.ERROR_REQUEST_ERROR, false, e .getMessage() ) ); } } /** * Downloads all pages of the given feed even if feed has not been modified since last refresh * * @param context Used for requesting the download. * @param feed The Feed object. */ public static void forceRefreshCompleteFeed(final Context context, final Feed feed) { try { refreshFeed(context, feed, true, true); } catch (DownloadRequestException e) { e.printStackTrace(); DBWriter.addDownloadStatus( new DownloadStatus(feed, feed .getHumanReadableIdentifier(), DownloadError.ERROR_REQUEST_ERROR, false, e .getMessage() ) ); } } /** * Queues the next page of this Feed for download. The given Feed has to be a paged * Feed (isPaged()=true) and must contain a nextPageLink. * * @param context Used for requesting the download. * @param feed The feed whose next page should be loaded. * @param loadAllPages True if any subsequent pages should also be loaded, false otherwise. */ public static void loadNextPageOfFeed(final Context context, Feed feed, boolean loadAllPages) throws DownloadRequestException { if (feed.isPaged() && feed.getNextPageLink() != null) { int pageNr = feed.getPageNr() + 1; Feed nextFeed = new Feed(feed.getNextPageLink(), null, feed.getTitle() + "(" + pageNr + ")"); nextFeed.setPageNr(pageNr); nextFeed.setPaged(true); nextFeed.setId(feed.getId()); DownloadRequester.getInstance().downloadFeed(context, nextFeed, loadAllPages, false); } else { Log.e(TAG, "loadNextPageOfFeed: Feed was either not paged or contained no nextPageLink"); } } /** * Refresh a specific Feed. The refresh may get canceled if the feed does not seem to be modified * and the last update was only few days ago. * * @param context Used for requesting the download. * @param feed The Feed object. */ public static void refreshFeed(Context context, Feed feed) throws DownloadRequestException { Log.d(TAG, "refreshFeed(feed.id: " + feed.getId() +")"); refreshFeed(context, feed, false, false); } /** * Refresh a specific feed even if feed has not been modified since last refresh * * @param context Used for requesting the download. * @param feed The Feed object. */ public static void forceRefreshFeed(Context context, Feed feed) throws DownloadRequestException { Log.d(TAG, "refreshFeed(feed.id: " + feed.getId() +")"); refreshFeed(context, feed, false, true); } private static void refreshFeed(Context context, Feed feed, boolean loadAllPages, boolean force) throws DownloadRequestException { Feed f; String lastUpdate = feed.hasLastUpdateFailed() ? null : feed.getLastUpdate(); if (feed.getPreferences() == null) { f = new Feed(feed.getDownload_url(), lastUpdate, feed.getTitle()); } else { f = new Feed(feed.getDownload_url(), lastUpdate, feed.getTitle(), feed.getPreferences().getUsername(), feed.getPreferences().getPassword()); } f.setId(feed.getId()); DownloadRequester.getInstance().downloadFeed(context, f, loadAllPages, force); } /** * Notifies the database about a missing FeedMedia file. This method will correct the FeedMedia object's values in the * DB and send a FeedUpdateBroadcast. */ public static void notifyMissingFeedMediaFile(final Context context, final FeedMedia media) { Log.i(TAG, "The feedmanager was notified about a missing episode. It will update its database now."); media.setDownloaded(false); media.setFile_url(null); DBWriter.setFeedMedia(media); EventDistributor.getInstance().sendFeedUpdateBroadcast(); } /** * Request the download of all objects in the queue. from a separate Thread. * * @param context Used for requesting the download an accessing the database. */ public static void downloadAllItemsInQueue(final Context context) { new Thread() { public void run() { List<FeedItem> queue = DBReader.getQueue(); if (!queue.isEmpty()) { try { downloadFeedItems(context, queue.toArray(new FeedItem[queue.size()])); } catch (DownloadRequestException e) { e.printStackTrace(); } } } }.start(); } /** * Requests the download of a list of FeedItem objects. * * @param context Used for requesting the download and accessing the DB. * @param items The FeedItem objects. */ public static void downloadFeedItems(final Context context, FeedItem... items) throws DownloadRequestException { downloadFeedItems(true, context, items); } static void downloadFeedItems(boolean performAutoCleanup, final Context context, final FeedItem... items) throws DownloadRequestException { final DownloadRequester requester = DownloadRequester.getInstance(); if (performAutoCleanup) { new Thread() { @Override public void run() { ClientConfig.dbTasksCallbacks.getEpisodeCacheCleanupAlgorithm() .makeRoomForEpisodes(context, items.length); } }.start(); } for (FeedItem item : items) { if (item.getMedia() != null && !requester.isDownloadingFile(item.getMedia()) && !item.getMedia().isDownloaded()) { if (items.length > 1) { try { requester.downloadMedia(context, item.getMedia()); } catch (DownloadRequestException e) { e.printStackTrace(); DBWriter.addDownloadStatus( new DownloadStatus(item.getMedia(), item .getMedia() .getHumanReadableIdentifier(), DownloadError.ERROR_REQUEST_ERROR, false, e.getMessage() ) ); } } else { requester.downloadMedia(context, item.getMedia()); } } } } /** * Looks for undownloaded episodes in the queue or list of unread items and request a download if * 1. Network is available * 2. The device is charging or the user allows auto download on battery * 3. There is free space in the episode cache * This method is executed on an internal single thread executor. * * @param context Used for accessing the DB. * @return A Future that can be used for waiting for the methods completion. */ public static Future<?> autodownloadUndownloadedItems(final Context context) { Log.d(TAG, "autodownloadUndownloadedItems"); return autodownloadExec.submit(ClientConfig.dbTasksCallbacks.getAutomaticDownloadAlgorithm() .autoDownloadUndownloadedItems(context)); } /** * Removed downloaded episodes outside of the queue if the episode cache is full. Episodes with a smaller * 'playbackCompletionDate'-value will be deleted first. * <p/> * This method should NOT be executed on the GUI thread. * * @param context Used for accessing the DB. */ public static void performAutoCleanup(final Context context) { ClientConfig.dbTasksCallbacks.getEpisodeCacheCleanupAlgorithm().performCleanup(context); } /** * Returns the successor of a FeedItem in the queue. * * @param itemId ID of the FeedItem * @param queue Used for determining the successor of the item. If this parameter is null, the method will load * the queue from the database in the same thread. * @return Successor of the FeedItem or null if the FeedItem is not in the queue or has no successor. */ public static FeedItem getQueueSuccessorOfItem(final long itemId, List<FeedItem> queue) { FeedItem result = null; if (queue == null) { queue = DBReader.getQueue(); } if (queue != null) { Iterator<FeedItem> iterator = queue.iterator(); while (iterator.hasNext()) { FeedItem item = iterator.next(); if (item.getId() == itemId) { if (iterator.hasNext()) { result = iterator.next(); } break; } } } return result; } /** * Loads the queue from the database and checks if the specified FeedItem is in the queue. * This method should NOT be executed in the GUI thread. * * @param context Used for accessing the DB. * @param feedItemId ID of the FeedItem */ public static boolean isInQueue(Context context, final long feedItemId) { LongList queue = DBReader.getQueueIDList(); return queue.contains(feedItemId); } private static Feed searchFeedByIdentifyingValueOrID(PodDBAdapter adapter, Feed feed) { if (feed.getId() != 0) { return DBReader.getFeed(feed.getId(), adapter); } 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(Feed feed, String identifier) { for (FeedItem item : feed.getItems()) { if (item.getIdentifyingValue().equals(identifier)) { 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. * <p/> * This method can update multiple feeds at once. Submitting a feed twice in the same method call can result in undefined behavior. * <p/> * This method should NOT be executed on the GUI thread. * * @param context Used for accessing the DB. * @param newFeeds The new Feed objects. * @return The updated Feeds from the database if it already existed, or the new Feed from the parameters otherwise. */ public static synchronized Feed[] updateFeed(final Context context, final Feed... newFeeds) { List<Feed> newFeedsList = new ArrayList<>(); List<Feed> updatedFeedsList = new ArrayList<>(); Feed[] resultFeeds = new Feed[newFeeds.length]; PodDBAdapter adapter = PodDBAdapter.getInstance(); adapter.open(); for (int feedIdx = 0; feedIdx < newFeeds.length; feedIdx++) { final Feed newFeed = newFeeds[feedIdx]; // Look up feed in the feedslist final Feed savedFeed = searchFeedByIdentifyingValueOrID(adapter, newFeed); if (savedFeed == null) { Log.d(TAG, "Found no existing Feed with title " + newFeed.getTitle() + ". Adding as new one."); // Add a new Feed // all new feeds will have the most recent item marked as unplayed FeedItem mostRecent = newFeed.getMostRecentItem(); if (mostRecent != null) { mostRecent.setNew(); } newFeedsList.add(newFeed); resultFeeds[feedIdx] = 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()) { if (savedFeed.compareWithOther(newFeed)) { Log.d(TAG, "Feed has updated attribute values. Updating old feed's attributes"); savedFeed.updateFromOther(newFeed); } } else { Log.d(TAG, "New feed has a higher page number."); savedFeed.setNextPageLink(newFeed.getNextPageLink()); } if (savedFeed.getPreferences().compareWithOther(newFeed.getPreferences())) { Log.d(TAG, "Feed has updated preferences. Updating old feed's preferences"); savedFeed.getPreferences().updateFromOther(newFeed.getPreferences()); } // get the most recent date now, before we start changing the list FeedItem priorMostRecent = savedFeed.getMostRecentItem(); Date priorMostRecentDate = null; 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 oldItem = searchFeedItemByIdentifyingValue(savedFeed, item.getIdentifyingValue()); if (oldItem == null) { // item is new item.setFeed(savedFeed); item.setAutoDownload(savedFeed.getPreferences().getAutoDownload()); savedFeed.getItems().add(idx, item); // only mark the item new if it was published after or at the same time // as the most recent item // (if the most recent date is null then we can assume there are no items // and this is the first, hence 'new') if (priorMostRecentDate == null || priorMostRecentDate.before(item.getPubDate()) || priorMostRecentDate.equals(item.getPubDate())) { Log.d(TAG, "Marking item published on " + item.getPubDate() + " new, prior most recent date = " + priorMostRecentDate); item.setNew(); } } else { oldItem.updateFromOther(item); } } // update attributes savedFeed.setLastUpdate(newFeed.getLastUpdate()); savedFeed.setType(newFeed.getType()); savedFeed.setLastUpdateFailed(false); updatedFeedsList.add(savedFeed); resultFeeds[feedIdx] = savedFeed; } } adapter.close(); try { DBWriter.addNewFeed(context, newFeedsList.toArray(new Feed[newFeedsList.size()])).get(); DBWriter.setCompleteFeed(updatedFeedsList.toArray(new Feed[updatedFeedsList.size()])).get(); } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } EventDistributor.getInstance().sendFeedUpdateBroadcast(); return resultFeeds; } /** * Searches the titles of FeedItems of a specific Feed for a given * string. * * @param context Used for accessing the DB. * @param feedID The id of the feed whose items should be searched. * @param query The search string. * @return A FutureTask object that executes the search request and returns the search result as a List of FeedItems. */ public static FutureTask<List<FeedItem>> searchFeedItemTitle(final Context context, final long feedID, final String query) { return new FutureTask<>(new QueryTask<List<FeedItem>>(context) { @Override public void execute(PodDBAdapter adapter) { Cursor searchResult = adapter.searchItemTitles(feedID, query); List<FeedItem> items = DBReader.extractItemlistFromCursor(searchResult); DBReader.loadAdditionalFeedItemListData(items); setResult(items); searchResult.close(); } }); } /** * Searches the descriptions of FeedItems of a specific Feed for a given * string. * * @param context Used for accessing the DB. * @param feedID The id of the feed whose items should be searched. * @param query The search string * @return A FutureTask object that executes the search request and returns the search result as a List of FeedItems. */ public static FutureTask<List<FeedItem>> searchFeedItemDescription(final Context context, final long feedID, final String query) { return new FutureTask<>(new QueryTask<List<FeedItem>>(context) { @Override public void execute(PodDBAdapter adapter) { Cursor searchResult = adapter.searchItemDescriptions(feedID, query); List<FeedItem> items = DBReader.extractItemlistFromCursor(searchResult); DBReader.loadAdditionalFeedItemListData(items); setResult(items); searchResult.close(); } }); } /** * Searches the contentEncoded-value of FeedItems of a specific Feed for a given * string. * * @param context Used for accessing the DB. * @param feedID The id of the feed whose items should be searched. * @param query The search string * @return A FutureTask object that executes the search request and returns the search result as a List of FeedItems. */ public static FutureTask<List<FeedItem>> searchFeedItemContentEncoded(final Context context, final long feedID, final String query) { return new FutureTask<>(new QueryTask<List<FeedItem>>(context) { @Override public void execute(PodDBAdapter adapter) { Cursor searchResult = adapter.searchItemContentEncoded(feedID, query); List<FeedItem> items = DBReader.extractItemlistFromCursor(searchResult); DBReader.loadAdditionalFeedItemListData(items); setResult(items); searchResult.close(); } }); } /** * Searches chapters of the FeedItems of a specific Feed for a given string. * * @param context Used for accessing the DB. * @param feedID The id of the feed whose items should be searched. * @param query The search string * @return A FutureTask object that executes the search request and returns the search result as a List of FeedItems. */ public static FutureTask<List<FeedItem>> searchFeedItemChapters(final Context context, final long feedID, final String query) { return new FutureTask<>(new QueryTask<List<FeedItem>>(context) { @Override public void execute(PodDBAdapter adapter) { Cursor searchResult = adapter.searchItemChapters(feedID, query); List<FeedItem> items = DBReader.extractItemlistFromCursor(searchResult); DBReader.loadAdditionalFeedItemListData(items); setResult(items); searchResult.close(); } }); } /** * A runnable which should be used for database queries. The onCompletion * method is executed on the database executor to handle Cursors correctly. * This class automatically creates a PodDBAdapter object and closes it when * it is no longer in use. */ static abstract class QueryTask<T> implements Callable<T> { private T result; private Context context; public QueryTask(Context context) { this.context = context; } @Override public T call() throws Exception { PodDBAdapter adapter = PodDBAdapter.getInstance(); adapter.open(); execute(adapter); adapter.close(); return result; } public abstract void execute(PodDBAdapter adapter); protected void setResult(T result) { this.result = result; } } /** * Adds the given FeedItem to the flattr queue if the user is logged in. Otherwise, a dialog * will be opened that lets the user go either to the login screen or the website of the flattr thing. * * @param context * @param item */ public static void flattrItemIfLoggedIn(Context context, FeedItem item) { if (FlattrUtils.hasToken()) { item.getFlattrStatus().setFlattrQueue(); DBWriter.setFlattredStatus(context, item, true); } else { FlattrUtils.showNoTokenDialogOrRedirect(context, item.getPaymentLink()); } } /** * Adds the given Feed to the flattr queue if the user is logged in. Otherwise, a dialog * will be opened that lets the user go either to the login screen or the website of the flattr thing. * * @param context * @param feed */ public static void flattrFeedIfLoggedIn(Context context, Feed feed) { if (FlattrUtils.hasToken()) { feed.getFlattrStatus().setFlattrQueue(); DBWriter.setFlattredStatus(context, feed, true); } else { FlattrUtils.showNoTokenDialogOrRedirect(context, feed.getPaymentLink()); } } }