diff options
author | ByteHamster <info@bytehamster.com> | 2021-03-05 10:09:10 +0100 |
---|---|---|
committer | ByteHamster <info@bytehamster.com> | 2021-03-05 10:12:35 +0100 |
commit | f76d3ad09e41c544b8af2f33db0529e3bcdabc0e (patch) | |
tree | f33d371de5a74e3ac75ff9431168b4a7c76a9246 /core/src/main/java/de/danoeh/antennapod | |
parent | 5a8bfc0ea483d0af4db8f266969f1e52c2cd529d (diff) | |
parent | c58aa40b212c7ff5a798c2b3faafabbaaeac0b3f (diff) | |
download | AntennaPod-f76d3ad09e41c544b8af2f33db0529e3bcdabc0e.zip |
Merge branch 'develop' into folders
Diffstat (limited to 'core/src/main/java/de/danoeh/antennapod')
75 files changed, 2116 insertions, 2728 deletions
diff --git a/core/src/main/java/de/danoeh/antennapod/core/PlaybackServiceCallbacks.java b/core/src/main/java/de/danoeh/antennapod/core/PlaybackServiceCallbacks.java deleted file mode 100644 index 3dcaac4dc..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/PlaybackServiceCallbacks.java +++ /dev/null @@ -1,22 +0,0 @@ -package de.danoeh.antennapod.core; - -import android.content.Context; -import android.content.Intent; - -import de.danoeh.antennapod.core.feed.MediaType; - -/** - * Callbacks for the PlaybackService of the core module - */ -public interface PlaybackServiceCallbacks { - - /** - * Returns an intent which starts an audio- or videoplayer, depending on the - * type of media that is being played. - * - * @param mediaType The type of media that is being played. - * @param remotePlayback true if the media is played on a remote device. - * @return A non-null activity intent. - */ - Intent getPlayerActivityIntent(Context context, MediaType mediaType, boolean remotePlayback); -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/asynctask/ImageResource.java b/core/src/main/java/de/danoeh/antennapod/core/asynctask/ImageResource.java deleted file mode 100644 index b01e3f3ba..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/asynctask/ImageResource.java +++ /dev/null @@ -1,15 +0,0 @@ -package de.danoeh.antennapod.core.asynctask; - -/** - * Classes that implement this interface provide access to an image resource that can - * be loaded by the Picasso library. - */ -public interface ImageResource { - - /** - * Returns the location of the image or null if no image is available. - * <p/> - * The location can either be an URL or a local path - */ - String getImageLocation(); -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/Feed.java b/core/src/main/java/de/danoeh/antennapod/core/feed/Feed.java index a3b66c951..dd8a466eb 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/feed/Feed.java +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/Feed.java @@ -1,6 +1,5 @@ package de.danoeh.antennapod.core.feed; -import android.database.Cursor; import android.text.TextUtils; import androidx.annotation.Nullable; @@ -9,26 +8,28 @@ import java.util.ArrayList; import java.util.Date; import java.util.List; -import de.danoeh.antennapod.core.asynctask.ImageResource; -import de.danoeh.antennapod.core.storage.DBWriter; -import de.danoeh.antennapod.core.storage.PodDBAdapter; import de.danoeh.antennapod.core.util.SortOrder; /** - * Data Object for a whole feed + * Data Object for a whole feed. * * @author daniel */ -public class Feed extends FeedFile implements ImageResource { +public class Feed extends FeedFile { public static final int FEEDFILETYPE_FEED = 0; public static final String TYPE_RSS2 = "rss"; public static final String TYPE_ATOM1 = "atom"; public static final String PREFIX_LOCAL_FOLDER = "antennapod_local:"; - /* title as defined by the feed */ + /** + * title as defined by the feed. + */ private String feedTitle; - /* custom title set by the user */ + + /** + * custom title set by the user. + */ private String customTitle; /** @@ -42,25 +43,25 @@ public class Feed extends FeedFile implements ImageResource { private String description; private String language; /** - * Name of the author + * Name of the author. */ private String author; private String imageUrl; private List<FeedItem> items; /** - * String that identifies the last update (adopted from Last-Modified or ETag header) + * String that identifies the last update (adopted from Last-Modified or ETag header). */ private String lastUpdate; private String paymentLink; /** - * Feed type, for example RSS 2 or Atom + * Feed type, for example RSS 2 or Atom. */ private String type; /** - * Feed preferences + * Feed preferences. */ private FeedPreferences preferences; @@ -122,7 +123,7 @@ public class Feed extends FeedFile implements ImageResource { this.paged = paged; this.nextPageLink = nextPageLink; this.items = new ArrayList<>(); - if(filter != null) { + if (filter != null) { this.itemfilter = new FeedItemFilter(filter); } else { this.itemfilter = new FeedItemFilter(new String[0]); @@ -132,7 +133,7 @@ public class Feed extends FeedFile implements ImageResource { } /** - * This constructor is used for test purposes + * This constructor is used for test purposes. */ public Feed(long id, String lastUpdate, String title, String link, String description, String paymentLink, String author, String language, String type, String feedIdentifier, String imageUrl, String fileUrl, @@ -175,56 +176,6 @@ public class Feed extends FeedFile implements ImageResource { preferences = new FeedPreferences(0, true, FeedPreferences.AutoDeleteAction.GLOBAL, VolumeAdaptionSetting.OFF, username, password); } - public static Feed fromCursor(Cursor cursor) { - int indexId = cursor.getColumnIndex(PodDBAdapter.KEY_ID); - int indexLastUpdate = cursor.getColumnIndex(PodDBAdapter.KEY_LASTUPDATE); - int indexTitle = cursor.getColumnIndex(PodDBAdapter.KEY_TITLE); - int indexCustomTitle = cursor.getColumnIndex(PodDBAdapter.KEY_CUSTOM_TITLE); - int indexLink = cursor.getColumnIndex(PodDBAdapter.KEY_LINK); - int indexDescription = cursor.getColumnIndex(PodDBAdapter.KEY_DESCRIPTION); - int indexPaymentLink = cursor.getColumnIndex(PodDBAdapter.KEY_PAYMENT_LINK); - int indexAuthor = cursor.getColumnIndex(PodDBAdapter.KEY_AUTHOR); - int indexLanguage = cursor.getColumnIndex(PodDBAdapter.KEY_LANGUAGE); - int indexType = cursor.getColumnIndex(PodDBAdapter.KEY_TYPE); - int indexFeedIdentifier = cursor.getColumnIndex(PodDBAdapter.KEY_FEED_IDENTIFIER); - int indexFileUrl = cursor.getColumnIndex(PodDBAdapter.KEY_FILE_URL); - int indexDownloadUrl = cursor.getColumnIndex(PodDBAdapter.KEY_DOWNLOAD_URL); - int indexDownloaded = cursor.getColumnIndex(PodDBAdapter.KEY_DOWNLOADED); - int indexIsPaged = cursor.getColumnIndex(PodDBAdapter.KEY_IS_PAGED); - int indexNextPageLink = cursor.getColumnIndex(PodDBAdapter.KEY_NEXT_PAGE_LINK); - int indexHide = cursor.getColumnIndex(PodDBAdapter.KEY_HIDE); - int indexSortOrder = cursor.getColumnIndex(PodDBAdapter.KEY_SORT_ORDER); - int indexLastUpdateFailed = cursor.getColumnIndex(PodDBAdapter.KEY_LAST_UPDATE_FAILED); - int indexImageUrl = cursor.getColumnIndex(PodDBAdapter.KEY_IMAGE_URL); - - Feed feed = new Feed( - cursor.getLong(indexId), - cursor.getString(indexLastUpdate), - cursor.getString(indexTitle), - cursor.getString(indexCustomTitle), - cursor.getString(indexLink), - cursor.getString(indexDescription), - cursor.getString(indexPaymentLink), - cursor.getString(indexAuthor), - cursor.getString(indexLanguage), - cursor.getString(indexType), - cursor.getString(indexFeedIdentifier), - cursor.getString(indexImageUrl), - cursor.getString(indexFileUrl), - cursor.getString(indexDownloadUrl), - cursor.getInt(indexDownloaded) > 0, - cursor.getInt(indexIsPaged) > 0, - cursor.getString(indexNextPageLink), - cursor.getString(indexHide), - SortOrder.fromCodeString(cursor.getString(indexSortOrder)), - cursor.getInt(indexLastUpdateFailed) > 0 - ); - - FeedPreferences preferences = FeedPreferences.fromCursor(cursor); - feed.setPreferences(preferences); - return feed; - } - /** * Returns the item at the specified index. * @@ -384,7 +335,7 @@ public class Feed extends FeedFile implements ImageResource { } public void setCustomTitle(String customTitle) { - if(customTitle == null || customTitle.equals(feedTitle)) { + if (customTitle == null || customTitle.equals(feedTitle)) { this.customTitle = null; } else { this.customTitle = customTitle; @@ -479,10 +430,6 @@ public class Feed extends FeedFile implements ImageResource { return preferences; } - public void savePreferences() { - DBWriter.setFeedPreferences(preferences); - } - @Override public void setId(long id) { super.setId(id); @@ -491,11 +438,6 @@ public class Feed extends FeedFile implements ImageResource { } } - @Override - public String getImageLocation() { - return imageUrl; - } - public int getPageNr() { return pageNr; } diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java index 131cbe563..d6926385e 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java @@ -4,7 +4,6 @@ import android.database.Cursor; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import android.text.TextUtils; import org.apache.commons.lang3.builder.ToStringBuilder; import org.apache.commons.lang3.builder.ToStringStyle; @@ -14,20 +13,16 @@ import java.util.Date; import java.util.HashSet; import java.util.List; import java.util.Set; -import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; -import de.danoeh.antennapod.core.asynctask.ImageResource; -import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.core.storage.PodDBAdapter; -import de.danoeh.antennapod.core.util.ShownotesProvider; /** - * Data Object for a XML message + * Item (episode) within a feed. * * @author daniel */ -public class FeedItem extends FeedComponent implements ShownotesProvider, ImageResource, Serializable { +public class FeedItem extends FeedComponent implements Serializable { /** tag that indicates this item is in the queue */ public static final String TAG_QUEUE = "Queue"; @@ -43,10 +38,6 @@ public class FeedItem extends FeedComponent implements ShownotesProvider, ImageR * The description of a feeditem. */ private String description; - /** - * The content of the content-encoded tag of a feeditem. - */ - private String contentEncoded; private String link; private Date pubDate; @@ -182,9 +173,6 @@ public class FeedItem extends FeedComponent implements ShownotesProvider, ImageR if (other.getDescription() != null) { description = other.getDescription(); } - if (other.getContentEncoded() != null) { - contentEncoded = other.contentEncoded; - } if (other.link != null) { link = other.link; } @@ -240,10 +228,6 @@ public class FeedItem extends FeedComponent implements ShownotesProvider, ImageR return description; } - public void setDescription(String description) { - this.description = description; - } - public String getLink() { return link; } @@ -307,7 +291,7 @@ public class FeedItem extends FeedComponent implements ShownotesProvider, ImageR } public void setPlayed(boolean played) { - if(played) { + if (played) { state = PLAYED; } else { state = UNPLAYED; @@ -318,12 +302,19 @@ public class FeedItem extends FeedComponent implements ShownotesProvider, ImageR return (media != null && media.isInProgress()); } - public String getContentEncoded() { - return contentEncoded; - } - - public void setContentEncoded(String contentEncoded) { - this.contentEncoded = contentEncoded; + /** + * Updates this item's description property if the given argument is longer than the already stored description + * @param newDescription The new item description, content:encoded, itunes:description, etc. + */ + public void setDescriptionIfLonger(String newDescription) { + if (newDescription == null) { + return; + } + if (this.description == null) { + this.description = newDescription; + } else if (this.description.length() < newDescription.length()) { + this.description = newDescription; + } } public String getPaymentLink() { @@ -358,32 +349,13 @@ public class FeedItem extends FeedComponent implements ShownotesProvider, ImageR return media != null && media.isPlaying(); } - @Override - public Callable<String> loadShownotes() { - return () -> { - if (contentEncoded == null || description == null) { - DBReader.loadDescriptionOfFeedItem(FeedItem.this); - } - if (TextUtils.isEmpty(contentEncoded)) { - return description; - } else if (TextUtils.isEmpty(description)) { - return contentEncoded; - } else if (description.length() > 1.25 * contentEncoded.length()) { - return description; - } else { - return contentEncoded; - } - }; - } - - @Override public String getImageLocation() { if (imageUrl != null) { return imageUrl; } else if (media != null && media.hasEmbeddedPicture()) { return FeedMedia.FILENAME_PREFIX_EMBEDDED_COVER + media.getLocalMediaUrl(); } else if (feed != null) { - return feed.getImageLocation(); + return feed.getImageUrl(); } else { return null; } @@ -472,17 +444,23 @@ public class FeedItem extends FeedComponent implements ShownotesProvider, ImageR /** * @return true if the item has this tag */ - public boolean isTagged(String tag) { return tags.contains(tag); } + public boolean isTagged(String tag) { + return tags.contains(tag); + } /** * @param tag adds this tag to the item. NOTE: does NOT persist to the database */ - public void addTag(String tag) { tags.add(tag); } + public void addTag(String tag) { + tags.add(tag); + } /** * @param tag the to remove */ - public void removeTag(String tag) { tags.remove(tag); } + public void removeTag(String tag) { + tags.remove(tag); + } @NonNull @Override diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItemFilter.java b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItemFilter.java index 787f0e5e7..ac742e765 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItemFilter.java +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItemFilter.java @@ -1,134 +1,60 @@ package de.danoeh.antennapod.core.feed; import android.text.TextUtils; - -import java.util.ArrayList; -import java.util.List; - -import de.danoeh.antennapod.core.storage.DBReader; -import de.danoeh.antennapod.core.util.LongList; - -import static de.danoeh.antennapod.core.feed.FeedItem.TAG_FAVORITE; +import java.util.Arrays; public class FeedItemFilter { - private final String[] mProperties; - - private boolean showPlayed = false; - private boolean showUnplayed = false; - private boolean showPaused = false; - private boolean showNotPaused = false; - private boolean showQueued = false; - private boolean showNotQueued = false; - private boolean showDownloaded = false; - private boolean showNotDownloaded = false; - private boolean showHasMedia = false; - private boolean showNoMedia = false; - private boolean showIsFavorite = false; - private boolean showNotFavorite = false; + private final String[] properties; + + public final boolean showPlayed; + public final boolean showUnplayed; + public final boolean showPaused; + public final boolean showNotPaused; + public final boolean showQueued; + public final boolean showNotQueued; + public final boolean showDownloaded; + public final boolean showNotDownloaded; + public final boolean showHasMedia; + public final boolean showNoMedia; + public final boolean showIsFavorite; + public final boolean showNotFavorite; + + public static FeedItemFilter unfiltered() { + return new FeedItemFilter(""); + } public FeedItemFilter(String properties) { this(TextUtils.split(properties, ",")); } public FeedItemFilter(String[] properties) { - this.mProperties = properties; - for (String property : properties) { - // see R.arrays.feed_filter_values - switch (property) { - case "unplayed": - showUnplayed = true; - break; - case "paused": - showPaused = true; - break; - case "not_paused": - showNotPaused = true; - break; - case "played": - showPlayed = true; - break; - case "queued": - showQueued = true; - break; - case "not_queued": - showNotQueued = true; - break; - case "downloaded": - showDownloaded = true; - break; - case "not_downloaded": - showNotDownloaded = true; - break; - case "has_media": - showHasMedia = true; - break; - case "no_media": - showNoMedia = true; - break; - case "is_favorite": - showIsFavorite = true; - break; - case "not_favorite": - showNotFavorite = true; - break; - default: - break; - } - } + this.properties = properties; + + // see R.arrays.feed_filter_values + showUnplayed = hasProperty("unplayed"); + showPaused = hasProperty("paused"); + showNotPaused = hasProperty("not_paused"); + showPlayed = hasProperty("played"); + showQueued = hasProperty("queued"); + showNotQueued = hasProperty("not_queued"); + showDownloaded = hasProperty("downloaded"); + showNotDownloaded = hasProperty("not_downloaded"); + showHasMedia = hasProperty("has_media"); + showNoMedia = hasProperty("no_media"); + showIsFavorite = hasProperty("is_favorite"); + showNotFavorite = hasProperty("not_favorite"); } - /** - * Run a list of feed items through the filter. - */ - public List<FeedItem> filter(List<FeedItem> items) { - if(mProperties.length == 0) return items; - - List<FeedItem> result = new ArrayList<>(); - - // Check for filter combinations that will always return an empty list - // (e.g. requiring played and unplayed at the same time) - if (showPlayed && showUnplayed) return result; - if (showQueued && showNotQueued) return result; - if (showDownloaded && showNotDownloaded) return result; - - final LongList queuedIds = DBReader.getQueueIDList(); - for (FeedItem item : items) { - // If the item does not meet a requirement, skip it. - - if (showPlayed && !item.isPlayed()) continue; - if (showUnplayed && item.isPlayed()) continue; - - if (showPaused && item.getState() != FeedItem.State.IN_PROGRESS) continue; - if (showNotPaused && item.getState() == FeedItem.State.IN_PROGRESS) continue; - - boolean queued = queuedIds.contains(item.getId()); - if (showQueued && !queued) continue; - if (showNotQueued && queued) continue; - - boolean downloaded = item.getMedia() != null && item.getMedia().isDownloaded(); - if (showDownloaded && !downloaded) continue; - if (showNotDownloaded && downloaded) continue; - - if (showHasMedia && !item.hasMedia()) continue; - if (showNoMedia && item.hasMedia()) continue; - - if (showIsFavorite && !item.isTagged(TAG_FAVORITE)) continue; - if (showNotFavorite && item.isTagged(TAG_FAVORITE)) continue; - - // If the item reaches here, it meets all criteria - result.add(item); - } - - return result; + private boolean hasProperty(String property) { + return Arrays.asList(properties).contains(property); } public String[] getValues() { - return mProperties.clone(); + return properties.clone(); } public boolean isShowDownloaded() { return showDownloaded; } - } diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java index 4857e899d..3070f882c 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java @@ -12,10 +12,8 @@ import androidx.annotation.Nullable; import android.support.v4.media.MediaBrowserCompat; import android.support.v4.media.MediaDescriptionCompat; -import java.util.Collections; import java.util.Date; import java.util.List; -import java.util.concurrent.Callable; import de.danoeh.antennapod.core.preferences.GpodnetPreferences; import de.danoeh.antennapod.core.preferences.PlaybackPreferences; @@ -24,10 +22,10 @@ import de.danoeh.antennapod.core.service.playback.PlaybackService; import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.core.storage.DBWriter; import de.danoeh.antennapod.core.storage.PodDBAdapter; -import de.danoeh.antennapod.core.util.ChapterUtils; import de.danoeh.antennapod.core.util.playback.Playable; import de.danoeh.antennapod.core.sync.SyncService; import de.danoeh.antennapod.core.sync.model.EpisodeAction; +import de.danoeh.antennapod.core.util.playback.PlayableException; public class FeedMedia extends FeedFile implements Playable { private static final String TAG = "FeedMedia"; @@ -175,8 +173,8 @@ public class FeedMedia extends FeedFile implements Playable { // getImageLocation() also loads embedded images, which we can not send to external devices if (item.getImageUrl() != null) { builder.setIconUri(Uri.parse(item.getImageUrl())); - } else if (item.getFeed() != null && item.getFeed().getImageLocation() != null) { - builder.setIconUri(Uri.parse(item.getFeed().getImageLocation())); + } else if (item.getFeed() != null && item.getFeed().getImageUrl() != null) { + builder.setIconUri(Uri.parse(item.getFeed().getImageUrl())); } } return new MediaBrowserCompat.MediaItem(builder.build(), MediaBrowserCompat.MediaItem.FLAG_PLAYABLE); @@ -287,6 +285,14 @@ public class FeedMedia extends FeedFile implements Playable { this.size = size; } + @Override + public String getDescription() { + if (item != null) { + return item.getDescription(); + } + return null; + } + /** * Indicates we asked the service what the size was, but didn't * get a valid answer and we shoudln't check using the network again. @@ -385,40 +391,6 @@ public class FeedMedia extends FeedFile implements Playable { } @Override - public void loadChapterMarks(Context context) { - if (item == null && itemID != 0) { - item = DBReader.getFeedItem(itemID); - } - if (item == null || item.getChapters() != null) { - return; - } - - List<Chapter> chapters = loadChapters(context); - if (chapters == null) { - // Do not try loading again. There are no chapters. - item.setChapters(Collections.emptyList()); - } else { - item.setChapters(chapters); - } - } - - private List<Chapter> loadChapters(Context context) { - List<Chapter> chaptersFromDatabase = null; - if (item.hasChapters()) { - chaptersFromDatabase = DBReader.loadChaptersOfFeedItem(item); - } - - List<Chapter> chaptersFromMediaFile; - if (localFileAvailable()) { - chaptersFromMediaFile = ChapterUtils.loadChaptersFromFileUrl(this); - } else { - chaptersFromMediaFile = ChapterUtils.loadChaptersFromStreamUrl(this, context); - } - - return ChapterMerger.merge(chaptersFromDatabase, chaptersFromMediaFile); - } - - @Override public String getEpisodeTitle() { if (item == null) { return null; @@ -478,6 +450,18 @@ public class FeedMedia extends FeedFile implements Playable { } @Override + public Date getPubDate() { + if (item == null) { + return null; + } + if (item.getPubDate() != null) { + return item.getPubDate(); + } else { + return null; + } + } + + @Override public boolean localFileAvailable() { return isDownloaded() && file_url != null; } @@ -487,6 +471,10 @@ public class FeedMedia extends FeedFile implements Playable { return download_url != null; } + public long getItemId() { + return itemID; + } + @Override public void saveCurrentPosition(SharedPreferences pref, int newPosition, long timeStamp) { if(item != null && item.isNew()) { @@ -543,21 +531,11 @@ public class FeedMedia extends FeedFile implements Playable { @Override public void setChapters(List<Chapter> chapters) { - if(item != null) { + if (item != null) { item.setChapters(chapters); } } - @Override - public Callable<String> loadShownotes() { - return () -> { - if (item == null) { - item = DBReader.getFeedItem(itemID); - } - return item.loadShownotes().call(); - }; - } - public static final Parcelable.Creator<FeedMedia> CREATOR = new Parcelable.Creator<FeedMedia>() { public FeedMedia createFromParcel(Parcel in) { final long id = in.readLong(); diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedPreferences.java index bd4690684..794c71cf3 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedPreferences.java +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedPreferences.java @@ -1,12 +1,10 @@ package de.danoeh.antennapod.core.feed; -import android.content.Context; import android.database.Cursor; import androidx.annotation.NonNull; import android.text.TextUtils; import de.danoeh.antennapod.core.preferences.UserPreferences; -import de.danoeh.antennapod.core.storage.DBWriter; import de.danoeh.antennapod.core.storage.PodDBAdapter; import java.util.Arrays; @@ -22,36 +20,37 @@ public class FeedPreferences { public static final String TAG_ROOT = "#root"; public static final String TAG_SEPARATOR = ","; - @NonNull - private FeedFilter filter; - private long feedID; - private boolean autoDownload; - private boolean keepUpdated; - public enum AutoDeleteAction { GLOBAL, YES, NO } - private AutoDeleteAction autoDeleteAction; + @NonNull + private FeedFilter filter; + private long feedID; + private boolean autoDownload; + private boolean keepUpdated; + private AutoDeleteAction autoDeleteAction; private VolumeAdaptionSetting volumeAdaptionSetting; - private String username; private String password; private float feedPlaybackSpeed; private int feedSkipIntro; private int feedSkipEnding; + private boolean showEpisodeNotification; private final Set<String> tags = new HashSet<>(); - public FeedPreferences(long feedID, boolean autoDownload, AutoDeleteAction autoDeleteAction, VolumeAdaptionSetting volumeAdaptionSetting, String username, String password) { + public FeedPreferences(long feedID, boolean autoDownload, AutoDeleteAction autoDeleteAction, + VolumeAdaptionSetting volumeAdaptionSetting, String username, String password) { this(feedID, autoDownload, true, autoDeleteAction, volumeAdaptionSetting, - username, password, new FeedFilter(), SPEED_USE_GLOBAL, 0, 0, new HashSet<>()); + username, password, new FeedFilter(), SPEED_USE_GLOBAL, 0, 0, false, new HashSet<>()); } - private FeedPreferences(long feedID, boolean autoDownload, boolean keepUpdated, AutoDeleteAction autoDeleteAction, - VolumeAdaptionSetting volumeAdaptionSetting, String username, String password, - @NonNull FeedFilter filter, float feedPlaybackSpeed, int feedSkipIntro, int feedSkipEnding, + private FeedPreferences(long feedID, boolean autoDownload, boolean keepUpdated, + AutoDeleteAction autoDeleteAction, VolumeAdaptionSetting volumeAdaptionSetting, + String username, String password, @NonNull FeedFilter filter, float feedPlaybackSpeed, + int feedSkipIntro, int feedSkipEnding, boolean showEpisodeNotification, Set<String> tags) { this.feedID = feedID; this.autoDownload = autoDownload; @@ -64,6 +63,7 @@ public class FeedPreferences { this.feedPlaybackSpeed = feedPlaybackSpeed; this.feedSkipIntro = feedSkipIntro; this.feedSkipEnding = feedSkipEnding; + this.showEpisodeNotification = showEpisodeNotification; this.tags.addAll(tags); } @@ -80,6 +80,7 @@ public class FeedPreferences { int indexFeedPlaybackSpeed = cursor.getColumnIndex(PodDBAdapter.KEY_FEED_PLAYBACK_SPEED); int indexAutoSkipIntro = cursor.getColumnIndex(PodDBAdapter.KEY_FEED_SKIP_INTRO); int indexAutoSkipEnding = cursor.getColumnIndex(PodDBAdapter.KEY_FEED_SKIP_ENDING); + int indexEpisodeNotification = cursor.getColumnIndex(PodDBAdapter.KEY_EPISODE_NOTIFICATION); int indexTags = cursor.getColumnIndex(PodDBAdapter.KEY_FEED_TAGS); long feedId = cursor.getLong(indexId); @@ -96,11 +97,11 @@ public class FeedPreferences { float feedPlaybackSpeed = cursor.getFloat(indexFeedPlaybackSpeed); int feedAutoSkipIntro = cursor.getInt(indexAutoSkipIntro); int feedAutoSkipEnding = cursor.getInt(indexAutoSkipEnding); + boolean showNotification = cursor.getInt(indexEpisodeNotification) > 0; String tagsString = cursor.getString(indexTags); if (TextUtils.isEmpty(tagsString)) { tagsString = TAG_ROOT; } - return new FeedPreferences(feedId, autoDownload, autoRefresh, @@ -112,6 +113,7 @@ public class FeedPreferences { feedPlaybackSpeed, feedAutoSkipIntro, feedAutoSkipEnding, + showNotification, new HashSet<>(Arrays.asList(tagsString.split(TAG_SEPARATOR)))); } @@ -192,8 +194,8 @@ public class FeedPreferences { return volumeAdaptionSetting; } - public void setAutoDeleteAction(AutoDeleteAction auto_delete_action) { - this.autoDeleteAction = auto_delete_action; + public void setAutoDeleteAction(AutoDeleteAction autoDeleteAction) { + this.autoDeleteAction = autoDeleteAction; } public void setVolumeAdaptionSetting(VolumeAdaptionSetting volumeAdaptionSetting) { @@ -204,18 +206,12 @@ public class FeedPreferences { switch (autoDeleteAction) { case GLOBAL: return UserPreferences.isAutoDelete(); - case YES: return true; - case NO: + default: // fall-through return false; } - return false; // TODO - add exceptions here - } - - public void save(Context context) { - DBWriter.setFeedPreferences(this); } public String getUsername() { @@ -265,4 +261,16 @@ public class FeedPreferences { public String getTagsAsString() { return TextUtils.join(TAG_SEPARATOR, tags); } + + /** + * getter for preference if notifications should be display for new episodes. + * @return true for displaying notifications + */ + public boolean getShowEpisodeNotification() { + return showEpisodeNotification; + } + + public void setShowEpisodeNotification(boolean showEpisodeNotification) { + this.showEpisodeNotification = showEpisodeNotification; + } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/LocalFeedUpdater.java b/core/src/main/java/de/danoeh/antennapod/core/feed/LocalFeedUpdater.java index 4e59fd750..1418a4e78 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/feed/LocalFeedUpdater.java +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/LocalFeedUpdater.java @@ -6,15 +6,13 @@ import android.media.MediaMetadataRetriever; import android.net.Uri; import android.text.TextUtils; +import androidx.annotation.NonNull; import androidx.documentfile.provider.DocumentFile; -import org.apache.commons.lang3.StringUtils; - import java.io.IOException; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.HashSet; @@ -34,6 +32,8 @@ import de.danoeh.antennapod.core.util.DownloadError; public class LocalFeedUpdater { + static final String[] PREFERRED_FEED_IMAGE_FILENAMES = { "folder.jpg", "Folder.jpg", "folder.png", "Folder.png" }; + public static void updateFeed(Feed feed, Context context) { try { tryUpdateFeed(feed, context); @@ -97,18 +97,7 @@ public class LocalFeedUpdater { } } - List<String> iconLocations = Arrays.asList("folder.jpg", "Folder.jpg", "folder.png", "Folder.png"); - for (String iconLocation : iconLocations) { - DocumentFile image = documentFolder.findFile(iconLocation); - if (image != null) { - feed.setImageUrl(image.getUri().toString()); - break; - } - } - if (StringUtils.isBlank(feed.getImageUrl())) { - // set default feed image - feed.setImageUrl(getDefaultIconUrl(context)); - } + feed.setImageUrl(getImageUrl(context, documentFolder)); feed.getPreferences().setAutoDownload(false); feed.getPreferences().setAutoDeleteAction(FeedPreferences.AutoDeleteAction.NO); @@ -123,6 +112,31 @@ public class LocalFeedUpdater { } /** + * Returns the image URL for the local feed. + */ + @NonNull + static String getImageUrl(@NonNull Context context, @NonNull DocumentFile documentFolder) { + // look for special file names + for (String iconLocation : PREFERRED_FEED_IMAGE_FILENAMES) { + DocumentFile image = documentFolder.findFile(iconLocation); + if (image != null) { + return image.getUri().toString(); + } + } + + // use the first image in the folder if existing + for (DocumentFile file : documentFolder.listFiles()) { + String mime = file.getType(); + if (mime != null && (mime.startsWith("image/jpeg") || mime.startsWith("image/png"))) { + return file.getUri().toString(); + } + } + + // use default icon as fallback + return getDefaultIconUrl(context); + } + + /** * Returns the URL of the default icon for a local feed. The URL refers to an app resource file. */ public static String getDefaultIconUrl(Context context) { @@ -155,13 +169,13 @@ public class LocalFeedUpdater { try { loadMetadata(item, file, context); } catch (Exception e) { - item.setDescription(e.getMessage()); + item.setDescriptionIfLonger(e.getMessage()); } return item; } - private static void loadMetadata(FeedItem item, DocumentFile file, Context context) throws Exception { + private static void loadMetadata(FeedItem item, DocumentFile file, Context context) { MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever(); mediaMetadataRetriever.setDataSource(context, file.getUri()); diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/util/ImageResourceUtils.java b/core/src/main/java/de/danoeh/antennapod/core/feed/util/ImageResourceUtils.java index 674663a6d..b0aee3d77 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/feed/util/ImageResourceUtils.java +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/util/ImageResourceUtils.java @@ -1,45 +1,66 @@ package de.danoeh.antennapod.core.feed.util; -import de.danoeh.antennapod.core.asynctask.ImageResource; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + import de.danoeh.antennapod.core.feed.FeedItem; import de.danoeh.antennapod.core.feed.FeedMedia; import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.util.playback.Playable; /** - * Utility class to use the appropriate image resource based on {@link UserPreferences} + * Utility class to use the appropriate image resource based on {@link UserPreferences}. */ public final class ImageResourceUtils { private ImageResourceUtils() { } - public static String getImageLocation(ImageResource resource) { + /** + * returns the image location, does prefer the episode cover if available and enabled in settings. + */ + @Nullable + public static String getEpisodeListImageLocation(@NonNull Playable playable) { if (UserPreferences.getUseEpisodeCoverSetting()) { - return resource.getImageLocation(); + return playable.getImageLocation(); } else { - return getShowImageLocation(resource); + return getFallbackImageLocation(playable); } } - private static String getShowImageLocation(ImageResource resource) { + /** + * returns the image location, does prefer the episode cover if available and enabled in settings. + */ + @Nullable + public static String getEpisodeListImageLocation(@NonNull FeedItem feedItem) { + if (UserPreferences.getUseEpisodeCoverSetting()) { + return feedItem.getImageLocation(); + } else { + return getFallbackImageLocation(feedItem); + } + } - if (resource instanceof FeedItem) { - FeedItem item = (FeedItem) resource; - if (item.getFeed() != null) { - return item.getFeed().getImageLocation(); - } else { - return null; - } - } else if (resource instanceof FeedMedia) { - FeedMedia media = (FeedMedia) resource; + @Nullable + public static String getFallbackImageLocation(@NonNull Playable playable) { + if (playable instanceof FeedMedia) { + FeedMedia media = (FeedMedia) playable; FeedItem item = media.getItem(); if (item != null && item.getFeed() != null) { - return item.getFeed().getImageLocation(); + return item.getFeed().getImageUrl(); } else { return null; } } else { - return resource.getImageLocation(); + return playable.getImageLocation(); + } + } + + @Nullable + public static String getFallbackImageLocation(@NonNull FeedItem feedItem) { + if (feedItem.getFeed() != null) { + return feedItem.getFeed().getImageUrl(); + } else { + return null; } } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java index 0a72b5d5c..209558b19 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java +++ b/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java @@ -26,7 +26,7 @@ public class GpodnetPreferences { private static String username; private static String password; private static String deviceID; - private static String hostname; + private static String hosturl; private static boolean preferencesLoaded = false; @@ -40,7 +40,7 @@ public class GpodnetPreferences { username = prefs.getString(PREF_GPODNET_USERNAME, null); password = prefs.getString(PREF_GPODNET_PASSWORD, null); deviceID = prefs.getString(PREF_GPODNET_DEVICEID, null); - hostname = checkGpodnetHostname(prefs.getString(PREF_GPODNET_HOSTNAME, GpodnetService.DEFAULT_BASE_HOST)); + hosturl = prefs.getString(PREF_GPODNET_HOSTNAME, GpodnetService.DEFAULT_BASE_HOST); preferencesLoaded = true; } @@ -82,17 +82,16 @@ public class GpodnetPreferences { writePreference(PREF_GPODNET_DEVICEID, deviceID); } - public static String getHostname() { + public static String getHosturl() { ensurePreferencesLoaded(); - return hostname; + return hosturl; } - public static void setHostname(String value) { - value = checkGpodnetHostname(value); - if (!value.equals(hostname)) { + public static void setHosturl(String value) { + if (!value.equals(hosturl)) { logout(); writePreference(PREF_GPODNET_HOSTNAME, value); - hostname = value; + hosturl = value; } } @@ -113,13 +112,4 @@ public class GpodnetPreferences { UserPreferences.setGpodnetNotificationsEnabled(); } - private static String checkGpodnetHostname(String value) { - int startIndex = 0; - if (value.startsWith("http://")) { - startIndex = "http://".length(); - } else if (value.startsWith("https://")) { - startIndex = "https://".length(); - } - return value.substring(startIndex); - } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/preferences/PlaybackPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/preferences/PlaybackPreferences.java index 08ea27434..95b828e28 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/preferences/PlaybackPreferences.java +++ b/core/src/main/java/de/danoeh/antennapod/core/preferences/PlaybackPreferences.java @@ -100,7 +100,7 @@ public class PlaybackPreferences implements SharedPreferences.OnSharedPreference } public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { - if (key.equals(PREF_CURRENT_PLAYER_STATUS)) { + if (PREF_CURRENT_PLAYER_STATUS.equals(key)) { EventBus.getDefault().post(new PlayerStatusEvent()); } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java index ed9c519a6..cbfe28ded 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java +++ b/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java @@ -6,6 +6,7 @@ import android.content.res.Configuration; import android.os.Build; import android.text.TextUtils; import android.util.Log; +import android.view.KeyEvent; import androidx.annotation.IntRange; import androidx.annotation.NonNull; @@ -35,6 +36,7 @@ import de.danoeh.antennapod.core.feed.MediaType; import de.danoeh.antennapod.core.feed.SubscriptionsFilter; import de.danoeh.antennapod.core.service.download.ProxyConfig; import de.danoeh.antennapod.core.storage.APCleanupAlgorithm; +import de.danoeh.antennapod.core.storage.ExceptFavoriteCleanupAlgorithm; import de.danoeh.antennapod.core.storage.APNullCleanupAlgorithm; import de.danoeh.antennapod.core.storage.APQueueCleanupAlgorithm; import de.danoeh.antennapod.core.storage.EpisodeCleanupAlgorithm; @@ -60,6 +62,7 @@ public class UserPreferences { private static final String PREF_DRAWER_FEED_COUNTER = "prefDrawerFeedIndicator"; public static final String PREF_EXPANDED_NOTIFICATION = "prefExpandNotify"; public static final String PREF_USE_EPISODE_COVER = "prefEpisodeCover"; + public static final String PREF_SHOW_TIME_LEFT = "showTimeLeft"; private static final String PREF_PERSISTENT_NOTIFICATION = "prefPersistNotify"; public static final String PREF_COMPACT_NOTIFICATION_BUTTONS = "prefCompactNotificationButtons"; public static final String PREF_LOCKSCREEN_BACKGROUND = "prefLockscreenBackground"; @@ -76,8 +79,8 @@ public class UserPreferences { public static final String PREF_PAUSE_ON_HEADSET_DISCONNECT = "prefPauseOnHeadsetDisconnect"; public static final String PREF_UNPAUSE_ON_HEADSET_RECONNECT = "prefUnpauseOnHeadsetReconnect"; private static final String PREF_UNPAUSE_ON_BLUETOOTH_RECONNECT = "prefUnpauseOnBluetoothReconnect"; - private static final String PREF_HARDWARE_FOWARD_BUTTON_SKIPS = "prefHardwareForwardButtonSkips"; - private static final String PREF_HARDWARE_PREVIOUS_BUTTON_RESTARTS = "prefHardwarePreviousButtonRestarts"; + public static final String PREF_HARDWARE_FORWARD_BUTTON = "prefHardwareForwardButton"; + public static final String PREF_HARDWARE_PREVIOUS_BUTTON = "prefHardwarePreviousButton"; public static final String PREF_FOLLOW_QUEUE = "prefFollowQueue"; public static final String PREF_SKIP_KEEPS_EPISODE = "prefSkipKeepsEpisode"; private static final String PREF_FAVORITE_KEEPS_EPISODE = "prefFavoriteKeepsEpisode"; @@ -136,6 +139,7 @@ public class UserPreferences { public static final String PREF_CAST_ENABLED = "prefCast"; //Used for enabling Chromecast support public static final int EPISODE_CLEANUP_QUEUE = -1; public static final int EPISODE_CLEANUP_NULL = -2; + public static final int EPISODE_CLEANUP_EXCEPT_FAVORITE = -3; public static final int EPISODE_CLEANUP_DEFAULT = 0; // Constants @@ -265,6 +269,23 @@ public class UserPreferences { } /** + * @return {@code true} if we should show remaining time or the duration + */ + public static boolean shouldShowRemainingTime() { + return prefs.getBoolean(PREF_SHOW_TIME_LEFT, false); + } + + /** + * Sets the preference for whether we show the remain time, if not show the duration. This will + * send out events so the current playing screen, queue and the episode list would refresh + * + * @return {@code true} if we should show remaining time or the duration + */ + public static void setShowRemainTimeSetting(Boolean showRemain) { + prefs.edit().putBoolean(PREF_SHOW_TIME_LEFT, showRemain).apply(); + } + + /** * Returns notification priority. * * @return NotificationCompat.PRIORITY_MAX or NotificationCompat.PRIORITY_DEFAULT @@ -373,12 +394,14 @@ public class UserPreferences { return prefs.getBoolean(PREF_UNPAUSE_ON_BLUETOOTH_RECONNECT, false); } - public static boolean shouldHardwareButtonSkip() { - return prefs.getBoolean(PREF_HARDWARE_FOWARD_BUTTON_SKIPS, false); + public static int getHardwareForwardButton() { + return Integer.parseInt(prefs.getString(PREF_HARDWARE_FORWARD_BUTTON, + String.valueOf(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD))); } - public static boolean shouldHardwarePreviousButtonRestart() { - return prefs.getBoolean(PREF_HARDWARE_PREVIOUS_BUTTON_RESTARTS, false); + public static int getHardwarePreviousButton() { + return Integer.parseInt(prefs.getString(PREF_HARDWARE_PREVIOUS_BUTTON, + String.valueOf(KeyEvent.KEYCODE_MEDIA_REWIND))); } @@ -879,7 +902,9 @@ public class UserPreferences { return new APNullCleanupAlgorithm(); } int cleanupValue = getEpisodeCleanupValue(); - if (cleanupValue == EPISODE_CLEANUP_QUEUE) { + if (cleanupValue == EPISODE_CLEANUP_EXCEPT_FAVORITE) { + return new ExceptFavoriteCleanupAlgorithm(); + } else if (cleanupValue == EPISODE_CLEANUP_QUEUE) { return new APQueueCleanupAlgorithm(); } else if (cleanupValue == EPISODE_CLEANUP_NULL) { return new APNullCleanupAlgorithm(); diff --git a/core/src/main/java/de/danoeh/antennapod/core/receiver/PlayerWidget.java b/core/src/main/java/de/danoeh/antennapod/core/receiver/PlayerWidget.java index 2e592bdf5..cf0debed2 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/receiver/PlayerWidget.java +++ b/core/src/main/java/de/danoeh/antennapod/core/receiver/PlayerWidget.java @@ -9,20 +9,23 @@ import android.util.Log; import java.util.Arrays; -import de.danoeh.antennapod.core.service.PlayerWidgetJobService; +import de.danoeh.antennapod.core.widget.WidgetUpdaterJobService; public class PlayerWidget extends AppWidgetProvider { private static final String TAG = "PlayerWidget"; public static final String PREFS_NAME = "PlayerWidgetPrefs"; private static final String KEY_ENABLED = "WidgetEnabled"; public static final String KEY_WIDGET_COLOR = "widget_color"; + public static final String KEY_WIDGET_SKIP = "widget_skip"; + public static final String KEY_WIDGET_FAST_FORWARD = "widget_fast_forward"; + public static final String KEY_WIDGET_REWIND = "widget_rewind"; public static final int DEFAULT_COLOR = 0x00262C31; @Override public void onReceive(Context context, Intent intent) { Log.d(TAG, "onReceive"); super.onReceive(context, intent); - PlayerWidgetJobService.updateWidget(context); + WidgetUpdaterJobService.performBackgroundUpdate(context); } @Override @@ -30,13 +33,14 @@ public class PlayerWidget extends AppWidgetProvider { super.onEnabled(context); Log.d(TAG, "Widget enabled"); setEnabled(context, true); - PlayerWidgetJobService.updateWidget(context); + WidgetUpdaterJobService.performBackgroundUpdate(context); } @Override public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) { - Log.d(TAG, "onUpdate() called with: " + "context = [" + context + "], appWidgetManager = [" + appWidgetManager + "], appWidgetIds = [" + Arrays.toString(appWidgetIds) + "]"); - PlayerWidgetJobService.updateWidget(context); + Log.d(TAG, "onUpdate() called with: " + "context = [" + context + "], appWidgetManager = [" + + appWidgetManager + "], appWidgetIds = [" + Arrays.toString(appWidgetIds) + "]"); + WidgetUpdaterJobService.performBackgroundUpdate(context); } @Override @@ -52,6 +56,9 @@ public class PlayerWidget extends AppWidgetProvider { for (int appWidgetId : appWidgetIds) { SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); prefs.edit().remove(KEY_WIDGET_COLOR + appWidgetId).apply(); + prefs.edit().remove(KEY_WIDGET_REWIND + appWidgetId).apply(); + prefs.edit().remove(KEY_WIDGET_FAST_FORWARD + appWidgetId).apply(); + prefs.edit().remove(KEY_WIDGET_SKIP + appWidgetId).apply(); } super.onDeleted(context, appWidgetIds); } diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/PlayerWidgetJobService.java b/core/src/main/java/de/danoeh/antennapod/core/service/PlayerWidgetJobService.java deleted file mode 100644 index 74735a264..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/service/PlayerWidgetJobService.java +++ /dev/null @@ -1,243 +0,0 @@ -package de.danoeh.antennapod.core.service; - -import android.app.PendingIntent; -import android.appwidget.AppWidgetManager; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.content.SharedPreferences; -import android.graphics.Bitmap; -import android.os.Bundle; -import android.os.IBinder; -import androidx.annotation.NonNull; -import androidx.core.app.SafeJobIntentService; -import android.util.Log; -import android.view.KeyEvent; -import android.view.View; -import android.widget.RemoteViews; - -import com.bumptech.glide.Glide; -import com.bumptech.glide.request.RequestOptions; - -import java.util.concurrent.TimeUnit; - -import de.danoeh.antennapod.core.R; -import de.danoeh.antennapod.core.glide.ApGlideSettings; -import de.danoeh.antennapod.core.feed.util.PlaybackSpeedUtils; -import de.danoeh.antennapod.core.receiver.MediaButtonReceiver; -import de.danoeh.antennapod.core.receiver.PlayerWidget; -import de.danoeh.antennapod.core.service.playback.PlaybackService; -import de.danoeh.antennapod.core.service.playback.PlayerStatus; -import de.danoeh.antennapod.core.util.Converter; -import de.danoeh.antennapod.core.feed.util.ImageResourceUtils; -import de.danoeh.antennapod.core.util.TimeSpeedConverter; -import de.danoeh.antennapod.core.util.playback.Playable; - -/** - * Updates the state of the player widget - */ -public class PlayerWidgetJobService extends SafeJobIntentService { - - private static final String TAG = "PlayerWidgetJobService"; - - private PlaybackService playbackService; - private final Object waitForService = new Object(); - private final Object waitUsingService = new Object(); - - private static final int JOB_ID = -17001; - - public static void updateWidget(Context context) { - enqueueWork(context, PlayerWidgetJobService.class, JOB_ID, new Intent(context, PlayerWidgetJobService.class)); - } - - @Override - protected void onHandleWork(@NonNull Intent intent) { - if (!PlayerWidget.isEnabled(getApplicationContext())) { - return; - } - - synchronized (waitForService) { - if (PlaybackService.isRunning && playbackService == null) { - bindService(new Intent(this, PlaybackService.class), mConnection, 0); - while (playbackService == null) { - try { - waitForService.wait(); - } catch (InterruptedException e) { - return; - } - } - } - } - - synchronized (waitUsingService) { - updateViews(); - } - - if (playbackService != null) { - try { - unbindService(mConnection); - } catch (IllegalArgumentException e) { - Log.w(TAG, "IllegalArgumentException when trying to unbind service"); - } - } - } - - /** - * Returns number of cells needed for given size of the widget. - * - * @param size Widget size in dp. - * @return Size in number of cells. - */ - private static int getCellsForSize(int size) { - int n = 2; - while (70 * n - 30 < size) { - ++n; - } - return n - 1; - } - - private void updateViews() { - - ComponentName playerWidget = new ComponentName(this, PlayerWidget.class); - AppWidgetManager manager = AppWidgetManager.getInstance(this); - int[] widgetIds = manager.getAppWidgetIds(playerWidget); - RemoteViews views = new RemoteViews(getPackageName(), R.layout.player_widget); - final PendingIntent startMediaPlayer = PendingIntent.getActivity(this, R.id.pending_intent_player_activity, - PlaybackService.getPlayerActivityIntent(this), PendingIntent.FLAG_UPDATE_CURRENT); - - boolean nothingPlaying = false; - Playable media; - PlayerStatus status; - if (playbackService != null) { - media = playbackService.getPlayable(); - status = playbackService.getStatus(); - } else { - media = Playable.PlayableUtils.createInstanceFromPreferences(getApplicationContext()); - status = PlayerStatus.STOPPED; - } - - if (media != null) { - views.setOnClickPendingIntent(R.id.layout_left, startMediaPlayer); - - try { - Bitmap icon; - int iconSize = getResources().getDimensionPixelSize(android.R.dimen.app_icon_size); - icon = Glide.with(PlayerWidgetJobService.this) - .asBitmap() - .load(ImageResourceUtils.getImageLocation(media)) - .apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY)) - .submit(iconSize, iconSize) - .get(500, TimeUnit.MILLISECONDS); - views.setImageViewBitmap(R.id.imgvCover, icon); - } catch (Throwable tr) { - Log.e(TAG, "Error loading the media icon for the widget", tr); - views.setImageViewResource(R.id.imgvCover, R.mipmap.ic_launcher_round); - } - - views.setTextViewText(R.id.txtvTitle, media.getEpisodeTitle()); - views.setViewVisibility(R.id.txtvTitle, View.VISIBLE); - views.setViewVisibility(R.id.txtNoPlaying, View.GONE); - - String progressString; - if (playbackService != null) { - progressString = getProgressString(playbackService.getCurrentPosition(), - playbackService.getDuration(), playbackService.getCurrentPlaybackSpeed()); - } else { - progressString = getProgressString(media.getPosition(), media.getDuration(), PlaybackSpeedUtils.getCurrentPlaybackSpeed(media)); - } - - if (progressString != null) { - views.setViewVisibility(R.id.txtvProgress, View.VISIBLE); - views.setTextViewText(R.id.txtvProgress, progressString); - } - - if (status == PlayerStatus.PLAYING) { - views.setImageViewResource(R.id.butPlay, R.drawable.ic_av_pause_white_48dp); - views.setContentDescription(R.id.butPlay, getString(R.string.pause_label)); - } else { - views.setImageViewResource(R.id.butPlay, R.drawable.ic_av_play_white_48dp); - views.setContentDescription(R.id.butPlay, getString(R.string.play_label)); - } - views.setOnClickPendingIntent(R.id.butPlay, createMediaButtonIntent()); - } else { - nothingPlaying = true; - } - - if (nothingPlaying) { - // start the app if they click anything - views.setOnClickPendingIntent(R.id.layout_left, startMediaPlayer); - views.setOnClickPendingIntent(R.id.butPlay, startMediaPlayer); - views.setViewVisibility(R.id.txtvProgress, View.GONE); - views.setViewVisibility(R.id.txtvTitle, View.GONE); - views.setViewVisibility(R.id.txtNoPlaying, View.VISIBLE); - views.setImageViewResource(R.id.imgvCover, R.mipmap.ic_launcher_round); - views.setImageViewResource(R.id.butPlay, R.drawable.ic_av_play_white_48dp); - } - - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { - for (int id : widgetIds) { - Bundle options = manager.getAppWidgetOptions(id); - int minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH); - int columns = getCellsForSize(minWidth); - if (columns < 3) { - views.setViewVisibility(R.id.layout_center, View.INVISIBLE); - } else { - views.setViewVisibility(R.id.layout_center, View.VISIBLE); - } - - SharedPreferences prefs = getSharedPreferences(PlayerWidget.PREFS_NAME, Context.MODE_PRIVATE); - int backgroundColor = prefs.getInt(PlayerWidget.KEY_WIDGET_COLOR + id, PlayerWidget.DEFAULT_COLOR); - views.setInt(R.id.widgetLayout, "setBackgroundColor", backgroundColor); - - manager.updateAppWidget(id, views); - } - } else { - manager.updateAppWidget(playerWidget, views); - } - } - - /** - * Creates an intent which fakes a mediabutton press - */ - private PendingIntent createMediaButtonIntent() { - KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE); - Intent startingIntent = new Intent(getBaseContext(), MediaButtonReceiver.class); - startingIntent.setAction(MediaButtonReceiver.NOTIFY_BUTTON_RECEIVER); - startingIntent.putExtra(Intent.EXTRA_KEY_EVENT, event); - - return PendingIntent.getBroadcast(this, 0, startingIntent, 0); - } - - private String getProgressString(int position, int duration, float speed) { - if (position >= 0 && duration > 0) { - TimeSpeedConverter converter = new TimeSpeedConverter(speed); - position = converter.convert(position); - duration = converter.convert(duration); - return Converter.getDurationStringLong(position) + " / " - + Converter.getDurationStringLong(duration); - } else { - return null; - } - } - - private final ServiceConnection mConnection = new ServiceConnection() { - public void onServiceConnected(ComponentName className, IBinder service) { - Log.d(TAG, "Connection to service established"); - if (service instanceof PlaybackService.LocalBinder) { - synchronized (waitForService) { - playbackService = ((PlaybackService.LocalBinder) service).getService(); - waitForService.notifyAll(); - } - } - } - - @Override - public void onServiceDisconnected(ComponentName name) { - synchronized (waitUsingService) { - playbackService = null; - } - Log.d(TAG, "Disconnected from service"); - } - }; -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/AntennapodHttpClient.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/AntennapodHttpClient.java index a01b3cb52..c4029d57f 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/download/AntennapodHttpClient.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/AntennapodHttpClient.java @@ -1,19 +1,14 @@ package de.danoeh.antennapod.core.service.download; -import android.os.Build; import android.text.TextUtils; import android.util.Log; import androidx.annotation.NonNull; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.service.BasicAuthorizationInterceptor; import de.danoeh.antennapod.core.service.UserAgentInterceptor; -import de.danoeh.antennapod.core.ssl.BackportTrustManager; -import de.danoeh.antennapod.core.ssl.NoV1SslSocketFactory; import de.danoeh.antennapod.core.storage.DBWriter; -import de.danoeh.antennapod.core.util.Flavors; +import de.danoeh.antennapod.net.ssl.SslClientSetup; import okhttp3.Cache; -import okhttp3.CipherSuite; -import okhttp3.ConnectionSpec; import okhttp3.Credentials; import okhttp3.HttpUrl; import okhttp3.JavaNetCookieJar; @@ -21,8 +16,6 @@ import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; import okhttp3.internal.http.StatusLine; - -import javax.net.ssl.X509TrustManager; import java.io.File; import java.net.CookieManager; import java.net.CookiePolicy; @@ -30,9 +23,6 @@ import java.net.HttpURLConnection; import java.net.InetSocketAddress; import java.net.Proxy; import java.net.SocketAddress; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; import java.util.concurrent.TimeUnit; /** @@ -140,28 +130,7 @@ public class AntennapodHttpClient { } } - if (Flavors.FLAVOR == Flavors.FREE) { - // The Free flavor bundles a modern conscrypt (security provider), so CustomSslSocketFactory - // is only used to make sure that modern protocols (TLSv1.3 and TLSv1.2) are enabled and - // that old, deprecated, protocols (like SSLv3, TLSv1.0 and TLSv1.1) are disabled. - X509TrustManager trustManager = BackportTrustManager.create(); - builder.sslSocketFactory(new NoV1SslSocketFactory(trustManager), trustManager); - } else if (Build.VERSION.SDK_INT < 21) { - X509TrustManager trustManager = BackportTrustManager.create(); - builder.sslSocketFactory(new NoV1SslSocketFactory(trustManager), trustManager); - - // workaround for Android 4.x for certain web sites. - // see: https://github.com/square/okhttp/issues/4053#issuecomment-402579554 - List<CipherSuite> cipherSuites = new ArrayList<>(ConnectionSpec.MODERN_TLS.cipherSuites()); - cipherSuites.add(CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA); - cipherSuites.add(CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA); - - ConnectionSpec legacyTls = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) - .cipherSuites(cipherSuites.toArray(new CipherSuite[0])) - .build(); - builder.connectionSpecs(Arrays.asList(legacyTls, ConnectionSpec.CLEARTEXT)); - } - + SslClientSetup.installCertificates(builder); return builder; } diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadService.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadService.java index 5a2c653d6..2e0cb705b 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadService.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadService.java @@ -25,7 +25,6 @@ import org.greenrobot.eventbus.EventBus; import java.io.File; import java.io.IOException; -import java.net.HttpURLConnection; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -96,6 +95,7 @@ public class DownloadService extends Service { private final CompletionService<Downloader> downloadExecutor; private final DownloadRequester requester; private DownloadServiceNotification notificationManager; + private final NewEpisodesNotification newEpisodesNotification; /** * Currently running downloads. @@ -118,7 +118,7 @@ public class DownloadService extends Service { private ScheduledFuture<?> notificationUpdaterFuture; private ScheduledFuture<?> downloadPostFuture; private static final int SCHED_EX_POOL_SIZE = 1; - private ScheduledThreadPoolExecutor schedExecutor; + private final ScheduledThreadPoolExecutor schedExecutor; private static DownloaderFactory downloaderFactory = new DefaultDownloaderFactory(); private final IBinder mBinder = new LocalBinder(); @@ -134,12 +134,16 @@ public class DownloadService extends Service { downloads = Collections.synchronizedList(new ArrayList<>()); numberOfDownloads = new AtomicInteger(0); requester = DownloadRequester.getInstance(); + newEpisodesNotification = new NewEpisodesNotification(); syncExecutor = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r, "SyncThread"); t.setPriority(Thread.MIN_PRIORITY); return t; }); + // Must be the first runnable in syncExecutor + syncExecutor.execute(newEpisodesNotification::loadCountersBeforeRefresh); + Log.d(TAG, "parallel downloads: " + UserPreferences.getParallelDownloads()); downloadExecutor = new ExecutorCompletionService<>( Executors.newFixedThreadPool(UserPreferences.getParallelDownloads(), @@ -165,10 +169,10 @@ public class DownloadService extends Service { Notification notification = notificationManager.updateNotifications( requester.getNumberOfDownloads(), downloads); startForeground(R.id.notification_downloading, notification); + setupNotificationUpdaterIfNecessary(); syncExecutor.execute(() -> onDownloadQueued(intent)); } else if (numberOfDownloads.get() == 0) { - stopForeground(true); - stopSelf(); + shutdown(); } else { Log.d(TAG, "onStartCommand: Unknown intent"); } @@ -188,10 +192,6 @@ public class DownloadService extends Service { registerReceiver(cancelDownloadReceiver, cancelDownloadReceiverFilter); downloadCompletionThread.start(); - - Notification notification = notificationManager.updateNotifications( - requester.getNumberOfDownloads(), downloads); - startForeground(R.id.notification_downloading, notification); } @Override @@ -226,10 +226,6 @@ public class DownloadService extends Service { } unregisterReceiver(cancelDownloadReceiver); - stopForeground(true); - NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); - nm.cancel(R.id.notification_downloading); - // if this was the initial gpodder sync, i.e. we just synced the feeds successfully, // it is now time to sync the episode actions SyncService.sync(this); @@ -254,13 +250,13 @@ public class DownloadService extends Service { handleSuccessfulDownload(downloader); removeDownload(downloader); numberOfDownloads.decrementAndGet(); - queryDownloadsAsync(); + stopServiceIfEverythingDoneAsync(); }); } else { handleFailedDownload(downloader); removeDownload(downloader); numberOfDownloads.decrementAndGet(); - queryDownloadsAsync(); + stopServiceIfEverythingDoneAsync(); } } catch (InterruptedException e) { Log.e(TAG, "DownloadCompletionThread was interrupted"); @@ -290,6 +286,10 @@ public class DownloadService extends Service { if (log.size() > 0 && !log.get(0).isSuccessful()) { saveDownloadStatus(task.getDownloadStatus()); } + if (request.getFeedfileId() != 0 && !request.isInitiatedByUser()) { + // Was stored in the database before and not initiated manually + newEpisodesNotification.showIfNeeded(DownloadService.this, task.getSavedFeed()); + } } else { DBWriter.setFeedLastUpdateFailed(request.getFeedfileId(), true); saveDownloadStatus(task.getDownloadStatus()); @@ -325,18 +325,11 @@ public class DownloadService extends Service { if (item == null) { return; } - boolean httpNotFound = status.getReason() == DownloadError.ERROR_HTTP_DATA_ERROR - && String.valueOf(HttpURLConnection.HTTP_NOT_FOUND).equals(status.getReasonDetailed()); - boolean forbidden = status.getReason() == DownloadError.ERROR_FORBIDDEN - && String.valueOf(HttpURLConnection.HTTP_FORBIDDEN).equals(status.getReasonDetailed()); - boolean notEnoughSpace = status.getReason() == DownloadError.ERROR_NOT_ENOUGH_SPACE; - boolean wrongFileType = status.getReason() == DownloadError.ERROR_FILE_TYPE; - boolean httpGone = status.getReason() == DownloadError.ERROR_HTTP_DATA_ERROR - && String.valueOf(HttpURLConnection.HTTP_GONE).equals(status.getReasonDetailed()); - boolean httpBadReq = status.getReason() == DownloadError.ERROR_HTTP_DATA_ERROR - && String.valueOf(HttpURLConnection.HTTP_BAD_REQUEST).equals(status.getReasonDetailed()); - - if (httpNotFound || forbidden || notEnoughSpace || wrongFileType || httpGone || httpBadReq ) { + boolean unknownHost = status.getReason() == DownloadError.ERROR_UNKNOWN_HOST; + boolean unsupportedType = status.getReason() == DownloadError.ERROR_UNSUPPORTED_TYPE; + boolean wrongSize = status.getReason() == DownloadError.ERROR_IO_WRONG_SIZE; + + if (! (unknownHost || unsupportedType || wrongSize)) { try { DBWriter.saveFeedItemAutoDownloadFailed(item).get(); } catch (ExecutionException | InterruptedException e) { @@ -412,7 +405,7 @@ public class DownloadService extends Service { } postDownloaders(); } - queryDownloads(); + stopServiceIfEverythingDone(); } }; @@ -483,7 +476,7 @@ public class DownloadService extends Service { postDownloaders(); }); } - handler.post(this::queryDownloads); + handler.post(this::stopServiceIfEverythingDone); } private static boolean isEnqueued(@NonNull DownloadRequest request, @@ -540,30 +533,19 @@ public class DownloadService extends Service { * Calls query downloads on the services main thread. This method should be used instead of queryDownloads if it is * used from a thread other than the main thread. */ - private void queryDownloadsAsync() { - handler.post(DownloadService.this::queryDownloads); + private void stopServiceIfEverythingDoneAsync() { + handler.post(DownloadService.this::stopServiceIfEverythingDone); } /** * Check if there's something else to download, otherwise stop. */ - private void queryDownloads() { + private void stopServiceIfEverythingDone() { Log.d(TAG, numberOfDownloads.get() + " downloads left"); if (numberOfDownloads.get() <= 0 && DownloadRequester.getInstance().hasNoDownloads()) { - Log.d(TAG, "Number of downloads is " + numberOfDownloads.get() + ", attempting shutdown"); - stopForeground(true); - stopSelf(); - if (notificationUpdater != null) { - notificationUpdater.run(); - } else { - Log.d(TAG, "Skipping notification update"); - } - } else { - setupNotificationUpdater(); - Notification notification = notificationManager.updateNotifications( - requester.getNumberOfDownloads(), downloads); - startForeground(R.id.notification_downloading, notification); + Log.d(TAG, "Attempting shutdown"); + shutdown(); } } @@ -616,7 +598,7 @@ public class DownloadService extends Service { /** * Schedules the notification updater task if it hasn't been scheduled yet. */ - private void setupNotificationUpdater() { + private void setupNotificationUpdaterIfNecessary() { if (notificationUpdater == null) { Log.d(TAG, "Setting up notification updater"); notificationUpdater = new NotificationUpdater(); @@ -653,4 +635,16 @@ public class DownloadService extends Service { new PostDownloaderTask(downloads), 1, 1, TimeUnit.SECONDS); } } + + private void shutdown() { + // If the service was run for a very short time, the system may delay closing + // the notification. Set the notification text now so that a misleading message + // is not left on the notification. + if (notificationUpdater != null) { + notificationUpdater.run(); + } + cancelNotificationUpdater(); + stopForeground(true); + stopSelf(); + } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadServiceNotification.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadServiceNotification.java index fb6009c02..7b7879409 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadServiceNotification.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadServiceNotification.java @@ -28,7 +28,10 @@ public class DownloadServiceNotification { private void setupNotificationBuilders() { notificationCompatBuilder = new NotificationCompat.Builder(context, NotificationUtils.CHANNEL_ID_DOWNLOADING) - .setOngoing(true) + .setOngoing(false) + .setWhen(0) + .setOnlyAlertOnce(true) + .setShowWhen(false) .setContentIntent(ClientConfig.downloadServiceCallbacks.getNotificationContentIntent(context)) .setSmallIcon(R.drawable.ic_notification_sync); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { @@ -50,7 +53,7 @@ public class DownloadServiceNotification { String contentTitle = context.getString(R.string.download_notification_title); String downloadsLeft = (numDownloads > 0) ? context.getResources().getQuantityString(R.plurals.downloads_left, numDownloads, numDownloads) - : context.getString(R.string.downloads_processing); + : context.getString(R.string.service_shutting_down); String bigText = compileNotificationString(downloads); notificationCompatBuilder.setContentTitle(contentTitle); @@ -106,6 +109,23 @@ public class DownloadServiceNotification { return sb.toString(); } + private String createFailedDownloadNotificationContent(List<DownloadStatus> statuses) { + StringBuilder sb = new StringBuilder(); + + for (int i = 0; i < statuses.size(); i++) { + if (statuses.get(i).isSuccessful()) { + continue; + } + sb.append("• ").append(statuses.get(i).getTitle()); + sb.append(": ").append(statuses.get(i).getReason().getErrorString(context)); + if (i != statuses.size() - 1) { + sb.append("\n"); + } + } + + return sb.toString(); + } + /** * Creates a notification at the end of the service lifecycle to notify the * user about the number of completed downloads. A report will only be @@ -143,7 +163,7 @@ public class DownloadServiceNotification { // We are generating an auto-download report channelId = NotificationUtils.CHANNEL_ID_AUTO_DOWNLOAD; titleId = R.string.auto_download_report_title; - iconId = R.drawable.ic_notification_auto_download_complete; + iconId = R.drawable.ic_notification_new; intent = ClientConfig.downloadServiceCallbacks.getAutoDownloadReportNotificationContentIntent(context); id = R.id.notification_auto_download_report; content = createAutoDownloadNotificationContent(reportQueue); @@ -153,11 +173,7 @@ public class DownloadServiceNotification { iconId = R.drawable.ic_notification_sync_error; intent = ClientConfig.downloadServiceCallbacks.getReportNotificationContentIntent(context); id = R.id.notification_download_report; - content = context.getResources() - .getQuantityString(R.plurals.download_report_content, - successfulDownloads, - successfulDownloads, - failedDownloads); + content = createFailedDownloadNotificationContent(reportQueue); } NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId); diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/HttpDownloader.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/HttpDownloader.java index 393592cf9..2d955859f 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/download/HttpDownloader.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/HttpDownloader.java @@ -19,6 +19,8 @@ import java.net.URI; import java.net.UnknownHostException; import java.util.Collections; import java.util.Date; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import de.danoeh.antennapod.core.R; import de.danoeh.antennapod.core.feed.FeedMedia; @@ -37,6 +39,7 @@ public class HttpDownloader extends Downloader { private static final String TAG = "HttpDownloader"; private static final int BUFFER_SIZE = 8 * 1024; + private static final String REGEX_PATTERN_IP_ADDRESS = "([0-9]{1,3}[\\.]){3}[0-9]{1,3}"; public HttpDownloader(@NonNull DownloadRequest request) { super(request); @@ -134,6 +137,9 @@ public class HttpDownloader extends Downloader { } else if (response.code() == HttpURLConnection.HTTP_FORBIDDEN) { error = DownloadError.ERROR_FORBIDDEN; details = String.valueOf(response.code()); + } else if (response.code() == HttpURLConnection.HTTP_NOT_FOUND) { + error = DownloadError.ERROR_NOT_FOUND; + details = String.valueOf(response.code()); } else { error = DownloadError.ERROR_HTTP_DATA_ERROR; details = String.valueOf(response.code()); @@ -223,7 +229,7 @@ public class HttpDownloader extends Downloader { // written file. This check cannot be made if compression was used if (!isGzip && request.getSize() != DownloadStatus.SIZE_UNKNOWN && request.getSoFar() != request.getSize()) { - onFail(DownloadError.ERROR_IO_ERROR, "Download completed but size: " + + onFail(DownloadError.ERROR_IO_WRONG_SIZE, "Download completed but size: " + request.getSoFar() + " does not equal expected size " + request.getSize()); return; } else if (request.getSize() > 0 && request.getSoFar() == 0) { @@ -250,6 +256,22 @@ public class HttpDownloader extends Downloader { onFail(DownloadError.ERROR_UNKNOWN_HOST, e.getMessage()); } catch (IOException e) { e.printStackTrace(); + String message = e.getMessage(); + if (message != null) { + // Try to parse message for a more detailed error message + Pattern pattern = Pattern.compile(REGEX_PATTERN_IP_ADDRESS); + Matcher matcher = pattern.matcher(message); + if (matcher.find()) { + String ip = matcher.group(); + if (ip.startsWith("127.") || ip.startsWith("0.")) { + onFail(DownloadError.ERROR_IO_BLOCKED, e.getMessage()); + return; + } + } else if (message.contains("Trust anchor for certification path not found")) { + onFail(DownloadError.ERROR_CERTIFICATE, e.getMessage()); + return; + } + } onFail(DownloadError.ERROR_IO_ERROR, e.getMessage()); } catch (NullPointerException e) { // might be thrown by connection.getInputStream() diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/NewEpisodesNotification.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/NewEpisodesNotification.java new file mode 100644 index 000000000..799a68037 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/NewEpisodesNotification.java @@ -0,0 +1,132 @@ +package de.danoeh.antennapod.core.service.download; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; + +import android.graphics.Bitmap; +import android.util.Log; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.request.RequestOptions; +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.feed.Feed; +import de.danoeh.antennapod.core.feed.FeedPreferences; +import de.danoeh.antennapod.core.glide.ApGlideSettings; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.storage.PodDBAdapter; +import de.danoeh.antennapod.core.util.LongIntMap; +import de.danoeh.antennapod.core.util.gui.NotificationUtils; + +public class NewEpisodesNotification { + private static final String TAG = "NewEpisodesNotification"; + private static final String GROUP_KEY = "de.danoeh.antennapod.EPISODES"; + + private LongIntMap countersBefore; + + public NewEpisodesNotification() { + } + + public void loadCountersBeforeRefresh() { + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + countersBefore = adapter.getFeedCounters(UserPreferences.FEED_COUNTER_SHOW_NEW); + adapter.close(); + } + + public void showIfNeeded(Context context, Feed feed) { + FeedPreferences prefs = feed.getPreferences(); + if (!prefs.getKeepUpdated() || !prefs.getShowEpisodeNotification()) { + return; + } + + int newEpisodesBefore = countersBefore.get(feed.getId()); + int newEpisodesAfter = getNewEpisodeCount(feed.getId()); + + Log.d(TAG, "New episodes before: " + newEpisodesBefore + ", after: " + newEpisodesAfter); + if (newEpisodesAfter > newEpisodesBefore) { + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + showNotification(newEpisodesAfter, feed, context, notificationManager); + } + } + + private static void showNotification(int newEpisodes, Feed feed, Context context, + NotificationManagerCompat notificationManager) { + Resources res = context.getResources(); + String text = res.getQuantityString( + R.plurals.new_episode_notification_message, newEpisodes, newEpisodes, feed.getTitle() + ); + String title = res.getQuantityString(R.plurals.new_episode_notification_title, newEpisodes); + + Intent intent = new Intent(); + intent.setAction("NewEpisodes" + feed.getId()); + intent.setComponent(new ComponentName(context, "de.danoeh.antennapod.activity.MainActivity")); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + intent.putExtra("fragment_feed_id", feed.getId()); + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0); + + Notification notification = new NotificationCompat.Builder( + context, NotificationUtils.CHANNEL_ID_EPISODE_NOTIFICATIONS) + .setSmallIcon(R.drawable.ic_notification_new) + .setContentTitle(title) + .setLargeIcon(loadIcon(context, feed)) + .setContentText(text) + .setContentIntent(pendingIntent) + .setGroup(GROUP_KEY) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + .setAutoCancel(true) + .build(); + + notificationManager.notify(NotificationUtils.CHANNEL_ID_EPISODE_NOTIFICATIONS, feed.hashCode(), notification); + showGroupSummaryNotification(context, notificationManager); + } + + private static void showGroupSummaryNotification(Context context, NotificationManagerCompat notificationManager) { + Intent intent = new Intent(); + intent.setAction("NewEpisodes"); + intent.setComponent(new ComponentName(context, "de.danoeh.antennapod.activity.MainActivity")); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + intent.putExtra("fragment_tag", "EpisodesFragment"); + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0); + + Notification notificationGroupSummary = new NotificationCompat.Builder( + context, NotificationUtils.CHANNEL_ID_EPISODE_NOTIFICATIONS) + .setSmallIcon(R.drawable.ic_notification_new) + .setContentTitle(context.getString(R.string.new_episode_notification_group_text)) + .setContentIntent(pendingIntent) + .setGroup(GROUP_KEY) + .setGroupSummary(true) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) + .setAutoCancel(true) + .build(); + notificationManager.notify(NotificationUtils.CHANNEL_ID_EPISODE_NOTIFICATIONS, 0, notificationGroupSummary); + } + + private static Bitmap loadIcon(Context context, Feed feed) { + int iconSize = (int) (128 * context.getResources().getDisplayMetrics().density); + try { + return Glide.with(context) + .asBitmap() + .load(feed.getImageUrl()) + .apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY)) + .apply(new RequestOptions().centerCrop()) + .submit(iconSize, iconSize) + .get(); + } catch (Throwable tr) { + return null; + } + } + + private static int getNewEpisodeCount(long feedId) { + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + int episodeCount = adapter.getFeedCounters(UserPreferences.FEED_COUNTER_SHOW_NEW, feedId).get(feedId); + adapter.close(); + return episodeCount; + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FeedParserTask.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FeedParserTask.java index 18c5fce27..d07018f13 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FeedParserTask.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FeedParserTask.java @@ -58,6 +58,9 @@ public class FeedParserTask implements Callable<FeedHandlerResult> { e.printStackTrace(); successful = false; reason = DownloadError.ERROR_UNSUPPORTED_TYPE; + if ("html".equalsIgnoreCase(e.getRootElement())) { + reason = DownloadError.ERROR_UNSUPPORTED_TYPE_HTML; + } reasonDetailed = e.getMessage(); } catch (InvalidFeedException e) { e.printStackTrace(); diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FeedSyncTask.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FeedSyncTask.java index 483a2aa56..e2d9ee614 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FeedSyncTask.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FeedSyncTask.java @@ -2,6 +2,7 @@ package de.danoeh.antennapod.core.service.download.handler; import android.content.Context; import android.util.Log; + import de.danoeh.antennapod.core.feed.Feed; import de.danoeh.antennapod.core.service.download.DownloadRequest; import de.danoeh.antennapod.core.service.download.DownloadStatus; @@ -15,6 +16,7 @@ public class FeedSyncTask { private final DownloadRequest request; private final Context context; private DownloadStatus downloadStatus; + private Feed savedFeed; public FeedSyncTask(Context context, DownloadRequest request) { this.request = request; @@ -30,7 +32,7 @@ public class FeedSyncTask { return false; } - Feed savedFeed = DBTasks.updateFeed(context, result.feed, false); + savedFeed = DBTasks.updateFeed(context, result.feed, false); // If loadAllPages=true, check if another page is available and queue it for download final boolean loadAllPages = request.getArguments().getBoolean(DownloadRequester.REQUEST_ARG_LOAD_ALL_PAGES); final Feed feed = result.feed; @@ -48,4 +50,8 @@ public class FeedSyncTask { public DownloadStatus getDownloadStatus() { return downloadStatus; } + + public Feed getSavedFeed() { + return savedFeed; + } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java index 501214399..7712ca36b 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java @@ -56,7 +56,7 @@ public class MediaDownloadedHandler implements Runnable { // check if file has chapters if (media.getItem() != null && !media.getItem().hasChapters()) { - media.setChapters(ChapterUtils.loadChaptersFromFileUrl(media)); + media.setChapters(ChapterUtils.loadChaptersFromMediaFile(media, context)); } // Get duration diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/ExoPlayerWrapper.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/ExoPlayerWrapper.java index 71bbf2efd..9a8248984 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/ExoPlayerWrapper.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/ExoPlayerWrapper.java @@ -2,6 +2,7 @@ package de.danoeh.antennapod.core.service.playback; import android.content.Context; import android.net.Uri; +import android.text.TextUtils; import android.util.Log; import android.view.SurfaceHolder; import com.google.android.exoplayer2.C; @@ -28,8 +29,10 @@ import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; + import de.danoeh.antennapod.core.ClientConfig; import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.service.download.HttpDownloader; import de.danoeh.antennapod.core.util.playback.IPlayer; import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; @@ -184,14 +187,22 @@ public class ExoPlayerWrapper implements IPlayer { exoPlayer.setAudioAttributes(b.build()); } - @Override - public void setDataSource(String s) throws IllegalArgumentException, IllegalStateException { + public void setDataSource(String s, String user, String password) + throws IllegalArgumentException, IllegalStateException { Log.d(TAG, "setDataSource: " + s); DefaultHttpDataSourceFactory httpDataSourceFactory = new DefaultHttpDataSourceFactory( ClientConfig.USER_AGENT, null, DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS, DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS, true); + + if (!TextUtils.isEmpty(user) && !TextUtils.isEmpty(password)) { + httpDataSourceFactory.getDefaultRequestProperties().set("Authorization", + HttpDownloader.encodeCredentials( + user, + password, + "ISO-8859-1")); + } DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(context, null, httpDataSourceFactory); DefaultExtractorsFactory extractorsFactory = new DefaultExtractorsFactory(); extractorsFactory.setConstantBitrateSeekingEnabled(true); @@ -200,6 +211,11 @@ public class ExoPlayerWrapper implements IPlayer { } @Override + public void setDataSource(String s) throws IllegalArgumentException, IllegalStateException { + setDataSource(s, null, null); + } + + @Override public void setDisplay(SurfaceHolder sh) { exoPlayer.setVideoSurfaceHolder(sh); } diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/LocalPSMP.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/LocalPSMP.java index 325b04e9a..28d8a0e29 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/LocalPSMP.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/LocalPSMP.java @@ -1,6 +1,8 @@ package de.danoeh.antennapod.core.service.playback; +import android.app.UiModeManager; import android.content.Context; +import android.content.res.Configuration; import android.media.AudioManager; import android.os.PowerManager; import androidx.annotation.NonNull; @@ -36,6 +38,7 @@ import de.danoeh.antennapod.core.util.RewindAfterPauseUtils; import de.danoeh.antennapod.core.util.playback.AudioPlayer; import de.danoeh.antennapod.core.util.playback.IPlayer; import de.danoeh.antennapod.core.util.playback.Playable; +import de.danoeh.antennapod.core.util.playback.PlayableException; import de.danoeh.antennapod.core.util.playback.PlaybackServiceStarter; import de.danoeh.antennapod.core.util.playback.VideoPlayer; @@ -260,13 +263,25 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer { callback.onMediaChanged(false); setPlaybackParams(PlaybackSpeedUtils.getCurrentPlaybackSpeed(media), UserPreferences.isSkipSilence()); if (stream) { - mediaPlayer.setDataSource(media.getStreamUrl()); + if (playable instanceof FeedMedia) { + FeedMedia feedMedia = (FeedMedia) playable; + FeedPreferences preferences = feedMedia.getItem().getFeed().getPreferences(); + mediaPlayer.setDataSource( + media.getStreamUrl(), + preferences.getUsername(), + preferences.getPassword()); + } else { + mediaPlayer.setDataSource(media.getStreamUrl()); + } } else if (media.getLocalMediaUrl() != null && new File(media.getLocalMediaUrl()).canRead()) { mediaPlayer.setDataSource(media.getLocalMediaUrl()); } else { throw new IOException("Unable to read local file " + media.getLocalMediaUrl()); } - setPlayerStatus(PlayerStatus.INITIALIZED, media); + UiModeManager uiModeManager = (UiModeManager) context.getSystemService(Context.UI_MODE_SERVICE); + if (uiModeManager.getCurrentModeType() != Configuration.UI_MODE_TYPE_CAR) { + setPlayerStatus(PlayerStatus.INITIALIZED, media); + } if (prepareImmediately) { setPlayerStatus(PlayerStatus.PREPARING, media); @@ -274,7 +289,7 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer { onPrepared(startWhenPrepared); } - } catch (Playable.PlayableException | IOException | IllegalStateException e) { + } catch (PlayableException | IOException | IllegalStateException e) { e.printStackTrace(); setPlayerStatus(PlayerStatus.ERROR, null); } @@ -924,9 +939,6 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer { boolean isPlaying = playerStatus == PlayerStatus.PLAYING; - if (playerStatus != PlayerStatus.INDETERMINATE) { - setPlayerStatus(PlayerStatus.INDETERMINATE, media); - } // we're relying on the position stored in the Playable object for post-playback processing if (media != null) { int position = getPosition(); diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java index c1500d78b..9430e2e3c 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java @@ -50,7 +50,6 @@ import java.util.Collections; import java.util.List; import java.util.concurrent.TimeUnit; -import de.danoeh.antennapod.core.ClientConfig; import de.danoeh.antennapod.core.R; import de.danoeh.antennapod.core.event.MessageEvent; import de.danoeh.antennapod.core.event.PlaybackPositionEvent; @@ -69,7 +68,6 @@ import de.danoeh.antennapod.core.preferences.PlaybackPreferences; import de.danoeh.antennapod.core.preferences.SleepTimerPreferences; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.receiver.MediaButtonReceiver; -import de.danoeh.antennapod.core.service.PlayerWidgetJobService; import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.core.storage.DBTasks; import de.danoeh.antennapod.core.storage.DBWriter; @@ -78,9 +76,13 @@ import de.danoeh.antennapod.core.feed.util.ImageResourceUtils; import de.danoeh.antennapod.core.util.IntentUtils; import de.danoeh.antennapod.core.util.NetworkUtils; import de.danoeh.antennapod.core.util.gui.NotificationUtils; -import de.danoeh.antennapod.core.util.playback.ExternalMedia; import de.danoeh.antennapod.core.util.playback.Playable; +import de.danoeh.antennapod.core.util.playback.PlayableException; +import de.danoeh.antennapod.core.util.playback.PlayableUtils; import de.danoeh.antennapod.core.util.playback.PlaybackServiceStarter; +import de.danoeh.antennapod.core.widget.WidgetUpdater; +import de.danoeh.antennapod.ui.appstartintent.MainActivityStarter; +import de.danoeh.antennapod.ui.appstartintent.VideoPlayerActivityStarter; import io.reactivex.Observable; import io.reactivex.Single; import io.reactivex.android.schedulers.AndroidSchedulers; @@ -245,24 +247,31 @@ public class PlaybackService extends MediaBrowserServiceCompat { * running, the type of the last played media will be looked up. */ public static Intent getPlayerActivityIntent(Context context) { + boolean showVideoPlayer; + if (isRunning) { - return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, currentMediaType, isCasting); + showVideoPlayer = currentMediaType == MediaType.VIDEO && !isCasting; } else { - if (PlaybackPreferences.getCurrentEpisodeIsVideo()) { - return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, MediaType.VIDEO, isCasting); - } else { - return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, MediaType.AUDIO, isCasting); - } + showVideoPlayer = PlaybackPreferences.getCurrentEpisodeIsVideo(); + } + + if (showVideoPlayer) { + return new VideoPlayerActivityStarter(context).getIntent(); + } else { + return new MainActivityStarter(context).withOpenPlayer().getIntent(); } } /** - * Same as getPlayerActivityIntent(context), but here the type of activity + * Same as {@link #getPlayerActivityIntent(Context)}, but here the type of activity * depends on the FeedMedia that is provided as an argument. */ public static Intent getPlayerActivityIntent(Context context, Playable media) { - MediaType mt = media.getMediaType(); - return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, mt, isCasting); + if (media.getMediaType() == MediaType.VIDEO && !isCasting) { + return new VideoPlayerActivityStarter(context).getIntent(); + } else { + return new MainActivityStarter(context).withOpenPlayer().getIntent(); + } } @Override @@ -401,8 +410,8 @@ public class PlaybackService extends MediaBrowserServiceCompat { .setTitle(feed.getTitle()) .setDescription(feed.getDescription()) .setSubtitle(feed.getCustomTitle()); - if (feed.getImageLocation() != null) { - builder.setIconUri(Uri.parse(feed.getImageLocation())); + if (feed.getImageUrl() != null) { + builder.setIconUri(Uri.parse(feed.getImageUrl())); } if (feed.getLink() != null) { builder.setMediaUri(Uri.parse(feed.getLink())); @@ -509,8 +518,6 @@ public class PlaybackService extends MediaBrowserServiceCompat { boolean startWhenPrepared = intent.getBooleanExtra(EXTRA_START_WHEN_PREPARED, false); boolean prepareImmediately = intent.getBooleanExtra(EXTRA_PREPARE_IMMEDIATELY, false); sendNotificationBroadcast(NOTIFICATION_TYPE_RELOAD, 0); - //If the user asks to play External Media, the casting session, if on, should end. - flavorHelper.castDisconnect(playable instanceof ExternalMedia); if (allowStreamAlways) { UserPreferences.setAllowMobileStreaming(true); } @@ -668,18 +675,14 @@ public class PlaybackService extends MediaBrowserServiceCompat { } return false; case KeyEvent.KEYCODE_MEDIA_NEXT: - if (getStatus() != PlayerStatus.PLAYING && getStatus() != PlayerStatus.PAUSED) { - return false; - } else if (notificationButton || UserPreferences.shouldHardwareButtonSkip()) { - // assume the skip command comes from a notification or the lockscreen - // a >| skip button should actually skip + if (!notificationButton) { + // Handle remapped button as notification button which is not remapped again. + return handleKeycode(UserPreferences.getHardwareForwardButton(), true); + } else if (getStatus() == PlayerStatus.PLAYING || getStatus() == PlayerStatus.PAUSED) { mediaPlayer.skip(); - } else { - // assume skip command comes from a (bluetooth) media button - // user actually wants to fast-forward - seekDelta(UserPreferences.getFastForwardSecs() * 1000); + return true; } - return true; + return false; case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD: if (getStatus() == PlayerStatus.PLAYING || getStatus() == PlayerStatus.PAUSED) { mediaPlayer.seekDelta(UserPreferences.getFastForwardSecs() * 1000); @@ -687,23 +690,20 @@ public class PlaybackService extends MediaBrowserServiceCompat { } return false; case KeyEvent.KEYCODE_MEDIA_PREVIOUS: - if (getStatus() != PlayerStatus.PLAYING && getStatus() != PlayerStatus.PAUSED) { - return false; - } else if (UserPreferences.shouldHardwarePreviousButtonRestart()) { - // user wants to restart current episode + if (!notificationButton) { + // Handle remapped button as notification button which is not remapped again. + return handleKeycode(UserPreferences.getHardwarePreviousButton(), true); + } else if (getStatus() == PlayerStatus.PLAYING || getStatus() == PlayerStatus.PAUSED) { mediaPlayer.seekTo(0); - } else { - // user wants to rewind current episode - mediaPlayer.seekDelta(-UserPreferences.getRewindSecs() * 1000); + return true; } - return true; + return false; case KeyEvent.KEYCODE_MEDIA_REWIND: if (getStatus() == PlayerStatus.PLAYING || getStatus() == PlayerStatus.PAUSED) { mediaPlayer.seekDelta(-UserPreferences.getRewindSecs() * 1000); - } else { - return false; + return true; } - return true; + return false; case KeyEvent.KEYCODE_MEDIA_STOP: if (status == PlayerStatus.PLAYING) { mediaPlayer.pause(true, true); @@ -722,7 +722,7 @@ public class PlaybackService extends MediaBrowserServiceCompat { } private void startPlayingFromPreferences() { - Observable.fromCallable(() -> Playable.PlayableUtils.createInstanceFromPreferences(getApplicationContext())) + Observable.fromCallable(() -> PlayableUtils.createInstanceFromPreferences(getApplicationContext())) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( @@ -801,8 +801,9 @@ public class PlaybackService extends MediaBrowserServiceCompat { } @Override - public void onWidgetUpdaterTick() { - PlayerWidgetJobService.updateWidget(getBaseContext()); + public WidgetUpdater.WidgetState requestWidgetState() { + return new WidgetUpdater.WidgetState(getPlayable(), getStatus(), + getCurrentPosition(), getDuration(), getCurrentPlaybackSpeed(), isCasting()); } @Override @@ -873,9 +874,9 @@ public class PlaybackService extends MediaBrowserServiceCompat { } IntentUtils.sendLocalBroadcast(getApplicationContext(), ACTION_PLAYER_STATUS_CHANGED); - PlayerWidgetJobService.updateWidget(getBaseContext()); bluetoothNotifyChange(newInfo, AVRCP_ACTION_PLAYER_STATUS_CHANGED); bluetoothNotifyChange(newInfo, AVRCP_ACTION_META_CHANGED); + taskManager.requestWidgetUpdate(); } @Override @@ -994,7 +995,7 @@ public class PlaybackService extends MediaBrowserServiceCompat { FeedMedia media = (FeedMedia) currentMedia; try { media.loadMetadata(); - } catch (Playable.PlayableException e) { + } catch (PlayableException e) { Log.e(TAG, "Unable to load metadata to get next in queue", e); return null; } @@ -1240,7 +1241,8 @@ public class PlaybackService extends MediaBrowserServiceCompat { capabilities = capabilities | PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS; } - UiModeManager uiModeManager = (UiModeManager) getApplicationContext().getSystemService(Context.UI_MODE_SERVICE); + UiModeManager uiModeManager = (UiModeManager) getApplicationContext() + .getSystemService(Context.UI_MODE_SERVICE); if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_CAR) { sessionState.addCustomAction( new PlaybackStateCompat.CustomAction.Builder( @@ -1303,21 +1305,32 @@ public class PlaybackService extends MediaBrowserServiceCompat { builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, p.getEpisodeTitle()); builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, p.getFeedTitle()); - String imageLocation = ImageResourceUtils.getImageLocation(p); + String imageLocation = p.getImageLocation(); if (!TextUtils.isEmpty(imageLocation)) { if (UserPreferences.setLockscreenBackground()) { + Bitmap art; builder.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, imageLocation); try { - Bitmap art = Glide.with(this) + art = Glide.with(this) .asBitmap() .load(imageLocation) .apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY)) .submit(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) .get(); builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, art); - } catch (Throwable tr) { - Log.e(TAG, Log.getStackTraceString(tr)); + } catch (Throwable tr1) { + try { + art = Glide.with(this) + .asBitmap() + .load(ImageResourceUtils.getFallbackImageLocation(p)) + .apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY)) + .submit(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL) + .get(); + builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, art); + } catch (Throwable tr2) { + Log.e(TAG, Log.getStackTraceString(tr2)); + } } } else if (isCasting) { // In the absence of metadata art, the controller dialog takes care of creating it. @@ -1897,7 +1910,10 @@ public class PlaybackService extends MediaBrowserServiceCompat { @Override public void onSkipToNext() { Log.d(TAG, "onSkipToNext()"); - if (UserPreferences.shouldHardwareButtonSkip()) { + UiModeManager uiModeManager = (UiModeManager) getApplicationContext() + .getSystemService(Context.UI_MODE_SERVICE); + if (UserPreferences.getHardwareForwardButton() == KeyEvent.KEYCODE_MEDIA_NEXT + || uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_CAR) { mediaPlayer.skip(); } else { seekDelta(UserPreferences.getFastForwardSecs() * 1000); diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceNotificationBuilder.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceNotificationBuilder.java index 9d249620d..cbfc36266 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceNotificationBuilder.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceNotificationBuilder.java @@ -29,6 +29,8 @@ import de.danoeh.antennapod.core.util.TimeSpeedConverter; import de.danoeh.antennapod.core.util.gui.NotificationUtils; import de.danoeh.antennapod.core.util.playback.Playable; import java.util.ArrayList; +import java.util.concurrent.ExecutionException; + import org.apache.commons.lang3.ArrayUtils; public class PlaybackServiceNotificationBuilder { @@ -73,11 +75,23 @@ public class PlaybackServiceNotificationBuilder { try { icon = Glide.with(context) .asBitmap() - .load(ImageResourceUtils.getImageLocation(playable)) + .load(playable.getImageLocation()) .apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY)) .apply(new RequestOptions().centerCrop()) .submit(iconSize, iconSize) .get(); + } catch (ExecutionException e) { + try { + icon = Glide.with(context) + .asBitmap() + .load(ImageResourceUtils.getFallbackImageLocation(playable)) + .apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY)) + .apply(new RequestOptions().centerCrop()) + .submit(iconSize, iconSize) + .get(); + } catch (Throwable tr) { + Log.e(TAG, "Error loading the media icon for the notification", tr); + } } catch (Throwable tr) { Log.e(TAG, "Error loading the media icon for the notification", tr); } diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceTaskManager.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceTaskManager.java index 05d64ea3e..556d9b3c0 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceTaskManager.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceTaskManager.java @@ -8,6 +8,8 @@ import androidx.annotation.NonNull; import android.util.Log; import de.danoeh.antennapod.core.preferences.SleepTimerPreferences; +import de.danoeh.antennapod.core.util.ChapterUtils; +import de.danoeh.antennapod.core.widget.WidgetUpdater; import io.reactivex.disposables.Disposable; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; @@ -199,11 +201,10 @@ public class PlaybackServiceTaskManager { */ public synchronized void startWidgetUpdater() { if (!isWidgetUpdaterActive() && !schedExecutor.isShutdown()) { - Runnable widgetUpdater = callback::onWidgetUpdaterTick; + Runnable widgetUpdater = this::requestWidgetUpdate; widgetUpdater = useMainThreadIfNecessary(widgetUpdater); - widgetUpdaterFuture = schedExecutor.scheduleWithFixedDelay(widgetUpdater, WIDGET_UPDATER_NOTIFICATION_INTERVAL, - WIDGET_UPDATER_NOTIFICATION_INTERVAL, TimeUnit.MILLISECONDS); - + widgetUpdaterFuture = schedExecutor.scheduleWithFixedDelay(widgetUpdater, + WIDGET_UPDATER_NOTIFICATION_INTERVAL, WIDGET_UPDATER_NOTIFICATION_INTERVAL, TimeUnit.MILLISECONDS); Log.d(TAG, "Started WidgetUpdater"); } else { Log.d(TAG, "Call to startWidgetUpdater was ignored."); @@ -211,6 +212,18 @@ public class PlaybackServiceTaskManager { } /** + * Retrieves information about the widget state in the calling thread and then displays it in a background thread. + */ + public synchronized void requestWidgetUpdate() { + WidgetUpdater.WidgetState state = callback.requestWidgetState(); + if (!schedExecutor.isShutdown()) { + schedExecutor.execute(() -> WidgetUpdater.updateWidget(context, state)); + } else { + Log.d(TAG, "Call to requestWidgetUpdate was ignored."); + } + } + + /** * Starts a new sleep timer with the given waiting time. If another sleep timer is already active, it will be * cancelled first. * After waitingTime has elapsed, onSleepTimerExpired() will be called. @@ -303,7 +316,7 @@ public class PlaybackServiceTaskManager { if (media.getChapters() == null) { chapterLoaderFuture = Completable.create(emitter -> { - media.loadChapterMarks(context); + ChapterUtils.loadChapters(media, context); emitter.onComplete(); }) .subscribeOn(Schedulers.io()) @@ -464,7 +477,7 @@ public class PlaybackServiceTaskManager { void onSleepTimerReset(); - void onWidgetUpdaterTick(); + WidgetUpdater.WidgetState requestWidgetState(); void onChapterLoaded(Playable media); } diff --git a/core/src/main/java/de/danoeh/antennapod/core/ssl/BackportCaCerts.java b/core/src/main/java/de/danoeh/antennapod/core/ssl/BackportCaCerts.java deleted file mode 100644 index 78c105e38..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/ssl/BackportCaCerts.java +++ /dev/null @@ -1,105 +0,0 @@ -package de.danoeh.antennapod.core.ssl; - -public class BackportCaCerts { - public static final String SECTIGO_USER_TRUST = "-----BEGIN CERTIFICATE-----\n" - + "MIIF3jCCA8agAwIBAgIQAf1tMPyjylGoG7xkDjUDLTANBgkqhkiG9w0BAQwFADCB\n" - + "iDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0pl\n" - + "cnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNV\n" - + "BAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAw\n" - + "MjAxMDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBiDELMAkGA1UEBhMCVVMxEzARBgNV\n" - + "BAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0plcnNleSBDaXR5MR4wHAYDVQQKExVU\n" - + "aGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNVBAMTJVVTRVJUcnVzdCBSU0EgQ2Vy\n" - + "dGlmaWNhdGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIK\n" - + "AoICAQCAEmUXNg7D2wiz0KxXDXbtzSfTTK1Qg2HiqiBNCS1kCdzOiZ/MPans9s/B\n" - + "3PHTsdZ7NygRK0faOca8Ohm0X6a9fZ2jY0K2dvKpOyuR+OJv0OwWIJAJPuLodMkY\n" - + "tJHUYmTbf6MG8YgYapAiPLz+E/CHFHv25B+O1ORRxhFnRghRy4YUVD+8M/5+bJz/\n" - + "Fp0YvVGONaanZshyZ9shZrHUm3gDwFA66Mzw3LyeTP6vBZY1H1dat//O+T23LLb2\n" - + "VN3I5xI6Ta5MirdcmrS3ID3KfyI0rn47aGYBROcBTkZTmzNg95S+UzeQc0PzMsNT\n" - + "79uq/nROacdrjGCT3sTHDN/hMq7MkztReJVni+49Vv4M0GkPGw/zJSZrM233bkf6\n" - + "c0Plfg6lZrEpfDKEY1WJxA3Bk1QwGROs0303p+tdOmw1XNtB1xLaqUkL39iAigmT\n" - + "Yo61Zs8liM2EuLE/pDkP2QKe6xJMlXzzawWpXhaDzLhn4ugTncxbgtNMs+1b/97l\n" - + "c6wjOy0AvzVVdAlJ2ElYGn+SNuZRkg7zJn0cTRe8yexDJtC/QV9AqURE9JnnV4ee\n" - + "UB9XVKg+/XRjL7FQZQnmWEIuQxpMtPAlR1n6BB6T1CZGSlCBst6+eLf8ZxXhyVeE\n" - + "Hg9j1uliutZfVS7qXMYoCAQlObgOK6nyTJccBz8NUvXt7y+CDwIDAQABo0IwQDAd\n" - + "BgNVHQ4EFgQUU3m/WqorSs9UgOHYm8Cd8rIDZsswDgYDVR0PAQH/BAQDAgEGMA8G\n" - + "A1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAFzUfA3P9wF9QZllDHPF\n" - + "Up/L+M+ZBn8b2kMVn54CVVeWFPFSPCeHlCjtHzoBN6J2/FNQwISbxmtOuowhT6KO\n" - + "VWKR82kV2LyI48SqC/3vqOlLVSoGIG1VeCkZ7l8wXEskEVX/JJpuXior7gtNn3/3\n" - + "ATiUFJVDBwn7YKnuHKsSjKCaXqeYalltiz8I+8jRRa8YFWSQEg9zKC7F4iRO/Fjs\n" - + "8PRF/iKz6y+O0tlFYQXBl2+odnKPi4w2r78NBc5xjeambx9spnFixdjQg3IM8WcR\n" - + "iQycE0xyNN+81XHfqnHd4blsjDwSXWXavVcStkNr/+XeTWYRUc+ZruwXtuhxkYze\n" - + "Sf7dNXGiFSeUHM9h4ya7b6NnJSFd5t0dCy5oGzuCr+yDZ4XUmFF0sbmZgIn/f3gZ\n" - + "XHlKYC6SQK5MNyosycdiyA5d9zZbyuAlJQG03RoHnHcAP9Dc1ew91Pq7P8yF1m9/\n" - + "qS3fuQL39ZeatTXaw2ewh0qpKJ4jjv9cJ2vhsE/zB+4ALtRZh8tSQZXq9EfX7mRB\n" - + "VXyNWQKV3WKdwrnuWih0hKWbt5DHDAff9Yk2dDLWKMGwsAvgnEzDHNb842m1R0aB\n" - + "L6KCq9NjRHDEjf8tM7qtj3u1cIiuPhnPQCjY/MiQu12ZIvVS5ljFH4gxQ+6IHdfG\n" - + "jjxDah2nGN59PRbxYvnKkKj9\n" - + "-----END CERTIFICATE-----\n"; - - public static final String COMODO = "-----BEGIN CERTIFICATE-----\n" - + "MIIF2DCCA8CgAwIBAgIQTKr5yttjb+Af907YWwOGnTANBgkqhkiG9w0BAQwFADCB\n" - + "hTELMAkGA1UEBhMCR0IxGzAZBgNVBAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\n" - + "A1UEBxMHU2FsZm9yZDEaMBgGA1UEChMRQ09NT0RPIENBIExpbWl0ZWQxKzApBgNV\n" - + "BAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTAwMTE5\n" - + "MDAwMDAwWhcNMzgwMTE4MjM1OTU5WjCBhTELMAkGA1UEBhMCR0IxGzAZBgNVBAgT\n" - + "EkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEaMBgGA1UEChMR\n" - + "Q09NT0RPIENBIExpbWl0ZWQxKzApBgNVBAMTIkNPTU9ETyBSU0EgQ2VydGlmaWNh\n" - + "dGlvbiBBdXRob3JpdHkwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCR\n" - + "6FSS0gpWsawNJN3Fz0RndJkrN6N9I3AAcbxT38T6KhKPS38QVr2fcHK3YX/JSw8X\n" - + "pz3jsARh7v8Rl8f0hj4K+j5c+ZPmNHrZFGvnnLOFoIJ6dq9xkNfs/Q36nGz637CC\n" - + "9BR++b7Epi9Pf5l/tfxnQ3K9DADWietrLNPtj5gcFKt+5eNu/Nio5JIk2kNrYrhV\n" - + "/erBvGy2i/MOjZrkm2xpmfh4SDBF1a3hDTxFYPwyllEnvGfDyi62a+pGx8cgoLEf\n" - + "Zd5ICLqkTqnyg0Y3hOvozIFIQ2dOciqbXL1MGyiKXCJ7tKuY2e7gUYPDCUZObT6Z\n" - + "+pUX2nwzV0E8jVHtC7ZcryxjGt9XyD+86V3Em69FmeKjWiS0uqlWPc9vqv9JWL7w\n" - + "qP/0uK3pN/u6uPQLOvnoQ0IeidiEyxPx2bvhiWC4jChWrBQdnArncevPDt09qZah\n" - + "SL0896+1DSJMwBGB7FY79tOi4lu3sgQiUpWAk2nojkxl8ZEDLXB0AuqLZxUpaVIC\n" - + "u9ffUGpVRr+goyhhf3DQw6KqLCGqR84onAZFdr+CGCe01a60y1Dma/RMhnEw6abf\n" - + "Fobg2P9A3fvQQoh/ozM6LlweQRGBY84YcWsr7KaKtzFcOmpH4MN5WdYgGq/yapiq\n" - + "crxXStJLnbsQ/LBMQeXtHT1eKJ2czL+zUdqnR+WEUwIDAQABo0IwQDAdBgNVHQ4E\n" - + "FgQUu69+Aj36pvE8hI6t7jiY7NkyMtQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB\n" - + "/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAArx1UaEt65Ru2yyTUEUAJNMnMvl\n" - + "wFTPoCWOAvn9sKIN9SCYPBMtrFaisNZ+EZLpLrqeLppysb0ZRGxhNaKatBYSaVqM\n" - + "4dc+pBroLwP0rmEdEBsqpIt6xf4FpuHA1sj+nq6PK7o9mfjYcwlYRm6mnPTXJ9OV\n" - + "2jeDchzTc+CiR5kDOF3VSXkAKRzH7JsgHAckaVd4sjn8OoSgtZx8jb8uk2Intzna\n" - + "FxiuvTwJaP+EmzzV1gsD41eeFPfR60/IvYcjt7ZJQ3mFXLrrkguhxuhoqEwWsRqZ\n" - + "CuhTLJK7oQkYdQxlqHvLI7cawiiFwxv/0Cti76R7CZGYZ4wUAc1oBmpjIXUDgIiK\n" - + "boHGhfKppC3n9KUkEEeDys30jXlYsQab5xoq2Z0B15R97QNKyvDb6KkBPvVWmcke\n" - + "jkk9u+UJueBPSZI9FoJAzMxZxuY67RIuaTxslbH9qh17f4a+Hg4yRvv7E491f0yL\n" - + "S0Zj/gA0QHDBw7mh3aZw4gSzQbzpgJHqZJx64SIDqZxubw5lT2yHh17zbqD5daWb\n" - + "QOhTsiedSrnAdyGN/4fy3ryM7xfft0kL0fJuMAsaDk527RH89elWsn2/x20Kk4yl\n" - + "0MC2Hb46TpSi125sC8KKfPog88Tk5c0NqMuRkrF8hey1FGlmDoLnzc7ILaZRfyHB\n" - + "NVOFBkpdn627G190\n" - + "-----END CERTIFICATE-----"; - - public static final String LETSENCRYPT_ISRG = "-----BEGIN CERTIFICATE-----\n" - + "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n" - + "TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n" - + "cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n" - + "WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n" - + "ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n" - + "MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n" - + "h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n" - + "0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n" - + "A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n" - + "T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n" - + "B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n" - + "B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n" - + "KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n" - + "OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n" - + "jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n" - + "qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n" - + "rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n" - + "HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n" - + "hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n" - + "ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n" - + "3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n" - + "NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n" - + "ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n" - + "TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n" - + "jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n" - + "oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n" - + "4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n" - + "mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n" - + "emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n" - + "-----END CERTIFICATE-----"; -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/ssl/BackportTrustManager.java b/core/src/main/java/de/danoeh/antennapod/core/ssl/BackportTrustManager.java deleted file mode 100644 index 81d2a0709..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/ssl/BackportTrustManager.java +++ /dev/null @@ -1,60 +0,0 @@ -package de.danoeh.antennapod.core.ssl; - -import android.util.Log; - -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509TrustManager; -import java.io.ByteArrayInputStream; -import java.nio.charset.Charset; -import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.security.cert.CertificateFactory; -import java.util.ArrayList; -import java.util.List; - -/** - * SSL trust manager that allows old Android systems to use modern certificates. - */ -public class BackportTrustManager { - private static final String TAG = "BackportTrustManager"; - - private static X509TrustManager getSystemTrustManager(KeyStore keystore) { - TrustManagerFactory factory; - try { - factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - factory.init(keystore); - for (TrustManager manager : factory.getTrustManagers()) { - if (manager instanceof X509TrustManager) { - return (X509TrustManager) manager; - } - } - } catch (NoSuchAlgorithmException | KeyStoreException e) { - e.printStackTrace(); - } - throw new IllegalStateException("Unexpected default trust managers"); - } - - public static X509TrustManager create() { - try { - KeyStore keystore = KeyStore.getInstance(KeyStore.getDefaultType()); - keystore.load(null); // Clear - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - keystore.setCertificateEntry("BACKPORT_COMODO_ROOT_CA", cf.generateCertificate( - new ByteArrayInputStream(BackportCaCerts.COMODO.getBytes(Charset.forName("UTF-8"))))); - keystore.setCertificateEntry("SECTIGO_USER_TRUST_CA", cf.generateCertificate( - new ByteArrayInputStream(BackportCaCerts.SECTIGO_USER_TRUST.getBytes(Charset.forName("UTF-8"))))); - keystore.setCertificateEntry("LETSENCRYPT_ISRG_CA", cf.generateCertificate( - new ByteArrayInputStream(BackportCaCerts.LETSENCRYPT_ISRG.getBytes(Charset.forName("UTF-8"))))); - - List<X509TrustManager> managers = new ArrayList<>(); - managers.add(getSystemTrustManager(keystore)); - managers.add(getSystemTrustManager(null)); - return new CompositeX509TrustManager(managers); - } catch (Exception e) { - Log.e(TAG, Log.getStackTraceString(e)); - return null; - } - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/ssl/CompositeX509TrustManager.java b/core/src/main/java/de/danoeh/antennapod/core/ssl/CompositeX509TrustManager.java deleted file mode 100644 index 7af96a492..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/ssl/CompositeX509TrustManager.java +++ /dev/null @@ -1,60 +0,0 @@ -package de.danoeh.antennapod.core.ssl; - -import javax.net.ssl.X509TrustManager; -import java.security.cert.CertificateException; -import java.security.cert.X509Certificate; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -/** - * Represents an ordered list of {@link X509TrustManager}s with additive trust. If any one of the composed managers - * trusts a certificate chain, then it is trusted by the composite manager. - * Based on https://stackoverflow.com/a/16229909 - */ -public class CompositeX509TrustManager implements X509TrustManager { - private final List<X509TrustManager> trustManagers; - - public CompositeX509TrustManager(List<X509TrustManager> trustManagers) { - this.trustManagers = trustManagers; - } - - @Override - public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { - CertificateException reason = null; - for (X509TrustManager trustManager : trustManagers) { - try { - trustManager.checkClientTrusted(chain, authType); - return; // someone trusts them. success! - } catch (CertificateException e) { - // maybe someone else will trust them - reason = e; - } - } - throw reason; - } - - @Override - public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { - CertificateException reason = null; - for (X509TrustManager trustManager : trustManagers) { - try { - trustManager.checkServerTrusted(chain, authType); - return; // someone trusts them. success! - } catch (CertificateException e) { - // maybe someone else will trust them - reason = e; - } - } - throw reason; - } - - @Override - public X509Certificate[] getAcceptedIssuers() { - List<X509Certificate> certificates = new ArrayList<>(); - for (X509TrustManager trustManager : trustManagers) { - certificates.addAll(Arrays.asList(trustManager.getAcceptedIssuers())); - } - return certificates.toArray(new X509Certificate[0]); - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/ssl/NoV1SslSocketFactory.java b/core/src/main/java/de/danoeh/antennapod/core/ssl/NoV1SslSocketFactory.java deleted file mode 100644 index 96a42f22d..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/ssl/NoV1SslSocketFactory.java +++ /dev/null @@ -1,100 +0,0 @@ -package de.danoeh.antennapod.core.ssl; - -import de.danoeh.antennapod.core.util.Flavors; - -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLSocket; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.TrustManager; -import java.io.IOException; -import java.net.InetAddress; -import java.net.Socket; -import java.security.GeneralSecurityException; - -/** - * SSLSocketFactory that does not use TLS 1.0 - * This fixes issues with old Android versions that abort if the server does not know TLS 1.0 - */ -public class NoV1SslSocketFactory extends SSLSocketFactory { - private SSLSocketFactory factory; - - public NoV1SslSocketFactory(TrustManager trustManager) { - try { - SSLContext sslContext; - - if (Flavors.FLAVOR == Flavors.FREE) { - // Free flavor (bundles modern conscrypt): support for TLSv1.3 is guaranteed. - sslContext = SSLContext.getInstance("TLSv1.3"); - } else { - // Play flavor (security provider can vary): only TLSv1.2 is guaranteed. - sslContext = SSLContext.getInstance("TLSv1.2"); - } - - sslContext.init(null, new TrustManager[] {trustManager}, null); - factory = sslContext.getSocketFactory(); - } catch (GeneralSecurityException e) { - e.printStackTrace(); - } - } - - @Override - public String[] getDefaultCipherSuites() { - return factory.getDefaultCipherSuites(); - } - - @Override - public String[] getSupportedCipherSuites() { - return factory.getSupportedCipherSuites(); - } - - public Socket createSocket() throws IOException { - SSLSocket result = (SSLSocket) factory.createSocket(); - configureSocket(result); - return result; - } - - public Socket createSocket(String var1, int var2) throws IOException { - SSLSocket result = (SSLSocket) factory.createSocket(var1, var2); - configureSocket(result); - return result; - } - - public Socket createSocket(Socket var1, String var2, int var3, boolean var4) throws IOException { - SSLSocket result = (SSLSocket) factory.createSocket(var1, var2, var3, var4); - configureSocket(result); - return result; - } - - public Socket createSocket(InetAddress var1, int var2) throws IOException { - SSLSocket result = (SSLSocket) factory.createSocket(var1, var2); - configureSocket(result); - return result; - } - - public Socket createSocket(String var1, int var2, InetAddress var3, int var4) throws IOException { - SSLSocket result = (SSLSocket) factory.createSocket(var1, var2, var3, var4); - configureSocket(result); - return result; - } - - public Socket createSocket(InetAddress var1, int var2, InetAddress var3, int var4) throws IOException { - SSLSocket result = (SSLSocket) factory.createSocket(var1, var2, var3, var4); - configureSocket(result); - return result; - } - - private void configureSocket(SSLSocket s) { - if (Flavors.FLAVOR == Flavors.FREE) { - // Free flavor (bundles modern conscrypt): TLSv1.3 and modern cipher suites are - // guaranteed. Protocols older than TLSv1.2 are now deprecated and can be disabled. - s.setEnabledProtocols(new String[] { "TLSv1.3", "TLSv1.2" }); - } else { - // Play flavor (security provider can vary): only TLSv1.2 is guaranteed, supported - // cipher suites may vary. Old protocols might be necessary to keep things working. - - // TLS 1.0 is enabled by default on some old systems, which causes connection errors. - // This disables that. - s.setEnabledProtocols(new String[] { "TLSv1.2", "TLSv1.1", "TLSv1" }); - } - } -}
\ No newline at end of file diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/APDownloadAlgorithm.java b/core/src/main/java/de/danoeh/antennapod/core/storage/APDownloadAlgorithm.java deleted file mode 100644 index 061d6cf3f..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/APDownloadAlgorithm.java +++ /dev/null @@ -1,103 +0,0 @@ -package de.danoeh.antennapod.core.storage; - -import android.content.Context; -import android.util.Log; - -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; - -import de.danoeh.antennapod.core.feed.FeedFilter; -import de.danoeh.antennapod.core.feed.FeedItem; -import de.danoeh.antennapod.core.feed.FeedPreferences; -import de.danoeh.antennapod.core.preferences.UserPreferences; -import de.danoeh.antennapod.core.util.NetworkUtils; -import de.danoeh.antennapod.core.util.PowerUtils; - -/** - * Implements the automatic download algorithm used by AntennaPod. This class assumes that - * the client uses the APEpisodeCleanupAlgorithm. - */ -public class APDownloadAlgorithm implements AutomaticDownloadAlgorithm { - private static final String TAG = "APDownloadAlgorithm"; - - /** - * Looks for undownloaded episodes in the queue or list of new 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 Runnable that will be submitted to an ExecutorService. - */ - @Override - public Runnable autoDownloadUndownloadedItems(final Context context) { - return () -> { - - // true if we should auto download based on network status - boolean networkShouldAutoDl = NetworkUtils.autodownloadNetworkAvailable() - && UserPreferences.isEnableAutodownload(); - - // true if we should auto download based on power status - boolean powerShouldAutoDl = PowerUtils.deviceCharging(context) - || UserPreferences.isEnableAutodownloadOnBattery(); - - // we should only auto download if both network AND power are happy - if (networkShouldAutoDl && powerShouldAutoDl) { - - Log.d(TAG, "Performing auto-dl of undownloaded episodes"); - - List<FeedItem> candidates; - final List<FeedItem> queue = DBReader.getQueue(); - final List<FeedItem> newItems = DBReader.getNewItemsList(0, Integer.MAX_VALUE); - candidates = new ArrayList<>(queue.size() + newItems.size()); - candidates.addAll(queue); - for (FeedItem newItem : newItems) { - FeedPreferences feedPrefs = newItem.getFeed().getPreferences(); - FeedFilter feedFilter = feedPrefs.getFilter(); - if (!candidates.contains(newItem) && feedFilter.shouldAutoDownload(newItem)) { - candidates.add(newItem); - } - } - - // filter items that are not auto downloadable - Iterator<FeedItem> it = candidates.iterator(); - while (it.hasNext()) { - FeedItem item = it.next(); - if (!item.isAutoDownloadable() || item.getFeed().isLocalFeed()) { - it.remove(); - } - } - - int autoDownloadableEpisodes = candidates.size(); - int downloadedEpisodes = DBReader.getNumberOfDownloadedEpisodes(); - int deletedEpisodes = UserPreferences.getEpisodeCleanupAlgorithm() - .makeRoomForEpisodes(context, autoDownloadableEpisodes); - boolean cacheIsUnlimited = - UserPreferences.getEpisodeCacheSize() == UserPreferences.getEpisodeCacheSizeUnlimited(); - int episodeCacheSize = UserPreferences.getEpisodeCacheSize(); - - int episodeSpaceLeft; - if (cacheIsUnlimited || episodeCacheSize >= downloadedEpisodes + autoDownloadableEpisodes) { - episodeSpaceLeft = autoDownloadableEpisodes; - } else { - episodeSpaceLeft = episodeCacheSize - (downloadedEpisodes - deletedEpisodes); - } - - FeedItem[] itemsToDownload = candidates.subList(0, episodeSpaceLeft) - .toArray(new FeedItem[episodeSpaceLeft]); - - if (itemsToDownload.length > 0) { - Log.d(TAG, "Enqueueing " + itemsToDownload.length + " items for download"); - - try { - DownloadRequester.getInstance().downloadMedia(false, context, false, itemsToDownload); - } catch (DownloadRequestException e) { - e.printStackTrace(); - } - } - } - }; - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/AutomaticDownloadAlgorithm.java b/core/src/main/java/de/danoeh/antennapod/core/storage/AutomaticDownloadAlgorithm.java index dbb77e19c..f8b643ccf 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/AutomaticDownloadAlgorithm.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/AutomaticDownloadAlgorithm.java @@ -1,11 +1,28 @@ package de.danoeh.antennapod.core.storage; import android.content.Context; +import android.util.Log; -public interface AutomaticDownloadAlgorithm { +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import de.danoeh.antennapod.core.feed.FeedFilter; +import de.danoeh.antennapod.core.feed.FeedItem; +import de.danoeh.antennapod.core.feed.FeedPreferences; +import de.danoeh.antennapod.core.preferences.UserPreferences; +import de.danoeh.antennapod.core.util.NetworkUtils; +import de.danoeh.antennapod.core.util.PowerUtils; + +/** + * Implements the automatic download algorithm used by AntennaPod. This class assumes that + * the client uses the {@link EpisodeCleanupAlgorithm}. + */ +public class AutomaticDownloadAlgorithm { + private static final String TAG = "DownloadAlgorithm"; /** - * Looks for undownloaded episodes and request a download if + * Looks for undownloaded episodes in the queue or list of new 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 @@ -14,5 +31,72 @@ public interface AutomaticDownloadAlgorithm { * @param context Used for accessing the DB. * @return A Runnable that will be submitted to an ExecutorService. */ - Runnable autoDownloadUndownloadedItems(Context context); + public Runnable autoDownloadUndownloadedItems(final Context context) { + return () -> { + + // true if we should auto download based on network status + boolean networkShouldAutoDl = NetworkUtils.autodownloadNetworkAvailable() + && UserPreferences.isEnableAutodownload(); + + // true if we should auto download based on power status + boolean powerShouldAutoDl = PowerUtils.deviceCharging(context) + || UserPreferences.isEnableAutodownloadOnBattery(); + + // we should only auto download if both network AND power are happy + if (networkShouldAutoDl && powerShouldAutoDl) { + + Log.d(TAG, "Performing auto-dl of undownloaded episodes"); + + List<FeedItem> candidates; + final List<FeedItem> queue = DBReader.getQueue(); + final List<FeedItem> newItems = DBReader.getNewItemsList(0, Integer.MAX_VALUE); + candidates = new ArrayList<>(queue.size() + newItems.size()); + candidates.addAll(queue); + for (FeedItem newItem : newItems) { + FeedPreferences feedPrefs = newItem.getFeed().getPreferences(); + FeedFilter feedFilter = feedPrefs.getFilter(); + if (!candidates.contains(newItem) && feedFilter.shouldAutoDownload(newItem)) { + candidates.add(newItem); + } + } + + // filter items that are not auto downloadable + Iterator<FeedItem> it = candidates.iterator(); + while (it.hasNext()) { + FeedItem item = it.next(); + if (!item.isAutoDownloadable() || item.getFeed().isLocalFeed()) { + it.remove(); + } + } + + int autoDownloadableEpisodes = candidates.size(); + int downloadedEpisodes = DBReader.getNumberOfDownloadedEpisodes(); + int deletedEpisodes = UserPreferences.getEpisodeCleanupAlgorithm() + .makeRoomForEpisodes(context, autoDownloadableEpisodes); + boolean cacheIsUnlimited = + UserPreferences.getEpisodeCacheSize() == UserPreferences.getEpisodeCacheSizeUnlimited(); + int episodeCacheSize = UserPreferences.getEpisodeCacheSize(); + + int episodeSpaceLeft; + if (cacheIsUnlimited || episodeCacheSize >= downloadedEpisodes + autoDownloadableEpisodes) { + episodeSpaceLeft = autoDownloadableEpisodes; + } else { + episodeSpaceLeft = episodeCacheSize - (downloadedEpisodes - deletedEpisodes); + } + + FeedItem[] itemsToDownload = candidates.subList(0, episodeSpaceLeft) + .toArray(new FeedItem[episodeSpaceLeft]); + + if (itemsToDownload.length > 0) { + Log.d(TAG, "Enqueueing " + itemsToDownload.length + " items for download"); + + try { + DownloadRequester.getInstance().downloadMedia(false, context, false, itemsToDownload); + } catch (DownloadRequestException e) { + e.printStackTrace(); + } + } + } + }; + } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java index 74e8e23cb..e45d53af3 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java @@ -18,11 +18,13 @@ import java.util.Map; import de.danoeh.antennapod.core.feed.Chapter; import de.danoeh.antennapod.core.feed.Feed; import de.danoeh.antennapod.core.feed.FeedItem; +import de.danoeh.antennapod.core.feed.FeedItemFilter; import de.danoeh.antennapod.core.feed.FeedMedia; import de.danoeh.antennapod.core.feed.FeedPreferences; import de.danoeh.antennapod.core.feed.SubscriptionsFilter; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.service.download.DownloadStatus; +import de.danoeh.antennapod.core.storage.mapper.FeedCursorMapper; import de.danoeh.antennapod.core.util.LongIntMap; import de.danoeh.antennapod.core.util.LongList; import de.danoeh.antennapod.core.util.comparator.DownloadStatusComparator; @@ -160,11 +162,15 @@ public final class DBReader { * The method does NOT change the items-attribute of the feed. */ public static List<FeedItem> getFeedItemList(final Feed feed) { + return getFeedItemList(feed, FeedItemFilter.unfiltered()); + } + + public static List<FeedItem> getFeedItemList(final Feed feed, final FeedItemFilter filter) { Log.d(TAG, "getFeedItemList() called with: " + "feed = [" + feed + "]"); PodDBAdapter adapter = PodDBAdapter.getInstance(); adapter.open(); - try (Cursor cursor = adapter.getAllItemsOfFeedCursor(feed)) { + try (Cursor cursor = adapter.getItemsOfFeedCursor(feed, filter)) { List<FeedItem> items = extractItemlistFromCursor(adapter, cursor); Collections.sort(items, new FeedItemPubdateComparator()); for (FeedItem item : items) { @@ -204,7 +210,7 @@ public final class DBReader { } private static Feed extractFeedFromCursorRow(Cursor cursor) { - Feed feed = Feed.fromCursor(cursor); + Feed feed = FeedCursorMapper.convert(cursor); FeedPreferences preferences = FeedPreferences.fromCursor(cursor); feed.setPreferences(preferences); return feed; @@ -367,18 +373,19 @@ public final class DBReader { } /** - * Loads a list of FeedItems sorted by pubDate in descending order. + * Loads a filtered list of FeedItems sorted by pubDate in descending order. * * @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<FeedItem> getRecentlyPublishedEpisodes(int offset, int limit) { - Log.d(TAG, "getRecentlyPublishedEpisodes() called with: " + "offset = [" + offset + "]" + " limit = [" + limit + "]" ); + public static List<FeedItem> getRecentlyPublishedEpisodes(int offset, int limit, FeedItemFilter filter) { + Log.d(TAG, "getRecentlyPublishedEpisodes() called with: offset=" + offset + ", limit=" + limit); PodDBAdapter adapter = PodDBAdapter.getInstance(); adapter.open(); - try (Cursor cursor = adapter.getRecentlyPublishedItemsCursor(offset, limit)) { + try (Cursor cursor = adapter.getRecentlyPublishedItemsCursor(offset, limit, filter)) { List<FeedItem> items = extractItemlistFromCursor(adapter, cursor); loadAdditionalFeedItemListData(items); return items; @@ -478,31 +485,41 @@ public final class DBReader { * * @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. + * database and the items-attribute will be set correctly. */ + @Nullable public static Feed getFeed(final long feedId) { - Log.d(TAG, "getFeed() called with: " + "feedId = [" + feedId + "]"); - - PodDBAdapter adapter = PodDBAdapter.getInstance(); - adapter.open(); - try { - return getFeed(feedId, adapter); - } finally { - adapter.close(); - } + return getFeed(feedId, false); } + /** + * Loads a specific Feed from the database. + * + * @param feedId The ID of the Feed + * @param filtered <code>true</code> 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 - static Feed getFeed(final long feedId, PodDBAdapter adapter) { + 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); - feed.setItems(getFeedItemList(feed)); + 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(); } } @@ -635,10 +652,7 @@ public final class DBReader { if (cursor.moveToFirst()) { int indexDescription = cursor.getColumnIndex(PodDBAdapter.KEY_DESCRIPTION); String description = cursor.getString(indexDescription); - int indexContentEncoded = cursor.getColumnIndex(PodDBAdapter.KEY_CONTENT_ENCODED); - String contentEncoded = cursor.getString(indexContentEncoded); - item.setDescription(description); - item.setContentEncoded(contentEncoded); + item.setDescriptionIfLonger(description); } } finally { adapter.close(); @@ -801,15 +815,9 @@ public final class DBReader { PodDBAdapter adapter = PodDBAdapter.getInstance(); adapter.open(); - List<Feed> feeds = getFeedList(adapter); - long[] feedIds = new long[feeds.size()]; - for (int i = 0; i < feeds.size(); i++) { - feedIds[i] = feeds.get(i).getId(); - } - final LongIntMap feedCounters = adapter.getFeedCounters(feedIds); - + final LongIntMap feedCounters = adapter.getFeedCounters(); SubscriptionsFilter subscriptionsFilter = UserPreferences.getSubscriptionsFilter(); - feeds = subscriptionsFilter.filter(getFeedList(adapter), feedCounters); + List<Feed> feeds = subscriptionsFilter.filter(getFeedList(adapter), feedCounters); Comparator<Feed> comparator; int feedOrder = UserPreferences.getFeedOrder(); @@ -839,7 +847,7 @@ public final class DBReader { } }; } else if (feedOrder == UserPreferences.FEED_ORDER_MOST_PLAYED) { - final LongIntMap playedCounters = adapter.getPlayedEpisodesCounters(feedIds); + final LongIntMap playedCounters = adapter.getPlayedEpisodesCounters(); comparator = (lhs, rhs) -> { long counterLhs = playedCounters.get(lhs.getId()); diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java index ec39e7144..d16432cd6 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java @@ -6,7 +6,9 @@ import android.database.Cursor; import android.os.Looper; import android.text.TextUtils; import android.util.Log; -import de.danoeh.antennapod.core.ClientConfig; + +import androidx.annotation.VisibleForTesting; + import de.danoeh.antennapod.core.R; import de.danoeh.antennapod.core.event.FeedItemEvent; import de.danoeh.antennapod.core.event.FeedListUpdateEvent; @@ -14,10 +16,10 @@ import de.danoeh.antennapod.core.event.MessageEvent; 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.feed.LocalFeedUpdater; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.service.download.DownloadStatus; +import de.danoeh.antennapod.core.storage.mapper.FeedCursorMapper; import de.danoeh.antennapod.core.sync.SyncService; import de.danoeh.antennapod.core.util.DownloadError; import de.danoeh.antennapod.core.util.LongList; @@ -29,6 +31,7 @@ import java.util.Collections; import java.util.Date; import java.util.Iterator; import java.util.List; +import java.util.ListIterator; import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; @@ -53,6 +56,8 @@ public final class DBTasks { */ private static final ExecutorService autodownloadExec; + private static AutomaticDownloadAlgorithm downloadAlgorithm = new AutomaticDownloadAlgorithm(); + static { autodownloadExec = Executors.newSingleThreadExecutor(r -> { Thread t = new Thread(r); @@ -117,7 +122,18 @@ public final class DBTasks { throw new IllegalStateException("DBTasks.refreshAllFeeds() must not be called from the main thread."); } - refreshFeeds(context, DBReader.getFeedList(), initiatedByUser); + List<Feed> feeds = DBReader.getFeedList(); + ListIterator<Feed> iterator = feeds.listIterator(); + while (iterator.hasNext()) { + if (!iterator.next().getPreferences().getKeepUpdated()) { + iterator.remove(); + } + } + try { + refreshFeeds(context, feeds, false, false, false); + } catch (DownloadRequestException e) { + e.printStackTrace(); + } isRefreshing.set(false); SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, MODE_PRIVATE); @@ -131,38 +147,6 @@ public final class DBTasks { } /** - * @param context - * @param feedList the list of feeds to refresh - * @param initiatedByUser a boolean indicating if the refresh was triggered by user action. - */ - private static void refreshFeeds(final Context context, - final List<Feed> feedList, - boolean initiatedByUser) { - - 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(), - initiatedByUser) - ); - } - } - } - - } - - /** * Downloads all pages of the given feed even if feed has not been modified since last refresh * * @param context Used for requesting the download. @@ -170,7 +154,7 @@ public final class DBTasks { */ public static void forceRefreshCompleteFeed(final Context context, final Feed feed) { try { - refreshFeed(context, feed, true, true, false); + refreshFeeds(context, Collections.singletonList(feed), true, true, false); } catch (DownloadRequestException e) { e.printStackTrace(); DBWriter.addDownloadStatus( @@ -206,19 +190,6 @@ public final class DBTasks { } /** - * 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. - */ - private static void refreshFeed(Context context, Feed feed) - throws DownloadRequestException { - Log.d(TAG, "refreshFeed(feed.id: " + feed.getId() +")"); - refreshFeed(context, feed, false, false, false); - } - - /** * Refresh a specific feed even if feed has not been modified since last refresh * * @param context Used for requesting the download. @@ -226,26 +197,32 @@ public final class DBTasks { */ public static void forceRefreshFeed(Context context, Feed feed, boolean initiatedByUser) throws DownloadRequestException { - Log.d(TAG, "refreshFeed(feed.id: " + feed.getId() +")"); - refreshFeed(context, feed, false, true, initiatedByUser); + Log.d(TAG, "refreshFeed(feed.id: " + feed.getId() + ")"); + refreshFeeds(context, Collections.singletonList(feed), false, true, initiatedByUser); } - private static void refreshFeed(Context context, Feed feed, boolean loadAllPages, boolean force, boolean initiatedByUser) - 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()); + private static void refreshFeeds(Context context, List<Feed> feeds, boolean loadAllPages, + boolean force, boolean initiatedByUser) throws DownloadRequestException { + List<Feed> localFeeds = new ArrayList<>(); + List<Feed> normalFeeds = new ArrayList<>(); + + for (Feed feed : feeds) { + if (feed.isLocalFeed()) { + localFeeds.add(feed); + } else { + normalFeeds.add(feed); + } } - f.setId(feed.getId()); - if (f.isLocalFeed()) { - new Thread(() -> LocalFeedUpdater.updateFeed(f, context)).start(); - } else { - DownloadRequester.getInstance().downloadFeed(context, f, loadAllPages, force, initiatedByUser); + if (!localFeeds.isEmpty()) { + new Thread(() -> { + for (Feed feed : localFeeds) { + LocalFeedUpdater.updateFeed(feed, context); + } + }).start(); + } + if (!normalFeeds.isEmpty()) { + DownloadRequester.getInstance().downloadFeeds(context, feeds, loadAllPages, force, initiatedByUser); } } @@ -278,7 +255,7 @@ public final class DBTasks { } /** - * Looks for undownloaded episodes in the queue or list of unread items and request a download if + * Looks for non-downloaded 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 @@ -289,9 +266,15 @@ public final class DBTasks { */ public static Future<?> autodownloadUndownloadedItems(final Context context) { Log.d(TAG, "autodownloadUndownloadedItems"); - return autodownloadExec.submit(ClientConfig.automaticDownloadAlgorithm - .autoDownloadUndownloadedItems(context)); + return autodownloadExec.submit(downloadAlgorithm.autoDownloadUndownloadedItems(context)); + } + /** + * For testing purpose only. + */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public static void setDownloadAlgorithm(AutomaticDownloadAlgorithm newDownloadAlgorithm) { + downloadAlgorithm = newDownloadAlgorithm; } /** @@ -337,7 +320,7 @@ public final class DBTasks { private static Feed searchFeedByIdentifyingValueOrID(PodDBAdapter adapter, Feed feed) { if (feed.getId() != 0) { - return DBReader.getFeed(feed.getId(), adapter); + return DBReader.getFeed(feed.getId()); } else { List<Feed> feeds = DBReader.getFeedList(); for (Feed f : feeds) { @@ -534,7 +517,7 @@ public final class DBTasks { List<Feed> items = new ArrayList<>(); if (cursor.moveToFirst()) { do { - items.add(Feed.fromCursor(cursor)); + items.add(FeedCursorMapper.convert(cursor)); } while (cursor.moveToNext()); } setResult(items); diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBUpgrader.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBUpgrader.java index 622389ed8..4e2eb6e5a 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBUpgrader.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBUpgrader.java @@ -312,6 +312,14 @@ class DBUpgrader { } if (oldVersion < 2020000) { db.execSQL("ALTER TABLE " + PodDBAdapter.TABLE_NAME_FEEDS + + " ADD COLUMN " + PodDBAdapter.KEY_EPISODE_NOTIFICATION + " INTEGER DEFAULT 0;"); + } + if (oldVersion < 2030000) { + db.execSQL("UPDATE " + PodDBAdapter.TABLE_NAME_FEED_ITEMS + + " SET " + PodDBAdapter.KEY_DESCRIPTION + " = content_encoded, content_encoded = NULL " + + "WHERE length(" + PodDBAdapter.KEY_DESCRIPTION + ") < length(content_encoded)"); + db.execSQL("UPDATE " + PodDBAdapter.TABLE_NAME_FEED_ITEMS + " SET content_encoded = NULL"); + db.execSQL("ALTER TABLE " + PodDBAdapter.TABLE_NAME_FEEDS + " ADD COLUMN " + PodDBAdapter.KEY_FEED_TAGS + " TEXT;"); } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java index 84cc4b6a8..a86bdaa65 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java @@ -48,6 +48,7 @@ import de.danoeh.antennapod.core.util.LongList; import de.danoeh.antennapod.core.util.Permutor; import de.danoeh.antennapod.core.util.SortOrder; import de.danoeh.antennapod.core.util.playback.Playable; +import de.danoeh.antennapod.core.util.playback.PlayableUtils; /** * Provides methods for writing data to AntennaPod's database. @@ -382,7 +383,7 @@ public class DBWriter { List<FeedItem> updatedItems = new ArrayList<>(); ItemEnqueuePositionCalculator positionCalculator = new ItemEnqueuePositionCalculator(UserPreferences.getEnqueueLocation()); - Playable currentlyPlaying = Playable.PlayableUtils.createInstanceFromPreferences(context); + Playable currentlyPlaying = PlayableUtils.createInstanceFromPreferences(context); int insertPosition = positionCalculator.calcPosition(queue, currentlyPlaying); for (long itemId : itemIds) { if (!itemListContains(queue, itemId)) { @@ -789,6 +790,7 @@ public class DBWriter { adapter.open(); adapter.setFeedItemlist(items); adapter.close(); + EventBus.getDefault().post(FeedItemEvent.updated(items)); }); } diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java index e3121caa2..638c1bef5 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java @@ -17,6 +17,7 @@ import org.apache.commons.io.FilenameUtils; import java.io.File; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -184,16 +185,31 @@ public class DownloadRequester implements DownloadStateProvider { } /** - * Downloads a feed + * Downloads a feed. * * @param context The application's environment. - * @param feed Feed to download + * @param feed Feeds to download * @param loadAllPages Set to true to download all pages */ public synchronized void downloadFeed(Context context, Feed feed, boolean loadAllPages, - boolean force, boolean initiatedByUser) - throws DownloadRequestException { - if (feedFileValid(feed)) { + boolean force, boolean initiatedByUser) throws DownloadRequestException { + downloadFeeds(context, Collections.singletonList(feed), loadAllPages, force, initiatedByUser); + } + + /** + * Downloads a list of feeds. + * + * @param context The application's environment. + * @param feeds Feeds to download + * @param loadAllPages Set to true to download all pages + */ + public synchronized void downloadFeeds(Context context, List<Feed> feeds, boolean loadAllPages, + boolean force, boolean initiatedByUser) throws DownloadRequestException { + List<DownloadRequest> requests = new ArrayList<>(); + for (Feed feed : feeds) { + if (!feedFileValid(feed)) { + continue; + } String username = (feed.getPreferences() != null) ? feed.getPreferences().getUsername() : null; String password = (feed.getPreferences() != null) ? feed.getPreferences().getPassword() : null; String lastModified = feed.isPaged() || force ? null : feed.getLastUpdate(); @@ -206,9 +222,12 @@ public class DownloadRequester implements DownloadStateProvider { true, username, password, lastModified, true, args, initiatedByUser ); if (request != null) { - download(context, request); + requests.add(request); } } + if (!requests.isEmpty()) { + download(context, requests.toArray(new DownloadRequest[0])); + } } public synchronized void downloadFeed(Context context, Feed feed) throws DownloadRequestException { diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/ExceptFavoriteCleanupAlgorithm.java b/core/src/main/java/de/danoeh/antennapod/core/storage/ExceptFavoriteCleanupAlgorithm.java new file mode 100644 index 000000000..f0788db33 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/ExceptFavoriteCleanupAlgorithm.java @@ -0,0 +1,99 @@ +package de.danoeh.antennapod.core.storage; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.ExecutionException; + +import de.danoeh.antennapod.core.feed.FeedItem; +import de.danoeh.antennapod.core.preferences.UserPreferences; + +/** + * A cleanup algorithm that removes any item that isn't a favorite but only if space is needed. + */ +public class ExceptFavoriteCleanupAlgorithm extends EpisodeCleanupAlgorithm { + + private static final String TAG = "ExceptFavCleanupAlgo"; + + /** + * The maximum number of episodes that could be cleaned up. + * + * @return the number of episodes that *could* be cleaned up, if needed + */ + public int getReclaimableItems() { + return getCandidates().size(); + } + + @Override + public int performCleanup(Context context, int numberOfEpisodesToDelete) { + List<FeedItem> candidates = getCandidates(); + List<FeedItem> delete; + + // in the absence of better data, we'll sort by item publication date + Collections.sort(candidates, (lhs, rhs) -> { + Date l = lhs.getPubDate(); + Date r = rhs.getPubDate(); + + if (l != null && r != null) { + return l.compareTo(r); + } else { + // No date - compare by id which should be always incremented + return Long.compare(lhs.getId(), rhs.getId()); + } + }); + + if (candidates.size() > numberOfEpisodesToDelete) { + delete = candidates.subList(0, numberOfEpisodesToDelete); + } else { + delete = candidates; + } + + for (FeedItem item : delete) { + try { + DBWriter.deleteFeedMediaOfItem(context, item.getMedia().getId()).get(); + } catch (InterruptedException | ExecutionException e) { + e.printStackTrace(); + } + } + + int counter = delete.size(); + Log.i(TAG, String.format(Locale.US, + "Auto-delete deleted %d episodes (%d requested)", counter, + numberOfEpisodesToDelete)); + + return counter; + } + + @NonNull + private List<FeedItem> getCandidates() { + List<FeedItem> candidates = new ArrayList<>(); + List<FeedItem> downloadedItems = DBReader.getDownloadedItems(); + for (FeedItem item : downloadedItems) { + if (item.hasMedia() + && item.getMedia().isDownloaded() + && !item.isTagged(FeedItem.TAG_FAVORITE)) { + candidates.add(item); + } + } + return candidates; + } + + @Override + public int getDefaultCleanupParameter() { + int cacheSize = UserPreferences.getEpisodeCacheSize(); + if (cacheSize != UserPreferences.getEpisodeCacheSizeUnlimited()) { + int downloadedEpisodes = DBReader.getNumberOfDownloadedEpisodes(); + if (downloadedEpisodes > cacheSize) { + return downloadedEpisodes - cacheSize; + } + } + return 0; + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java b/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java index 8f47675a8..98d5e6310 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java @@ -15,7 +15,9 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import de.danoeh.antennapod.core.storage.mapper.FeedItemFilterQuery; import org.apache.commons.io.FileUtils; import java.io.File; @@ -30,6 +32,7 @@ import java.util.Set; import de.danoeh.antennapod.core.feed.Chapter; import de.danoeh.antennapod.core.feed.Feed; import de.danoeh.antennapod.core.feed.FeedItem; +import de.danoeh.antennapod.core.feed.FeedItemFilter; import de.danoeh.antennapod.core.feed.FeedMedia; import de.danoeh.antennapod.core.feed.FeedPreferences; import de.danoeh.antennapod.core.preferences.UserPreferences; @@ -49,7 +52,7 @@ public class PodDBAdapter { private static final String TAG = "PodDBAdapter"; public static final String DATABASE_NAME = "Antennapod.db"; - public static final int VERSION = 2020000; + public static final int VERSION = 2030000; /** * Maximum number of arguments for IN-operator. @@ -81,7 +84,6 @@ public class PodDBAdapter { public static final String KEY_FEEDFILETYPE = "feedfile_type"; public static final String KEY_COMPLETION_DATE = "completion_date"; public static final String KEY_FEEDITEM = "feeditem"; - public static final String KEY_CONTENT_ENCODED = "content_encoded"; public static final String KEY_PAYMENT_LINK = "payment_link"; public static final String KEY_START = "start"; public static final String KEY_LANGUAGE = "language"; @@ -114,16 +116,17 @@ public class PodDBAdapter { public static final String KEY_FEED_SKIP_INTRO = "feed_skip_intro"; public static final String KEY_FEED_SKIP_ENDING = "feed_skip_ending"; public static final String KEY_FEED_TAGS = "tags"; + public static final String KEY_EPISODE_NOTIFICATION = "episode_notification"; // Table names - static final String TABLE_NAME_FEEDS = "Feeds"; - static final String TABLE_NAME_FEED_ITEMS = "FeedItems"; - static final String TABLE_NAME_FEED_IMAGES = "FeedImages"; - static final String TABLE_NAME_FEED_MEDIA = "FeedMedia"; - static final String TABLE_NAME_DOWNLOAD_LOG = "DownloadLog"; - static final String TABLE_NAME_QUEUE = "Queue"; - static final String TABLE_NAME_SIMPLECHAPTERS = "SimpleChapters"; - static final String TABLE_NAME_FAVORITES = "Favorites"; + public static final String TABLE_NAME_FEEDS = "Feeds"; + public static final String TABLE_NAME_FEED_ITEMS = "FeedItems"; + public static final String TABLE_NAME_FEED_IMAGES = "FeedImages"; + public static final String TABLE_NAME_FEED_MEDIA = "FeedMedia"; + public static final String TABLE_NAME_DOWNLOAD_LOG = "DownloadLog"; + public static final String TABLE_NAME_QUEUE = "Queue"; + public static final String TABLE_NAME_SIMPLECHAPTERS = "SimpleChapters"; + public static final String TABLE_NAME_FAVORITES = "Favorites"; // SQL Statements for creating new tables private static final String TABLE_PRIMARY_KEY = KEY_ID @@ -152,12 +155,13 @@ public class PodDBAdapter { + KEY_FEED_VOLUME_ADAPTION + " INTEGER DEFAULT 0," + KEY_FEED_TAGS + " TEXT," + KEY_FEED_SKIP_INTRO + " INTEGER DEFAULT 0," - + KEY_FEED_SKIP_ENDING + " INTEGER DEFAULT 0)"; + + KEY_FEED_SKIP_ENDING + " INTEGER DEFAULT 0," + + KEY_EPISODE_NOTIFICATION + " INTEGER DEFAULT 0)"; private static final String CREATE_TABLE_FEED_ITEMS = "CREATE TABLE " - + TABLE_NAME_FEED_ITEMS + " (" + TABLE_PRIMARY_KEY + KEY_TITLE - + " TEXT," + KEY_CONTENT_ENCODED + " TEXT," + KEY_PUBDATE - + " INTEGER," + KEY_READ + " INTEGER," + KEY_LINK + " TEXT," + + TABLE_NAME_FEED_ITEMS + " (" + TABLE_PRIMARY_KEY + + KEY_TITLE + " TEXT," + KEY_PUBDATE + " INTEGER," + + KEY_READ + " INTEGER," + KEY_LINK + " TEXT," + KEY_DESCRIPTION + " TEXT," + KEY_PAYMENT_LINK + " TEXT," + KEY_MEDIA + " INTEGER," + KEY_FEED + " INTEGER," + KEY_HAS_CHAPTERS + " INTEGER," + KEY_ITEM_IDENTIFIER + " TEXT," @@ -255,7 +259,8 @@ public class PodDBAdapter { TABLE_NAME_FEEDS + "." + KEY_FEED_PLAYBACK_SPEED, TABLE_NAME_FEEDS + "." + KEY_FEED_TAGS, TABLE_NAME_FEEDS + "." + KEY_FEED_SKIP_INTRO, - TABLE_NAME_FEEDS + "." + KEY_FEED_SKIP_ENDING + TABLE_NAME_FEEDS + "." + KEY_FEED_SKIP_ENDING, + TABLE_NAME_FEEDS + "." + KEY_EPISODE_NOTIFICATION }; /** @@ -308,8 +313,7 @@ public class PodDBAdapter { private static final String SELECT_FEED_ITEMS_AND_MEDIA_WITH_DESCRIPTION = "SELECT " + KEYS_FEED_ITEM_WITHOUT_DESCRIPTION + ", " + KEYS_FEED_MEDIA + ", " - + TABLE_NAME_FEED_ITEMS + "." + KEY_DESCRIPTION + ", " - + TABLE_NAME_FEED_ITEMS + "." + KEY_CONTENT_ENCODED + + TABLE_NAME_FEED_ITEMS + "." + KEY_DESCRIPTION + " FROM " + TABLE_NAME_FEED_ITEMS + JOIN_FEED_ITEM_AND_MEDIA; private static final String SELECT_FEED_ITEMS_AND_MEDIA = @@ -370,6 +374,7 @@ public class PodDBAdapter { * For more information see * <a href="https://github.com/robolectric/robolectric/issues/1890">robolectric/robolectric#1890</a>.</p> */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) public static void tearDownTests() { getInstance().dbHelper.close(); instance = null; @@ -448,6 +453,7 @@ public class PodDBAdapter { values.put(KEY_FEED_TAGS, prefs.getTagsAsString()); values.put(KEY_FEED_SKIP_INTRO, prefs.getFeedSkipIntro()); values.put(KEY_FEED_SKIP_ENDING, prefs.getFeedSkipEnding()); + values.put(KEY_EPISODE_NOTIFICATION, prefs.getShowEpisodeNotification()); db.update(TABLE_NAME_FEEDS, values, KEY_ID + "=?", new String[]{String.valueOf(prefs.getFeedID())}); } @@ -623,9 +629,6 @@ public class PodDBAdapter { if (item.getDescription() != null) { values.put(KEY_DESCRIPTION, item.getDescription()); } - if (item.getContentEncoded() != null) { - values.put(KEY_CONTENT_ENCODED, item.getContentEncoded()); - } values.put(KEY_PUBDATE, item.getPubDate().getTime()); values.put(KEY_PAYMENT_LINK, item.getPaymentLink()); if (saveFeed && item.getFeed() != null) { @@ -947,9 +950,12 @@ public class PodDBAdapter { * @param feed The feed you want to get the FeedItems from. * @return The cursor of the query */ - public final Cursor getAllItemsOfFeedCursor(final Feed feed) { + public final Cursor getItemsOfFeedCursor(final Feed feed, FeedItemFilter filter) { + String filterQuery = FeedItemFilterQuery.generateFrom(filter); + String whereClauseAnd = "".equals(filterQuery) ? "" : " AND " + filterQuery; final String query = SELECT_FEED_ITEMS_AND_MEDIA - + " WHERE " + TABLE_NAME_FEED_ITEMS + "." + KEY_FEED + "=" + feed.getId(); + + " WHERE " + TABLE_NAME_FEED_ITEMS + "." + KEY_FEED + "=" + feed.getId() + + whereClauseAnd; return db.rawQuery(query, null); } @@ -957,7 +963,7 @@ public class PodDBAdapter { * Return the description and content_encoded of item */ public final Cursor getDescriptionOfItem(final FeedItem item) { - final String query = "SELECT " + KEY_DESCRIPTION + ", " + KEY_CONTENT_ENCODED + final String query = "SELECT " + KEY_DESCRIPTION + " FROM " + TABLE_NAME_FEED_ITEMS + " WHERE " + KEY_ID + "=" + item.getId(); return db.rawQuery(query, null); @@ -1048,9 +1054,11 @@ public class PodDBAdapter { return db.rawQuery(query, null); } - public final Cursor getRecentlyPublishedItemsCursor(int offset, int limit) { - final String query = SELECT_FEED_ITEMS_AND_MEDIA - + "ORDER BY " + KEY_PUBDATE + " DESC LIMIT " + offset + ", " + limit; + public final Cursor getRecentlyPublishedItemsCursor(int offset, int limit, FeedItemFilter filter) { + String filterQuery = FeedItemFilterQuery.generateFrom(filter); + String whereClause = "".equals(filterQuery) ? "" : " WHERE " + filterQuery; + final String query = SELECT_FEED_ITEMS_AND_MEDIA + whereClause + + " ORDER BY " + KEY_PUBDATE + " DESC LIMIT " + offset + ", " + limit; return db.rawQuery(query, null); } @@ -1164,6 +1172,11 @@ public class PodDBAdapter { public final LongIntMap getFeedCounters(long... feedIds) { int setting = UserPreferences.getFeedCounterSetting(); + + return getFeedCounters(setting, feedIds); + } + + public final LongIntMap getFeedCounters(int setting, long... feedIds) { String whereRead; switch (setting) { case UserPreferences.FEED_COUNTER_SHOW_NEW_UNPLAYED_SUM: @@ -1188,24 +1201,26 @@ public class PodDBAdapter { } private LongIntMap conditionalFeedCounterRead(String whereRead, long... feedIds) { - // work around TextUtils.join wanting only boxed items - // and StringUtils.join() causing NoSuchMethodErrors on MIUI - StringBuilder builder = new StringBuilder(); - for (long id : feedIds) { - builder.append(id); - builder.append(','); - } + String limitFeeds = ""; if (feedIds.length > 0) { + // work around TextUtils.join wanting only boxed items + // and StringUtils.join() causing NoSuchMethodErrors on MIUI + StringBuilder builder = new StringBuilder(); + for (long id : feedIds) { + builder.append(id); + builder.append(','); + } // there's an extra ',', get rid of it builder.deleteCharAt(builder.length() - 1); + limitFeeds = KEY_FEED + " IN (" + builder.toString() + ") AND "; } final String query = "SELECT " + KEY_FEED + ", COUNT(" + TABLE_NAME_FEED_ITEMS + "." + KEY_ID + ") AS count " + " FROM " + TABLE_NAME_FEED_ITEMS + " LEFT JOIN " + TABLE_NAME_FEED_MEDIA + " ON " + TABLE_NAME_FEED_ITEMS + "." + KEY_ID + "=" + TABLE_NAME_FEED_MEDIA + "." + KEY_FEEDITEM - + " WHERE " + KEY_FEED + " IN (" + builder.toString() + ") " - + " AND " + whereRead + " GROUP BY " + KEY_FEED; + + " WHERE " + limitFeeds + " " + + whereRead + " GROUP BY " + KEY_FEED; Cursor c = db.rawQuery(query, null); LongIntMap result = new LongIntMap(c.getCount()); @@ -1301,8 +1316,6 @@ public class PodDBAdapter { .append("(") .append(KEY_DESCRIPTION + " LIKE '%").append(queryWords[i]) .append("%' OR ") - .append(KEY_CONTENT_ENCODED).append(" LIKE '%").append(queryWords[i]) - .append("%' OR ") .append(KEY_TITLE).append(" LIKE '%").append(queryWords[i]) .append("%') "); @@ -1368,7 +1381,16 @@ public class PodDBAdapter { } /** - * Called when a database corruption happens + * Insert raw data to the database. * + * Call method only for unit tests. + */ + @VisibleForTesting(otherwise = VisibleForTesting.NONE) + public void insertTestData(@NonNull String table, @NonNull ContentValues values) { + db.insert(table, null, values); + } + + /** + * Called when a database corruption happens. */ public static class PodDbErrorHandler implements DatabaseErrorHandler { @Override diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedCursorMapper.java b/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedCursorMapper.java new file mode 100644 index 000000000..783fba596 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedCursorMapper.java @@ -0,0 +1,70 @@ +package de.danoeh.antennapod.core.storage.mapper; + +import android.database.Cursor; + +import androidx.annotation.NonNull; + +import de.danoeh.antennapod.core.feed.Feed; +import de.danoeh.antennapod.core.feed.FeedPreferences; +import de.danoeh.antennapod.core.storage.PodDBAdapter; +import de.danoeh.antennapod.core.util.SortOrder; + +/** + * Converts a {@link Cursor} to a {@link Feed} object. + */ +public abstract class FeedCursorMapper { + + /** + * Create a {@link Feed} instance from a database row (cursor). + */ + @NonNull + public static Feed convert(@NonNull Cursor cursor) { + int indexId = cursor.getColumnIndex(PodDBAdapter.KEY_ID); + int indexLastUpdate = cursor.getColumnIndex(PodDBAdapter.KEY_LASTUPDATE); + int indexTitle = cursor.getColumnIndex(PodDBAdapter.KEY_TITLE); + int indexCustomTitle = cursor.getColumnIndex(PodDBAdapter.KEY_CUSTOM_TITLE); + int indexLink = cursor.getColumnIndex(PodDBAdapter.KEY_LINK); + int indexDescription = cursor.getColumnIndex(PodDBAdapter.KEY_DESCRIPTION); + int indexPaymentLink = cursor.getColumnIndex(PodDBAdapter.KEY_PAYMENT_LINK); + int indexAuthor = cursor.getColumnIndex(PodDBAdapter.KEY_AUTHOR); + int indexLanguage = cursor.getColumnIndex(PodDBAdapter.KEY_LANGUAGE); + int indexType = cursor.getColumnIndex(PodDBAdapter.KEY_TYPE); + int indexFeedIdentifier = cursor.getColumnIndex(PodDBAdapter.KEY_FEED_IDENTIFIER); + int indexFileUrl = cursor.getColumnIndex(PodDBAdapter.KEY_FILE_URL); + int indexDownloadUrl = cursor.getColumnIndex(PodDBAdapter.KEY_DOWNLOAD_URL); + int indexDownloaded = cursor.getColumnIndex(PodDBAdapter.KEY_DOWNLOADED); + int indexIsPaged = cursor.getColumnIndex(PodDBAdapter.KEY_IS_PAGED); + int indexNextPageLink = cursor.getColumnIndex(PodDBAdapter.KEY_NEXT_PAGE_LINK); + int indexHide = cursor.getColumnIndex(PodDBAdapter.KEY_HIDE); + int indexSortOrder = cursor.getColumnIndex(PodDBAdapter.KEY_SORT_ORDER); + int indexLastUpdateFailed = cursor.getColumnIndex(PodDBAdapter.KEY_LAST_UPDATE_FAILED); + int indexImageUrl = cursor.getColumnIndex(PodDBAdapter.KEY_IMAGE_URL); + + Feed feed = new Feed( + cursor.getLong(indexId), + cursor.getString(indexLastUpdate), + cursor.getString(indexTitle), + cursor.getString(indexCustomTitle), + cursor.getString(indexLink), + cursor.getString(indexDescription), + cursor.getString(indexPaymentLink), + cursor.getString(indexAuthor), + cursor.getString(indexLanguage), + cursor.getString(indexType), + cursor.getString(indexFeedIdentifier), + cursor.getString(indexImageUrl), + cursor.getString(indexFileUrl), + cursor.getString(indexDownloadUrl), + cursor.getInt(indexDownloaded) > 0, + cursor.getInt(indexIsPaged) > 0, + cursor.getString(indexNextPageLink), + cursor.getString(indexHide), + SortOrder.fromCodeString(cursor.getString(indexSortOrder)), + cursor.getInt(indexLastUpdateFailed) > 0 + ); + + FeedPreferences preferences = FeedPreferences.fromCursor(cursor); + feed.setPreferences(preferences); + return feed; + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedItemFilterQuery.java b/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedItemFilterQuery.java new file mode 100644 index 000000000..f6963b5ac --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedItemFilterQuery.java @@ -0,0 +1,76 @@ +package de.danoeh.antennapod.core.storage.mapper; + +import de.danoeh.antennapod.core.feed.FeedItemFilter; +import de.danoeh.antennapod.core.storage.PodDBAdapter; + +import java.util.ArrayList; +import java.util.List; + +public class FeedItemFilterQuery { + private FeedItemFilterQuery() { + // Must not be instantiated + } + + /** + * Express the filter using an SQL boolean statement that can be inserted into an SQL WHERE clause + * to yield output filtered according to the rules of this filter. + * + * @return An SQL boolean statement that matches the desired items, + * empty string if there is nothing to filter + */ + public static String generateFrom(FeedItemFilter filter) { + // The keys used within this method, but explicitly combined with their table + String keyRead = PodDBAdapter.TABLE_NAME_FEED_ITEMS + "." + PodDBAdapter.KEY_READ; + String keyPosition = PodDBAdapter.TABLE_NAME_FEED_MEDIA + "." + PodDBAdapter.KEY_POSITION; + String keyDownloaded = PodDBAdapter.TABLE_NAME_FEED_MEDIA + "." + PodDBAdapter.KEY_DOWNLOADED; + String keyMediaId = PodDBAdapter.TABLE_NAME_FEED_MEDIA + "." + PodDBAdapter.KEY_ID; + String keyItemId = PodDBAdapter.TABLE_NAME_FEED_ITEMS + "." + PodDBAdapter.KEY_ID; + String keyFeedItem = PodDBAdapter.KEY_FEEDITEM; + String tableQueue = PodDBAdapter.TABLE_NAME_QUEUE; + String tableFavorites = PodDBAdapter.TABLE_NAME_FAVORITES; + + List<String> statements = new ArrayList<>(); + if (filter.showPlayed) { + statements.add(keyRead + " = 1 "); + } else if (filter.showUnplayed) { + statements.add(" NOT " + keyRead + " = 1 "); // Match "New" items (read = -1) as well + } + if (filter.showPaused) { + statements.add(" (" + keyPosition + " NOT NULL AND " + keyPosition + " > 0 " + ") "); + } else if (filter.showNotPaused) { + statements.add(" (" + keyPosition + " IS NULL OR " + keyPosition + " = 0 " + ") "); + } + if (filter.showQueued) { + statements.add(keyItemId + " IN (SELECT " + keyFeedItem + " FROM " + tableQueue + ") "); + } else if (filter.showNotQueued) { + statements.add(keyItemId + " NOT IN (SELECT " + keyFeedItem + " FROM " + tableQueue + ") "); + } + if (filter.showDownloaded) { + statements.add(keyDownloaded + " = 1 "); + } else if (filter.showNotDownloaded) { + statements.add(keyDownloaded + " = 0 "); + } + if (filter.showHasMedia) { + statements.add(keyMediaId + " NOT NULL "); + } else if (filter.showNoMedia) { + statements.add(keyMediaId + " IS NULL "); + } + if (filter.showIsFavorite) { + statements.add(keyItemId + " IN (SELECT " + keyFeedItem + " FROM " + tableFavorites + ") "); + } else if (filter.showNotFavorite) { + statements.add(keyItemId + " NOT IN (SELECT " + keyFeedItem + " FROM " + tableFavorites + ") "); + } + + if (statements.isEmpty()) { + return ""; + } + + StringBuilder query = new StringBuilder(" (" + statements.get(0)); + for (String r : statements.subList(1, statements.size())) { + query.append(" AND "); + query.append(r); + } + query.append(") "); + return query.toString(); + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java b/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java index 7563ab715..670a65e44 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java +++ b/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java @@ -80,7 +80,7 @@ public class SyncService extends Worker { if (!GpodnetPreferences.loggedIn()) { return Result.success(); } - syncServiceImpl = new GpodnetService(AntennapodHttpClient.getHttpClient(), GpodnetPreferences.getHostname()); + syncServiceImpl = new GpodnetService(AntennapodHttpClient.getHttpClient(), GpodnetPreferences.getHosturl()); SharedPreferences.Editor prefs = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) .edit(); prefs.putLong(PREF_LAST_SYNC_ATTEMPT_TIMESTAMP, System.currentTimeMillis()).apply(); @@ -474,6 +474,7 @@ public class SyncService extends Worker { } } DBWriter.removeQueueItem(getApplicationContext(), false, queueToBeRemoved.toArray()); + DBReader.loadAdditionalFeedItemListData(updatedItems); DBWriter.setItemList(updatedItems); } diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/gpoddernet/GpodnetService.java b/core/src/main/java/de/danoeh/antennapod/core/sync/gpoddernet/GpodnetService.java index 62c8ce5f3..cecfc0d2c 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/sync/gpoddernet/GpodnetService.java +++ b/core/src/main/java/de/danoeh/antennapod/core/sync/gpoddernet/GpodnetService.java @@ -2,6 +2,7 @@ package de.danoeh.antennapod.core.sync.gpoddernet; import android.util.Log; import androidx.annotation.NonNull; +import de.danoeh.antennapod.core.BuildConfig; import de.danoeh.antennapod.core.preferences.GpodnetPreferences; import de.danoeh.antennapod.core.sync.gpoddernet.model.GpodnetDevice; import de.danoeh.antennapod.core.sync.model.EpisodeAction; @@ -36,27 +37,63 @@ import java.net.URL; import java.nio.charset.Charset; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + /** * Communicates with the gpodder.net service. */ public class GpodnetService implements ISyncService { public static final String TAG = "GpodnetService"; public static final String DEFAULT_BASE_HOST = "gpodder.net"; - private static final String BASE_SCHEME = "https"; private static final int UPLOAD_BULK_SIZE = 30; private static final MediaType TEXT = MediaType.parse("plain/text; charset=utf-8"); private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); - private final String baseHost; + private String baseScheme; + private String baseHost; + private int basePort; + private final OkHttpClient httpClient; private String username = null; - public GpodnetService(OkHttpClient httpClient, String baseHost) { + // split into schema, host and port - missing parts are null + private static Pattern urlsplit_regex = Pattern.compile("(?:(https?)://)?([^:]+)(?::(\\d+))?"); + + public GpodnetService(OkHttpClient httpClient, String baseHosturl) { this.httpClient = httpClient; - this.baseHost = baseHost; + + Matcher m = urlsplit_regex.matcher(baseHosturl); + if (m.matches()) { + this.baseScheme = m.group(1); + this.baseHost = m.group(2); + if (m.group(3) == null) { + this.basePort = -1; + } else { + this.basePort = Integer.parseInt(m.group(3)); // regex -> can only be digits + } + } else { + // URL does not match regex: use it anyway -> this will cause an exception on connect + this.baseScheme = "https"; + this.baseHost = baseHosturl; + this.basePort = 443; + } + + if (this.baseScheme == null) { // assume https + this.baseScheme = "https"; + } + + if (this.baseScheme.equals("https") && this.basePort == -1) { + this.basePort = 443; + } + + if (this.baseScheme.equals("http") && this.basePort == -1) { + this.basePort = 80; + } } private void requireLoggedIn() { @@ -71,7 +108,8 @@ public class GpodnetService implements ISyncService { public List<GpodnetTag> getTopTags(int count) throws GpodnetServiceException { URL url; try { - url = new URI(BASE_SCHEME, baseHost, String.format(Locale.US, "/api/2/tags/%d.json", count), null).toURL(); + url = new URI(baseScheme, null, baseHost, basePort, + String.format(Locale.US, "/api/2/tags/%d.json", count), null, null).toURL(); } catch (MalformedURLException | URISyntaxException e) { e.printStackTrace(); throw new GpodnetServiceException(e); @@ -104,8 +142,8 @@ public class GpodnetService implements ISyncService { public List<GpodnetPodcast> getPodcastsForTag(@NonNull GpodnetTag tag, int count) throws GpodnetServiceException { try { - URL url = new URI(BASE_SCHEME, baseHost, String.format(Locale.US, - "/api/2/tag/%s/%d.json", tag.getTag(), count), null).toURL(); + URL url = new URI(baseScheme, null, baseHost, basePort, + String.format(Locale.US, "/api/2/tag/%s/%d.json", tag.getTag(), count), null, null).toURL(); Request.Builder request = new Request.Builder().url(url); String response = executeRequest(request); @@ -130,7 +168,8 @@ public class GpodnetService implements ISyncService { } try { - URL url = new URI(BASE_SCHEME, baseHost, String.format(Locale.US, "/toplist/%d.json", count), null).toURL(); + URL url = new URI(baseScheme, null, baseHost, basePort, + String.format(Locale.US, "/toplist/%d.json", count), null, null).toURL(); Request.Builder request = new Request.Builder().url(url); String response = executeRequest(request); @@ -161,8 +200,8 @@ public class GpodnetService implements ISyncService { } try { - URL url = new URI(BASE_SCHEME, baseHost, - String.format(Locale.US, "/suggestions/%d.json", count), null).toURL(); + URL url = new URI(baseScheme, null, baseHost, basePort, + String.format(Locale.US, "/suggestions/%d.json", count), null, null).toURL(); Request.Builder request = new Request.Builder().url(url); String response = executeRequest(request); @@ -187,7 +226,7 @@ public class GpodnetService implements ISyncService { .format(Locale.US, "q=%s&scale_logo=%d", query, scaledLogoSize) : String .format("q=%s", query); try { - URL url = new URI(BASE_SCHEME, null, baseHost, -1, "/search.json", + URL url = new URI(baseScheme, null, baseHost, basePort, "/search.json", parameters, null).toURL(); Request.Builder request = new Request.Builder().url(url); String response = executeRequest(request); @@ -214,7 +253,8 @@ public class GpodnetService implements ISyncService { public List<GpodnetDevice> getDevices() throws GpodnetServiceException { requireLoggedIn(); try { - URL url = new URI(BASE_SCHEME, baseHost, String.format("/api/2/devices/%s.json", username), null).toURL(); + URL url = new URI(baseScheme, null, baseHost, basePort, + String.format("/api/2/devices/%s.json", username), null, null).toURL(); Request.Builder request = new Request.Builder().url(url); String response = executeRequest(request); JSONArray devicesArray = new JSONArray(response); @@ -226,6 +266,45 @@ public class GpodnetService implements ISyncService { } /** + * Returns synchronization status of devices. + * <p/> + * This method requires authentication. + * + * @throws GpodnetServiceAuthenticationException If there is an authentication error. + */ + public List<List<String>> getSynchronizedDevices() throws GpodnetServiceException { + requireLoggedIn(); + try { + URL url = new URI(baseScheme, null, baseHost, basePort, + String.format("/api/2/sync-devices/%s.json", username), null, null).toURL(); + Request.Builder request = new Request.Builder().url(url); + String response = executeRequest(request); + JSONObject syncStatus = new JSONObject(response); + List<List<String>> result = new ArrayList<>(); + + JSONArray synchronizedDevices = syncStatus.getJSONArray("synchronized"); + for (int i = 0; i < synchronizedDevices.length(); i++) { + JSONArray groupDevices = synchronizedDevices.getJSONArray(i); + List<String> group = new ArrayList<>(); + for (int j = 0; j < groupDevices.length(); j++) { + group.add(groupDevices.getString(j)); + } + result.add(group); + } + + JSONArray notSynchronizedDevices = syncStatus.getJSONArray("not-synchronized"); + for (int i = 0; i < notSynchronizedDevices.length(); i++) { + result.add(Collections.singletonList(notSynchronizedDevices.getString(i))); + } + + return result; + } catch (JSONException | MalformedURLException | URISyntaxException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } + } + + /** * Configures the device of a given user. * <p/> * This method requires authentication. @@ -237,8 +316,8 @@ public class GpodnetService implements ISyncService { throws GpodnetServiceException { requireLoggedIn(); try { - URL url = new URI(BASE_SCHEME, baseHost, String.format( - "/api/2/devices/%s/%s.json", username, deviceId), null).toURL(); + URL url = new URI(baseScheme, null, baseHost, basePort, + String.format("/api/2/devices/%s/%s.json", username, deviceId), null, null).toURL(); String content; if (caption != null || type != null) { JSONObject jsonContent = new JSONObject(); @@ -262,6 +341,39 @@ public class GpodnetService implements ISyncService { } /** + * Links devices for synchronization. + * <p/> + * This method requires authentication. + * + * @throws GpodnetServiceAuthenticationException If there is an authentication error. + */ + public void linkDevices(@NonNull List<String> deviceIds) throws GpodnetServiceException { + requireLoggedIn(); + try { + final URL url = new URI(baseScheme, null, baseHost, basePort, + String.format("/api/2/sync-devices/%s.json", username), null, null).toURL(); + JSONObject jsonContent = new JSONObject(); + JSONArray group = new JSONArray(); + for (String deviceId : deviceIds) { + group.put(deviceId); + } + + JSONArray synchronizedGroups = new JSONArray(); + synchronizedGroups.put(group); + jsonContent.put("synchronize", synchronizedGroups); + jsonContent.put("stop-synchronize", new JSONArray()); + + Log.d("aaaa", jsonContent.toString()); + RequestBody body = RequestBody.create(JSON, jsonContent.toString()); + Request.Builder request = new Request.Builder().post(body).url(url); + executeRequest(request); + } catch (JSONException | MalformedURLException | URISyntaxException e) { + e.printStackTrace(); + throw new GpodnetServiceException(e); + } + } + + /** * Returns the subscriptions of a specific device. * <p/> * This method requires authentication. @@ -273,8 +385,8 @@ public class GpodnetService implements ISyncService { public String getSubscriptionsOfDevice(@NonNull String deviceId) throws GpodnetServiceException { requireLoggedIn(); try { - URL url = new URI(BASE_SCHEME, baseHost, String.format( - "/subscriptions/%s/%s.opml", username, deviceId), null).toURL(); + URL url = new URI(baseScheme, null, baseHost, basePort, + String.format("/subscriptions/%s/%s.opml", username, deviceId), null, null).toURL(); Request.Builder request = new Request.Builder().url(url); return executeRequest(request); } catch (MalformedURLException | URISyntaxException e) { @@ -295,7 +407,8 @@ public class GpodnetService implements ISyncService { public String getSubscriptionsOfUser() throws GpodnetServiceException { requireLoggedIn(); try { - URL url = new URI(BASE_SCHEME, baseHost, String.format("/subscriptions/%s.opml", username), null).toURL(); + URL url = new URI(baseScheme, null, baseHost, basePort, + String.format("/subscriptions/%s.opml", username), null, null).toURL(); Request.Builder request = new Request.Builder().url(url); return executeRequest(request); } catch (MalformedURLException | URISyntaxException e) { @@ -319,8 +432,8 @@ public class GpodnetService implements ISyncService { throws GpodnetServiceException { requireLoggedIn(); try { - URL url = new URI(BASE_SCHEME, baseHost, String.format( - "/subscriptions/%s/%s.txt", username, deviceId), null).toURL(); + URL url = new URI(baseScheme, null, baseHost, basePort, + String.format("/subscriptions/%s/%s.txt", username, deviceId), null, null).toURL(); StringBuilder builder = new StringBuilder(); for (String s : subscriptions) { builder.append(s); @@ -353,8 +466,8 @@ public class GpodnetService implements ISyncService { @NonNull Collection<String> removed) throws GpodnetServiceException { requireLoggedIn(); try { - URL url = new URI(BASE_SCHEME, baseHost, String.format( - "/api/2/subscriptions/%s/%s.json", username, deviceId), null).toURL(); + URL url = new URI(baseScheme, null, baseHost, basePort, + String.format("/api/2/subscriptions/%s/%s.json", username, deviceId), null, null).toURL(); final JSONObject requestObject = new JSONObject(); requestObject.put("add", new JSONArray(added)); @@ -389,8 +502,7 @@ public class GpodnetService implements ISyncService { String params = String.format(Locale.US, "since=%d", timestamp); String path = String.format("/api/2/subscriptions/%s/%s.json", username, deviceId); try { - URL url = new URI(BASE_SCHEME, null, baseHost, -1, path, params, - null).toURL(); + URL url = new URI(baseScheme, null, baseHost, basePort, path, params, null).toURL(); Request.Builder request = new Request.Builder().url(url); String response = executeRequest(request); @@ -432,8 +544,8 @@ public class GpodnetService implements ISyncService { throws SyncServiceException { try { Log.d(TAG, "Uploading partial actions " + from + " to " + to + " of " + episodeActions.size()); - URL url = new URI(BASE_SCHEME, baseHost, String.format( - "/api/2/episodes/%s.json", username), null).toURL(); + URL url = new URI(baseScheme, null, baseHost, basePort, + String.format("/api/2/episodes/%s.json", username), null, null).toURL(); final JSONArray list = new JSONArray(); for (int i = from; i < to; i++) { @@ -471,7 +583,7 @@ public class GpodnetService implements ISyncService { String params = String.format(Locale.US, "since=%d", timestamp); String path = String.format("/api/2/episodes/%s.json", username); try { - URL url = new URI(BASE_SCHEME, null, baseHost, -1, path, params, null).toURL(); + URL url = new URI(baseScheme, null, baseHost, basePort, path, params, null).toURL(); Request.Builder request = new Request.Builder().url(url); String response = executeRequest(request); @@ -497,7 +609,8 @@ public class GpodnetService implements ISyncService { public void authenticate(@NonNull String username, @NonNull String password) throws GpodnetServiceException { URL url; try { - url = new URI(BASE_SCHEME, baseHost, String.format("/api/2/auth/%s/login.json", username), null).toURL(); + url = new URI(baseScheme, null, baseHost, basePort, + String.format("/api/2/auth/%s/login.json", username), null, null).toURL(); } catch (MalformedURLException | URISyntaxException e) { e.printStackTrace(); throw new GpodnetServiceException(e); @@ -567,6 +680,13 @@ public class GpodnetService implements ISyncService { if (responseCode == HttpURLConnection.HTTP_UNAUTHORIZED) { throw new GpodnetServiceAuthenticationException("Wrong username or password"); } else { + if (BuildConfig.DEBUG) { + try { + Log.d(TAG, response.body().string()); + } catch (IOException e) { + e.printStackTrace(); + } + } throw new GpodnetServiceBadStatusCodeException("Bad response code: " + responseCode, responseCode); } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/UnsupportedFeedtypeException.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/UnsupportedFeedtypeException.java index 11588967a..c9f9f19c8 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/UnsupportedFeedtypeException.java +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/handler/UnsupportedFeedtypeException.java @@ -36,9 +36,6 @@ public class UnsupportedFeedtypeException extends Exception { if (message != null) { return message; } else if (type == TypeGetter.Type.INVALID) { - if ("html".equals(rootElement)) { - return "The server returned a website, not a podcast feed"; - } return "Invalid type"; } else { return "Type " + type + " not supported"; diff --git a/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSContent.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSContent.java index 306b79c15..bedf377aa 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSContent.java +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSContent.java @@ -5,23 +5,21 @@ import org.xml.sax.Attributes; import de.danoeh.antennapod.core.syndication.handler.HandlerState; public class NSContent extends Namespace { - public static final String NSTAG = "content"; - public static final String NSURI = "http://purl.org/rss/1.0/modules/content/"; - - private static final String ENCODED = "encoded"; - - @Override - public SyndElement handleElementStart(String localName, HandlerState state, - Attributes attributes) { - return new SyndElement(localName, this); - } + public static final String NSTAG = "content"; + public static final String NSURI = "http://purl.org/rss/1.0/modules/content/"; - @Override - public void handleElementEnd(String localName, HandlerState state) { - if (ENCODED.equals(localName) && state.getCurrentItem() != null && - state.getContentBuf() != null) { - state.getCurrentItem().setContentEncoded(state.getContentBuf().toString()); - } - } + private static final String ENCODED = "encoded"; + + @Override + public SyndElement handleElementStart(String localName, HandlerState state, Attributes attributes) { + return new SyndElement(localName, this); + } + + @Override + public void handleElementEnd(String localName, HandlerState state) { + if (ENCODED.equals(localName) && state.getCurrentItem() != null && state.getContentBuf() != null) { + state.getCurrentItem().setDescriptionIfLonger(state.getContentBuf().toString()); + } + } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSITunes.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSITunes.java index 1e069a1f0..1dc8d8af3 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSITunes.java +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSITunes.java @@ -3,9 +3,10 @@ package de.danoeh.antennapod.core.syndication.namespace; import android.text.TextUtils; import android.util.Log; +import androidx.core.text.HtmlCompat; + import org.xml.sax.Attributes; -import de.danoeh.antennapod.core.feed.FeedItem; import de.danoeh.antennapod.core.syndication.handler.HandlerState; import de.danoeh.antennapod.core.syndication.parsers.DurationParser; @@ -62,7 +63,8 @@ public class NSITunes extends Namespace { private void parseAuthor(HandlerState state) { if (state.getFeed() != null) { String author = state.getContentBuf().toString(); - state.getFeed().setAuthor(author); + state.getFeed().setAuthor(HtmlCompat.fromHtml(author, + HtmlCompat.FROM_HTML_MODE_LEGACY).toString()); } } @@ -87,7 +89,7 @@ public class NSITunes extends Namespace { } if (state.getCurrentItem() != null) { if (TextUtils.isEmpty(state.getCurrentItem().getDescription())) { - state.getCurrentItem().setDescription(subtitle); + state.getCurrentItem().setDescriptionIfLonger(subtitle); } } else { if (state.getFeed() != null && TextUtils.isEmpty(state.getFeed().getDescription())) { @@ -102,16 +104,10 @@ public class NSITunes extends Namespace { return; } - FeedItem currentItem = state.getCurrentItem(); - String description = getDescription(currentItem); - if (currentItem != null && description.length() * 1.25 < summary.length()) { - currentItem.setDescription(summary); + if (state.getCurrentItem() != null) { + state.getCurrentItem().setDescriptionIfLonger(summary); } else if (NSRSS20.CHANNEL.equals(secondElementName) && state.getFeed() != null) { state.getFeed().setDescription(summary); } } - - private String getDescription(FeedItem item) { - return (item != null && item.getDescription() != null) ? item.getDescription() : ""; - } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSMedia.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSMedia.java index 30b01f0bc..b5d5a1b3f 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSMedia.java +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSMedia.java @@ -121,9 +121,8 @@ public class NSMedia extends Namespace { public void handleElementEnd(String localName, HandlerState state) { if (DESCRIPTION.equals(localName)) { String content = state.getContentBuf().toString(); - if (state.getCurrentItem() != null && content != null - && state.getCurrentItem().getDescription() == null) { - state.getCurrentItem().setDescription(content); + if (state.getCurrentItem() != null) { + state.getCurrentItem().setDescriptionIfLonger(content); } } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSRSS20.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSRSS20.java index 45c5d4884..b1cd6d1c2 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSRSS20.java +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSRSS20.java @@ -13,10 +13,7 @@ import de.danoeh.antennapod.core.syndication.util.SyndTypeUtils; import de.danoeh.antennapod.core.util.DateUtils; /** - * SAX-Parser for reading RSS-Feeds - * - * @author daniel - * + * SAX-Parser for reading RSS-Feeds. */ public class NSRSS20 extends Namespace { @@ -83,8 +80,7 @@ public class NSRSS20 extends Namespace { if (state.getCurrentItem() != null) { FeedItem currentItem = state.getCurrentItem(); // the title tag is optional in RSS 2.0. The description is used - // as a - // title if the item has no title-tag. + // as a title if the item has no title-tag. if (currentItem.getTitle() == null) { currentItem.setTitle(currentItem.getDescription()); } @@ -138,7 +134,7 @@ public class NSRSS20 extends Namespace { if (CHANNEL.equals(second) && state.getFeed() != null) { state.getFeed().setDescription(content); } else if (ITEM.equals(second) && state.getCurrentItem() != null) { - state.getCurrentItem().setDescription(content); + state.getCurrentItem().setDescriptionIfLonger(content); } } else if (LANGUAGE.equals(localName) && state.getFeed() != null) { state.getFeed().setLanguage(content.toLowerCase()); diff --git a/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/atom/NSAtom.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/atom/NSAtom.java index 7e4350fd4..42f787d98 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/atom/NSAtom.java +++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/atom/NSAtom.java @@ -198,10 +198,10 @@ public class NSAtom extends Namespace { state.getFeed().setDescription(textElement.getProcessedContent()); } else if (CONTENT.equals(top) && ENTRY.equals(second) && textElement != null && state.getCurrentItem() != null) { - state.getCurrentItem().setDescription(textElement.getProcessedContent()); - } else if (SUMMARY.equals(top) && ENTRY.equals(second) && textElement != null && - state.getCurrentItem() != null && state.getCurrentItem().getDescription() == null) { - state.getCurrentItem().setDescription(textElement.getProcessedContent()); + state.getCurrentItem().setDescriptionIfLonger(textElement.getProcessedContent()); + } else if (SUMMARY.equals(top) && ENTRY.equals(second) && textElement != null + && state.getCurrentItem() != null) { + state.getCurrentItem().setDescriptionIfLonger(textElement.getProcessedContent()); } else if (UPDATED.equals(top) && ENTRY.equals(second) && state.getCurrentItem() != null && state.getCurrentItem().getPubDate() == null) { state.getCurrentItem().setPubDate(DateUtils.parse(content)); diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/ChapterUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/ChapterUtils.java index d4a2cdca6..ca9689048 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/ChapterUtils.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/ChapterUtils.java @@ -3,32 +3,30 @@ package de.danoeh.antennapod.core.util; import android.content.ContentResolver; import android.content.Context; import android.net.Uri; -import androidx.annotation.NonNull; import android.util.Log; - -import java.net.URLConnection; -import de.danoeh.antennapod.core.ClientConfig; -import org.apache.commons.io.IOUtils; - -import java.io.BufferedInputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.io.InputStream; -import java.net.URL; -import java.util.Collections; -import java.util.List; - +import androidx.annotation.NonNull; import de.danoeh.antennapod.core.feed.Chapter; +import de.danoeh.antennapod.core.feed.ChapterMerger; +import de.danoeh.antennapod.core.feed.FeedMedia; +import de.danoeh.antennapod.core.service.download.AntennapodHttpClient; +import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.core.util.comparator.ChapterStartTimeComparator; import de.danoeh.antennapod.core.util.id3reader.ChapterReader; import de.danoeh.antennapod.core.util.id3reader.ID3ReaderException; import de.danoeh.antennapod.core.util.playback.Playable; import de.danoeh.antennapod.core.util.vorbiscommentreader.VorbisCommentChapterReader; import de.danoeh.antennapod.core.util.vorbiscommentreader.VorbisCommentReaderException; +import okhttp3.Request; +import okhttp3.Response; import org.apache.commons.io.input.CountingInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Collections; +import java.util.List; + /** * Utility class for getting chapter data from media files. */ @@ -52,101 +50,84 @@ public class ChapterUtils { return chapters.size() - 1; } - public static List<Chapter> loadChaptersFromStreamUrl(Playable media, Context context) { - List<Chapter> chapters = ChapterUtils.readID3ChaptersFromPlayableStreamUrl(media, context); - if (chapters == null) { - chapters = ChapterUtils.readOggChaptersFromPlayableStreamUrl(media, context); + public static void loadChapters(Playable playable, Context context) { + if (playable.getChapters() != null) { + // Already loaded + return; } - return chapters; - } - public static List<Chapter> loadChaptersFromFileUrl(Playable media) { - if (!media.localFileAvailable()) { - Log.e(TAG, "Could not load chapters from file url: local file not available"); - return null; + List<Chapter> chaptersFromDatabase = null; + if (playable instanceof FeedMedia) { + FeedMedia feedMedia = (FeedMedia) playable; + if (feedMedia.getItem() == null) { + feedMedia.setItem(DBReader.getFeedItem(feedMedia.getItemId())); + } + if (feedMedia.getItem().hasChapters()) { + chaptersFromDatabase = DBReader.loadChaptersOfFeedItem(feedMedia.getItem()); + } } - List<Chapter> chapters = ChapterUtils.readID3ChaptersFromPlayableFileUrl(media); + + List<Chapter> chaptersFromMediaFile = ChapterUtils.loadChaptersFromMediaFile(playable, context); + List<Chapter> chapters = ChapterMerger.merge(chaptersFromDatabase, chaptersFromMediaFile); if (chapters == null) { - chapters = ChapterUtils.readOggChaptersFromPlayableFileUrl(media); + // Do not try loading again. There are no chapters. + playable.setChapters(Collections.emptyList()); + } else { + playable.setChapters(chapters); } - return chapters; } - /** - * Uses the download URL of a media object of a feeditem to read its ID3 - * chapters. - */ - private static List<Chapter> readID3ChaptersFromPlayableStreamUrl(Playable p, Context context) { - if (p == null || p.getStreamUrl() == null) { - Log.e(TAG, "Unable to read ID3 chapters: media or download URL was null"); - return null; - } - Log.d(TAG, "Reading id3 chapters from item " + p.getEpisodeTitle()); - CountingInputStream in = null; - try { - if (p.getStreamUrl().startsWith(ContentResolver.SCHEME_CONTENT)) { - Uri uri = Uri.parse(p.getStreamUrl()); - in = new CountingInputStream(context.getContentResolver().openInputStream(uri)); - } else { - URL url = new URL(p.getStreamUrl()); - URLConnection urlConnection = url.openConnection(); - urlConnection.setRequestProperty("User-Agent", ClientConfig.USER_AGENT); - in = new CountingInputStream(urlConnection.getInputStream()); - } - List<Chapter> chapters = readChaptersFrom(in); + public static List<Chapter> loadChaptersFromMediaFile(Playable playable, Context context) { + try (CountingInputStream in = openStream(playable, context)) { + List<Chapter> chapters = readId3ChaptersFrom(in); if (!chapters.isEmpty()) { + Log.i(TAG, "Chapters loaded"); return chapters; } - Log.i(TAG, "Chapters loaded"); - } catch (IOException | ID3ReaderException | IllegalArgumentException e) { - Log.e(TAG, Log.getStackTraceString(e)); - } finally { - IOUtils.closeQuietly(in); - } - return null; - } - - /** - * Uses the file URL of a media object of a feeditem to read its ID3 - * chapters. - */ - private static List<Chapter> readID3ChaptersFromPlayableFileUrl(Playable p) { - if (p == null || !p.localFileAvailable() || p.getLocalMediaUrl() == null) { - return null; - } - Log.d(TAG, "Reading id3 chapters from item " + p.getEpisodeTitle()); - File source = new File(p.getLocalMediaUrl()); - if (!source.exists()) { - Log.e(TAG, "Unable to read id3 chapters: Source doesn't exist"); - return null; + } catch (IOException | ID3ReaderException e) { + Log.e(TAG, "Unable to load ID3 chapters: " + e.getMessage()); } - CountingInputStream in = null; - try { - in = new CountingInputStream(new BufferedInputStream(new FileInputStream(source))); - List<Chapter> chapters = readChaptersFrom(in); + try (CountingInputStream in = openStream(playable, context)) { + List<Chapter> chapters = readOggChaptersFromInputStream(in); if (!chapters.isEmpty()) { + Log.i(TAG, "Chapters loaded"); return chapters; } - Log.i(TAG, "Chapters loaded"); - } catch (IOException | ID3ReaderException e) { - Log.e(TAG, Log.getStackTraceString(e)); - } finally { - IOUtils.closeQuietly(in); + } catch (IOException | VorbisCommentReaderException e) { + Log.e(TAG, "Unable to load vorbis chapters: " + e.getMessage()); } return null; } + private static CountingInputStream openStream(Playable playable, Context context) throws IOException { + if (playable.localFileAvailable()) { + if (playable.getLocalMediaUrl() == null) { + throw new IOException("No local url"); + } + File source = new File(playable.getLocalMediaUrl()); + if (!source.exists()) { + throw new IOException("Local file does not exist"); + } + return new CountingInputStream(new FileInputStream(source)); + } else if (playable.getStreamUrl().startsWith(ContentResolver.SCHEME_CONTENT)) { + Uri uri = Uri.parse(playable.getStreamUrl()); + return new CountingInputStream(context.getContentResolver().openInputStream(uri)); + } else { + Request request = new Request.Builder().url(playable.getStreamUrl()).build(); + Response response = AntennapodHttpClient.getHttpClient().newCall(request).execute(); + if (response.body() == null) { + throw new IOException("Body is null"); + } + return new CountingInputStream(response.body().byteStream()); + } + } + @NonNull - private static List<Chapter> readChaptersFrom(CountingInputStream in) throws IOException, ID3ReaderException { - ChapterReader reader = new ChapterReader(); - reader.readInputStream(in); + private static List<Chapter> readId3ChaptersFrom(CountingInputStream in) throws IOException, ID3ReaderException { + ChapterReader reader = new ChapterReader(in); + reader.readInputStream(); List<Chapter> chapters = reader.getChapters(); - - if (chapters == null) { - Log.i(TAG, "ChapterReader could not find any ID3 chapters"); - return Collections.emptyList(); - } Collections.sort(chapters, new ChapterStartTimeComparator()); enumerateEmptyChapterTitles(chapters); if (!chaptersValid(chapters)) { @@ -156,73 +137,20 @@ public class ChapterUtils { return chapters; } - private static List<Chapter> readOggChaptersFromPlayableStreamUrl(Playable media, Context context) { - if (media == null || !media.streamAvailable()) { - return null; - } - InputStream input = null; - try { - if (media.getStreamUrl().startsWith(ContentResolver.SCHEME_CONTENT)) { - Uri uri = Uri.parse(media.getStreamUrl()); - input = context.getContentResolver().openInputStream(uri); - } else { - URL url = new URL(media.getStreamUrl()); - URLConnection urlConnection = url.openConnection(); - urlConnection.setRequestProperty("User-Agent", ClientConfig.USER_AGENT); - input = urlConnection.getInputStream(); - } - if (input != null) { - return readOggChaptersFromInputStream(media, input); - } - } catch (IOException | IllegalArgumentException e) { - Log.e(TAG, Log.getStackTraceString(e)); - } finally { - IOUtils.closeQuietly(input); - } - return null; - } - - private static List<Chapter> readOggChaptersFromPlayableFileUrl(Playable media) { - if (media == null || media.getLocalMediaUrl() == null) { - return null; - } - File source = new File(media.getLocalMediaUrl()); - if (source.exists()) { - InputStream input = null; - try { - input = new BufferedInputStream(new FileInputStream(source)); - return readOggChaptersFromInputStream(media, input); - } catch (FileNotFoundException e) { - Log.e(TAG, Log.getStackTraceString(e)); - } finally { - IOUtils.closeQuietly(input); - } + @NonNull + private static List<Chapter> readOggChaptersFromInputStream(InputStream input) throws VorbisCommentReaderException { + VorbisCommentChapterReader reader = new VorbisCommentChapterReader(); + reader.readInputStream(input); + List<Chapter> chapters = reader.getChapters(); + if (chapters == null) { + return Collections.emptyList(); } - return null; - } - - private static List<Chapter> readOggChaptersFromInputStream(Playable p, InputStream input) { - Log.d(TAG, "Trying to read chapters from item with title " + p.getEpisodeTitle()); - try { - VorbisCommentChapterReader reader = new VorbisCommentChapterReader(); - reader.readInputStream(input); - List<Chapter> chapters = reader.getChapters(); - if (chapters == null) { - Log.i(TAG, "ChapterReader could not find any Ogg vorbis chapters"); - return null; - } - Collections.sort(chapters, new ChapterStartTimeComparator()); - enumerateEmptyChapterTitles(chapters); - if (chaptersValid(chapters)) { - Log.i(TAG, "Chapters loaded"); - return chapters; - } else { - Log.e(TAG, "Chapter data was invalid"); - } - } catch (VorbisCommentReaderException e) { - e.printStackTrace(); + Collections.sort(chapters, new ChapterStartTimeComparator()); + enumerateEmptyChapterTitles(chapters); + if (chaptersValid(chapters)) { + return chapters; } - return null; + return Collections.emptyList(); } /** @@ -248,5 +176,4 @@ public class ChapterUtils { } return true; } - } diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/DateUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/DateUtils.java index 833ff33f1..196583bcd 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/DateUtils.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/DateUtils.java @@ -30,9 +30,12 @@ public class DateUtils { } String date = input.trim().replace('/', '-').replaceAll("( ){2,}+", " "); + // remove colon from timezone to avoid differences between Android and Java SimpleDateFormat + date = date.replaceAll("([+-]\\d\\d):(\\d\\d)$", "$1$2"); + // CEST is widely used but not in the "ISO 8601 Time zone" list. Let's hack around. - date = date.replaceAll("CEST$", "+02:00"); - date = date.replaceAll("CET$", "+01:00"); + date = date.replaceAll("CEST$", "+0200"); + date = date.replaceAll("CET$", "+0100"); // some generators use "Sept" for September date = date.replaceAll("\\bSept\\b", "Sep"); diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/DownloadError.java b/core/src/main/java/de/danoeh/antennapod/core/util/DownloadError.java index 0c9989b43..9c4a61cd8 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/DownloadError.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/DownloadError.java @@ -6,51 +6,54 @@ import de.danoeh.antennapod.core.R; /** Utility class for Download Errors. */ public enum DownloadError { - SUCCESS(0, R.string.download_successful), - ERROR_PARSER_EXCEPTION(1, R.string.download_error_parser_exception), - ERROR_UNSUPPORTED_TYPE(2, R.string.download_error_unsupported_type), - ERROR_CONNECTION_ERROR(3, R.string.download_error_connection_error), - ERROR_MALFORMED_URL(4, R.string.download_error_error_unknown), - ERROR_IO_ERROR(5, R.string.download_error_io_error), - ERROR_FILE_EXISTS(6, R.string.download_error_error_unknown), - ERROR_DOWNLOAD_CANCELLED(7, R.string.download_error_error_unknown), - ERROR_DEVICE_NOT_FOUND(8, R.string.download_error_device_not_found), - ERROR_HTTP_DATA_ERROR(9, R.string.download_error_http_data_error), - ERROR_NOT_ENOUGH_SPACE(10, R.string.download_error_insufficient_space), - ERROR_UNKNOWN_HOST(11, R.string.download_error_unknown_host), - ERROR_REQUEST_ERROR(12, R.string.download_error_request_error), + SUCCESS(0, R.string.download_successful), + ERROR_PARSER_EXCEPTION(1, R.string.download_error_parser_exception), + ERROR_UNSUPPORTED_TYPE(2, R.string.download_error_unsupported_type), + ERROR_CONNECTION_ERROR(3, R.string.download_error_connection_error), + ERROR_MALFORMED_URL(4, R.string.download_error_error_unknown), + ERROR_IO_ERROR(5, R.string.download_error_io_error), + ERROR_FILE_EXISTS(6, R.string.download_error_error_unknown), + ERROR_DOWNLOAD_CANCELLED(7, R.string.download_canceled_msg), + ERROR_DEVICE_NOT_FOUND(8, R.string.download_error_device_not_found), + ERROR_HTTP_DATA_ERROR(9, R.string.download_error_http_data_error), + ERROR_NOT_ENOUGH_SPACE(10, R.string.download_error_insufficient_space), + ERROR_UNKNOWN_HOST(11, R.string.download_error_unknown_host), + ERROR_REQUEST_ERROR(12, R.string.download_error_request_error), ERROR_DB_ACCESS_ERROR(13, R.string.download_error_db_access), ERROR_UNAUTHORIZED(14, R.string.download_error_unauthorized), - ERROR_FILE_TYPE(15, R.string.download_error_file_type_type), - ERROR_FORBIDDEN(16, R.string.download_error_forbidden); - - - private final int code; - private final int resId; - - DownloadError(int code, int resId) { - this.code = code; - this.resId = resId; - } - - /** Return DownloadError from its associated code. */ - public static DownloadError fromCode(int code) { - for (DownloadError reason : values()) { - if (reason.getCode() == code) { - return reason; - } - } - throw new IllegalArgumentException("unknown code: " + code); - } - - /** Get machine-readable code. */ - public int getCode() { - return code; - } - - /** Get a human-readable string. */ - public String getErrorString(Context context) { - return context.getString(resId); - } - + ERROR_FILE_TYPE(15, R.string.download_error_file_type_type), + ERROR_FORBIDDEN(16, R.string.download_error_forbidden), + ERROR_IO_WRONG_SIZE(17, R.string.download_error_wrong_size), + ERROR_IO_BLOCKED(18, R.string.download_error_blocked), + ERROR_UNSUPPORTED_TYPE_HTML(19, R.string.download_error_unsupported_type_html), + ERROR_NOT_FOUND(20, R.string.download_error_not_found), + ERROR_CERTIFICATE(21, R.string.download_error_certificate); + + private final int code; + private final int resId; + + DownloadError(int code, int resId) { + this.code = code; + this.resId = resId; + } + + /** Return DownloadError from its associated code. */ + public static DownloadError fromCode(int code) { + for (DownloadError reason : values()) { + if (reason.getCode() == code) { + return reason; + } + } + throw new IllegalArgumentException("unknown code: " + code); + } + + /** Get machine-readable code. */ + public int getCode() { + return code; + } + + /** Get a human-readable string. */ + public String getErrorString(Context context) { + return context.getString(resId); + } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/FileNameGenerator.java b/core/src/main/java/de/danoeh/antennapod/core/util/FileNameGenerator.java index 2a387b7b0..69c23efc2 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/FileNameGenerator.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/FileNameGenerator.java @@ -13,7 +13,7 @@ import java.security.NoSuchAlgorithmException; /** Generates valid filenames for a given string. */ public class FileNameGenerator { @VisibleForTesting - public static final int MAX_FILENAME_LENGTH = 255; // Limited by ext4 + public static final int MAX_FILENAME_LENGTH = 242; // limited by CircleCI private static final int MD5_HEX_LENGTH = 32; private static final char[] validChars = diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/Flavors.java b/core/src/main/java/de/danoeh/antennapod/core/util/Flavors.java deleted file mode 100644 index 5feb232e7..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/util/Flavors.java +++ /dev/null @@ -1,24 +0,0 @@ -package de.danoeh.antennapod.core.util; - -import de.danoeh.antennapod.core.BuildConfig; - -/** - * Helper class to handle the different build flavors. - */ -public enum Flavors { - FREE, - PLAY, - UNKNOWN; - - public static final Flavors FLAVOR; - - static { - if (BuildConfig.FLAVOR.equals("free")) { - FLAVOR = FREE; - } else if (BuildConfig.FLAVOR.equals("play")) { - FLAVOR = PLAY; - } else { - FLAVOR = UNKNOWN; - } - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/Optional.java b/core/src/main/java/de/danoeh/antennapod/core/util/Optional.java deleted file mode 100644 index 37f12c01c..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/util/Optional.java +++ /dev/null @@ -1,213 +0,0 @@ -/* - * Copyright (c) 2012, 2013, Oracle and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Oracle designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA - * or visit www.oracle.com if you need additional information or have any - * questions. - */ -package de.danoeh.antennapod.core.util; - -import java.util.NoSuchElementException; -import java.util.Objects; - -// AntennaPod's stripped-down version of Java/Android platform's java.util.Optional -// so that it can be used on lower API level (API level 14) - -// Android-changed: removed ValueBased paragraph. -/** - * A container object which may or may not contain a non-null value. - * If a value is present, {@code isPresent()} will return {@code true} and - * {@code get()} will return the value. - * - * <p>Additional methods that depend on the presence or absence of a contained - * value are provided, such as {@link #orElse(java.lang.Object) orElse()} - * (return a default value if value not present) and - * {@link #ifPresent(java.util.function.Consumer) ifPresent()} (execute a block - * of code if the value is present). - * - * @since 1.8 - */ -public final class Optional<T> { - /** - * Common instance for {@code empty()}. - */ - private static final Optional<?> EMPTY = new Optional<>(); - - /** - * If non-null, the value; if null, indicates no value is present - */ - private final T value; - - /** - * Constructs an empty instance. - * - * @implNote Generally only one empty instance, {@link Optional#EMPTY}, - * should exist per VM. - */ - private Optional() { - this.value = null; - } - - /** - * Returns an empty {@code Optional} instance. No value is present for this - * Optional. - * - * @apiNote Though it may be tempting to do so, avoid testing if an object - * is empty by comparing with {@code ==} against instances returned by - * {@code Option.empty()}. There is no guarantee that it is a singleton. - * Instead, use {@link #isPresent()}. - * - * @param <T> Type of the non-existent value - * @return an empty {@code Optional} - */ - public static <T> Optional<T> empty() { - @SuppressWarnings("unchecked") - Optional<T> t = (Optional<T>) EMPTY; - return t; - } - - /** - * Constructs an instance with the value present. - * - * @param value the non-null value to be present - * @throws NullPointerException if value is null - */ - private Optional(T value) { - this.value = Objects.requireNonNull(value); - } - - /** - * Returns an {@code Optional} with the specified present non-null value. - * - * @param <T> the class of the value - * @param value the value to be present, which must be non-null - * @return an {@code Optional} with the value present - * @throws NullPointerException if value is null - */ - public static <T> Optional<T> of(T value) { - return new Optional<>(value); - } - - /** - * Returns an {@code Optional} describing the specified value, if non-null, - * otherwise returns an empty {@code Optional}. - * - * @param <T> the class of the value - * @param value the possibly-null value to describe - * @return an {@code Optional} with a present value if the specified value - * is non-null, otherwise an empty {@code Optional} - */ - public static <T> Optional<T> ofNullable(T value) { - return value == null ? empty() : of(value); - } - - /** - * If a value is present in this {@code Optional}, returns the value, - * otherwise throws {@code NoSuchElementException}. - * - * @return the non-null value held by this {@code Optional} - * @throws NoSuchElementException if there is no value present - * - * @see Optional#isPresent() - */ - public T get() { - if (value == null) { - throw new NoSuchElementException("No value present"); - } - return value; - } - - /** - * Return {@code true} if there is a value present, otherwise {@code false}. - * - * @return {@code true} if there is a value present, otherwise {@code false} - */ - public boolean isPresent() { - return value != null; - } - - - /** - * Return the value if present, otherwise return {@code other}. - * - * @param other the value to be returned if there is no value present, may - * be null - * @return the value, if present, otherwise {@code other} - */ - public T orElse(T other) { - return value != null ? value : other; - } - - /** - * Indicates whether some other object is "equal to" this Optional. The - * other object is considered equal if: - * <ul> - * <li>it is also an {@code Optional} and; - * <li>both instances have no value present or; - * <li>the present values are "equal to" each other via {@code equals()}. - * </ul> - * - * @param obj an object to be tested for equality - * @return {code true} if the other object is "equal to" this object - * otherwise {@code false} - */ - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - - if (!(obj instanceof Optional)) { - return false; - } - - Optional<?> other = (Optional<?>) obj; - return (value == other.value) || (value != null && value.equals(other.value)); - } - - /** - * Returns the hash code value of the present value, if any, or 0 (zero) if - * no value is present. - * - * @return hash code value of the present value or 0 if no value is present - */ - @Override - public int hashCode() { - return value != null ? value.hashCode() : 0; - } - - /** - * Returns a non-empty string representation of this Optional suitable for - * debugging. The exact presentation format is unspecified and may vary - * between implementations and versions. - * - * @implSpec If a value is present the result must include its string - * representation in the result. Empty and present Optionals must be - * unambiguously differentiable. - * - * @return the string representation of this instance - */ - @Override - public String toString() { - return value != null - ? String.format("Optional[%s]", value) - : "Optional.empty"; - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/ShownotesProvider.java b/core/src/main/java/de/danoeh/antennapod/core/util/ShownotesProvider.java deleted file mode 100644 index a4cd83f70..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/util/ShownotesProvider.java +++ /dev/null @@ -1,16 +0,0 @@ -package de.danoeh.antennapod.core.util; - -import java.util.concurrent.Callable; - -/** - * Created by daniel on 04.08.13. - */ -public interface ShownotesProvider { - /** - * Loads shownotes. If the shownotes have to be loaded from a file or from a - * database, it should be done in a separate thread. After the shownotes - * have been loaded, callback.onShownotesLoaded should be called. - */ - Callable<String> loadShownotes(); - -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/ThemeUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/ThemeUtils.java deleted file mode 100644 index 44b31f0be..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/util/ThemeUtils.java +++ /dev/null @@ -1,25 +0,0 @@ -package de.danoeh.antennapod.core.util; - -import android.content.Context; -import androidx.annotation.AttrRes; -import androidx.annotation.ColorInt; -import android.util.TypedValue; -import androidx.annotation.DrawableRes; - -public class ThemeUtils { - private ThemeUtils() { - - } - - public static @ColorInt int getColorFromAttr(Context context, @AttrRes int attr) { - TypedValue typedValue = new TypedValue(); - context.getTheme().resolveAttribute(attr, typedValue, true); - return typedValue.data; - } - - public static @DrawableRes int getDrawableFromAttr(Context context, @AttrRes int attr) { - TypedValue typedValue = new TypedValue(); - context.getTheme().resolveAttribute(attr, typedValue, true); - return typedValue.resourceId; - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/gui/NotificationUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/gui/NotificationUtils.java index 3101eac34..5895c5933 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/gui/NotificationUtils.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/gui/NotificationUtils.java @@ -18,6 +18,7 @@ public class NotificationUtils { public static final String CHANNEL_ID_DOWNLOAD_ERROR = "error"; public static final String CHANNEL_ID_SYNC_ERROR = "sync_error"; public static final String CHANNEL_ID_AUTO_DOWNLOAD = "auto_download"; + public static final String CHANNEL_ID_EPISODE_NOTIFICATIONS = "episode_notifications"; public static final String GROUP_ID_ERRORS = "group_errors"; public static final String GROUP_ID_NEWS = "group_news"; @@ -38,6 +39,7 @@ public class NotificationUtils { mNotificationManager.createNotificationChannel(createChannelError(context)); mNotificationManager.createNotificationChannel(createChannelSyncError(context)); mNotificationManager.createNotificationChannel(createChannelAutoDownload(context)); + mNotificationManager.createNotificationChannel(createChannelEpisodeNotification(context)); } } @@ -111,6 +113,15 @@ public class NotificationUtils { } @RequiresApi(api = Build.VERSION_CODES.O) + private static NotificationChannel createChannelEpisodeNotification(Context c) { + NotificationChannel channel = new NotificationChannel(CHANNEL_ID_EPISODE_NOTIFICATIONS, + c.getString(R.string.notification_channel_new_episode), NotificationManager.IMPORTANCE_DEFAULT); + channel.setDescription(c.getString(R.string.notification_channel_new_episode_description)); + channel.setGroup(GROUP_ID_NEWS); + return channel; + } + + @RequiresApi(api = Build.VERSION_CODES.O) private static NotificationChannelGroup createGroupErrors(Context c) { return new NotificationChannelGroup(GROUP_ID_ERRORS, c.getString(R.string.notification_group_errors)); diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ChapterReader.java b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ChapterReader.java index ce3577a9e..69d8316c2 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ChapterReader.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ChapterReader.java @@ -2,149 +2,114 @@ package de.danoeh.antennapod.core.util.id3reader; import android.text.TextUtils; import android.util.Log; +import androidx.annotation.NonNull; import de.danoeh.antennapod.core.feed.Chapter; import de.danoeh.antennapod.core.feed.ID3Chapter; import de.danoeh.antennapod.core.util.EmbeddedChapterImage; import de.danoeh.antennapod.core.util.id3reader.model.FrameHeader; -import de.danoeh.antennapod.core.util.id3reader.model.TagHeader; +import org.apache.commons.io.input.CountingInputStream; import java.io.IOException; import java.net.URLDecoder; import java.util.ArrayList; import java.util.List; -import org.apache.commons.io.input.CountingInputStream; +/** + * Reads ID3 chapters. + * See https://id3.org/id3v2-chapters-1.0 + */ public class ChapterReader extends ID3Reader { private static final String TAG = "ID3ChapterReader"; - private static final String FRAME_ID_CHAPTER = "CHAP"; - private static final String FRAME_ID_TITLE = "TIT2"; - private static final String FRAME_ID_LINK = "WXXX"; - private static final String FRAME_ID_PICTURE = "APIC"; - private static final int IMAGE_TYPE_COVER = 3; + public static final String FRAME_ID_CHAPTER = "CHAP"; + public static final String FRAME_ID_TITLE = "TIT2"; + public static final String FRAME_ID_LINK = "WXXX"; + public static final String FRAME_ID_PICTURE = "APIC"; + public static final String MIME_IMAGE_URL = "-->"; + public static final int IMAGE_TYPE_COVER = 3; - private List<Chapter> chapters; - private ID3Chapter currentChapter; + private final List<Chapter> chapters = new ArrayList<>(); - @Override - public int onStartTagHeader(TagHeader header) { - chapters = new ArrayList<>(); - Log.d(TAG, "header: " + header); - return ID3Reader.ACTION_DONT_SKIP; + public ChapterReader(CountingInputStream input) { + super(input); } @Override - public int onStartFrameHeader(FrameHeader header, CountingInputStream input) throws IOException, ID3ReaderException { - Log.d(TAG, "header: " + header); - switch (header.getId()) { - case FRAME_ID_CHAPTER: - if (currentChapter != null) { - if (!hasId3Chapter(currentChapter)) { - chapters.add(currentChapter); - Log.d(TAG, "Found chapter: " + currentChapter); - currentChapter = null; - } - } - StringBuilder elementId = new StringBuilder(); - readISOString(elementId, input, Integer.MAX_VALUE); - char[] startTimeSource = readChars(input, 4); - long startTime = ((int) startTimeSource[0] << 24) - | ((int) startTimeSource[1] << 16) - | ((int) startTimeSource[2] << 8) | startTimeSource[3]; - currentChapter = new ID3Chapter(elementId.toString(), startTime); - skipBytes(input, 12); - return ID3Reader.ACTION_DONT_SKIP; - case FRAME_ID_TITLE: - if (currentChapter != null && currentChapter.getTitle() == null) { - StringBuilder title = new StringBuilder(); - readString(title, input, header.getSize()); - currentChapter - .setTitle(title.toString()); - Log.d(TAG, "Found title: " + currentChapter.getTitle()); + protected void readFrame(@NonNull FrameHeader frameHeader) throws IOException, ID3ReaderException { + if (FRAME_ID_CHAPTER.equals(frameHeader.getId())) { + Log.d(TAG, "Handling frame: " + frameHeader.toString()); + Chapter chapter = readChapter(frameHeader); + Log.d(TAG, "Chapter done: " + chapter); + chapters.add(chapter); + } else { + super.readFrame(frameHeader); + } + } - return ID3Reader.ACTION_DONT_SKIP; - } + public Chapter readChapter(@NonNull FrameHeader frameHeader) throws IOException, ID3ReaderException { + int chapterStartedPosition = getPosition(); + String elementId = readIsoStringNullTerminated(100); + long startTime = readInt(); + skipBytes(12); // Ignore end time, start offset, end offset + ID3Chapter chapter = new ID3Chapter(elementId, startTime); + + // Read sub-frames + while (getPosition() < chapterStartedPosition + frameHeader.getSize()) { + FrameHeader subFrameHeader = readFrameHeader(); + readChapterSubFrame(subFrameHeader, chapter); + } + return chapter; + } + + public void readChapterSubFrame(@NonNull FrameHeader frameHeader, @NonNull Chapter chapter) + throws IOException, ID3ReaderException { + Log.d(TAG, "Handling subframe: " + frameHeader.toString()); + int frameStartPosition = getPosition(); + switch (frameHeader.getId()) { + case FRAME_ID_TITLE: + chapter.setTitle(readEncodingAndString(frameHeader.getSize())); + Log.d(TAG, "Found title: " + chapter.getTitle()); break; case FRAME_ID_LINK: - if (currentChapter != null) { - // skip description - int descriptionLength = readString(null, input, header.getSize()); - StringBuilder link = new StringBuilder(); - readISOString(link, input, header.getSize() - descriptionLength); - try { - String decodedLink = URLDecoder.decode(link.toString(), "UTF-8"); - currentChapter.setLink(decodedLink); - Log.d(TAG, "Found link: " + currentChapter.getLink()); - } catch (IllegalArgumentException iae) { - Log.w(TAG, "Bad URL found in ID3 data"); - } - - return ID3Reader.ACTION_DONT_SKIP; + readEncodingAndString(frameHeader.getSize()); // skip description + String url = readIsoStringNullTerminated(frameStartPosition + frameHeader.getSize() - getPosition()); + try { + String decodedLink = URLDecoder.decode(url, "ISO-8859-1"); + chapter.setLink(decodedLink); + Log.d(TAG, "Found link: " + chapter.getLink()); + } catch (IllegalArgumentException iae) { + Log.w(TAG, "Bad URL found in ID3 data"); } break; case FRAME_ID_PICTURE: - if (currentChapter != null) { - Log.d(TAG, header.toString()); - StringBuilder mime = new StringBuilder(); - int read = readString(mime, input, header.getSize()); - byte type = (byte) readChars(input, 1)[0]; - read++; - StringBuilder description = new StringBuilder(); - read += readISOString(description, input, header.getSize()); // Should use same encoding as mime - - Log.d(TAG, "Found apic: " + mime + "," + description); - if (mime.toString().equals("-->")) { - // Data contains a link to a picture - StringBuilder link = new StringBuilder(); - readISOString(link, input, header.getSize()); - Log.d(TAG, "link: " + link.toString()); - if (TextUtils.isEmpty(currentChapter.getImageUrl()) || type == IMAGE_TYPE_COVER) { - currentChapter.setImageUrl(link.toString()); - } - } else { - // Data contains the picture - int length = header.getSize() - read; - if (TextUtils.isEmpty(currentChapter.getImageUrl()) || type == IMAGE_TYPE_COVER) { - currentChapter.setImageUrl(EmbeddedChapterImage.makeUrl(input.getCount(), length)); - } - skipBytes(input, length); + byte encoding = readByte(); + String mime = readEncodedString(encoding, frameHeader.getSize()); + byte type = readByte(); + String description = readEncodedString(encoding, frameHeader.getSize()); + Log.d(TAG, "Found apic: " + mime + "," + description); + if (MIME_IMAGE_URL.equals(mime)) { + String link = readIsoStringNullTerminated(frameHeader.getSize()); + Log.d(TAG, "Link: " + link); + if (TextUtils.isEmpty(chapter.getImageUrl()) || type == IMAGE_TYPE_COVER) { + chapter.setImageUrl(link); + } + } else { + int alreadyConsumed = getPosition() - frameStartPosition; + int rawImageDataLength = frameHeader.getSize() - alreadyConsumed; + if (TextUtils.isEmpty(chapter.getImageUrl()) || type == IMAGE_TYPE_COVER) { + chapter.setImageUrl(EmbeddedChapterImage.makeUrl(getPosition(), rawImageDataLength)); } - return ID3Reader.ACTION_DONT_SKIP; } break; + default: + Log.d(TAG, "Unknown chapter sub-frame."); + break; } - return super.onStartFrameHeader(header, input); - } - - private boolean hasId3Chapter(ID3Chapter chapter) { - for (Chapter c : chapters) { - if (((ID3Chapter) c).getId3ID().equals(chapter.getId3ID())) { - return true; - } - } - return false; - } - - @Override - public void onEndTag() { - if (currentChapter != null) { - if (!hasId3Chapter(currentChapter)) { - chapters.add(currentChapter); - } - } - Log.d(TAG, "Reached end of tag"); - if (chapters != null) { - for (Chapter c : chapters) { - Log.d(TAG, "chapter: " + c); - } - } - } - - @Override - public void onNoTagHeaderFound() { - Log.d(TAG, "No tag header found"); - super.onNoTagHeaderFound(); + // Skip garbage to fill frame completely + // This also asserts that we are not reading too many bytes from this frame. + int alreadyConsumed = getPosition() - frameStartPosition; + skipBytes(frameHeader.getSize() - alreadyConsumed); } public List<Chapter> getChapters() { diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ID3Reader.java b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ID3Reader.java index 124388254..17313ca14 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ID3Reader.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ID3Reader.java @@ -1,151 +1,112 @@ package de.danoeh.antennapod.core.util.id3reader; +import android.util.Log; +import androidx.annotation.NonNull; +import de.danoeh.antennapod.core.util.id3reader.model.FrameHeader; +import de.danoeh.antennapod.core.util.id3reader.model.TagHeader; import org.apache.commons.io.IOUtils; +import org.apache.commons.io.input.CountingInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.InputStream; import java.nio.ByteBuffer; import java.nio.charset.Charset; -import de.danoeh.antennapod.core.util.id3reader.model.FrameHeader; -import de.danoeh.antennapod.core.util.id3reader.model.TagHeader; -import org.apache.commons.io.input.CountingInputStream; - /** - * Reads the ID3 Tag of a given file. In order to use this class, you should - * create a subclass of it and overwrite the onStart* - or onEnd* - methods. + * Reads the ID3 Tag of a given file. + * See https://id3.org/id3v2.3.0 */ public class ID3Reader { - private static final int HEADER_LENGTH = 10; - private static final int ID3_LENGTH = 3; + private static final String TAG = "ID3Reader"; private static final int FRAME_ID_LENGTH = 4; - - private static final int ACTION_SKIP = 1; - static final int ACTION_DONT_SKIP = 2; - - private int readerPosition; - - private static final byte ENCODING_UTF16_WITH_BOM = 1; - private static final byte ENCODING_UTF16_WITHOUT_BOM = 2; - private static final byte ENCODING_UTF8 = 3; + public static final byte ENCODING_ISO = 0; + public static final byte ENCODING_UTF16_WITH_BOM = 1; + public static final byte ENCODING_UTF16_WITHOUT_BOM = 2; + public static final byte ENCODING_UTF8 = 3; private TagHeader tagHeader; + private final CountingInputStream inputStream; - ID3Reader() { + public ID3Reader(CountingInputStream input) { + inputStream = input; } - public final void readInputStream(CountingInputStream input) throws IOException, ID3ReaderException { - int rc; - readerPosition = 0; - char[] tagHeaderSource = readChars(input, HEADER_LENGTH); - tagHeader = createTagHeader(tagHeaderSource); - if (tagHeader == null) { - onNoTagHeaderFound(); - } else { - rc = onStartTagHeader(tagHeader); - if (rc != ACTION_SKIP) { - while (readerPosition < tagHeader.getSize()) { - FrameHeader frameHeader = createFrameHeader(readChars(input, HEADER_LENGTH)); - if (checkForNullString(frameHeader.getId())) { - break; - } - rc = onStartFrameHeader(frameHeader, input); - if (rc == ACTION_SKIP) { - if (frameHeader.getSize() + readerPosition > tagHeader.getSize()) { - break; - } - skipBytes(input, frameHeader.getSize()); - } - } + public void readInputStream() throws IOException, ID3ReaderException { + tagHeader = readTagHeader(); + int tagContentStartPosition = getPosition(); + while (getPosition() < tagContentStartPosition + tagHeader.getSize()) { + FrameHeader frameHeader = readFrameHeader(); + if (frameHeader.getId().charAt(0) < '0' || frameHeader.getId().charAt(0) > 'z') { + Log.d(TAG, "Stopping because of invalid frame: " + frameHeader.toString()); + return; } - onEndTag(); + readFrame(frameHeader); } } - /** Returns true if string only contains null-bytes. */ - private boolean checkForNullString(String s) { - if (!s.isEmpty()) { - int i = 0; - if (s.charAt(i) == 0) { - for (i = 1; i < s.length(); i++) { - if (s.charAt(i) != 0) { - return false; - } - } - return true; - } - return false; - } else { - return true; - } + protected void readFrame(@NonNull FrameHeader frameHeader) throws IOException, ID3ReaderException { + Log.d(TAG, "Skipping frame: " + frameHeader.toString()); + skipBytes(frameHeader.getSize()); + } + int getPosition() { + return inputStream.getCount(); } /** - * Read a certain number of chars from the given input stream. This method - * changes the readerPosition-attribute. + * Skip a certain number of bytes on the given input stream. */ - char[] readChars(InputStream input, int number) throws IOException, ID3ReaderException { - char[] header = new char[number]; - for (int i = 0; i < number; i++) { - int b = input.read(); - readerPosition++; - if (b != -1) { - header[i] = (char) b; - } else { - throw new ID3ReaderException("Unexpected end of stream"); - } + void skipBytes(int number) throws IOException, ID3ReaderException { + if (number < 0) { + throw new ID3ReaderException("Trying to read a negative number of bytes"); } - return header; + IOUtils.skipFully(inputStream, number); } - /** - * Skip a certain number of bytes on the given input stream. This method - * changes the readerPosition-attribute. - */ - void skipBytes(InputStream input, int number) throws IOException { - if (number <= 0) { - number = 1; - } - IOUtils.skipFully(input, number); + byte readByte() throws IOException { + return (byte) inputStream.read(); + } - readerPosition += number; + short readShort() throws IOException { + char firstByte = (char) inputStream.read(); + char secondByte = (char) inputStream.read(); + return (short) ((firstByte << 8) | secondByte); } - private TagHeader createTagHeader(char[] source) throws ID3ReaderException { - boolean hasTag = (source[0] == 0x49) && (source[1] == 0x44) - && (source[2] == 0x33); - if (source.length != HEADER_LENGTH) { - throw new ID3ReaderException("Length of header must be " - + HEADER_LENGTH); - } - if (hasTag) { - String id = new String(source, 0, ID3_LENGTH); - char version = (char) ((source[3] << 8) | source[4]); - byte flags = (byte) source[5]; - int size = (source[6] << 24) | (source[7] << 16) | (source[8] << 8) - | source[9]; - size = unsynchsafe(size); - return new TagHeader(id, size, version, flags); - } else { - return null; - } + int readInt() throws IOException { + char firstByte = (char) inputStream.read(); + char secondByte = (char) inputStream.read(); + char thirdByte = (char) inputStream.read(); + char fourthByte = (char) inputStream.read(); + return (firstByte << 24) | (secondByte << 16) | (thirdByte << 8) | fourthByte; } - private FrameHeader createFrameHeader(char[] source) - throws ID3ReaderException { - if (source.length != HEADER_LENGTH) { - throw new ID3ReaderException("Length of header must be " - + HEADER_LENGTH); + void expectChar(char expected) throws ID3ReaderException, IOException { + char read = (char) inputStream.read(); + if (read != expected) { + throw new ID3ReaderException("Expected " + expected + " and got " + read); } - String id = new String(source, 0, FRAME_ID_LENGTH); + } - int size = (((int) source[4]) << 24) | (((int) source[5]) << 16) - | (((int) source[6]) << 8) | source[7]; + @NonNull + TagHeader readTagHeader() throws ID3ReaderException, IOException { + expectChar('I'); + expectChar('D'); + expectChar('3'); + short version = readShort(); + byte flags = readByte(); + int size = unsynchsafe(readInt()); + return new TagHeader("ID3", size, version, flags); + } + + @NonNull + FrameHeader readFrameHeader() throws IOException { + String id = readIsoStringFixed(FRAME_ID_LENGTH); + int size = readInt(); if (tagHeader != null && tagHeader.getVersion() >= 0x0400) { size = unsynchsafe(size); } - char flags = (char) ((source[8] << 8) | source[9]); + short flags = readShort(); return new FrameHeader(id, size, flags); } @@ -162,81 +123,73 @@ public class ID3Reader { return out; } - protected int readString(StringBuilder buffer, InputStream input, int max) throws IOException, - ID3ReaderException { - if (max > 0) { - char[] encoding = readChars(input, 1); - max--; - - if (encoding[0] == ENCODING_UTF16_WITH_BOM || encoding[0] == ENCODING_UTF16_WITHOUT_BOM) { - return readUnicodeString(buffer, input, max, Charset.forName("UTF-16")) + 1; // take encoding byte into account - } else if (encoding[0] == ENCODING_UTF8) { - return readUnicodeString(buffer, input, max, Charset.forName("UTF-8")) + 1; // take encoding byte into account - } else { - return readISOString(buffer, input, max) + 1; // take encoding byte into account - } - } else { - if (buffer != null) { - buffer.append(""); - } - return 0; - } + /** + * Reads a null-terminated string with encoding. + */ + protected String readEncodingAndString(int max) throws IOException { + byte encoding = readByte(); + return readEncodedString(encoding, max - 1); } - protected int readISOString(StringBuilder buffer, InputStream input, int max) - throws IOException, ID3ReaderException { + @SuppressWarnings("CharsetObjectCanBeUsed") + protected String readIsoStringFixed(int length) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); int bytesRead = 0; - char c; - while (++bytesRead <= max && (c = (char) input.read()) > 0) { - if (buffer != null) { - buffer.append(c); - } + while (bytesRead < length) { + bytes.write(readByte()); + bytesRead++; } - return bytesRead; - } - - private int readUnicodeString(StringBuilder strBuffer, InputStream input, int max, Charset charset) - throws IOException, ID3ReaderException { - byte[] buffer = new byte[max]; - int c; - int cZero = -1; - int i = 0; - for (; i < max; i++) { - c = input.read(); - if (c == -1) { - break; - } else if (c == 0) { - if (cZero == 0) { - // termination character found - break; - } else { - cZero = 0; - } - } else { - buffer[i] = (byte) c; - cZero = -1; - } - } - if (strBuffer != null) { - strBuffer.append(charset.newDecoder().decode(ByteBuffer.wrap(buffer)).toString()); - } - return i; + return Charset.forName("ISO-8859-1").newDecoder().decode(ByteBuffer.wrap(bytes.toByteArray())).toString(); } - int onStartTagHeader(TagHeader header) { - return ACTION_SKIP; + protected String readIsoStringNullTerminated(int max) throws IOException { + return readEncodedString(ENCODING_ISO, max); } - int onStartFrameHeader(FrameHeader header, CountingInputStream input) throws IOException, ID3ReaderException { - return ACTION_SKIP; + @SuppressWarnings("CharsetObjectCanBeUsed") + String readEncodedString(int encoding, int max) throws IOException { + if (encoding == ENCODING_UTF16_WITH_BOM || encoding == ENCODING_UTF16_WITHOUT_BOM) { + return readEncodedString2(Charset.forName("UTF-16"), max); + } else if (encoding == ENCODING_UTF8) { + return readEncodedString2(Charset.forName("UTF-8"), max); + } else { + return readEncodedString1(Charset.forName("ISO-8859-1"), max); + } } - void onEndTag() { - + /** + * Reads chars where the encoding uses 1 char per symbol. + */ + private String readEncodedString1(Charset charset, int max) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + int bytesRead = 0; + while (bytesRead < max) { + byte c = readByte(); + bytesRead++; + if (c == 0) { + break; + } + bytes.write(c); + } + return charset.newDecoder().decode(ByteBuffer.wrap(bytes.toByteArray())).toString(); } - void onNoTagHeaderFound() { - + /** + * Reads chars where the encoding uses 2 chars per symbol. + */ + private String readEncodedString2(Charset charset, int max) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + int bytesRead = 0; + while (bytesRead + 1 < max) { + byte c1 = readByte(); + byte c2 = readByte(); + if (c1 == 0 && c2 == 0) { + break; + } + bytesRead += 2; + bytes.write(c1); + bytes.write(c2); + } + return charset.newDecoder().decode(ByteBuffer.wrap(bytes.toByteArray())).toString(); } - } diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/FrameHeader.java b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/FrameHeader.java index 2f3f378ab..e4af89a86 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/FrameHeader.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/FrameHeader.java @@ -1,17 +1,19 @@ package de.danoeh.antennapod.core.util.id3reader.model; -public class FrameHeader extends Header { +import androidx.annotation.NonNull; - private final char flags; +public class FrameHeader extends Header { + private final short flags; - public FrameHeader(String id, int size, char flags) { - super(id, size); - this.flags = flags; - } + public FrameHeader(String id, int size, short flags) { + super(id, size); + this.flags = flags; + } - @Override - public String toString() { - return String.format("FrameHeader [flags=%s, id=%s, size=%s]", Integer.toBinaryString(flags), id, Integer.toBinaryString(size)); + @Override + @NonNull + public String toString() { + return String.format("FrameHeader [flags=%s, id=%s, size=%s]", Integer.toBinaryString(flags), id, size); } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/TagHeader.java b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/TagHeader.java index b652a139c..2590db029 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/TagHeader.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/TagHeader.java @@ -1,26 +1,25 @@ package de.danoeh.antennapod.core.util.id3reader.model; -public class TagHeader extends Header { - - private final char version; - private final byte flags; +import androidx.annotation.NonNull; - public TagHeader(String id, int size, char version, byte flags) { - super(id, size); - this.version = version; - this.flags = flags; - } - - @Override - public String toString() { - return "TagHeader [version=" + version + ", flags=" + flags + ", id=" - + id + ", size=" + size + "]"; - } +public class TagHeader extends Header { + private final short version; + private final byte flags; - public char getVersion() { - return version; - } + public TagHeader(String id, int size, short version, byte flags) { + super(id, size); + this.version = version; + this.flags = flags; + } - + @Override + @NonNull + public String toString() { + return "TagHeader [version=" + version + ", flags=" + flags + ", id=" + + id + ", size=" + size + "]"; + } + public short getVersion() { + return version; + } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/AudioPlayer.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/AudioPlayer.java index 0467c0a78..c948d98a3 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/AudioPlayer.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/AudioPlayer.java @@ -10,6 +10,7 @@ import org.antennapod.audio.MediaPlayer; import de.danoeh.antennapod.core.preferences.UserPreferences; +import java.io.IOException; import java.util.Collections; import java.util.List; @@ -20,7 +21,7 @@ public class AudioPlayer extends MediaPlayer implements IPlayer { super(context, true, ClientConfig.USER_AGENT); PreferenceManager.getDefaultSharedPreferences(context) .registerOnSharedPreferenceChangeListener((sharedPreferences, key) -> { - if (key.equals(UserPreferences.PREF_MEDIA_PLAYER)) { + if (UserPreferences.PREF_MEDIA_PLAYER.equals(key)) { checkMpi(); } }); @@ -64,4 +65,9 @@ public class AudioPlayer extends MediaPlayer implements IPlayer { public int getSelectedAudioTrack() { return -1; } + + @Override + public void setDataSource(String streamUrl, String username, String password) throws IOException { + setDataSource(streamUrl); + } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/ExternalMedia.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/ExternalMedia.java deleted file mode 100644 index 6c107996f..000000000 --- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/ExternalMedia.java +++ /dev/null @@ -1,264 +0,0 @@ -package de.danoeh.antennapod.core.util.playback; - -import android.content.Context; -import android.content.SharedPreferences; -import android.content.SharedPreferences.Editor; -import android.media.MediaMetadataRetriever; -import android.os.Parcel; -import android.os.Parcelable; -import de.danoeh.antennapod.core.feed.Chapter; -import de.danoeh.antennapod.core.feed.MediaType; -import de.danoeh.antennapod.core.util.ChapterUtils; -import java.util.List; -import java.util.concurrent.Callable; -import org.apache.commons.io.FilenameUtils; - -/** Represents a media file that is stored on the local storage device. */ -public class ExternalMedia implements Playable { - public static final int PLAYABLE_TYPE_EXTERNAL_MEDIA = 2; - public static final String PREF_SOURCE_URL = "ExternalMedia.PrefSourceUrl"; - public static final String PREF_POSITION = "ExternalMedia.PrefPosition"; - public static final String PREF_MEDIA_TYPE = "ExternalMedia.PrefMediaType"; - public static final String PREF_LAST_PLAYED_TIME = "ExternalMedia.PrefLastPlayedTime"; - - private final String source; - private String episodeTitle; - private String feedTitle; - private MediaType mediaType; - private List<Chapter> chapters; - private int duration; - private int position; - private long lastPlayedTime; - - /** - * Creates a new playable for files on the sd card. - * @param source File path of the file - * @param mediaType Type of the file - */ - public ExternalMedia(String source, MediaType mediaType) { - super(); - this.source = source; - this.mediaType = mediaType; - } - - /** - * Creates a new playable for files on the sd card. - * @param source File path of the file - * @param mediaType Type of the file - * @param position Position to start from - * @param lastPlayedTime Timestamp when it was played last - */ - public ExternalMedia(String source, MediaType mediaType, int position, long lastPlayedTime) { - this(source, mediaType); - this.position = position; - this.lastPlayedTime = lastPlayedTime; - } - - @Override - public int describeContents() { - return 0; - } - - @Override - public void writeToParcel(Parcel dest, int flags) { - dest.writeString(source); - dest.writeString(mediaType.toString()); - dest.writeInt(position); - dest.writeLong(lastPlayedTime); - } - - @Override - public void writeToPreferences(Editor prefEditor) { - prefEditor.putString(PREF_SOURCE_URL, source); - prefEditor.putString(PREF_MEDIA_TYPE, mediaType.toString()); - prefEditor.putInt(PREF_POSITION, position); - prefEditor.putLong(PREF_LAST_PLAYED_TIME, lastPlayedTime); - } - - @Override - public void loadMetadata() throws PlayableException { - MediaMetadataRetriever mmr = new MediaMetadataRetriever(); - try { - mmr.setDataSource(source); - } catch (IllegalArgumentException e) { - e.printStackTrace(); - throw new PlayableException("IllegalArgumentException when setting up MediaMetadataReceiver"); - } catch (RuntimeException e) { - // http://code.google.com/p/android/issues/detail?id=39770 - e.printStackTrace(); - throw new PlayableException("RuntimeException when setting up MediaMetadataRetriever"); - } - episodeTitle = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE); - if (episodeTitle == null) { - episodeTitle = FilenameUtils.getName(source); - } - feedTitle = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM); - try { - duration = Integer.parseInt(mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)); - } catch (NumberFormatException e) { - e.printStackTrace(); - throw new PlayableException("NumberFormatException when reading duration of media file"); - } - setChapters(ChapterUtils.loadChaptersFromFileUrl(this)); - } - - @Override - public void loadChapterMarks(Context context) { - - } - - @Override - public String getEpisodeTitle() { - return episodeTitle; - } - - @Override - public Callable<String> loadShownotes() { - return () -> ""; - } - - @Override - public List<Chapter> getChapters() { - return chapters; - } - - @Override - public String getWebsiteLink() { - return null; - } - - @Override - public String getPaymentLink() { - return null; - } - - @Override - public String getFeedTitle() { - return feedTitle; - } - - @Override - public Object getIdentifier() { - return source; - } - - @Override - public int getDuration() { - return duration; - } - - @Override - public int getPosition() { - return position; - } - - @Override - public long getLastPlayedTime() { - return lastPlayedTime; - } - - @Override - public MediaType getMediaType() { - return mediaType; - } - - @Override - public String getLocalMediaUrl() { - return source; - } - - @Override - public String getStreamUrl() { - return null; - } - - @Override - public boolean localFileAvailable() { - return true; - } - - @Override - public boolean streamAvailable() { - return false; - } - - @Override - public void saveCurrentPosition(SharedPreferences pref, int newPosition, long timestamp) { - SharedPreferences.Editor editor = pref.edit(); - editor.putInt(PREF_POSITION, newPosition); - editor.putLong(PREF_LAST_PLAYED_TIME, timestamp); - position = newPosition; - lastPlayedTime = timestamp; - editor.apply(); - } - - @Override - public void setPosition(int newPosition) { - position = newPosition; - } - - @Override - public void setDuration(int newDuration) { - duration = newDuration; - } - - @Override - public void setLastPlayedTime(long lastPlayedTime) { - this.lastPlayedTime = lastPlayedTime; - } - - @Override - public void onPlaybackStart() { - - } - - @Override - public void onPlaybackPause(Context context) { - - } - - @Override - public void onPlaybackCompleted(Context context) { - - } - - @Override - public int getPlayableType() { - return PLAYABLE_TYPE_EXTERNAL_MEDIA; - } - - @Override - public void setChapters(List<Chapter> chapters) { - this.chapters = chapters; - } - - public static final Parcelable.Creator<ExternalMedia> CREATOR = new Parcelable.Creator<ExternalMedia>() { - public ExternalMedia createFromParcel(Parcel in) { - String source = in.readString(); - MediaType type = MediaType.valueOf(in.readString()); - int position = 0; - if (in.dataAvail() > 0) { - position = in.readInt(); - } - long lastPlayedTime = 0; - if (in.dataAvail() > 0) { - lastPlayedTime = in.readLong(); - } - - return new ExternalMedia(source, type, position, lastPlayedTime); - } - - public ExternalMedia[] newArray(int size) { - return new ExternalMedia[size]; - } - }; - - @Override - public String getImageLocation() { - if (localFileAvailable()) { - return getLocalMediaUrl(); - } else { - return null; - } - } -} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/IPlayer.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/IPlayer.java index 363004709..a511916fa 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/IPlayer.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/IPlayer.java @@ -35,6 +35,8 @@ public interface IPlayer { void setDataSource(String path) throws IllegalStateException, IOException, IllegalArgumentException, SecurityException; + void setDataSource(String streamUrl, String username, String password) throws IOException; + void setDisplay(SurfaceHolder sh); void setPlaybackParams(float speed, boolean skipSilence); diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java index 5b15913c8..feba6db1c 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java @@ -3,24 +3,18 @@ package de.danoeh.antennapod.core.util.playback; import android.content.Context; import android.content.SharedPreferences; import android.os.Parcelable; -import androidx.preference.PreferenceManager; -import android.util.Log; + import androidx.annotation.Nullable; -import de.danoeh.antennapod.core.asynctask.ImageResource; import de.danoeh.antennapod.core.feed.Chapter; -import de.danoeh.antennapod.core.feed.FeedMedia; import de.danoeh.antennapod.core.feed.MediaType; -import de.danoeh.antennapod.core.preferences.PlaybackPreferences; -import de.danoeh.antennapod.core.storage.DBReader; -import de.danoeh.antennapod.core.util.ShownotesProvider; - +import java.util.Date; import java.util.List; /** * Interface for objects that can be played by the PlaybackService. */ -public interface Playable extends Parcelable, - ShownotesProvider, ImageResource { +public interface Playable extends Parcelable { + public static final int INVALID_TIME = -1; /** * Save information about the playable in a preference so that it can be @@ -39,13 +33,6 @@ public interface Playable extends Parcelable, void loadMetadata() throws PlayableException; /** - * This method is called from a separate thread by the PlaybackService. - * Playable objects should load their chapter marks in this method if no - * local file was available when loadMetadata() was called. - */ - void loadChapterMarks(Context context); - - /** * Returns the title of the episode that this playable represents */ String getEpisodeTitle(); @@ -68,6 +55,11 @@ public interface Playable extends Parcelable, String getFeedTitle(); /** + * Returns the published date + */ + Date getPubDate(); + + /** * Returns a unique identifier, for example a file url or an ID from a * database. */ @@ -90,6 +82,13 @@ public interface Playable extends Parcelable, long getLastPlayedTime(); /** + * Returns the description of the item, if available. + * For FeedItems, the description needs to be loaded from the database first. + */ + @Nullable + String getDescription(); + + /** * Returns the type of media. This method should return the correct value * BEFORE loadMetadata() is called. */ @@ -172,99 +171,11 @@ public interface Playable extends Parcelable, void setChapters(List<Chapter> chapters); /** - * Provides utility methods for Playable objects. + * Returns the location of the image or null if no image is available. + * This can be the feed item image URL, the local embedded media image path, the feed image URL, + * or the remote media image URL, depending on what's available. */ - class PlayableUtils { - private PlayableUtils(){} - - private static final String TAG = "PlayableUtils"; - - /** - * Restores a playable object from a sharedPreferences file. This method might load data from the database, - * depending on the type of playable that was restored. - * - * @return The restored Playable object - */ - @Nullable - public static Playable createInstanceFromPreferences(Context context) { - long currentlyPlayingMedia = PlaybackPreferences.getCurrentlyPlayingMediaType(); - if (currentlyPlayingMedia != PlaybackPreferences.NO_MEDIA_PLAYING) { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); - return PlayableUtils.createInstanceFromPreferences(context, - (int) currentlyPlayingMedia, prefs); - } - return null; - } - - /** - * Restores a playable object from a sharedPreferences file. This method might load data from the database, - * depending on the type of playable that was restored. - * - * @param type An integer that represents the type of the Playable object - * that is restored. - * @param pref The SharedPreferences file from which the Playable object - * is restored - * @return The restored Playable object - */ - public static Playable createInstanceFromPreferences(Context context, int type, - SharedPreferences pref) { - Playable result = null; - // ADD new Playable types here: - switch (type) { - case FeedMedia.PLAYABLE_TYPE_FEEDMEDIA: - result = createFeedMediaInstance(pref); - break; - case ExternalMedia.PLAYABLE_TYPE_EXTERNAL_MEDIA: - result = createExternalMediaInstance(pref); - break; - } - if (result == null) { - Log.e(TAG, "Could not restore Playable object from preferences"); - } - return result; - } - - private static Playable createFeedMediaInstance(SharedPreferences pref) { - Playable result = null; - long mediaId = pref.getLong(FeedMedia.PREF_MEDIA_ID, -1); - if (mediaId != -1) { - result = DBReader.getFeedMedia(mediaId); - } - return result; - } - - private static Playable createExternalMediaInstance(SharedPreferences pref) { - Playable result = null; - String source = pref.getString(ExternalMedia.PREF_SOURCE_URL, null); - String mediaType = pref.getString(ExternalMedia.PREF_MEDIA_TYPE, null); - if (source != null && mediaType != null) { - int position = pref.getInt(ExternalMedia.PREF_POSITION, 0); - long lastPlayedTime = pref.getLong(ExternalMedia.PREF_LAST_PLAYED_TIME, 0); - result = new ExternalMedia(source, MediaType.valueOf(mediaType), - position, lastPlayedTime); - } - return result; - } - } - - class PlayableException extends Exception { - private static final long serialVersionUID = 1L; - - public PlayableException() { - super(); - } - - public PlayableException(String detailMessage, Throwable throwable) { - super(detailMessage, throwable); - } - - public PlayableException(String detailMessage) { - super(detailMessage); - } - - public PlayableException(Throwable throwable) { - super(throwable); - } - - } + @Nullable + String getImageLocation(); + } diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlayableException.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlayableException.java new file mode 100644 index 000000000..c0c21d647 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlayableException.java @@ -0,0 +1,13 @@ +package de.danoeh.antennapod.core.util.playback; + +/** + * Exception thrown by {@link Playable} implementations. + */ +public class PlayableException extends Exception { + + private static final long serialVersionUID = 1L; + + public PlayableException(String detailMessage) { + super(detailMessage); + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlayableUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlayableUtils.java new file mode 100644 index 000000000..861d42c1b --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlayableUtils.java @@ -0,0 +1,73 @@ +package de.danoeh.antennapod.core.util.playback; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.preference.PreferenceManager; + +import de.danoeh.antennapod.core.feed.FeedMedia; +import de.danoeh.antennapod.core.preferences.PlaybackPreferences; +import de.danoeh.antennapod.core.storage.DBReader; + +/** + * Provides utility methods for Playable objects. + */ +public abstract class PlayableUtils { + + private static final String TAG = "PlayableUtils"; + + /** + * Restores a playable object from a sharedPreferences file. This method might load data from the database, + * depending on the type of playable that was restored. + * + * @return The restored Playable object + */ + @Nullable + public static Playable createInstanceFromPreferences(@NonNull Context context) { + long currentlyPlayingMedia = PlaybackPreferences.getCurrentlyPlayingMediaType(); + if (currentlyPlayingMedia != PlaybackPreferences.NO_MEDIA_PLAYING) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext()); + return PlayableUtils.createInstanceFromPreferences((int) currentlyPlayingMedia, prefs); + } + return null; + } + + /** + * Restores a playable object from a sharedPreferences file. This method might load data from the database, + * depending on the type of playable that was restored. + * + * @param type An integer that represents the type of the Playable object + * that is restored. + * @param pref The SharedPreferences file from which the Playable object + * is restored + * @return The restored Playable object + */ + private static Playable createInstanceFromPreferences(int type, SharedPreferences pref) { + Playable result; + // ADD new Playable types here: + switch (type) { + case FeedMedia.PLAYABLE_TYPE_FEEDMEDIA: + result = createFeedMediaInstance(pref); + break; + default: + result = null; + break; + } + if (result == null) { + Log.e(TAG, "Could not restore Playable object from preferences"); + } + return result; + } + + private static Playable createFeedMediaInstance(SharedPreferences pref) { + Playable result = null; + long mediaId = pref.getLong(FeedMedia.PREF_MEDIA_ID, -1); + if (mediaId != -1) { + result = DBReader.getFeedMedia(mediaId); + } + return result; + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java index e1b4c967c..117e32cd4 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java @@ -17,12 +17,10 @@ import android.util.Pair; import android.view.SurfaceHolder; import android.widget.ImageButton; import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; import de.danoeh.antennapod.core.R; import de.danoeh.antennapod.core.event.MessageEvent; import de.danoeh.antennapod.core.event.ServiceEvent; import de.danoeh.antennapod.core.feed.Chapter; -import de.danoeh.antennapod.core.feed.FeedMedia; import de.danoeh.antennapod.core.feed.MediaType; import de.danoeh.antennapod.core.feed.util.PlaybackSpeedUtils; import de.danoeh.antennapod.core.preferences.PlaybackPreferences; @@ -30,13 +28,9 @@ import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.service.playback.PlaybackService; import de.danoeh.antennapod.core.service.playback.PlaybackServiceMediaPlayer; import de.danoeh.antennapod.core.service.playback.PlayerStatus; -import de.danoeh.antennapod.core.storage.DBTasks; -import de.danoeh.antennapod.core.util.Optional; -import de.danoeh.antennapod.core.util.ThemeUtils; -import de.danoeh.antennapod.core.util.playback.Playable.PlayableUtils; +import de.danoeh.antennapod.ui.common.ThemeUtils; import io.reactivex.Maybe; import io.reactivex.MaybeOnSubscribe; -import io.reactivex.Observable; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.Disposable; import io.reactivex.schedulers.Schedulers; @@ -51,7 +45,7 @@ import java.util.List; * Communicates with the playback service. GUI classes should use this class to * control playback instead of communicating with the PlaybackService directly. */ -public class PlaybackController { +public abstract class PlaybackController { private static final String TAG = "PlaybackController"; private static final int INVALID_TIME = -1; @@ -66,7 +60,6 @@ public class PlaybackController { private boolean initialized = false; private boolean eventsRegistered = false; - private Disposable serviceBinder; private Disposable mediaLoader; public PlaybackController(@NonNull Activity activity) { @@ -153,9 +146,6 @@ public class PlaybackController { } private void unbind() { - if (serviceBinder != null) { - serviceBinder.dispose(); - } try { activity.unbindService(mConnection); } catch (IllegalArgumentException e) { @@ -178,56 +168,11 @@ public class PlaybackController { */ private void bindToService() { Log.d(TAG, "Trying to connect to service"); - if (serviceBinder != null) { - serviceBinder.dispose(); - } - serviceBinder = Observable.fromCallable(this::getPlayLastPlayedMediaIntent) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(optionalIntent -> { - boolean bound = false; - if (!PlaybackService.isRunning) { - if (optionalIntent.isPresent()) { - Log.d(TAG, "Calling start service"); - ContextCompat.startForegroundService(activity, optionalIntent.get()); - bound = activity.bindService(optionalIntent.get(), mConnection, 0); - } else { - status = PlayerStatus.STOPPED; - setupGUI(); - handleStatus(); - } - } else { - Log.d(TAG, "PlaybackService is running, trying to connect without start command."); - bound = activity.bindService(new Intent(activity, PlaybackService.class), - mConnection, 0); - } - Log.d(TAG, "Result for service binding: " + bound); - }, error -> Log.e(TAG, Log.getStackTraceString(error))); - } - - /** - * Returns an intent that starts the PlaybackService and plays the last - * played media or null if no last played media could be found. - */ - @NonNull - private Optional<Intent> getPlayLastPlayedMediaIntent() { - Log.d(TAG, "Trying to restore last played media"); - Playable media = PlayableUtils.createInstanceFromPreferences(activity); - if (media == null) { - Log.d(TAG, "No last played media found"); - return Optional.empty(); + if (!PlaybackService.isRunning) { + throw new IllegalStateException("Trying to bind but service is not running"); } - - boolean fileExists = media.localFileAvailable(); - boolean lastIsStream = PlaybackPreferences.getCurrentEpisodeIsStream(); - if (!fileExists && !lastIsStream && media instanceof FeedMedia) { - DBTasks.notifyMissingFeedMediaFile(activity, (FeedMedia) media); - } - - return Optional.of(new PlaybackServiceStarter(activity, media) - .startWhenPrepared(false) - .shouldStream(lastIsStream || !fileExists) - .getIntent()); + boolean bound = activity.bindService(new Intent(activity, PlaybackService.class), mConnection, 0); + Log.d(TAG, "Result for service binding: " + bound); } private final ServiceConnection mConnection = new ServiceConnection() { @@ -331,8 +276,6 @@ public class PlaybackController { } }; - public void setupGUI() {} - public void onPositionObserverUpdate() {} @@ -431,7 +374,10 @@ public class PlaybackController { } private void checkMediaInfoLoaded() { - mediaInfoLoaded = (mediaInfoLoaded || loadMediaInfo()); + if (!mediaInfoLoaded) { + loadMediaInfo(); + } + mediaInfoLoaded = true; } private void updatePlayButtonAppearance(int resource, CharSequence contentDescription) { @@ -446,9 +392,7 @@ public class PlaybackController { return null; } - public boolean loadMediaInfo() { - return false; - } + public abstract void loadMediaInfo(); public void onAwaitingVideoSurface() {} @@ -463,10 +407,9 @@ public class PlaybackController { status = info.playerStatus; media = info.playable; - setupGUI(); - handleStatus(); // make sure that new media is loaded if it's available mediaInfoLoaded = false; + handleStatus(); } else { Log.e(TAG, diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/RemoteMedia.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/RemoteMedia.java index 29eb20aca..926eaa315 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/RemoteMedia.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/RemoteMedia.java @@ -11,10 +11,8 @@ 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.MediaType; -import de.danoeh.antennapod.core.util.ChapterUtils; import java.util.Date; import java.util.List; -import java.util.concurrent.Callable; import org.apache.commons.lang3.builder.HashCodeBuilder; /** @@ -129,11 +127,6 @@ public class RemoteMedia implements Playable { } @Override - public void loadChapterMarks(Context context) { - setChapters(ChapterUtils.loadChaptersFromStreamUrl(this, context)); - } - - @Override public String getEpisodeTitle() { return episodeTitle; } @@ -266,8 +259,8 @@ public class RemoteMedia implements Playable { } @Override - public Callable<String> loadShownotes() { - return () -> (notes != null) ? notes : ""; + public String getDescription() { + return notes; } @Override diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/Timeline.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/Timeline.java index 40849a262..e125c7e66 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/Timeline.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/Timeline.java @@ -9,7 +9,7 @@ import android.text.TextUtils; import android.util.Log; import android.util.TypedValue; -import de.danoeh.antennapod.core.feed.FeedItem; +import androidx.annotation.Nullable; import org.apache.commons.io.IOUtils; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; @@ -24,7 +24,6 @@ import java.util.regex.Pattern; import de.danoeh.antennapod.core.R; import de.danoeh.antennapod.core.util.Converter; -import de.danoeh.antennapod.core.util.ShownotesProvider; /** * Connects chapter information and shownotes of a shownotesProvider, for example by making it possible to use the @@ -42,17 +41,16 @@ public class Timeline { private static final Pattern TIMECODE_REGEX = Pattern.compile("\\b((\\d+):)?(\\d+):(\\d{2})\\b"); private static final Pattern LINE_BREAK_REGEX = Pattern.compile("<br */?>"); - private final ShownotesProvider shownotesProvider; + private final String rawShownotes; private final String noShownotesLabel; + private final int playableDuration; private final String webviewStyle; - public Timeline(Context context, ShownotesProvider shownotesProvider) { - if (shownotesProvider == null) { - throw new IllegalArgumentException("shownotesProvider = null"); - } - this.shownotesProvider = shownotesProvider; + public Timeline(Context context, @Nullable String rawShownotes, int playableDuration) { + this.rawShownotes = rawShownotes; noShownotesLabel = context.getString(R.string.no_shownotes_label); + this.playableDuration = playableDuration; final String colorPrimary = colorToHtml(context, android.R.attr.textColorPrimary); final String colorAccent = colorToHtml(context, R.attr.colorAccent); final int margin = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8, @@ -87,13 +85,7 @@ public class Timeline { */ @NonNull public String processShownotes() { - String shownotes; - try { - shownotes = shownotesProvider.loadShownotes().call(); - } catch (Exception e) { - Log.e(TAG, "processShownotes() - encounters exceptions unexpectedly in load, treat as if no shownotes.", e); - shownotes = ""; - } + String shownotes = rawShownotes; if (TextUtils.isEmpty(shownotes)) { Log.d(TAG, "shownotesProvider contained no shownotes. Returning 'no shownotes' message"); @@ -147,14 +139,6 @@ public class Timeline { // No elements with timecodes return; } - - int playableDuration = Integer.MAX_VALUE; - if (shownotesProvider instanceof Playable) { - playableDuration = ((Playable) shownotesProvider).getDuration(); - } else if (shownotesProvider instanceof FeedItem && ((FeedItem) shownotesProvider).getMedia() != null) { - playableDuration = ((FeedItem) shownotesProvider).getMedia().getDuration(); - } - boolean useHourFormat = true; if (playableDuration != Integer.MAX_VALUE) { diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/VideoPlayer.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/VideoPlayer.java index d18801870..6728c027d 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/VideoPlayer.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/VideoPlayer.java @@ -3,6 +3,7 @@ package de.danoeh.antennapod.core.util.playback; import android.media.MediaPlayer; import android.util.Log; +import java.io.IOException; import java.util.Collections; import java.util.List; @@ -52,4 +53,9 @@ public class VideoPlayer extends MediaPlayer implements IPlayer { public int getSelectedAudioTrack() { return -1; } + + @Override + public void setDataSource(String streamUrl, String username, String password) throws IOException { + setDataSource(streamUrl); + } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdater.java b/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdater.java new file mode 100644 index 000000000..afbe6526b --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdater.java @@ -0,0 +1,229 @@ +package de.danoeh.antennapod.core.widget; + +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.os.Bundle; +import android.util.Log; +import android.view.KeyEvent; +import android.view.View; +import android.widget.RemoteViews; + +import com.bumptech.glide.Glide; +import com.bumptech.glide.request.RequestOptions; + +import java.util.concurrent.TimeUnit; + +import de.danoeh.antennapod.core.R; +import de.danoeh.antennapod.core.feed.MediaType; +import de.danoeh.antennapod.core.glide.ApGlideSettings; +import de.danoeh.antennapod.core.receiver.MediaButtonReceiver; +import de.danoeh.antennapod.core.receiver.PlayerWidget; +import de.danoeh.antennapod.core.service.playback.PlayerStatus; +import de.danoeh.antennapod.core.util.Converter; +import de.danoeh.antennapod.core.feed.util.ImageResourceUtils; +import de.danoeh.antennapod.core.util.TimeSpeedConverter; +import de.danoeh.antennapod.core.util.playback.Playable; +import de.danoeh.antennapod.ui.appstartintent.MainActivityStarter; +import de.danoeh.antennapod.ui.appstartintent.VideoPlayerActivityStarter; + +/** + * Updates the state of the player widget. + */ +public abstract class WidgetUpdater { + private static final String TAG = "WidgetUpdater"; + + public static class WidgetState { + final Playable media; + final PlayerStatus status; + final int position; + final int duration; + final float playbackSpeed; + final boolean isCasting; + + public WidgetState(Playable media, PlayerStatus status, int position, int duration, + float playbackSpeed, boolean isCasting) { + this.media = media; + this.status = status; + this.position = position; + this.duration = duration; + this.playbackSpeed = playbackSpeed; + this.isCasting = isCasting; + } + + public WidgetState(PlayerStatus status) { + this(null, status, Playable.INVALID_TIME, Playable.INVALID_TIME, 1.0f, false); + } + } + + /** + * Update the widgets with the given parameters. Must be called in a background thread. + */ + public static void updateWidget(Context context, WidgetState widgetState) { + if (!PlayerWidget.isEnabled(context) || widgetState == null) { + return; + } + ComponentName playerWidget = new ComponentName(context, PlayerWidget.class); + AppWidgetManager manager = AppWidgetManager.getInstance(context); + int[] widgetIds = manager.getAppWidgetIds(playerWidget); + + PendingIntent startMediaPlayer; + if (widgetState.media != null && widgetState.media.getMediaType() == MediaType.VIDEO + && !widgetState.isCasting) { + startMediaPlayer = new VideoPlayerActivityStarter(context).getPendingIntent(); + } else { + startMediaPlayer = new MainActivityStarter(context).withOpenPlayer().getPendingIntent(); + } + RemoteViews views; + views = new RemoteViews(context.getPackageName(), R.layout.player_widget); + + if (widgetState.media != null) { + Bitmap icon; + int iconSize = context.getResources().getDimensionPixelSize(android.R.dimen.app_icon_size); + views.setOnClickPendingIntent(R.id.layout_left, startMediaPlayer); + views.setOnClickPendingIntent(R.id.imgvCover, startMediaPlayer); + + try { + icon = Glide.with(context) + .asBitmap() + .load(widgetState.media.getImageLocation()) + .apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY)) + .submit(iconSize, iconSize) + .get(500, TimeUnit.MILLISECONDS); + views.setImageViewBitmap(R.id.imgvCover, icon); + } catch (Throwable tr1) { + try { + icon = Glide.with(context) + .asBitmap() + .load(ImageResourceUtils.getFallbackImageLocation(widgetState.media)) + .apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY)) + .submit(iconSize, iconSize) + .get(500, TimeUnit.MILLISECONDS); + views.setImageViewBitmap(R.id.imgvCover, icon); + } catch (Throwable tr2) { + Log.e(TAG, "Error loading the media icon for the widget", tr2); + views.setImageViewResource(R.id.imgvCover, R.mipmap.ic_launcher_round); + } + } + + views.setTextViewText(R.id.txtvTitle, widgetState.media.getEpisodeTitle()); + views.setViewVisibility(R.id.txtvTitle, View.VISIBLE); + views.setViewVisibility(R.id.txtNoPlaying, View.GONE); + + String progressString = getProgressString(widgetState.position, + widgetState.duration, widgetState.playbackSpeed); + if (progressString != null) { + views.setViewVisibility(R.id.txtvProgress, View.VISIBLE); + views.setTextViewText(R.id.txtvProgress, progressString); + } + + if (widgetState.status == PlayerStatus.PLAYING) { + views.setImageViewResource(R.id.butPlay, R.drawable.ic_av_pause_white_48dp); + views.setContentDescription(R.id.butPlay, context.getString(R.string.pause_label)); + views.setImageViewResource(R.id.butPlayExtended, R.drawable.ic_av_pause_white_48dp); + views.setContentDescription(R.id.butPlayExtended, context.getString(R.string.pause_label)); + } else { + views.setImageViewResource(R.id.butPlay, R.drawable.ic_av_play_white_48dp); + views.setContentDescription(R.id.butPlay, context.getString(R.string.play_label)); + views.setImageViewResource(R.id.butPlayExtended, R.drawable.ic_av_play_white_48dp); + views.setContentDescription(R.id.butPlayExtended, context.getString(R.string.play_label)); + } + views.setOnClickPendingIntent(R.id.butPlay, + createMediaButtonIntent(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)); + views.setOnClickPendingIntent(R.id.butPlayExtended, + createMediaButtonIntent(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)); + views.setOnClickPendingIntent(R.id.butRew, + createMediaButtonIntent(context, KeyEvent.KEYCODE_MEDIA_REWIND)); + views.setOnClickPendingIntent(R.id.butFastForward, + createMediaButtonIntent(context, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)); + views.setOnClickPendingIntent(R.id.butSkip, + createMediaButtonIntent(context, KeyEvent.KEYCODE_MEDIA_NEXT)); + } else { + // start the app if they click anything + views.setOnClickPendingIntent(R.id.layout_left, startMediaPlayer); + views.setOnClickPendingIntent(R.id.butPlay, startMediaPlayer); + views.setOnClickPendingIntent(R.id.butPlayExtended, + createMediaButtonIntent(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)); + views.setViewVisibility(R.id.txtvProgress, View.GONE); + views.setViewVisibility(R.id.txtvTitle, View.GONE); + views.setViewVisibility(R.id.txtNoPlaying, View.VISIBLE); + views.setImageViewResource(R.id.imgvCover, R.mipmap.ic_launcher_round); + views.setImageViewResource(R.id.butPlay, R.drawable.ic_av_play_white_48dp); + views.setImageViewResource(R.id.butPlayExtended, R.drawable.ic_av_play_white_48dp); + } + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { + for (int id : widgetIds) { + Bundle options = manager.getAppWidgetOptions(id); + SharedPreferences prefs = context.getSharedPreferences(PlayerWidget.PREFS_NAME, Context.MODE_PRIVATE); + int minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH); + int columns = getCellsForSize(minWidth); + if (columns < 3) { + views.setViewVisibility(R.id.layout_center, View.INVISIBLE); + } else { + views.setViewVisibility(R.id.layout_center, View.VISIBLE); + } + boolean showRewind = prefs.getBoolean(PlayerWidget.KEY_WIDGET_REWIND + id, false); + boolean showFastForward = prefs.getBoolean(PlayerWidget.KEY_WIDGET_FAST_FORWARD + id, false); + boolean showSkip = prefs.getBoolean(PlayerWidget.KEY_WIDGET_SKIP + id, false); + + if (showRewind || showSkip || showFastForward) { + views.setInt(R.id.extendedButtonsContainer, "setVisibility", View.VISIBLE); + views.setInt(R.id.butPlay, "setVisibility", View.GONE); + views.setInt(R.id.butRew, "setVisibility", showRewind ? View.VISIBLE : View.GONE); + views.setInt(R.id.butFastForward, "setVisibility", showFastForward ? View.VISIBLE : View.GONE); + views.setInt(R.id.butSkip, "setVisibility", showSkip ? View.VISIBLE : View.GONE); + } + + int backgroundColor = prefs.getInt(PlayerWidget.KEY_WIDGET_COLOR + id, PlayerWidget.DEFAULT_COLOR); + views.setInt(R.id.widgetLayout, "setBackgroundColor", backgroundColor); + + manager.updateAppWidget(id, views); + } + } else { + manager.updateAppWidget(playerWidget, views); + } + } + + /** + * Returns number of cells needed for given size of the widget. + * + * @param size Widget size in dp. + * @return Size in number of cells. + */ + private static int getCellsForSize(int size) { + int n = 2; + while (70 * n - 30 < size) { + ++n; + } + return n - 1; + } + + /** + * Creates an intent which fakes a mediabutton press. + */ + private static PendingIntent createMediaButtonIntent(Context context, int eventCode) { + KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, eventCode); + Intent startingIntent = new Intent(context, MediaButtonReceiver.class); + startingIntent.setAction(MediaButtonReceiver.NOTIFY_BUTTON_RECEIVER); + startingIntent.putExtra(Intent.EXTRA_KEY_EVENT, event); + + return PendingIntent.getBroadcast(context, eventCode, startingIntent, 0); + } + + private static String getProgressString(int position, int duration, float speed) { + if (position >= 0 && duration > 0) { + TimeSpeedConverter converter = new TimeSpeedConverter(speed); + position = converter.convert(position); + duration = converter.convert(duration); + return Converter.getDurationStringLong(position) + " / " + + Converter.getDurationStringLong(duration); + } else { + return null; + } + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdaterJobService.java b/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdaterJobService.java new file mode 100644 index 000000000..004588945 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdaterJobService.java @@ -0,0 +1,35 @@ +package de.danoeh.antennapod.core.widget; + +import android.content.Context; +import android.content.Intent; +import androidx.annotation.NonNull; +import androidx.core.app.SafeJobIntentService; +import de.danoeh.antennapod.core.feed.util.PlaybackSpeedUtils; +import de.danoeh.antennapod.core.preferences.PlaybackPreferences; +import de.danoeh.antennapod.core.service.playback.PlayerStatus; +import de.danoeh.antennapod.core.util.playback.Playable; +import de.danoeh.antennapod.core.util.playback.PlayableUtils; + +public class WidgetUpdaterJobService extends SafeJobIntentService { + private static final int JOB_ID = -17001; + + /** + * Loads the current media from the database and updates the widget in a background job. + */ + public static void performBackgroundUpdate(Context context) { + enqueueWork(context, WidgetUpdaterJobService.class, + WidgetUpdaterJobService.JOB_ID, new Intent(context, WidgetUpdaterJobService.class)); + } + + @Override + protected void onHandleWork(@NonNull Intent intent) { + Playable media = PlayableUtils.createInstanceFromPreferences(getApplicationContext()); + if (media != null) { + WidgetUpdater.updateWidget(this, new WidgetUpdater.WidgetState(media, PlayerStatus.STOPPED, + media.getPosition(), media.getDuration(), PlaybackSpeedUtils.getCurrentPlaybackSpeed(media), + PlaybackPreferences.getCurrentEpisodeIsStream())); + } else { + WidgetUpdater.updateWidget(this, new WidgetUpdater.WidgetState(PlayerStatus.STOPPED)); + } + } +}
\ No newline at end of file |