From 69b24699a36bc9c4ffc6200fbcf8069f7a94c1ab Mon Sep 17 00:00:00 2001 From: ByteHamster Date: Mon, 25 Mar 2024 21:45:43 +0100 Subject: Move DBReader to :storage:database (#7027) --- storage/database/build.gradle | 3 + .../antennapod/storage/database/DBReader.java | 860 +++++++++++++++++++++ .../storage/database/FeedItemPermutors.java | 200 +++++ .../antennapod/storage/database/LongList.java | 273 +++++++ .../antennapod/storage/database/NavDrawerData.java | 105 +++ .../antennapod/storage/database/Permutor.java | 17 + .../storage/database/StatisticsItem.java | 45 ++ 7 files changed, 1503 insertions(+) create mode 100644 storage/database/src/main/java/de/danoeh/antennapod/storage/database/DBReader.java create mode 100644 storage/database/src/main/java/de/danoeh/antennapod/storage/database/FeedItemPermutors.java create mode 100644 storage/database/src/main/java/de/danoeh/antennapod/storage/database/LongList.java create mode 100644 storage/database/src/main/java/de/danoeh/antennapod/storage/database/NavDrawerData.java create mode 100644 storage/database/src/main/java/de/danoeh/antennapod/storage/database/Permutor.java create mode 100644 storage/database/src/main/java/de/danoeh/antennapod/storage/database/StatisticsItem.java (limited to 'storage/database') diff --git a/storage/database/build.gradle b/storage/database/build.gradle index 283827ad3..0f3aed252 100644 --- a/storage/database/build.gradle +++ b/storage/database/build.gradle @@ -15,5 +15,8 @@ dependencies { implementation project(':model') annotationProcessor "androidx.annotation:annotation:$annotationVersion" + implementation "androidx.core:core:$coreVersion" implementation "commons-io:commons-io:$commonsioVersion" + + testImplementation "junit:junit:$junitVersion" } diff --git a/storage/database/src/main/java/de/danoeh/antennapod/storage/database/DBReader.java b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/DBReader.java new file mode 100644 index 000000000..89967c24b --- /dev/null +++ b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/DBReader.java @@ -0,0 +1,860 @@ +package de.danoeh.antennapod.storage.database; + +import android.database.Cursor; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.collection.ArrayMap; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import de.danoeh.antennapod.model.feed.Chapter; +import de.danoeh.antennapod.model.feed.Feed; +import de.danoeh.antennapod.model.feed.FeedCounter; +import de.danoeh.antennapod.model.feed.FeedItem; +import de.danoeh.antennapod.model.feed.FeedItemFilter; +import de.danoeh.antennapod.model.feed.FeedMedia; +import de.danoeh.antennapod.model.feed.FeedOrder; +import de.danoeh.antennapod.model.feed.FeedPreferences; +import de.danoeh.antennapod.model.feed.SortOrder; +import de.danoeh.antennapod.model.feed.SubscriptionsFilter; +import de.danoeh.antennapod.model.download.DownloadResult; +import de.danoeh.antennapod.storage.database.mapper.ChapterCursorMapper; +import de.danoeh.antennapod.storage.database.mapper.DownloadResultCursorMapper; +import de.danoeh.antennapod.storage.database.mapper.FeedCursorMapper; +import de.danoeh.antennapod.storage.database.mapper.FeedItemCursorMapper; +import de.danoeh.antennapod.storage.database.mapper.FeedMediaCursorMapper; +import de.danoeh.antennapod.storage.database.mapper.FeedPreferencesCursorMapper; + +/** + * Provides methods for reading data from the AntennaPod database. + * In general, all database calls in DBReader-methods are executed on the caller's thread. + * This means that the caller should make sure that DBReader-methods are not executed on the GUI-thread. + */ +public final class DBReader { + + private static final String TAG = "DBReader"; + + /** + * Maximum size of the list returned by {@link #getDownloadLog()}. + */ + private static final int DOWNLOAD_LOG_SIZE = 200; + + + private DBReader() { + } + + /** + * Returns a list of Feeds, sorted alphabetically by their title. + * + * @return A list of Feeds, sorted alphabetically by their title. A Feed-object + * of the returned list does NOT have its list of FeedItems yet. The FeedItem-list + * can be loaded separately with {@link #getFeedItemList(Feed)}. + */ + @NonNull + public static List getFeedList() { + Log.d(TAG, "Extracting Feedlist"); + + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try { + return getFeedList(adapter); + } finally { + adapter.close(); + } + } + + @NonNull + private static List getFeedList(PodDBAdapter adapter) { + try (Cursor cursor = adapter.getAllFeedsCursor()) { + List feeds = new ArrayList<>(cursor.getCount()); + while (cursor.moveToNext()) { + Feed feed = extractFeedFromCursorRow(cursor); + feeds.add(feed); + } + return feeds; + } + } + + /** + * Returns a list with the download URLs of all feeds. + * + * @return A list of Strings with the download URLs of all feeds. + */ + public static List getFeedListDownloadUrls() { + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try (Cursor cursor = adapter.getFeedCursorDownloadUrls()) { + List result = new ArrayList<>(cursor.getCount()); + while (cursor.moveToNext()) { + String url = cursor.getString(1); + if (url != null && !url.startsWith(Feed.PREFIX_LOCAL_FOLDER)) { + result.add(url); + } + } + return result; + } finally { + adapter.close(); + } + } + + /** + * Loads additional data in to the feed items from other database queries + * + * @param items the FeedItems who should have other data loaded + */ + public static void loadAdditionalFeedItemListData(List items) { + loadTagsOfFeedItemList(items); + loadFeedDataOfFeedItemList(items); + } + + private static void loadTagsOfFeedItemList(List items) { + LongList favoriteIds = getFavoriteIDList(); + LongList queueIds = getQueueIDList(); + + for (FeedItem item : items) { + if (favoriteIds.contains(item.getId())) { + item.addTag(FeedItem.TAG_FAVORITE); + } + if (queueIds.contains(item.getId())) { + item.addTag(FeedItem.TAG_QUEUE); + } + } + } + + /** + * Takes a list of FeedItems and loads their corresponding Feed-objects from the database. + * The feedID-attribute of a FeedItem must be set to the ID of its feed or the method will + * not find the correct feed of an item. + * + * @param items The FeedItems whose Feed-objects should be loaded. + */ + private static void loadFeedDataOfFeedItemList(List items) { + List feeds = getFeedList(); + + Map feedIndex = new ArrayMap<>(feeds.size()); + for (Feed feed : feeds) { + feedIndex.put(feed.getId(), feed); + } + for (FeedItem item : items) { + Feed feed = feedIndex.get(item.getFeedId()); + if (feed == null) { + Log.w(TAG, "No match found for item with ID " + item.getId() + ". Feed ID was " + item.getFeedId()); + feed = new Feed("", "", "Error: Item without feed"); + } + item.setFeed(feed); + } + } + + /** + * Loads the list of FeedItems for a certain Feed-object. + * This method should NOT be used if the FeedItems are not used. + * + * @param feed The Feed whose items should be loaded + * @return A list with the FeedItems of the Feed. The Feed-attribute of the FeedItems will already be set correctly. + * The method does NOT change the items-attribute of the feed. + */ + public static List getFeedItemList(final Feed feed) { + return getFeedItemList(feed, FeedItemFilter.unfiltered()); + } + + public static List getFeedItemList(final Feed feed, final FeedItemFilter filter) { + return getFeedItemList(feed, filter, SortOrder.DATE_NEW_OLD); + } + + public static List getFeedItemList(final Feed feed, final FeedItemFilter filter, SortOrder sortOrder) { + Log.d(TAG, "getFeedItemList() called with: " + "feed = [" + feed + "]"); + + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try (Cursor cursor = adapter.getItemsOfFeedCursor(feed, filter)) { + List items = extractItemlistFromCursor(adapter, cursor); + FeedItemPermutors.getPermutor(sortOrder).reorder(items); + feed.setItems(items); + for (FeedItem item : items) { + item.setFeed(feed); + } + return items; + } finally { + adapter.close(); + } + } + + public static List extractItemlistFromCursor(Cursor itemlistCursor) { + Log.d(TAG, "extractItemlistFromCursor() called with: " + "itemlistCursor = [" + itemlistCursor + "]"); + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try { + return extractItemlistFromCursor(adapter, itemlistCursor); + } finally { + adapter.close(); + } + } + + @NonNull + private static List extractItemlistFromCursor(PodDBAdapter adapter, Cursor cursor) { + List result = new ArrayList<>(cursor.getCount()); + if (cursor.moveToFirst()) { + int indexMediaId = cursor.getColumnIndexOrThrow(PodDBAdapter.SELECT_KEY_MEDIA_ID); + do { + FeedItem item = FeedItemCursorMapper.convert(cursor); + result.add(item); + if (!cursor.isNull(indexMediaId)) { + item.setMedia(FeedMediaCursorMapper.convert(cursor)); + } + } while (cursor.moveToNext()); + } + return result; + } + + private static Feed extractFeedFromCursorRow(Cursor cursor) { + Feed feed = FeedCursorMapper.convert(cursor); + FeedPreferences preferences = FeedPreferencesCursorMapper.convert(cursor); + feed.setPreferences(preferences); + return feed; + } + + @NonNull + public static List getQueue(PodDBAdapter adapter) { + Log.d(TAG, "getQueue()"); + try (Cursor cursor = adapter.getQueueCursor()) { + List items = extractItemlistFromCursor(adapter, cursor); + loadAdditionalFeedItemListData(items); + return items; + } + } + + /** + * Loads the IDs of the FeedItems in the queue. This method should be preferred over + * {@link #getQueue()} if the FeedItems of the queue are not needed. + * + * @return A list of IDs sorted by the same order as the queue. + */ + public static LongList getQueueIDList() { + Log.d(TAG, "getQueueIDList() called"); + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try { + return getQueueIDList(adapter); + } finally { + adapter.close(); + } + } + + private static LongList getQueueIDList(PodDBAdapter adapter) { + try (Cursor cursor = adapter.getQueueIDCursor()) { + LongList queueIds = new LongList(cursor.getCount()); + while (cursor.moveToNext()) { + queueIds.add(cursor.getLong(0)); + } + return queueIds; + } + } + + /** + * Loads a list of the FeedItems in the queue. If the FeedItems of the queue are not used directly, consider using + * {@link #getQueueIDList()} instead. + * + * @return A list of FeedItems sorted by the same order as the queue. + */ + @NonNull + public static List getQueue() { + Log.d(TAG, "getQueue() called"); + + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try { + return getQueue(adapter); + } finally { + adapter.close(); + } + } + + private static LongList getFavoriteIDList() { + Log.d(TAG, "getFavoriteIDList() called"); + + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try (Cursor cursor = adapter.getFavoritesIdsCursor(0, Integer.MAX_VALUE)) { + LongList favoriteIDs = new LongList(cursor.getCount()); + while (cursor.moveToNext()) { + favoriteIDs.add(cursor.getLong(0)); + } + return favoriteIDs; + } finally { + adapter.close(); + } + } + + /** + * + * @param offset The first episode that should be loaded. + * @param limit The maximum number of episodes that should be loaded. + * @param filter The filter describing which episodes to filter out. + */ + @NonNull + public static List getEpisodes(int offset, int limit, FeedItemFilter filter, SortOrder sortOrder) { + Log.d(TAG, "getRecentlyPublishedEpisodes() called with: offset=" + offset + ", limit=" + limit); + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try (Cursor cursor = adapter.getEpisodesCursor(offset, limit, filter, sortOrder)) { + List items = extractItemlistFromCursor(adapter, cursor); + loadAdditionalFeedItemListData(items); + return items; + } finally { + adapter.close(); + } + } + + public static int getTotalEpisodeCount(FeedItemFilter filter) { + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try (Cursor cursor = adapter.getEpisodeCountCursor(filter)) { + if (cursor.moveToFirst()) { + return cursor.getInt(0); + } + return -1; + } finally { + adapter.close(); + } + } + + public static List getRandomEpisodes(int limit, int seed) { + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try (Cursor cursor = adapter.getRandomEpisodesCursor(limit, seed)) { + List items = extractItemlistFromCursor(adapter, cursor); + loadAdditionalFeedItemListData(items); + return items; + } finally { + adapter.close(); + } + } + + /** + * Loads the download log from the database. + * + * @return A list with DownloadStatus objects that represent the download log. + * The size of the returned list is limited by {@link #DOWNLOAD_LOG_SIZE}. + */ + public static List getDownloadLog() { + Log.d(TAG, "getDownloadLog() called"); + + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try (Cursor cursor = adapter.getDownloadLogCursor(DOWNLOAD_LOG_SIZE)) { + List downloadLog = new ArrayList<>(cursor.getCount()); + while (cursor.moveToNext()) { + downloadLog.add(DownloadResultCursorMapper.convert(cursor)); + } + return downloadLog; + } finally { + adapter.close(); + } + } + + /** + * Loads the download log for a particular feed from the database. + * + * @param feedId Feed id for which the download log is loaded + * @return A list with DownloadStatus objects that represent the feed's download log, + * newest events first. + */ + public static List getFeedDownloadLog(long feedId) { + Log.d(TAG, "getFeedDownloadLog() called with: " + "feed = [" + feedId + "]"); + + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try (Cursor cursor = adapter.getDownloadLog(Feed.FEEDFILETYPE_FEED, feedId)) { + List downloadLog = new ArrayList<>(cursor.getCount()); + while (cursor.moveToNext()) { + downloadLog.add(DownloadResultCursorMapper.convert(cursor)); + } + return downloadLog; + } finally { + adapter.close(); + } + } + + /** + * Loads a specific Feed from the database. + * + * @param feedId The ID of the Feed + * @return The Feed or null if the Feed could not be found. The Feeds FeedItems will also be loaded from the + * database and the items-attribute will be set correctly. + */ + @Nullable + public static Feed getFeed(final long feedId) { + return getFeed(feedId, false); + } + + /** + * Loads a specific Feed from the database. + * + * @param feedId The ID of the Feed + * @param filtered true if only the visible items should be loaded according to the feed filter. + * @return The Feed or null if the Feed could not be found. The Feeds FeedItems will also be loaded from the + * database and the items-attribute will be set correctly. + */ + @Nullable + public static Feed getFeed(final long feedId, boolean filtered) { + Log.d(TAG, "getFeed() called with: " + "feedId = [" + feedId + "]"); + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + Feed feed = null; + try (Cursor cursor = adapter.getFeedCursor(feedId)) { + if (cursor.moveToNext()) { + feed = extractFeedFromCursorRow(cursor); + if (filtered) { + feed.setItems(getFeedItemList(feed, feed.getItemFilter())); + } else { + feed.setItems(getFeedItemList(feed)); + } + } else { + Log.e(TAG, "getFeed could not find feed with id " + feedId); + } + return feed; + } finally { + adapter.close(); + } + } + + @Nullable + private static FeedItem getFeedItem(final long itemId, PodDBAdapter adapter) { + Log.d(TAG, "Loading feeditem with id " + itemId); + + FeedItem item = null; + try (Cursor cursor = adapter.getFeedItemCursor(Long.toString(itemId))) { + if (cursor.moveToNext()) { + List list = extractItemlistFromCursor(adapter, cursor); + if (!list.isEmpty()) { + item = list.get(0); + loadAdditionalFeedItemListData(list); + } + } + return item; + } + } + + /** + * Loads a specific FeedItem from the database. This method should not be used for loading more + * than one FeedItem because this method might query the database several times for each item. + * + * @param itemId The ID of the FeedItem + * @return The FeedItem or null if the FeedItem could not be found. + */ + @Nullable + public static FeedItem getFeedItem(final long itemId) { + Log.d(TAG, "getFeedItem() called with: " + "itemId = [" + itemId + "]"); + + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try { + return getFeedItem(itemId, adapter); + } finally { + adapter.close(); + } + } + + /** + * Get next feed item in queue following a particular feeditem + * + * @param item The FeedItem + * @return The FeedItem next in queue or null if the FeedItem could not be found. + */ + @Nullable + public static FeedItem getNextInQueue(FeedItem item) { + Log.d(TAG, "getNextInQueue() called with: " + "itemId = [" + item.getId() + "]"); + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try { + FeedItem nextItem = null; + try (Cursor cursor = adapter.getNextInQueue(item)) { + List list = extractItemlistFromCursor(adapter, cursor); + if (!list.isEmpty()) { + nextItem = list.get(0); + loadAdditionalFeedItemListData(list); + } + return nextItem; + } catch (Exception e) { + return null; + } + } finally { + adapter.close(); + } + } + + @NonNull + public static List getPausedQueue(int limit) { + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try (Cursor cursor = adapter.getPausedQueueCursor(limit)) { + List items = extractItemlistFromCursor(adapter, cursor); + loadAdditionalFeedItemListData(items); + return items; + } finally { + adapter.close(); + } + } + + /** + * Loads a specific FeedItem from the database. + * + * @param guid feed item guid + * @param episodeUrl the feed item's url + * @return The FeedItem or null if the FeedItem could not be found. + * Does NOT load additional attributes like feed or queue state. + */ + @Nullable + private static FeedItem getFeedItemByGuidOrEpisodeUrl(final String guid, final String episodeUrl, + PodDBAdapter adapter) { + try (Cursor cursor = adapter.getFeedItemCursor(guid, episodeUrl)) { + if (!cursor.moveToNext()) { + return null; + } + List list = extractItemlistFromCursor(adapter, cursor); + if (!list.isEmpty()) { + return list.get(0); + } + return null; + } + } + + /** + * Loads a specific FeedItem from the database. + * + * @param guid feed item guid + * @param episodeUrl the feed item's url + * @return The FeedItem or null if the FeedItem could not be found. + * Does NOT load additional attributes like feed or queue state. + */ + public static FeedItem getFeedItemByGuidOrEpisodeUrl(final String guid, final String episodeUrl) { + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try { + return getFeedItemByGuidOrEpisodeUrl(guid, episodeUrl, adapter); + } finally { + adapter.close(); + } + } + + /** + * Loads shownotes information about a FeedItem. + * + * @param item The FeedItem + */ + public static void loadDescriptionOfFeedItem(final FeedItem item) { + Log.d(TAG, "loadDescriptionOfFeedItem() called with: " + "item = [" + item + "]"); + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try (Cursor cursor = adapter.getDescriptionOfItem(item)) { + if (cursor.moveToFirst()) { + int indexDescription = cursor.getColumnIndex(PodDBAdapter.KEY_DESCRIPTION); + String description = cursor.getString(indexDescription); + item.setDescriptionIfLonger(description); + } + } finally { + adapter.close(); + } + } + + /** + * Loads the list of chapters that belongs to this FeedItem if available. This method overwrites + * any chapters that this FeedItem has. If no chapters were found in the database, the chapters + * reference of the FeedItem will be set to null. + * + * @param item The FeedItem + */ + public static List loadChaptersOfFeedItem(final FeedItem item) { + Log.d(TAG, "loadChaptersOfFeedItem() called with: " + "item = [" + item + "]"); + + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try { + return loadChaptersOfFeedItem(adapter, item); + } finally { + adapter.close(); + } + } + + private static List loadChaptersOfFeedItem(PodDBAdapter adapter, FeedItem item) { + try (Cursor cursor = adapter.getSimpleChaptersOfFeedItemCursor(item)) { + int chaptersCount = cursor.getCount(); + if (chaptersCount == 0) { + item.setChapters(null); + return null; + } + ArrayList chapters = new ArrayList<>(); + while (cursor.moveToNext()) { + chapters.add(ChapterCursorMapper.convert(cursor)); + } + return chapters; + } + } + + /** + * Searches the DB for a FeedMedia of the given id. + * + * @param mediaId The id of the object + * @return The found object, or null if it does not exist + */ + @Nullable + public static FeedMedia getFeedMedia(final long mediaId) { + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + + try (Cursor mediaCursor = adapter.getSingleFeedMediaCursor(mediaId)) { + if (!mediaCursor.moveToFirst()) { + return null; + } + + int indexFeedItem = mediaCursor.getColumnIndex(PodDBAdapter.KEY_FEEDITEM); + long itemId = mediaCursor.getLong(indexFeedItem); + FeedMedia media = FeedMediaCursorMapper.convert(mediaCursor); + FeedItem item = getFeedItem(itemId); + if (item != null) { + media.setItem(item); + item.setMedia(media); + } + return media; + } finally { + adapter.close(); + } + } + + public static List getFeedItemsWithUrl(List urls) { + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try (Cursor itemCursor = adapter.getFeedItemCursorByUrl(urls)) { + List items = extractItemlistFromCursor(adapter, itemCursor); + loadAdditionalFeedItemListData(items); + return items; + } finally { + adapter.close(); + } + } + + public static class MonthlyStatisticsItem { + private int year = 0; + private int month = 0; + private long timePlayed = 0; + + public int getYear() { + return year; + } + + public void setYear(final int year) { + this.year = year; + } + + public int getMonth() { + return month; + } + + public void setMonth(final int month) { + this.month = month; + } + + public long getTimePlayed() { + return timePlayed; + } + + public void setTimePlayed(final long timePlayed) { + this.timePlayed = timePlayed; + } + } + + @NonNull + public static List getMonthlyTimeStatistics() { + List months = new ArrayList<>(); + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try (Cursor cursor = adapter.getMonthlyStatisticsCursor()) { + int indexMonth = cursor.getColumnIndexOrThrow("month"); + int indexYear = cursor.getColumnIndexOrThrow("year"); + int indexTotalDuration = cursor.getColumnIndexOrThrow("total_duration"); + while (cursor.moveToNext()) { + MonthlyStatisticsItem item = new MonthlyStatisticsItem(); + item.setMonth(Integer.parseInt(cursor.getString(indexMonth))); + item.setYear(Integer.parseInt(cursor.getString(indexYear))); + item.setTimePlayed(cursor.getLong(indexTotalDuration)); + months.add(item); + } + } + adapter.close(); + return months; + } + + public static class StatisticsResult { + public List feedTime = new ArrayList<>(); + public long oldestDate = System.currentTimeMillis(); + } + + /** + * Searches the DB for statistics. + * + * @return The list of statistics objects + */ + @NonNull + public static StatisticsResult getStatistics(boolean includeMarkedAsPlayed, + long timeFilterFrom, long timeFilterTo) { + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + + StatisticsResult result = new StatisticsResult(); + try (Cursor cursor = adapter.getFeedStatisticsCursor(includeMarkedAsPlayed, timeFilterFrom, timeFilterTo)) { + int indexOldestDate = cursor.getColumnIndexOrThrow("oldest_date"); + int indexNumEpisodes = cursor.getColumnIndexOrThrow("num_episodes"); + int indexEpisodesStarted = cursor.getColumnIndexOrThrow("episodes_started"); + int indexTotalTime = cursor.getColumnIndexOrThrow("total_time"); + int indexPlayedTime = cursor.getColumnIndexOrThrow("played_time"); + int indexNumDownloaded = cursor.getColumnIndexOrThrow("num_downloaded"); + int indexDownloadSize = cursor.getColumnIndexOrThrow("download_size"); + + while (cursor.moveToNext()) { + Feed feed = extractFeedFromCursorRow(cursor); + + long feedPlayedTime = Long.parseLong(cursor.getString(indexPlayedTime)) / 1000; + long feedTotalTime = Long.parseLong(cursor.getString(indexTotalTime)) / 1000; + long episodes = Long.parseLong(cursor.getString(indexNumEpisodes)); + long episodesStarted = Long.parseLong(cursor.getString(indexEpisodesStarted)); + long totalDownloadSize = Long.parseLong(cursor.getString(indexDownloadSize)); + long episodesDownloadCount = Long.parseLong(cursor.getString(indexNumDownloaded)); + long oldestDate = Long.parseLong(cursor.getString(indexOldestDate)); + + if (episodes > 0 && oldestDate < Long.MAX_VALUE) { + result.oldestDate = Math.min(result.oldestDate, oldestDate); + } + + result.feedTime.add(new StatisticsItem(feed, feedTotalTime, feedPlayedTime, episodes, + episodesStarted, totalDownloadSize, episodesDownloadCount)); + } + } + adapter.close(); + return result; + } + + public static long getTimeBetweenReleaseAndPlayback(long timeFilterFrom, long timeFilterTo) { + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + try (Cursor cursor = adapter.getTimeBetweenReleaseAndPlayback(timeFilterFrom, timeFilterTo)) { + cursor.moveToFirst(); + long result = Long.parseLong(cursor.getString(0)); + adapter.close(); + return result; + } + } + + /** + * Returns data necessary for displaying the navigation drawer. This includes + * the list of subscriptions, the number of items in the queue and the number of unread + * items. + */ + @NonNull + public static NavDrawerData getNavDrawerData(@Nullable SubscriptionsFilter subscriptionsFilter, + FeedOrder feedOrder, FeedCounter feedCounter) { + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + + final Map feedCounters = adapter.getFeedCounters(feedCounter); + List feeds = getFeedList(adapter); + + if (subscriptionsFilter != null) { + feeds = subscriptionsFilter.filter(feeds, feedCounters); + } + + Comparator comparator; + switch (feedOrder) { + case COUNTER: + comparator = (lhs, rhs) -> { + long counterLhs = feedCounters.containsKey(lhs.getId()) ? feedCounters.get(lhs.getId()) : 0; + long counterRhs = feedCounters.containsKey(rhs.getId()) ? feedCounters.get(rhs.getId()) : 0; + if (counterLhs > counterRhs) { + // reverse natural order: podcast with most unplayed episodes first + return -1; + } else if (counterLhs == counterRhs) { + return lhs.getTitle().compareToIgnoreCase(rhs.getTitle()); + } else { + return 1; + } + }; + break; + case ALPHABETICAL: + comparator = (lhs, rhs) -> { + String t1 = lhs.getTitle(); + String t2 = rhs.getTitle(); + if (t1 == null) { + return 1; + } else if (t2 == null) { + return -1; + } else { + return t1.compareToIgnoreCase(t2); + } + }; + break; + case MOST_PLAYED: + final Map playedCounters = adapter.getPlayedEpisodesCounters(); + comparator = (lhs, rhs) -> { + long counterLhs = playedCounters.containsKey(lhs.getId()) ? playedCounters.get(lhs.getId()) : 0; + long counterRhs = playedCounters.containsKey(rhs.getId()) ? playedCounters.get(rhs.getId()) : 0; + if (counterLhs > counterRhs) { + // podcast with most played episodes first + return -1; + } else if (counterLhs == counterRhs) { + return lhs.getTitle().compareToIgnoreCase(rhs.getTitle()); + } else { + return 1; + } + }; + break; + default: + final Map recentPubDates = adapter.getMostRecentItemDates(); + comparator = (lhs, rhs) -> { + long dateLhs = recentPubDates.containsKey(lhs.getId()) ? recentPubDates.get(lhs.getId()) : 0; + long dateRhs = recentPubDates.containsKey(rhs.getId()) ? recentPubDates.get(rhs.getId()) : 0; + return Long.compare(dateRhs, dateLhs); + }; + break; + } + + Collections.sort(feeds, comparator); + final int queueSize = adapter.getQueueSize(); + final int numNewItems = getTotalEpisodeCount(new FeedItemFilter(FeedItemFilter.NEW)); + final int numDownloadedItems = getTotalEpisodeCount(new FeedItemFilter(FeedItemFilter.DOWNLOADED)); + + List items = new ArrayList<>(); + Map folders = new HashMap<>(); + for (Feed feed : feeds) { + for (String tag : feed.getPreferences().getTags()) { + int counter = feedCounters.containsKey(feed.getId()) ? feedCounters.get(feed.getId()) : 0; + NavDrawerData.FeedDrawerItem drawerItem = new NavDrawerData.FeedDrawerItem(feed, feed.getId(), counter); + if (FeedPreferences.TAG_ROOT.equals(tag)) { + items.add(drawerItem); + continue; + } + NavDrawerData.TagDrawerItem folder; + if (folders.containsKey(tag)) { + folder = folders.get(tag); + } else { + folder = new NavDrawerData.TagDrawerItem(tag); + folders.put(tag, folder); + } + drawerItem.id |= folder.id; + folder.children.add(drawerItem); + } + } + List foldersSorted = new ArrayList<>(folders.values()); + Collections.sort(foldersSorted, (o1, o2) -> o1.getTitle().compareToIgnoreCase(o2.getTitle())); + items.addAll(foldersSorted); + + NavDrawerData result = new NavDrawerData(items, queueSize, numNewItems, numDownloadedItems, feedCounters); + adapter.close(); + return result; + } +} diff --git a/storage/database/src/main/java/de/danoeh/antennapod/storage/database/FeedItemPermutors.java b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/FeedItemPermutors.java new file mode 100644 index 000000000..a9c59e2bb --- /dev/null +++ b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/FeedItemPermutors.java @@ -0,0 +1,200 @@ +package de.danoeh.antennapod.storage.database; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import de.danoeh.antennapod.model.feed.FeedItem; +import de.danoeh.antennapod.model.feed.SortOrder; + +/** + * Provides method for sorting the a list of {@link FeedItem} according to rules. + */ +public class FeedItemPermutors { + + /** + * Returns a Permutor that sorts a list appropriate to the given sort order. + * + * @return Permutor that sorts a list appropriate to the given sort order. + */ + @NonNull + public static Permutor getPermutor(@NonNull SortOrder sortOrder) { + + Comparator comparator = null; + Permutor permutor = null; + + switch (sortOrder) { + case EPISODE_TITLE_A_Z: + comparator = (f1, f2) -> itemTitle(f1).compareTo(itemTitle(f2)); + break; + case EPISODE_TITLE_Z_A: + comparator = (f1, f2) -> itemTitle(f2).compareTo(itemTitle(f1)); + break; + case DATE_OLD_NEW: + comparator = (f1, f2) -> pubDate(f1).compareTo(pubDate(f2)); + break; + case DATE_NEW_OLD: + comparator = (f1, f2) -> pubDate(f2).compareTo(pubDate(f1)); + break; + case DURATION_SHORT_LONG: + comparator = (f1, f2) -> Integer.compare(duration(f1), duration(f2)); + break; + case DURATION_LONG_SHORT: + comparator = (f1, f2) -> Integer.compare(duration(f2), duration(f1)); + break; + case EPISODE_FILENAME_A_Z: + comparator = (f1, f2) -> itemLink(f1).compareTo(itemLink(f2)); + break; + case EPISODE_FILENAME_Z_A: + comparator = (f1, f2) -> itemLink(f2).compareTo(itemLink(f1)); + break; + case FEED_TITLE_A_Z: + comparator = (f1, f2) -> feedTitle(f1).compareTo(feedTitle(f2)); + break; + case FEED_TITLE_Z_A: + comparator = (f1, f2) -> feedTitle(f2).compareTo(feedTitle(f1)); + break; + case RANDOM: + permutor = Collections::shuffle; + break; + case SMART_SHUFFLE_OLD_NEW: + permutor = (queue) -> smartShuffle(queue, true); + break; + case SMART_SHUFFLE_NEW_OLD: + permutor = (queue) -> smartShuffle(queue, false); + break; + case SIZE_SMALL_LARGE: + comparator = (f1, f2) -> Long.compare(size(f1), size(f2)); + break; + case SIZE_LARGE_SMALL: + comparator = (f1, f2) -> Long.compare(size(f2), size(f1)); + break; + case COMPLETION_DATE_NEW_OLD: + comparator = (f1, f2) -> f2.getMedia().getPlaybackCompletionDate() + .compareTo(f1.getMedia().getPlaybackCompletionDate()); + break; + default: + throw new IllegalArgumentException("Permutor not implemented"); + } + + if (comparator != null) { + final Comparator comparator2 = comparator; + permutor = (queue) -> Collections.sort(queue, comparator2); + } + return permutor; + } + + // Null-safe accessors + + @NonNull + private static Date pubDate(@Nullable FeedItem item) { + return (item != null && item.getPubDate() != null) ? item.getPubDate() : new Date(0); + } + + @NonNull + private static String itemTitle(@Nullable FeedItem item) { + return (item != null && item.getTitle() != null) ? item.getTitle().toLowerCase(Locale.getDefault()) : ""; + } + + private static int duration(@Nullable FeedItem item) { + return (item != null && item.getMedia() != null) ? item.getMedia().getDuration() : 0; + } + + private static long size(@Nullable FeedItem item) { + return (item != null && item.getMedia() != null) ? item.getMedia().getSize() : 0; + } + + @NonNull + private static String itemLink(@Nullable FeedItem item) { + return (item != null && item.getLink() != null) + ? item.getLink().toLowerCase(Locale.getDefault()) : ""; + } + + @NonNull + private static String feedTitle(@Nullable FeedItem item) { + return (item != null && item.getFeed() != null && item.getFeed().getTitle() != null) + ? item.getFeed().getTitle().toLowerCase(Locale.getDefault()) : ""; + } + + /** + * Implements a reordering by pubdate that avoids consecutive episodes from the same feed in + * the queue. + * + * A listener might want to hear episodes from any given feed in pubdate order, but would + * prefer a more balanced ordering that avoids having to listen to clusters of consecutive + * episodes from the same feed. This is what "Smart Shuffle" tries to accomplish. + * + * Assume the queue looks like this: `ABCDDEEEEEEEEEE`. + * This method first starts with a queue of the final size, where each slot is empty (null). + * It takes the podcast with most episodes (`E`) and places the episodes spread out in the queue: `EE_E_EE_E_EE_EE`. + * The podcast with the second-most number of episodes (`D`) is then + * placed spread-out in the *available* slots: `EE_EDEE_EDEE_EE`. + * This continues, until we end up with: `EEBEDEECEDEEAEE`. + * + * Note that episodes aren't strictly ordered in terms of pubdate, but episodes of each feed are. + * + * @param queue A (modifiable) list of FeedItem elements to be reordered. + * @param ascending {@code true} to use ascending pubdate in the reordering; + * {@code false} for descending. + */ + private static void smartShuffle(List queue, boolean ascending) { + // Divide FeedItems into lists by feed + Map> map = new HashMap<>(); + for (FeedItem item : queue) { + Long id = item.getFeedId(); + if (!map.containsKey(id)) { + map.put(id, new ArrayList<>()); + } + map.get(id).add(item); + } + + // Sort each individual list by PubDate (ascending/descending) + Comparator itemComparator = ascending + ? (f1, f2) -> f1.getPubDate().compareTo(f2.getPubDate()) + : (f1, f2) -> f2.getPubDate().compareTo(f1.getPubDate()); + List> feeds = new ArrayList<>(); + for (Map.Entry> mapEntry : map.entrySet()) { + Collections.sort(mapEntry.getValue(), itemComparator); + feeds.add(mapEntry.getValue()); + } + + ArrayList emptySlots = new ArrayList<>(); + for (int i = 0; i < queue.size(); i++) { + queue.set(i, null); + emptySlots.add(i); + } + + // Starting with the largest feed, place items spread out through the empty slots in the queue + Collections.sort(feeds, (f1, f2) -> Integer.compare(f2.size(), f1.size())); + for (List feedItems : feeds) { + double spread = (double) emptySlots.size() / (feedItems.size() + 1); + Iterator emptySlotIterator = emptySlots.iterator(); + int skipped = 0; + int placed = 0; + while (emptySlotIterator.hasNext()) { + int nextEmptySlot = emptySlotIterator.next(); + skipped++; + if (skipped >= spread * (placed + 1)) { + if (queue.get(nextEmptySlot) != null) { + throw new RuntimeException("Slot to be placed in not empty"); + } + queue.set(nextEmptySlot, feedItems.get(placed)); + emptySlotIterator.remove(); + placed++; + if (placed == feedItems.size()) { + break; + } + } + } + } + } +} diff --git a/storage/database/src/main/java/de/danoeh/antennapod/storage/database/LongList.java b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/LongList.java new file mode 100644 index 000000000..23ec9ade9 --- /dev/null +++ b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/LongList.java @@ -0,0 +1,273 @@ +package de.danoeh.antennapod.storage.database; + +import java.util.Arrays; + +/** + * Fast and memory efficient long list + */ +public final class LongList { + + private long[] values; + private int size; + + /** + * Constructs an empty instance with a default initial capacity. + */ + public LongList() { + this(4); + } + + /** + * Constructs an empty instance. + * + * @param initialCapacity {@code >= 0;} initial capacity of the list + */ + public LongList(int initialCapacity) { + if(initialCapacity < 0) { + throw new IllegalArgumentException("initial capacity must be 0 or higher"); + } + values = new long[initialCapacity]; + size = 0; + } + + public static LongList of(long... values) { + if(values == null || values.length == 0) { + return new LongList(0); + } + LongList result = new LongList(values.length); + for(long value : values) { + result.add(value); + } + return result; + } + + @Override + public int hashCode() { + int hashCode = 1; + for (int i = 0; i < size; i++) { + long value = values[i]; + hashCode = 31 * hashCode + (int)(value ^ (value >>> 32)); + } + return hashCode; + } + + @Override + public boolean equals(Object other) { + if (other == this) { + return true; + } + if (! (other instanceof LongList)) { + return false; + } + LongList otherList = (LongList) other; + if (size != otherList.size) { + return false; + } + for (int i = 0; i < size; i++) { + if (values[i] != otherList.values[i]) { + return false; + } + } + return true; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(size * 5 + 10); + sb.append("LongList{"); + for (int i = 0; i < size; i++) { + if (i != 0) { + sb.append(", "); + } + sb.append(values[i]); + } + sb.append("}"); + return sb.toString(); + } + + /** + * Gets the number of elements in this list. + */ + public int size() { + return size; + } + + /** + * Gets the indicated value. + * + * @param n {@code >= 0, < size();} which element + * @return the indicated element's value + */ + public long get(int n) { + if (n >= size) { + throw new IndexOutOfBoundsException("n >= size()"); + } else if(n < 0) { + throw new IndexOutOfBoundsException("n < 0"); + } + return values[n]; + } + + /** + * Sets the value at the given index. + * + * @param index the index at which to put the specified object. + * @param value the object to add. + * @return the previous element at the index. + */ + public long set(int index, long value) { + if (index >= size) { + throw new IndexOutOfBoundsException("n >= size()"); + } else if(index < 0) { + throw new IndexOutOfBoundsException("n < 0"); + } + long result = values[index]; + values[index] = value; + return result; + } + + /** + * Adds an element to the end of the list. This will increase the + * list's capacity if necessary. + * + * @param value the value to add + */ + public void add(long value) { + growIfNeeded(); + values[size++] = value; + } + + /** + * Inserts element into specified index, moving elements at and above + * that index up one. May not be used to insert at an index beyond the + * current size (that is, insertion as a last element is legal but + * no further). + * + * @param n {@code >= 0, <=size();} index of where to insert + * @param value value to insert + */ + public void insert(int n, int value) { + if (n > size) { + throw new IndexOutOfBoundsException("n > size()"); + } else if(n < 0) { + throw new IndexOutOfBoundsException("n < 0"); + } + + growIfNeeded(); + + System.arraycopy(values, n, values, n+1, size - n); + values[n] = value; + size++; + } + + /** + * Removes value from this list. + * + * @param value value to remove + * return {@code true} if the value was removed, {@code false} otherwise + */ + public boolean remove(long value) { + for (int i = 0; i < size; i++) { + if (values[i] == value) { + size--; + System.arraycopy(values, i+1, values, i, size-i); + return true; + } + } + return false; + } + + /** + * Removes values from this list. + * + * @param values values to remove + */ + public void removeAll(long[] values) { + for(long value : values) { + remove(value); + } + } + + /** + * Removes values from this list. + * + * @param list List with values to remove + */ + public void removeAll(LongList list) { + for(long value : list.values) { + remove(value); + } + } + + /** + * Removes an element at a given index, shifting elements at greater + * indicies down one. + * + * @param index index of element to remove + */ + public void removeIndex(int index) { + if (index >= size) { + throw new IndexOutOfBoundsException("n >= size()"); + } else if(index < 0) { + throw new IndexOutOfBoundsException("n < 0"); + } + size--; + System.arraycopy(values, index + 1, values, index, size - index); + } + + /** + * Increases size of array if needed + */ + private void growIfNeeded() { + if (size == values.length) { + // Resize. + long[] newArray = new long[size * 3 / 2 + 10]; + System.arraycopy(values, 0, newArray, 0, size); + values = newArray; + } + } + + /** + * Returns the index of the given value, or -1 if the value does not + * appear in the list. + * + * @param value value to find + * @return index of value or -1 + */ + public int indexOf(long value) { + for (int i = 0; i < size; i++) { + if (values[i] == value) { + return i; + } + } + return -1; + } + + /** + * Removes all values from this list. + */ + public void clear() { + values = new long[4]; + size = 0; + } + + + /** + * Returns true if the given value is contained in the list + * + * @param value value to look for + * @return {@code true} if this list contains {@code value}, {@code false} otherwise + */ + public boolean contains(long value) { + return indexOf(value) >= 0; + } + + /** + * Returns an array with a copy of this list's values + * + * @return array with a copy of this list's values + */ + public long[] toArray() { + return Arrays.copyOf(values, size); + + } +} diff --git a/storage/database/src/main/java/de/danoeh/antennapod/storage/database/NavDrawerData.java b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/NavDrawerData.java new file mode 100644 index 000000000..d5856c10a --- /dev/null +++ b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/NavDrawerData.java @@ -0,0 +1,105 @@ +package de.danoeh.antennapod.storage.database; + +import de.danoeh.antennapod.model.feed.Feed; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public class NavDrawerData { + public final List items; + public final int queueSize; + public final int numNewItems; + public final int numDownloadedItems; + public final Map feedCounters; + + public NavDrawerData(List feeds, + int queueSize, + int numNewItems, + int numDownloadedItems, + Map feedIndicatorValues) { + this.items = feeds; + this.queueSize = queueSize; + this.numNewItems = numNewItems; + this.numDownloadedItems = numDownloadedItems; + this.feedCounters = feedIndicatorValues; + } + + public abstract static class DrawerItem { + public enum Type { + TAG, FEED + } + + public final Type type; + private int layer; + public long id; + + public DrawerItem(Type type, long id) { + this.type = type; + this.id = id; + } + + public int getLayer() { + return layer; + } + + public void setLayer(int layer) { + this.layer = layer; + } + + public abstract String getTitle(); + + public abstract int getCounter(); + } + + public static class TagDrawerItem extends DrawerItem { + public final List children = new ArrayList<>(); + private final String name; + private boolean isOpen; + + public TagDrawerItem(String name) { + // Keep IDs >0 but make room for many feeds + super(DrawerItem.Type.TAG, Math.abs((long) name.hashCode()) << 20); + this.name = name; + } + + public String getTitle() { + return name; + } + + public boolean isOpen() { + return isOpen; + } + + public void setOpen(final boolean open) { + isOpen = open; + } + + public int getCounter() { + int sum = 0; + for (DrawerItem item : children) { + sum += item.getCounter(); + } + return sum; + } + } + + public static class FeedDrawerItem extends DrawerItem { + public final Feed feed; + public final int counter; + + public FeedDrawerItem(Feed feed, long id, int counter) { + super(DrawerItem.Type.FEED, id); + this.feed = feed; + this.counter = counter; + } + + public String getTitle() { + return feed.getTitle(); + } + + public int getCounter() { + return counter; + } + } +} diff --git a/storage/database/src/main/java/de/danoeh/antennapod/storage/database/Permutor.java b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/Permutor.java new file mode 100644 index 000000000..b3704f780 --- /dev/null +++ b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/Permutor.java @@ -0,0 +1,17 @@ +package de.danoeh.antennapod.storage.database; + +import java.util.List; + +/** + * Interface for passing around list permutor method. This is used for cases where a simple comparator + * won't work (e.g. Random, Smart Shuffle, etc). + * + * @param the type of elements in the list + */ +public interface Permutor { + /** + * Reorders the specified list. + * @param queue A (modifiable) list of elements to be reordered + */ + void reorder(List queue); +} diff --git a/storage/database/src/main/java/de/danoeh/antennapod/storage/database/StatisticsItem.java b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/StatisticsItem.java new file mode 100644 index 000000000..27c5e3bdd --- /dev/null +++ b/storage/database/src/main/java/de/danoeh/antennapod/storage/database/StatisticsItem.java @@ -0,0 +1,45 @@ +package de.danoeh.antennapod.storage.database; + +import de.danoeh.antennapod.model.feed.Feed; + +public class StatisticsItem { + public final Feed feed; + public final long time; + + /** + * Respects speed, listening twice, ... + */ + public final long timePlayed; + + /** + * Number of episodes. + */ + public final long episodes; + + /** + * Episodes that are actually played. + */ + public final long episodesStarted; + + /** + * Simply sums up the size of download podcasts. + */ + public final long totalDownloadSize; + + /** + * Stores the number of episodes downloaded. + */ + public final long episodesDownloadCount; + + public StatisticsItem(Feed feed, long time, long timePlayed, + long episodes, long episodesStarted, + long totalDownloadSize, long episodesDownloadCount) { + this.feed = feed; + this.time = time; + this.timePlayed = timePlayed; + this.episodes = episodes; + this.episodesStarted = episodesStarted; + this.totalDownloadSize = totalDownloadSize; + this.episodesDownloadCount = episodesDownloadCount; + } +} -- cgit v1.2.3