diff options
author | ByteHamster <info@bytehamster.com> | 2021-04-22 23:17:11 +0200 |
---|---|---|
committer | ByteHamster <info@bytehamster.com> | 2021-04-22 23:17:11 +0200 |
commit | ba66ae76337133d92963fbf9c8ead27ee81ef148 (patch) | |
tree | fd08fbf6c70d43a39130a988deac97f80589cff3 /model | |
parent | 2a47f49fde3327ee3a1b3c2d66b2c950cda7e14e (diff) | |
download | antennapod-ba66ae76337133d92963fbf9c8ead27ee81ef148.zip |
Moved model to its own module
Diffstat (limited to 'model')
18 files changed, 2808 insertions, 0 deletions
diff --git a/model/README.md b/model/README.md new file mode 100644 index 000000000..42bba70c2 --- /dev/null +++ b/model/README.md @@ -0,0 +1,3 @@ +# :model + +This module provides basic model classes like `Feed` and `Chapter`. diff --git a/model/build.gradle b/model/build.gradle new file mode 100644 index 000000000..4d6d5b56d --- /dev/null +++ b/model/build.gradle @@ -0,0 +1,55 @@ +apply plugin: "com.android.library" + +android { + compileSdkVersion rootProject.ext.compileSdkVersion + + defaultConfig { + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + + vectorDrawables.useSupportLibrary false + multiDexEnabled false + + testApplicationId "de.danoeh.antennapod.core.tests" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile("proguard-android.txt") + } + debug { + // debug build has method count over 64k single-dex threshold. + // For building debug build to use on Android < 21 (pre-Android 5) devices, + // you need to manually change class + // de.danoeh.antennapod.PodcastApp to extend MultiDexApplication . + // See Issue #2813 + multiDexEnabled true + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + testOptions { + unitTests { + includeAndroidResources = true + } + } + + lintOptions { + disable 'GradleDependency' + warningsAsErrors true + abortOnError true + } +} + +dependencies { + annotationProcessor "androidx.annotation:annotation:$annotationVersion" + implementation "androidx.appcompat:appcompat:$appcompatVersion" + implementation "androidx.media:media:$mediaVersion" + + implementation "org.apache.commons:commons-lang3:$commonslangVersion" +} diff --git a/model/src/main/AndroidManifest.xml b/model/src/main/AndroidManifest.xml new file mode 100644 index 000000000..968649054 --- /dev/null +++ b/model/src/main/AndroidManifest.xml @@ -0,0 +1 @@ +<manifest package="de.danoeh.antennapod.model" /> diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/Chapter.java b/model/src/main/java/de/danoeh/antennapod/model/feed/Chapter.java new file mode 100644 index 000000000..0508df901 --- /dev/null +++ b/model/src/main/java/de/danoeh/antennapod/model/feed/Chapter.java @@ -0,0 +1,65 @@ +package de.danoeh.antennapod.model.feed; + +public abstract class Chapter extends FeedComponent { + + /** Defines starting point in milliseconds. */ + private long start; + private String title; + private String link; + private String imageUrl; + + protected Chapter() { + } + + protected Chapter(long start) { + super(); + this.start = start; + } + + protected Chapter(long start, String title, String link, String imageUrl) { + super(); + this.start = start; + this.title = title; + this.link = link; + this.imageUrl = imageUrl; + } + + public abstract int getChapterType(); + + public long getStart() { + return start; + } + + public String getTitle() { + return title; + } + + public String getLink() { + return link; + } + + public void setStart(long start) { + this.start = start; + } + + public void setTitle(String title) { + this.title = title; + } + + public void setLink(String link) { + this.link = link; + } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + @Override + public String getHumanReadableIdentifier() { + return title; + } +} diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/Feed.java b/model/src/main/java/de/danoeh/antennapod/model/feed/Feed.java new file mode 100644 index 000000000..e570f9bce --- /dev/null +++ b/model/src/main/java/de/danoeh/antennapod/model/feed/Feed.java @@ -0,0 +1,502 @@ +package de.danoeh.antennapod.model.feed; + +import android.text.TextUtils; + +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * Data Object for a whole feed. + * + * @author daniel + */ +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. + */ + private String feedTitle; + + /** + * custom title set by the user. + */ + private String customTitle; + + /** + * Contains 'id'-element in Atom feed. + */ + private String feedIdentifier; + /** + * Link to the website. + */ + private String link; + private String description; + private String language; + /** + * 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). + */ + private String lastUpdate; + + private ArrayList<FeedFunding> fundingList; + /** + * Feed type, for example RSS 2 or Atom. + */ + private String type; + + /** + * Feed preferences. + */ + private FeedPreferences preferences; + + /** + * The page number that this feed is on. Only feeds with page number "0" should be stored in the + * database, feed objects with a higher page number only exist temporarily and should be merged + * into feeds with page number "0". + * <p/> + * This attribute's value is not saved in the database + */ + private int pageNr; + + /** + * True if this is a "paged feed", i.e. there exist other feed files that belong to the same + * logical feed. + */ + private boolean paged; + + /** + * Link to the next page of this feed. If this feed object represents a logical feed (i.e. a feed + * that is saved in the database) this might be null while still being a paged feed. + */ + private String nextPageLink; + + private boolean lastUpdateFailed; + + /** + * Contains property strings. If such a property applies to a feed item, it is not shown in the feed list + */ + private FeedItemFilter itemfilter; + + /** + * User-preferred sortOrder for display. + * Only those of scope {@link SortOrder.Scope#INTRA_FEED} is allowed. + */ + @Nullable + private SortOrder sortOrder; + + /** + * This constructor is used for restoring a feed from the database. + */ + public Feed(long id, String lastUpdate, String title, String customTitle, String link, + String description, String paymentLinks, String author, String language, + String type, String feedIdentifier, String imageUrl, String fileUrl, + String downloadUrl, boolean downloaded, boolean paged, String nextPageLink, + String filter, @Nullable SortOrder sortOrder, boolean lastUpdateFailed) { + super(fileUrl, downloadUrl, downloaded); + this.id = id; + this.feedTitle = title; + this.customTitle = customTitle; + this.lastUpdate = lastUpdate; + this.link = link; + this.description = description; + this.fundingList = FeedFunding.extractPaymentLinks(paymentLinks); + this.author = author; + this.language = language; + this.type = type; + this.feedIdentifier = feedIdentifier; + this.imageUrl = imageUrl; + this.paged = paged; + this.nextPageLink = nextPageLink; + this.items = new ArrayList<>(); + if (filter != null) { + this.itemfilter = new FeedItemFilter(filter); + } else { + this.itemfilter = new FeedItemFilter(new String[0]); + } + setSortOrder(sortOrder); + this.lastUpdateFailed = lastUpdateFailed; + } + + /** + * 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, + String downloadUrl, boolean downloaded) { + this(id, lastUpdate, title, null, link, description, paymentLink, author, language, type, feedIdentifier, imageUrl, + fileUrl, downloadUrl, downloaded, false, null, null, null, false); + } + + /** + * This constructor can be used when parsing feed data. Only the 'lastUpdate' and 'items' field are initialized. + */ + public Feed() { + super(); + } + + /** + * This constructor is used for requesting a feed download (it must not be used for anything else!). It should NOT be + * used if the title of the feed is already known. + */ + public Feed(String url, String lastUpdate) { + super(null, url, false); + this.lastUpdate = lastUpdate; + } + + /** + * This constructor is used for requesting a feed download (it must not be used for anything else!). It should be + * used if the title of the feed is already known. + */ + public Feed(String url, String lastUpdate, String title) { + this(url, lastUpdate); + this.feedTitle = title; + } + + /** + * This constructor is used for requesting a feed download (it must not be used for anything else!). It should be + * used if the title of the feed is already known. + */ + public Feed(String url, String lastUpdate, String title, String username, String password) { + this(url, lastUpdate, title); + preferences = new FeedPreferences(0, true, FeedPreferences.AutoDeleteAction.GLOBAL, VolumeAdaptionSetting.OFF, username, password); + } + + /** + * Returns the item at the specified index. + * + */ + public FeedItem getItemAtIndex(int position) { + return items.get(position); + } + + /** + * Returns the value that uniquely identifies this Feed. If the + * feedIdentifier attribute is not null, it will be returned. Else it will + * try to return the title. If the title is not given, it will use the link + * of the feed. + */ + public String getIdentifyingValue() { + if (feedIdentifier != null && !feedIdentifier.isEmpty()) { + return feedIdentifier; + } else if (download_url != null && !download_url.isEmpty()) { + return download_url; + } else if (feedTitle != null && !feedTitle.isEmpty()) { + return feedTitle; + } else { + return link; + } + } + + @Override + public String getHumanReadableIdentifier() { + if (!TextUtils.isEmpty(customTitle)) { + return customTitle; + } else if (!TextUtils.isEmpty(feedTitle)) { + return feedTitle; + } else { + return download_url; + } + } + + public void updateFromOther(Feed other) { + // don't update feed's download_url, we do that manually if redirected + // see AntennapodHttpClient + if (other.imageUrl != null) { + this.imageUrl = other.imageUrl; + } + if (other.feedTitle != null) { + feedTitle = other.feedTitle; + } + if (other.feedIdentifier != null) { + feedIdentifier = other.feedIdentifier; + } + if (other.link != null) { + link = other.link; + } + if (other.description != null) { + description = other.description; + } + if (other.language != null) { + language = other.language; + } + if (other.author != null) { + author = other.author; + } + if (other.fundingList != null) { + fundingList = other.fundingList; + } + // this feed's nextPage might already point to a higher page, so we only update the nextPage value + // if this feed is not paged and the other feed is. + if (!this.paged && other.paged) { + this.paged = other.paged; + this.nextPageLink = other.nextPageLink; + } + } + + public boolean compareWithOther(Feed other) { + if (super.compareWithOther(other)) { + return true; + } + if (other.imageUrl != null) { + if (imageUrl == null || !TextUtils.equals(imageUrl, other.imageUrl)) { + return true; + } + } + if (!TextUtils.equals(feedTitle, other.feedTitle)) { + return true; + } + if (other.feedIdentifier != null) { + if (feedIdentifier == null || !feedIdentifier.equals(other.feedIdentifier)) { + return true; + } + } + if (other.link != null) { + if (link == null || !link.equals(other.link)) { + return true; + } + } + if (other.description != null) { + if (description == null || !description.equals(other.description)) { + return true; + } + } + if (other.language != null) { + if (language == null || !language.equals(other.language)) { + return true; + } + } + if (other.author != null) { + if (author == null || !author.equals(other.author)) { + return true; + } + } + if (other.fundingList != null) { + if (fundingList == null || !fundingList.equals(other.fundingList)) { + return true; + } + } + if (other.isPaged() && !this.isPaged()) { + return true; + } + if (!TextUtils.equals(other.getNextPageLink(), this.getNextPageLink())) { + return true; + } + return false; + } + + public FeedItem getMostRecentItem() { + // we could sort, but we don't need to, a simple search is fine... + Date mostRecentDate = new Date(0); + FeedItem mostRecentItem = null; + for (FeedItem item : items) { + if (item.getPubDate() != null && item.getPubDate().after(mostRecentDate)) { + mostRecentDate = item.getPubDate(); + mostRecentItem = item; + } + } + return mostRecentItem; + } + + @Override + public int getTypeAsInt() { + return FEEDFILETYPE_FEED; + } + + public String getTitle() { + return !TextUtils.isEmpty(customTitle) ? customTitle : feedTitle; + } + + public void setTitle(String title) { + this.feedTitle = title; + } + + public String getFeedTitle() { + return this.feedTitle; + } + + @Nullable + public String getCustomTitle() { + return this.customTitle; + } + + public void setCustomTitle(String customTitle) { + if (customTitle == null || customTitle.equals(feedTitle)) { + this.customTitle = null; + } else { + this.customTitle = customTitle; + } + } + + public String getLink() { + return link; + } + + public void setLink(String link) { + this.link = link; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public List<FeedItem> getItems() { + return items; + } + + public void setItems(List<FeedItem> list) { + this.items = list; + } + + public String getLastUpdate() { + return lastUpdate; + } + + public void setLastUpdate(String lastModified) { + this.lastUpdate = lastModified; + } + + public String getFeedIdentifier() { + return feedIdentifier; + } + + public void setFeedIdentifier(String feedIdentifier) { + this.feedIdentifier = feedIdentifier; + } + + public void addPayment(FeedFunding funding) { + if (fundingList == null) { + fundingList = new ArrayList<FeedFunding>(); + } + fundingList.add(funding); + } + + public ArrayList<FeedFunding> getPaymentLinks() { + return fundingList; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public String getAuthor() { + return author; + } + + public void setAuthor(String author) { + this.author = author; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public void setPreferences(FeedPreferences preferences) { + this.preferences = preferences; + } + + public FeedPreferences getPreferences() { + return preferences; + } + + @Override + public void setId(long id) { + super.setId(id); + if (preferences != null) { + preferences.setFeedID(id); + } + } + + public int getPageNr() { + return pageNr; + } + + public void setPageNr(int pageNr) { + this.pageNr = pageNr; + } + + public boolean isPaged() { + return paged; + } + + public void setPaged(boolean paged) { + this.paged = paged; + } + + public String getNextPageLink() { + return nextPageLink; + } + + public void setNextPageLink(String nextPageLink) { + this.nextPageLink = nextPageLink; + } + + @Nullable + public FeedItemFilter getItemFilter() { + return itemfilter; + } + + public void setItemFilter(String[] properties) { + if (properties != null) { + this.itemfilter = new FeedItemFilter(properties); + } + } + + @Nullable + public SortOrder getSortOrder() { + return sortOrder; + } + + public void setSortOrder(@Nullable SortOrder sortOrder) { + if (sortOrder != null && sortOrder.scope != SortOrder.Scope.INTRA_FEED) { + throw new IllegalArgumentException("The specified sortOrder " + sortOrder + + " is invalid. Only those with INTRA_FEED scope are allowed."); + } + this.sortOrder = sortOrder; + } + + public boolean hasLastUpdateFailed() { + return this.lastUpdateFailed; + } + + public void setLastUpdateFailed(boolean lastUpdateFailed) { + this.lastUpdateFailed = lastUpdateFailed; + } + + public boolean isLocalFeed() { + return download_url.startsWith(PREFIX_LOCAL_FOLDER); + } +} diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/FeedComponent.java b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedComponent.java new file mode 100644 index 000000000..fa0ace3dc --- /dev/null +++ b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedComponent.java @@ -0,0 +1,65 @@ +package de.danoeh.antennapod.model.feed; + +/** + * Represents every possible component of a feed + * + * @author daniel + */ +public abstract class FeedComponent { + + long id; + + FeedComponent() { + super(); + } + + public long getId() { + return id; + } + + public void setId(long id) { + this.id = id; + } + + /** + * Update this FeedComponent's attributes with the attributes from another + * FeedComponent. This method should only update attributes which where read from + * the feed. + */ + void updateFromOther(FeedComponent other) { + } + + /** + * Compare's this FeedComponent's attribute values with another FeedComponent's + * attribute values. This method will only compare attributes which were + * read from the feed. + * + * @return true if attribute values are different, false otherwise + */ + boolean compareWithOther(FeedComponent other) { + return false; + } + + + /** + * Should return a non-null, human-readable String so that the item can be + * identified by the user. Can be title, download-url, etc. + */ + public abstract String getHumanReadableIdentifier(); + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof FeedComponent)) return false; + + FeedComponent that = (FeedComponent) o; + + return id == that.id; + + } + + @Override + public int hashCode() { + return (int) (id ^ (id >>> 32)); + } +} diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/FeedFile.java b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedFile.java new file mode 100644 index 000000000..c04fa0bb9 --- /dev/null +++ b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedFile.java @@ -0,0 +1,107 @@ +package de.danoeh.antennapod.model.feed; + +import android.text.TextUtils; + +import java.io.File; + +/** + * Represents a component of a Feed that has to be downloaded + */ +public abstract class FeedFile extends FeedComponent { + + String file_url; + protected String download_url; + boolean downloaded; + + /** + * Creates a new FeedFile object. + * + * @param file_url The location of the FeedFile. If this is null, the downloaded-attribute + * will automatically be set to false. + * @param download_url The location where the FeedFile can be downloaded. + * @param downloaded true if the FeedFile has been downloaded, false otherwise. This parameter + * will automatically be interpreted as false if the file_url is null. + */ + public FeedFile(String file_url, String download_url, boolean downloaded) { + super(); + this.file_url = file_url; + this.download_url = download_url; + this.downloaded = (file_url != null) && downloaded; + } + + public FeedFile() { + this(null, null, false); + } + + public abstract int getTypeAsInt(); + + /** + * Update this FeedFile's attributes with the attributes from another + * FeedFile. This method should only update attributes which where read from + * the feed. + */ + void updateFromOther(FeedFile other) { + super.updateFromOther(other); + this.download_url = other.download_url; + } + + /** + * Compare's this FeedFile's attribute values with another FeedFile's + * attribute values. This method will only compare attributes which were + * read from the feed. + * + * @return true if attribute values are different, false otherwise + */ + boolean compareWithOther(FeedFile other) { + if (super.compareWithOther(other)) { + return true; + } + if (!TextUtils.equals(download_url, other.download_url)) { + return true; + } + return false; + } + + /** + * Returns true if the file exists at file_url. + */ + public boolean fileExists() { + if (file_url == null) { + return false; + } else { + File f = new File(file_url); + return f.exists(); + } + } + + public String getFile_url() { + return file_url; + } + + /** + * Changes the file_url of this FeedFile. Setting this value to + * null will also set the downloaded-attribute to false. + */ + public void setFile_url(String file_url) { + this.file_url = file_url; + if (file_url == null) { + downloaded = false; + } + } + + public String getDownload_url() { + return download_url; + } + + public void setDownload_url(String download_url) { + this.download_url = download_url; + } + + public boolean isDownloaded() { + return downloaded; + } + + public void setDownloaded(boolean downloaded) { + this.downloaded = downloaded; + } +} diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/FeedFilter.java b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedFilter.java new file mode 100644 index 000000000..4d1d76f1f --- /dev/null +++ b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedFilter.java @@ -0,0 +1,112 @@ +package de.danoeh.antennapod.model.feed; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class FeedFilter implements Serializable { + + private static final String TAG = "FeedFilter"; + + private final String includeFilter; + private final String excludeFilter; + + public FeedFilter() { + this("", ""); + } + + public FeedFilter(String includeFilter, String excludeFilter) { + // We're storing the strings and not the parsed terms because + // 1. It's easier to show the user exactly what they typed in this way + // (we don't have to recreate it) + // 2. We don't know if we'll actually be asked to parse anything anyways. + this.includeFilter = includeFilter; + this.excludeFilter = excludeFilter; + } + + /** + * Parses the text in to a list of single words or quoted strings. + * Example: "One "Two Three"" returns ["One", "Two Three"] + * @param filter string to parse in to terms + * @return list of terms + */ + private List<String> parseTerms(String filter) { + // from http://stackoverflow.com/questions/7804335/split-string-on-spaces-in-java-except-if-between-quotes-i-e-treat-hello-wor + List<String> list = new ArrayList<>(); + Matcher m = Pattern.compile("([^\"]\\S*|\".+?\")\\s*").matcher(filter); + while (m.find()) + list.add(m.group(1).replace("\"", "")); + return list; + } + + /** + * @param item + * @return true if the item should be downloaded + */ + public boolean shouldAutoDownload(FeedItem item) { + + List<String> includeTerms = parseTerms(includeFilter); + List<String> excludeTerms = parseTerms(excludeFilter); + + if (includeTerms.size() == 0 && excludeTerms.size() == 0) { + // nothing has been specified, so include everything + return true; + } + + // check using lowercase so the users don't have to worry about case. + String title = item.getTitle().toLowerCase(); + + // if it's explicitly excluded, it shouldn't be autodownloaded + // even if it has include terms + for (String term : excludeTerms) { + if (title.contains(term.trim().toLowerCase())) { + return false; + } + } + + for (String term : includeTerms) { + if (title.contains(term.trim().toLowerCase())) { + return true; + } + } + + // now's the tricky bit + // if they haven't set an include filter, but they have set an exclude filter + // default to including, but if they've set both, then exclude + if (!hasIncludeFilter() && hasExcludeFilter()) { + return true; + } + + return false; + } + + public String getIncludeFilter() { + return includeFilter; + } + + public String getExcludeFilter() { return excludeFilter; } + + /** + * @return true if only include is set + */ + public boolean includeOnly() { + return hasIncludeFilter() && !hasExcludeFilter(); + } + + /** + * @return true if only exclude is set + */ + public boolean excludeOnly() { + return hasExcludeFilter() && !hasIncludeFilter(); + } + + public boolean hasIncludeFilter() { + return includeFilter.length() > 0; + } + + public boolean hasExcludeFilter() { + return excludeFilter.length() > 0; + } +} diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/FeedFunding.java b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedFunding.java new file mode 100644 index 000000000..23c0abb97 --- /dev/null +++ b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedFunding.java @@ -0,0 +1,91 @@ +package de.danoeh.antennapod.model.feed; + +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; + +public class FeedFunding { + public static final String FUNDING_ENTRIES_SEPARATOR = "\u001e"; + public static final String FUNDING_TITLE_SEPARATOR = "\u001f"; + + public String url; + public String content; + + public FeedFunding(String url, String content) { + this.url = url; + this.content = content; + } + + public void setContent(String content) { + this.content = content; + } + + public void setUrl(String url) { + this.url = url; + } + + @Override + public boolean equals(Object obj) { + if (obj == null || !obj.getClass().equals(this.getClass())) { + return false; + } + + FeedFunding funding = (FeedFunding) obj; + if (url == null && funding.url == null && content == null && funding.content == null) { + return true; + } + if (url != null && url.equals(funding.url) && content != null && content.equals(funding.content)) { + return true; + } + return false; + } + + @Override + public int hashCode() { + return (url + FUNDING_TITLE_SEPARATOR + content).hashCode(); + } + + public static ArrayList<FeedFunding> extractPaymentLinks(String payLinks) { + if (StringUtils.isBlank(payLinks)) { + return null; + } + // old format before we started with PodcastIndex funding tag + ArrayList<FeedFunding> funding = new ArrayList<FeedFunding>(); + if (!payLinks.contains(FeedFunding.FUNDING_ENTRIES_SEPARATOR) + && !payLinks.contains(FeedFunding.FUNDING_TITLE_SEPARATOR)) { + funding.add(new FeedFunding(payLinks, "")); + return funding; + } + String [] list = payLinks.split(FeedFunding.FUNDING_ENTRIES_SEPARATOR); + if (list.length == 0) { + return null; + } + + for (String str : list) { + String [] linkContent = str.split(FeedFunding.FUNDING_TITLE_SEPARATOR); + if (StringUtils.isBlank(linkContent[0])) { + continue; + } + String url = linkContent[0]; + String title = ""; + if (linkContent.length > 1 && ! StringUtils.isBlank(linkContent[1])) { + title = linkContent[1]; + } + funding.add(new FeedFunding(url, title)); + } + return funding; + } + + public static String getPaymentLinksAsString(ArrayList<FeedFunding> fundingList) { + StringBuilder result = new StringBuilder(); + if (fundingList == null) { + return null; + } + for (FeedFunding fund : fundingList) { + result.append(fund.url).append(FeedFunding.FUNDING_TITLE_SEPARATOR).append(fund.content); + result.append(FeedFunding.FUNDING_ENTRIES_SEPARATOR); + } + return StringUtils.removeEnd(result.toString(), FeedFunding.FUNDING_ENTRIES_SEPARATOR); + } + +} diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/FeedItem.java b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedItem.java new file mode 100644 index 000000000..235c3fb8a --- /dev/null +++ b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedItem.java @@ -0,0 +1,421 @@ +package de.danoeh.antennapod.model.feed; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.apache.commons.lang3.builder.ToStringStyle; + +import java.io.Serializable; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +/** + * Item (episode) within a feed. + * + * @author daniel + */ +public class FeedItem extends FeedComponent implements Serializable { + + /** tag that indicates this item is in the queue */ + public static final String TAG_QUEUE = "Queue"; + /** tag that indicates this item is in favorites */ + public static final String TAG_FAVORITE = "Favorite"; + + /** + * The id/guid that can be found in the rss/atom feed. Might not be set. + */ + private String itemIdentifier; + private String title; + /** + * The description of a feeditem. + */ + private String description; + + private String link; + private Date pubDate; + private FeedMedia media; + + private transient Feed feed; + private long feedId; + + private int state; + public static final int NEW = -1; + public static final int UNPLAYED = 0; + public static final int PLAYED = 1; + + private String paymentLink; + + /** + * Is true if the database contains any chapters that belong to this item. This attribute is only + * written once by DBReader on initialization. + * The FeedItem might still have a non-null chapters value. In this case, the list of chapters + * has not been saved in the database yet. + * */ + private final boolean hasChapters; + + /** + * The list of chapters of this item. This might be null even if there are chapters of this item + * in the database. The 'hasChapters' attribute should be used to check if this item has any chapters. + * */ + private transient List<Chapter> chapters; + private String imageUrl; + + /* + * 0: auto download disabled + * 1: auto download enabled (default) + * > 1: auto download enabled, (approx.) timestamp of the last failed attempt + * where last digit denotes the number of failed attempts + */ + private long autoDownload = 1; + + /** + * Any tags assigned to this item + */ + private final Set<String> tags = new HashSet<>(); + + public FeedItem() { + this.state = UNPLAYED; + this.hasChapters = false; + } + + /** + * This constructor is used by DBReader. + * */ + public FeedItem(long id, String title, String link, Date pubDate, String paymentLink, long feedId, + boolean hasChapters, String imageUrl, int state, + String itemIdentifier, long autoDownload) { + this.id = id; + this.title = title; + this.link = link; + this.pubDate = pubDate; + this.paymentLink = paymentLink; + this.feedId = feedId; + this.hasChapters = hasChapters; + this.imageUrl = imageUrl; + this.state = state; + this.itemIdentifier = itemIdentifier; + this.autoDownload = autoDownload; + } + + /** + * This constructor should be used for creating test objects. + */ + public FeedItem(long id, String title, String itemIdentifier, String link, Date pubDate, int state, Feed feed) { + this.id = id; + this.title = title; + this.itemIdentifier = itemIdentifier; + this.link = link; + this.pubDate = (pubDate != null) ? (Date) pubDate.clone() : null; + this.state = state; + this.feed = feed; + this.hasChapters = false; + } + + /** + * This constructor should be used for creating test objects involving chapter marks. + */ + public FeedItem(long id, String title, String itemIdentifier, String link, Date pubDate, int state, Feed feed, boolean hasChapters) { + this.id = id; + this.title = title; + this.itemIdentifier = itemIdentifier; + this.link = link; + this.pubDate = (pubDate != null) ? (Date) pubDate.clone() : null; + this.state = state; + this.feed = feed; + this.hasChapters = hasChapters; + } + + public void updateFromOther(FeedItem other) { + super.updateFromOther(other); + if (other.imageUrl != null) { + this.imageUrl = other.imageUrl; + } + if (other.title != null) { + title = other.title; + } + if (other.getDescription() != null) { + description = other.getDescription(); + } + if (other.link != null) { + link = other.link; + } + if (other.pubDate != null && !other.pubDate.equals(pubDate)) { + pubDate = other.pubDate; + } + if (other.media != null) { + if (media == null) { + setMedia(other.media); + // reset to new if feed item did link to a file before + setNew(); + } else if (media.compareWithOther(other.media)) { + media.updateFromOther(other.media); + } + } + if (other.paymentLink != null) { + paymentLink = other.paymentLink; + } + if (other.chapters != null) { + if (!hasChapters) { + chapters = other.chapters; + } + } + } + + /** + * Returns the value that uniquely identifies this FeedItem. If the + * itemIdentifier attribute is not null, it will be returned. Else it will + * try to return the title. If the title is not given, it will use the link + * of the entry. + */ + public String getIdentifyingValue() { + if (itemIdentifier != null && !itemIdentifier.isEmpty()) { + return itemIdentifier; + } else if (title != null && !title.isEmpty()) { + return title; + } else if (hasMedia() && media.getDownload_url() != null) { + return media.getDownload_url(); + } else { + return link; + } + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public String getLink() { + return link; + } + + public void setLink(String link) { + this.link = link; + } + + public Date getPubDate() { + if (pubDate != null) { + return (Date) pubDate.clone(); + } else { + return null; + } + } + + public void setPubDate(Date pubDate) { + if (pubDate != null) { + this.pubDate = (Date) pubDate.clone(); + } else { + this.pubDate = null; + } + } + + @Nullable + public FeedMedia getMedia() { + return media; + } + + /** + * Sets the media object of this FeedItem. If the given + * FeedMedia object is not null, it's 'item'-attribute value + * will also be set to this item. + */ + public void setMedia(FeedMedia media) { + this.media = media; + if (media != null && media.getItem() != this) { + media.setItem(this); + } + } + + public Feed getFeed() { + return feed; + } + + public void setFeed(Feed feed) { + this.feed = feed; + } + + public boolean isNew() { + return state == NEW; + } + + + public void setNew() { + state = NEW; + } + + public boolean isPlayed() { + return state == PLAYED; + } + + public void setPlayed(boolean played) { + if (played) { + state = PLAYED; + } else { + state = UNPLAYED; + } + } + + public boolean isInProgress() { + return (media != null && media.isInProgress()); + } + + /** + * 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() { + return paymentLink; + } + + public void setPaymentLink(String paymentLink) { + this.paymentLink = paymentLink; + } + + public List<Chapter> getChapters() { + return chapters; + } + + public void setChapters(List<Chapter> chapters) { + this.chapters = chapters; + } + + public String getItemIdentifier() { + return itemIdentifier; + } + + public void setItemIdentifier(String itemIdentifier) { + this.itemIdentifier = itemIdentifier; + } + + public boolean hasMedia() { + return media != null; + } + + 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.getImageUrl(); + } else { + return null; + } + } + + public enum State { + UNREAD, IN_PROGRESS, READ, PLAYING + } + + public long getFeedId() { + return feedId; + } + + public void setFeedId(long feedId) { + this.feedId = feedId; + } + + /** + * Returns the image of this item, as specified in the feed. + * To load the image that can be displayed to the user, use {@link #getImageLocation}, + * which also considers embedded pictures or the feed picture if no other picture is present. + */ + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + @Override + public String getHumanReadableIdentifier() { + return title; + } + + public boolean hasChapters() { + return hasChapters; + } + + public void setAutoDownload(boolean autoDownload) { + this.autoDownload = autoDownload ? 1 : 0; + } + + public boolean getAutoDownload() { + return this.autoDownload > 0; + } + + public int getFailedAutoDownloadAttempts() { + if (autoDownload <= 1) { + return 0; + } + int failedAttempts = (int)(autoDownload % 10); + if (failedAttempts == 0) { + failedAttempts = 10; + } + return failedAttempts; + } + + public boolean isAutoDownloadable() { + if (media == null || media.isDownloaded() || autoDownload == 0) { + return false; + } + if (autoDownload == 1) { + return true; + } + int failedAttempts = getFailedAutoDownloadAttempts(); + double magicValue = 1.767; // 1.767^(10[=#maxNumAttempts]-1) = 168 hours / 7 days + int millisecondsInHour = 3600000; + long waitingTime = (long) (Math.pow(magicValue, failedAttempts - 1) * millisecondsInHour); + long grace = TimeUnit.MINUTES.toMillis(5); + return System.currentTimeMillis() > (autoDownload + waitingTime - grace); + } + + /** + * @return true if the item has this 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); + } + + /** + * @param tag the to remove + */ + public void removeTag(String tag) { + tags.remove(tag); + } + + @NonNull + @Override + public String toString() { + return ToStringBuilder.reflectionToString(this, ToStringStyle.SHORT_PREFIX_STYLE); + } +} diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/FeedItemFilter.java b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedItemFilter.java new file mode 100644 index 000000000..20434213a --- /dev/null +++ b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedItemFilter.java @@ -0,0 +1,60 @@ +package de.danoeh.antennapod.model.feed; + +import android.text.TextUtils; +import java.util.Arrays; + +public class FeedItemFilter { + + 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.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"); + } + + private boolean hasProperty(String property) { + return Arrays.asList(properties).contains(property); + } + + public String[] getValues() { + return properties.clone(); + } + + public boolean isShowDownloaded() { + return showDownloaded; + } +} diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/FeedMedia.java b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedMedia.java new file mode 100644 index 000000000..d2e4e4556 --- /dev/null +++ b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedMedia.java @@ -0,0 +1,484 @@ +package de.danoeh.antennapod.model.feed; + +import android.content.Context; +import android.content.SharedPreferences.Editor; +import android.media.MediaMetadataRetriever; +import android.net.Uri; +import android.os.Parcel; +import android.os.Parcelable; +import androidx.annotation.Nullable; +import android.support.v4.media.MediaBrowserCompat; +import android.support.v4.media.MediaDescriptionCompat; +import de.danoeh.antennapod.model.playback.MediaType; +import de.danoeh.antennapod.model.playback.Playable; +import de.danoeh.antennapod.model.playback.RemoteMedia; + +import java.util.Date; +import java.util.List; + +public class FeedMedia extends FeedFile implements Playable { + public static final int FEEDFILETYPE_FEEDMEDIA = 2; + public static final int PLAYABLE_TYPE_FEEDMEDIA = 1; + public static final String FILENAME_PREFIX_EMBEDDED_COVER = "metadata-retriever:"; + + public static final String PREF_MEDIA_ID = "FeedMedia.PrefMediaId"; + private static final String PREF_FEED_ID = "FeedMedia.PrefFeedId"; + + /** + * Indicates we've checked on the size of the item via the network + * and got an invalid response. Using Integer.MIN_VALUE because + * 1) we'll still check on it in case it gets downloaded (it's <= 0) + * 2) By default all FeedMedia have a size of 0 if we don't know it, + * so this won't conflict with existing practice. + */ + private static final int CHECKED_ON_SIZE_BUT_UNKNOWN = Integer.MIN_VALUE; + + private int duration; + private int position; // Current position in file + private long lastPlayedTime; // Last time this media was played (in ms) + private int played_duration; // How many ms of this file have been played + private long size; // File size in Byte + private String mime_type; + @Nullable private volatile FeedItem item; + private Date playbackCompletionDate; + private int startPosition = -1; + private int playedDurationWhenStarted; + + // if null: unknown, will be checked + private Boolean hasEmbeddedPicture; + + /* Used for loading item when restoring from parcel. */ + private long itemID; + + public FeedMedia(FeedItem i, String download_url, long size, + String mime_type) { + super(null, download_url, false); + this.item = i; + this.size = size; + this.mime_type = mime_type; + } + + public FeedMedia(long id, FeedItem item, int duration, int position, + long size, String mime_type, String file_url, String download_url, + boolean downloaded, Date playbackCompletionDate, int played_duration, + long lastPlayedTime) { + super(file_url, download_url, downloaded); + this.id = id; + this.item = item; + this.duration = duration; + this.position = position; + this.played_duration = played_duration; + this.playedDurationWhenStarted = played_duration; + this.size = size; + this.mime_type = mime_type; + this.playbackCompletionDate = playbackCompletionDate == null + ? null : (Date) playbackCompletionDate.clone(); + this.lastPlayedTime = lastPlayedTime; + } + + public FeedMedia(long id, FeedItem item, int duration, int position, + long size, String mime_type, String file_url, String download_url, + boolean downloaded, Date playbackCompletionDate, int played_duration, + Boolean hasEmbeddedPicture, long lastPlayedTime) { + this(id, item, duration, position, size, mime_type, file_url, download_url, downloaded, + playbackCompletionDate, played_duration, lastPlayedTime); + this.hasEmbeddedPicture = hasEmbeddedPicture; + } + + @Override + public String getHumanReadableIdentifier() { + if (item != null && item.getTitle() != null) { + return item.getTitle(); + } else { + return download_url; + } + } + + /** + * Returns a MediaItem representing the FeedMedia object. + * This is used by the MediaBrowserService + */ + public MediaBrowserCompat.MediaItem getMediaItem() { + Playable p = this; + MediaDescriptionCompat.Builder builder = new MediaDescriptionCompat.Builder() + .setMediaId(String.valueOf(id)) + .setTitle(p.getEpisodeTitle()) + .setDescription(p.getFeedTitle()) + .setSubtitle(p.getFeedTitle()); + if (item != null) { + // 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().getImageUrl() != null) { + builder.setIconUri(Uri.parse(item.getFeed().getImageUrl())); + } + } + return new MediaBrowserCompat.MediaItem(builder.build(), MediaBrowserCompat.MediaItem.FLAG_PLAYABLE); + } + + /** + * Uses mimetype to determine the type of media. + */ + public MediaType getMediaType() { + return MediaType.fromMimeType(mime_type); + } + + public void updateFromOther(FeedMedia other) { + super.updateFromOther(other); + if (other.size > 0) { + size = other.size; + } + if (other.mime_type != null) { + mime_type = other.mime_type; + } + } + + public boolean compareWithOther(FeedMedia other) { + if (super.compareWithOther(other)) { + return true; + } + if (other.mime_type != null) { + if (mime_type == null || !mime_type.equals(other.mime_type)) { + return true; + } + } + if (other.size > 0 && other.size != size) { + return true; + } + return false; + } + + @Override + public int getTypeAsInt() { + return FEEDFILETYPE_FEEDMEDIA; + } + + public int getDuration() { + return duration; + } + + public void setDuration(int duration) { + this.duration = duration; + } + + @Override + public void setLastPlayedTime(long lastPlayedTime) { + this.lastPlayedTime = lastPlayedTime; + } + + public int getPlayedDuration() { + return played_duration; + } + + public int getPlayedDurationWhenStarted() { + return playedDurationWhenStarted; + } + + public void setPlayedDuration(int played_duration) { + this.played_duration = played_duration; + } + + public int getPosition() { + return position; + } + + @Override + public long getLastPlayedTime() { + return lastPlayedTime; + } + + public void setPosition(int position) { + this.position = position; + if(position > 0 && item != null && item.isNew()) { + this.item.setPlayed(false); + } + } + + public long getSize() { + return size; + } + + public void setSize(long size) { + 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. + */ + public void setCheckedOnSizeButUnknown() { + this.size = CHECKED_ON_SIZE_BUT_UNKNOWN; + } + + public boolean checkedOnSizeButUnknown() { + return (CHECKED_ON_SIZE_BUT_UNKNOWN == this.size); + } + + public String getMime_type() { + return mime_type; + } + + public void setMime_type(String mime_type) { + this.mime_type = mime_type; + } + + @Nullable + public FeedItem getItem() { + return item; + } + + /** + * Sets the item object of this FeedMedia. If the given + * FeedItem object is not null, it's 'media'-attribute value + * will also be set to this media object. + */ + public void setItem(FeedItem item) { + this.item = item; + if (item != null && item.getMedia() != this) { + item.setMedia(this); + } + } + + public Date getPlaybackCompletionDate() { + return playbackCompletionDate == null + ? null : (Date) playbackCompletionDate.clone(); + } + + public void setPlaybackCompletionDate(Date playbackCompletionDate) { + this.playbackCompletionDate = playbackCompletionDate == null + ? null : (Date) playbackCompletionDate.clone(); + } + + public boolean isInProgress() { + return (this.position > 0); + } + + @Override + public int describeContents() { + return 0; + } + + public boolean hasEmbeddedPicture() { + if(hasEmbeddedPicture == null) { + checkEmbeddedPicture(); + } + return hasEmbeddedPicture; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeLong(id); + dest.writeLong(item != null ? item.getId() : 0L); + + dest.writeInt(duration); + dest.writeInt(position); + dest.writeLong(size); + dest.writeString(mime_type); + dest.writeString(file_url); + dest.writeString(download_url); + dest.writeByte((byte) ((downloaded) ? 1 : 0)); + dest.writeLong((playbackCompletionDate != null) ? playbackCompletionDate.getTime() : 0); + dest.writeInt(played_duration); + dest.writeLong(lastPlayedTime); + } + + @Override + public void writeToPreferences(Editor prefEditor) { + if(item != null && item.getFeed() != null) { + prefEditor.putLong(PREF_FEED_ID, item.getFeed().getId()); + } else { + prefEditor.putLong(PREF_FEED_ID, 0L); + } + prefEditor.putLong(PREF_MEDIA_ID, id); + } + + @Override + public String getEpisodeTitle() { + if (item == null) { + return null; + } + if (item.getTitle() != null) { + return item.getTitle(); + } else { + return item.getIdentifyingValue(); + } + } + + @Override + public List<Chapter> getChapters() { + if (item == null) { + return null; + } + return item.getChapters(); + } + + @Override + public String getWebsiteLink() { + if (item == null) { + return null; + } + return item.getLink(); + } + + @Override + public String getFeedTitle() { + if (item == null || item.getFeed() == null) { + return null; + } + return item.getFeed().getTitle(); + } + + @Override + public Object getIdentifier() { + return id; + } + + @Override + public String getLocalMediaUrl() { + return file_url; + } + + @Override + public String getStreamUrl() { + return download_url; + } + + public int getStartPosition() { + return startPosition; + } + + @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; + } + + public long getItemId() { + return itemID; + } + + @Override + public void onPlaybackStart() { + startPosition = Math.max(position, 0); + playedDurationWhenStarted = played_duration; + } + + @Override + public void onPlaybackPause(Context context) { + if (position > startPosition) { + played_duration = playedDurationWhenStarted + position - startPosition; + playedDurationWhenStarted = played_duration; + } + startPosition = position; + } + + @Override + public void onPlaybackCompleted(Context context) { + startPosition = -1; + } + + @Override + public int getPlayableType() { + return PLAYABLE_TYPE_FEEDMEDIA; + } + + @Override + public void setChapters(List<Chapter> chapters) { + if (item != null) { + item.setChapters(chapters); + } + } + + public static final Parcelable.Creator<FeedMedia> CREATOR = new Parcelable.Creator<FeedMedia>() { + public FeedMedia createFromParcel(Parcel in) { + final long id = in.readLong(); + final long itemID = in.readLong(); + FeedMedia result = new FeedMedia(id, null, in.readInt(), in.readInt(), in.readLong(), in.readString(), in.readString(), + in.readString(), in.readByte() != 0, new Date(in.readLong()), in.readInt(), in.readLong()); + result.itemID = itemID; + return result; + } + + public FeedMedia[] newArray(int size) { + return new FeedMedia[size]; + } + }; + + @Override + public String getImageLocation() { + if (item != null) { + return item.getImageLocation(); + } else if (hasEmbeddedPicture()) { + return FILENAME_PREFIX_EMBEDDED_COVER + getLocalMediaUrl(); + } else { + return null; + } + } + + public void setHasEmbeddedPicture(Boolean hasEmbeddedPicture) { + this.hasEmbeddedPicture = hasEmbeddedPicture; + } + + @Override + public void setDownloaded(boolean downloaded) { + super.setDownloaded(downloaded); + if(item != null && downloaded && item.isNew()) { + item.setPlayed(false); + } + } + + @Override + public void setFile_url(String file_url) { + super.setFile_url(file_url); + } + + public void checkEmbeddedPicture() { + if (!localFileAvailable()) { + hasEmbeddedPicture = Boolean.FALSE; + return; + } + MediaMetadataRetriever mmr = new MediaMetadataRetriever(); + try { + mmr.setDataSource(getLocalMediaUrl()); + byte[] image = mmr.getEmbeddedPicture(); + if(image != null) { + hasEmbeddedPicture = Boolean.TRUE; + } else { + hasEmbeddedPicture = Boolean.FALSE; + } + } catch (Exception e) { + e.printStackTrace(); + hasEmbeddedPicture = Boolean.FALSE; + } + } + + @Override + public boolean equals(Object o) { + if (o == null) { + return false; + } + if (o instanceof RemoteMedia) { + return o.equals(this); + } + return super.equals(o); + } +} diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/FeedPreferences.java b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedPreferences.java new file mode 100644 index 000000000..cbbb3c2e7 --- /dev/null +++ b/model/src/main/java/de/danoeh/antennapod/model/feed/FeedPreferences.java @@ -0,0 +1,214 @@ +package de.danoeh.antennapod.model.feed; + +import androidx.annotation.NonNull; +import android.text.TextUtils; + +import java.io.Serializable; +import java.util.HashSet; +import java.util.Set; + +/** + * Contains preferences for a single feed. + */ +public class FeedPreferences implements Serializable { + + public static final float SPEED_USE_GLOBAL = -1; + public static final String TAG_ROOT = "#root"; + public static final String TAG_SEPARATOR = "\u001e"; + + public enum AutoDeleteAction { + GLOBAL, + YES, + NO + } + + @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) { + this(feedID, autoDownload, true, autoDeleteAction, volumeAdaptionSetting, + username, password, new FeedFilter(), SPEED_USE_GLOBAL, 0, 0, false, new HashSet<>()); + } + + public 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; + this.keepUpdated = keepUpdated; + this.autoDeleteAction = autoDeleteAction; + this.volumeAdaptionSetting = volumeAdaptionSetting; + this.username = username; + this.password = password; + this.filter = filter; + this.feedPlaybackSpeed = feedPlaybackSpeed; + this.feedSkipIntro = feedSkipIntro; + this.feedSkipEnding = feedSkipEnding; + this.showEpisodeNotification = showEpisodeNotification; + this.tags.addAll(tags); + } + + /** + * @return the filter for this feed + */ + @NonNull public FeedFilter getFilter() { + return filter; + } + + public void setFilter(@NonNull FeedFilter filter) { + this.filter = filter; + } + + /** + * @return true if this feed should be refreshed when everything else is being refreshed + * if false the feed should only be refreshed if requested directly. + */ + public boolean getKeepUpdated() { + return keepUpdated; + } + + public void setKeepUpdated(boolean keepUpdated) { + this.keepUpdated = keepUpdated; + } + + /** + * Compare another FeedPreferences with this one. The feedID, autoDownload and AutoDeleteAction attribute are excluded from the + * comparison. + * + * @return True if the two objects are different. + */ + public boolean compareWithOther(FeedPreferences other) { + if (other == null) { + return true; + } + if (!TextUtils.equals(username, other.username)) { + return true; + } + if (!TextUtils.equals(password, other.password)) { + return true; + } + return false; + } + + /** + * Update this FeedPreferences object from another one. The feedID, autoDownload and AutoDeleteAction attributes are excluded + * from the update. + */ + public void updateFromOther(FeedPreferences other) { + if (other == null) + return; + this.username = other.username; + this.password = other.password; + } + + public long getFeedID() { + return feedID; + } + + public void setFeedID(long feedID) { + this.feedID = feedID; + } + + public boolean getAutoDownload() { + return autoDownload; + } + + public void setAutoDownload(boolean autoDownload) { + this.autoDownload = autoDownload; + } + + public AutoDeleteAction getAutoDeleteAction() { + return autoDeleteAction; + } + + public VolumeAdaptionSetting getVolumeAdaptionSetting() { + return volumeAdaptionSetting; + } + + public void setAutoDeleteAction(AutoDeleteAction autoDeleteAction) { + this.autoDeleteAction = autoDeleteAction; + } + + public void setVolumeAdaptionSetting(VolumeAdaptionSetting volumeAdaptionSetting) { + this.volumeAdaptionSetting = volumeAdaptionSetting; + } + + public AutoDeleteAction getCurrentAutoDelete() { + return autoDeleteAction; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public float getFeedPlaybackSpeed() { + return feedPlaybackSpeed; + } + + public void setFeedPlaybackSpeed(float playbackSpeed) { + feedPlaybackSpeed = playbackSpeed; + } + + public void setFeedSkipIntro(int skipIntro) { + feedSkipIntro = skipIntro; + } + + public int getFeedSkipIntro() { + return feedSkipIntro; + } + + public void setFeedSkipEnding(int skipEnding) { + feedSkipEnding = skipEnding; + } + + public int getFeedSkipEnding() { + return feedSkipEnding; + } + + public Set<String> getTags() { + return tags; + } + + 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/model/src/main/java/de/danoeh/antennapod/model/feed/SortOrder.java b/model/src/main/java/de/danoeh/antennapod/model/feed/SortOrder.java new file mode 100644 index 000000000..cbc56fd11 --- /dev/null +++ b/model/src/main/java/de/danoeh/antennapod/model/feed/SortOrder.java @@ -0,0 +1,71 @@ +package de.danoeh.antennapod.model.feed; + +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import static de.danoeh.antennapod.model.feed.SortOrder.Scope.INTER_FEED; +import static de.danoeh.antennapod.model.feed.SortOrder.Scope.INTRA_FEED; + +/** + * Provides sort orders to sort a list of episodes. + */ +public enum SortOrder { + DATE_OLD_NEW(1, INTRA_FEED), + DATE_NEW_OLD(2, INTRA_FEED), + EPISODE_TITLE_A_Z(3, INTRA_FEED), + EPISODE_TITLE_Z_A(4, INTRA_FEED), + DURATION_SHORT_LONG(5, INTRA_FEED), + DURATION_LONG_SHORT(6, INTRA_FEED), + FEED_TITLE_A_Z(101, INTER_FEED), + FEED_TITLE_Z_A(102, INTER_FEED), + RANDOM(103, INTER_FEED), + SMART_SHUFFLE_OLD_NEW(104, INTER_FEED), + SMART_SHUFFLE_NEW_OLD(105, INTER_FEED); + + public enum Scope { + INTRA_FEED, INTER_FEED + } + + public final int code; + + @NonNull + public final Scope scope; + + SortOrder(int code, @NonNull Scope scope) { + this.code = code; + this.scope = scope; + } + + /** + * Converts the string representation to its enum value. If the string value is unknown, + * the given default value is returned. + */ + public static SortOrder parseWithDefault(String value, SortOrder defaultValue) { + try { + return valueOf(value); + } catch (IllegalArgumentException e) { + return defaultValue; + } + } + + @Nullable + public static SortOrder fromCodeString(@Nullable String codeStr) { + if (TextUtils.isEmpty(codeStr)) { + return null; + } + int code = Integer.parseInt(codeStr); + for (SortOrder sortOrder : values()) { + if (sortOrder.code == code) { + return sortOrder; + } + } + throw new IllegalArgumentException("Unsupported code: " + code); + } + + @Nullable + public static String toCodeString(@Nullable SortOrder sortOrder) { + return sortOrder != null ? Integer.toString(sortOrder.code) : null; + } +} diff --git a/model/src/main/java/de/danoeh/antennapod/model/feed/VolumeAdaptionSetting.java b/model/src/main/java/de/danoeh/antennapod/model/feed/VolumeAdaptionSetting.java new file mode 100644 index 000000000..67e30e5c8 --- /dev/null +++ b/model/src/main/java/de/danoeh/antennapod/model/feed/VolumeAdaptionSetting.java @@ -0,0 +1,32 @@ +package de.danoeh.antennapod.model.feed; + +public enum VolumeAdaptionSetting { + OFF(0, 1.0f), + LIGHT_REDUCTION(1, 0.5f), + HEAVY_REDUCTION(2, 0.2f); + + private final int value; + private float adaptionFactor; + + VolumeAdaptionSetting(int value, float adaptionFactor) { + this.value = value; + this.adaptionFactor = adaptionFactor; + } + + public static VolumeAdaptionSetting fromInteger(int value) { + for (VolumeAdaptionSetting setting : values()) { + if (setting.value == value) { + return setting; + } + } + throw new IllegalArgumentException("Cannot map value to VolumeAdaptionSetting: " + value); + } + + public int toInteger() { + return value; + } + + public float getAdaptionFactor() { + return adaptionFactor; + } +} diff --git a/model/src/main/java/de/danoeh/antennapod/model/playback/MediaType.java b/model/src/main/java/de/danoeh/antennapod/model/playback/MediaType.java new file mode 100644 index 000000000..6a7b36097 --- /dev/null +++ b/model/src/main/java/de/danoeh/antennapod/model/playback/MediaType.java @@ -0,0 +1,56 @@ +package de.danoeh.antennapod.model.playback; + +import android.text.TextUtils; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +public enum MediaType { + AUDIO, VIDEO, UNKNOWN; + + private static final Set<String> AUDIO_APPLICATION_MIME_STRINGS = new HashSet<>(Arrays.asList( + "application/ogg", + "application/opus", + "application/x-flac" + )); + + // based on https://developer.android.com/guide/topics/media/media-formats + static final Set<String> AUDIO_FILE_EXTENSIONS = new HashSet<>(Arrays.asList( + "3gp", "aac", "amr", "flac", "imy", "m4a", "mid", "mkv", "mp3", "mp4", "mxmf", "oga", + "ogg", "ogx", "opus", "ota", "rtttl", "rtx", "wav", "xmf" + )); + + static final Set<String> VIDEO_FILE_EXTENSIONS = new HashSet<>(Arrays.asList( + "3gp", "mkv", "mp4", "ogg", "ogv", "ogx", "webm" + )); + + public static MediaType fromMimeType(String mimeType) { + if (TextUtils.isEmpty(mimeType)) { + return MediaType.UNKNOWN; + } else if (mimeType.startsWith("audio")) { + return MediaType.AUDIO; + } else if (mimeType.startsWith("video")) { + return MediaType.VIDEO; + } else if (AUDIO_APPLICATION_MIME_STRINGS.contains(mimeType)) { + return MediaType.AUDIO; + } + return MediaType.UNKNOWN; + } + + /** + * @param extensionWithoutDot the file extension (suffix) without the dot + * @return the {@link MediaType} that likely corresponds to the extension. However, since the + * extension is not always enough to determine whether a file is an audio or video (3gp + * can be both, for example), this may not be correct. As a result, where possible, + * {@link #fromMimeType(String) fromMimeType} should always be tried first. + */ + public static MediaType fromFileExtension(String extensionWithoutDot) { + if (AUDIO_FILE_EXTENSIONS.contains(extensionWithoutDot)) { + return MediaType.AUDIO; + } else if (VIDEO_FILE_EXTENSIONS.contains(extensionWithoutDot)) { + return MediaType.VIDEO; + } + return MediaType.UNKNOWN; + } +} diff --git a/model/src/main/java/de/danoeh/antennapod/model/playback/Playable.java b/model/src/main/java/de/danoeh/antennapod/model/playback/Playable.java new file mode 100644 index 000000000..151be92e6 --- /dev/null +++ b/model/src/main/java/de/danoeh/antennapod/model/playback/Playable.java @@ -0,0 +1,153 @@ +package de.danoeh.antennapod.model.playback; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Parcelable; + +import androidx.annotation.Nullable; +import de.danoeh.antennapod.model.feed.Chapter; + +import java.util.Date; +import java.util.List; + +/** + * Interface for objects that can be played by the PlaybackService. + */ +public interface Playable extends Parcelable { + int INVALID_TIME = -1; + + /** + * Save information about the playable in a preference so that it can be + * restored later via PlayableUtils.createInstanceFromPreferences. + * Implementations must NOT call commit() after they have written the values + * to the preferences file. + */ + void writeToPreferences(SharedPreferences.Editor prefEditor); + + /** + * Returns the title of the episode that this playable represents + */ + String getEpisodeTitle(); + + /** + * Returns a list of chapter marks or null if this Playable has no chapters. + */ + List<Chapter> getChapters(); + + /** + * Returns a link to a website that is meant to be shown in a browser + */ + String getWebsiteLink(); + + /** + * Returns the title of the feed this Playable belongs to. + */ + String getFeedTitle(); + + /** + * Returns the published date + */ + Date getPubDate(); + + /** + * Returns a unique identifier, for example a file url or an ID from a + * database. + */ + Object getIdentifier(); + + /** + * Return duration of object or 0 if duration is unknown. + */ + int getDuration(); + + /** + * Return position of object or 0 if position is unknown. + */ + int getPosition(); + + /** + * Returns last time (in ms) when this playable was played or 0 + * if last played time is unknown. + */ + 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. + */ + MediaType getMediaType(); + + /** + * Returns an url to a local file that can be played or null if this file + * does not exist. + */ + String getLocalMediaUrl(); + + /** + * Returns an url to a file that can be streamed by the player or null if + * this url is not known. + */ + String getStreamUrl(); + + /** + * Returns true if a local file that can be played is available. getFileUrl + * MUST return a non-null string if this method returns true. + */ + boolean localFileAvailable(); + + void setPosition(int newPosition); + + void setDuration(int newDuration); + + /** + * @param lastPlayedTimestamp timestamp in ms + */ + void setLastPlayedTime(long lastPlayedTimestamp); + + /** + * This method should be called every time playback starts on this object. + * <p/> + * Position held by this Playable should be set accurately before a call to this method is made. + */ + void onPlaybackStart(); + + /** + * This method should be called every time playback pauses or stops on this object, + * including just before a seeking operation is performed, after which a call to + * {@link #onPlaybackStart()} should be made. If playback completes, calling this method is not + * necessary, as long as a call to {@link #onPlaybackCompleted(Context)} is made. + * <p/> + * Position held by this Playable should be set accurately before a call to this method is made. + */ + void onPlaybackPause(Context context); + + /** + * This method should be called when playback completes for this object. + * @param context + */ + void onPlaybackCompleted(Context context); + + /** + * Returns an integer that must be unique among all Playable classes. The + * return value is later used by PlayableUtils to determine the type of the + * Playable object that is restored. + */ + int getPlayableType(); + + void setChapters(List<Chapter> chapters); + + /** + * 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. + */ + @Nullable + String getImageLocation(); + +} diff --git a/model/src/main/java/de/danoeh/antennapod/model/playback/RemoteMedia.java b/model/src/main/java/de/danoeh/antennapod/model/playback/RemoteMedia.java new file mode 100644 index 000000000..d4d91c79c --- /dev/null +++ b/model/src/main/java/de/danoeh/antennapod/model/playback/RemoteMedia.java @@ -0,0 +1,316 @@ +package de.danoeh.antennapod.model.playback; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Parcel; +import android.os.Parcelable; +import android.text.TextUtils; +import androidx.annotation.Nullable; +import de.danoeh.antennapod.model.feed.Chapter; +import de.danoeh.antennapod.model.feed.Feed; +import de.danoeh.antennapod.model.feed.FeedItem; +import de.danoeh.antennapod.model.feed.FeedMedia; + +import java.util.Date; +import java.util.List; + +import org.apache.commons.lang3.builder.HashCodeBuilder; + +/** + * Playable implementation for media for which a local version of + * {@link FeedMedia} hasn't been found. + * Used for Casting and for previewing unsubscribed feeds. + */ +public class RemoteMedia implements Playable { + public static final String TAG = "RemoteMedia"; + + public static final int PLAYABLE_TYPE_REMOTE_MEDIA = 3; + + private final String downloadUrl; + private final String itemIdentifier; + private final String feedUrl; + private final String feedTitle; + private final String episodeTitle; + private final String episodeLink; + private final String feedAuthor; + private final String imageUrl; + private final String feedLink; + private final String mimeType; + private final Date pubDate; + private final String notes; + private List<Chapter> chapters; + private int duration; + private int position; + private long lastPlayedTime; + + public RemoteMedia(String downloadUrl, String itemId, String feedUrl, String feedTitle, + String episodeTitle, String episodeLink, String feedAuthor, + String imageUrl, String feedLink, String mimeType, Date pubDate, + String notes) { + this.downloadUrl = downloadUrl; + this.itemIdentifier = itemId; + this.feedUrl = feedUrl; + this.feedTitle = feedTitle; + this.episodeTitle = episodeTitle; + this.episodeLink = episodeLink; + this.feedAuthor = feedAuthor; + this.imageUrl = imageUrl; + this.feedLink = feedLink; + this.mimeType = mimeType; + this.pubDate = pubDate; + this.notes = notes; + } + + public RemoteMedia(FeedItem item) { + this.downloadUrl = item.getMedia().getDownload_url(); + this.itemIdentifier = item.getItemIdentifier(); + this.feedUrl = item.getFeed().getDownload_url(); + this.feedTitle = item.getFeed().getTitle(); + this.episodeTitle = item.getTitle(); + this.episodeLink = item.getLink(); + this.feedAuthor = item.getFeed().getAuthor(); + if (!TextUtils.isEmpty(item.getImageUrl())) { + this.imageUrl = item.getImageUrl(); + } else { + this.imageUrl = item.getFeed().getImageUrl(); + } + this.feedLink = item.getFeed().getLink(); + this.mimeType = item.getMedia().getMime_type(); + this.pubDate = item.getPubDate(); + this.notes = item.getDescription(); + } + + public String getEpisodeIdentifier() { + return itemIdentifier; + } + + public String getFeedUrl() { + return feedUrl; + } + + public String getDownloadUrl() { + return downloadUrl; + } + + public String getEpisodeLink() { + return episodeLink; + } + + public String getFeedAuthor() { + return feedAuthor; + } + + public String getImageUrl() { + return imageUrl; + } + + public String getFeedLink() { + return feedLink; + } + + public String getMimeType() { + return mimeType; + } + + public Date getPubDate() { + return pubDate; + } + + public String getNotes() { + return notes; + } + + @Override + public void writeToPreferences(SharedPreferences.Editor prefEditor) { + //it seems pointless to do it, since the session should be kept by the remote device. + } + + @Override + public String getEpisodeTitle() { + return episodeTitle; + } + + @Override + public List<Chapter> getChapters() { + return chapters; + } + + @Override + public String getWebsiteLink() { + if (episodeLink != null) { + return episodeLink; + } else { + return feedUrl; + } + } + + @Override + public String getFeedTitle() { + return feedTitle; + } + + @Override + public Object getIdentifier() { + return itemIdentifier + "@" + feedUrl; + } + + @Override + public int getDuration() { + return duration; + } + + @Override + public int getPosition() { + return position; + } + + @Override + public long getLastPlayedTime() { + return lastPlayedTime; + } + + @Override + public MediaType getMediaType() { + return MediaType.fromMimeType(mimeType); + } + + @Override + public String getLocalMediaUrl() { + return null; + } + + @Override + public String getStreamUrl() { + return downloadUrl; + } + + @Override + public boolean localFileAvailable() { + return false; + } + + @Override + public void setPosition(int newPosition) { + position = newPosition; + } + + @Override + public void setDuration(int newDuration) { + duration = newDuration; + } + + @Override + public void setLastPlayedTime(long lastPlayedTimestamp) { + lastPlayedTime = lastPlayedTimestamp; + } + + @Override + public void onPlaybackStart() { + // no-op + } + + @Override + public void onPlaybackPause(Context context) { + // no-op + } + + @Override + public void onPlaybackCompleted(Context context) { + // no-op + } + + @Override + public int getPlayableType() { + return PLAYABLE_TYPE_REMOTE_MEDIA; + } + + @Override + public void setChapters(List<Chapter> chapters) { + this.chapters = chapters; + } + + @Override + @Nullable + public String getImageLocation() { + return imageUrl; + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public String getDescription() { + return notes; + } + + @Override + public void writeToParcel(Parcel dest, int flags) { + dest.writeString(downloadUrl); + dest.writeString(itemIdentifier); + dest.writeString(feedUrl); + dest.writeString(feedTitle); + dest.writeString(episodeTitle); + dest.writeString(episodeLink); + dest.writeString(feedAuthor); + dest.writeString(imageUrl); + dest.writeString(feedLink); + dest.writeString(mimeType); + dest.writeLong((pubDate != null) ? pubDate.getTime() : 0); + dest.writeString(notes); + dest.writeInt(duration); + dest.writeInt(position); + dest.writeLong(lastPlayedTime); + } + + public static final Parcelable.Creator<RemoteMedia> CREATOR = new Parcelable.Creator<RemoteMedia>() { + @Override + public RemoteMedia createFromParcel(Parcel in) { + RemoteMedia result = new RemoteMedia(in.readString(), in.readString(), in.readString(), + in.readString(), in.readString(), in.readString(), in.readString(), in.readString(), + in.readString(), in.readString(), new Date(in.readLong()), in.readString()); + result.setDuration(in.readInt()); + result.setPosition(in.readInt()); + result.setLastPlayedTime(in.readLong()); + return result; + } + + @Override + public RemoteMedia[] newArray(int size) { + return new RemoteMedia[size]; + } + }; + + @Override + public boolean equals(Object other) { + if (other instanceof RemoteMedia) { + RemoteMedia rm = (RemoteMedia) other; + return TextUtils.equals(downloadUrl, rm.downloadUrl) + && TextUtils.equals(feedUrl, rm.feedUrl) + && TextUtils.equals(itemIdentifier, rm.itemIdentifier); + } + if (other instanceof FeedMedia) { + FeedMedia fm = (FeedMedia) other; + if (!TextUtils.equals(downloadUrl, fm.getStreamUrl())) { + return false; + } + FeedItem fi = fm.getItem(); + if (fi == null || !TextUtils.equals(itemIdentifier, fi.getItemIdentifier())) { + return false; + } + Feed feed = fi.getFeed(); + return feed != null && TextUtils.equals(feedUrl, feed.getDownload_url()); + } + return false; + } + + @Override + public int hashCode() { + return new HashCodeBuilder() + .append(downloadUrl) + .append(feedUrl) + .append(itemIdentifier) + .toHashCode(); + } +} |