summaryrefslogtreecommitdiff
path: root/net/download
diff options
context:
space:
mode:
Diffstat (limited to 'net/download')
-rw-r--r--net/download/service-interface/build.gradle4
-rw-r--r--net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/AutoDownloadManager.java39
-rw-r--r--net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/DownloadRequest.java297
-rw-r--r--net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/DownloadRequestBuilder.java69
-rw-r--r--net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/DownloadRequestCreator.java122
-rw-r--r--net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/FeedUpdateManager.java30
-rw-r--r--net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/FileNameGenerator.java76
-rw-r--r--net/download/service-interface/src/test/java/de/danoeh/antennapod/net/download/serviceinterface/DownloadRequestBuilderTest.java (renamed from net/download/service-interface/src/test/java/de/danoeh/antennapod/net/download/serviceinterface/DownloadRequestTest.java)13
-rw-r--r--net/download/service-interface/src/test/java/de/danoeh/antennapod/net/download/serviceinterface/FilenameGeneratorTest.java98
-rw-r--r--net/download/service/README.md3
-rw-r--r--net/download/service/build.gradle47
-rw-r--r--net/download/service/src/main/AndroidManifest.xml36
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/ConnectivityActionReceiver.java34
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/PowerConnectionReceiver.java46
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/EpisodeDownloadWorker.java311
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/MediaDownloadedHandler.java119
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/APCleanupAlgorithm.java135
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/APNullCleanupAlgorithm.java29
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/APQueueCleanupAlgorithm.java99
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/AutoDownloadManagerImpl.java55
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/AutomaticDownloadAlgorithm.java121
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/EpisodeCleanupAlgorithm.java66
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/EpisodeCleanupAlgorithmFactory.java22
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/ExceptFavoriteCleanupAlgorithm.java104
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/DownloadServiceInterfaceImpl.java97
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateManagerImpl.java117
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateReceiver.java23
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateWorker.java217
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/NewEpisodesNotification.java147
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/local/FastDocumentFile.java72
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/local/LocalFeedUpdater.java285
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/DefaultDownloaderFactory.java21
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/Downloader.java62
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/DownloaderFactory.java10
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/FeedParserTask.java124
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/HttpDownloader.java328
-rw-r--r--net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/InvalidFeedException.java12
-rw-r--r--net/download/service/src/main/res/values/ids.xml6
-rw-r--r--net/download/service/src/test/assets/local-feed1/track1.mp3bin0 -> 43341 bytes
-rw-r--r--net/download/service/src/test/assets/local-feed2/folder.pngbin0 -> 1589 bytes
-rw-r--r--net/download/service/src/test/assets/local-feed2/track1.mp3bin0 -> 43341 bytes
-rw-r--r--net/download/service/src/test/assets/local-feed2/track2.mp3bin0 -> 43497 bytes
-rw-r--r--net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/APCleanupAlgorithmTest.java20
-rw-r--r--net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbCleanupTests.java234
-rw-r--r--net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbNullCleanupAlgorithmTest.java125
-rw-r--r--net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbQueueCleanupAlgorithmTest.java54
-rw-r--r--net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbReaderTest.java526
-rw-r--r--net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbTasksTest.java249
-rw-r--r--net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbTestUtils.java74
-rw-r--r--net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbWriterTest.java826
-rw-r--r--net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/ExceptFavoriteCleanupAlgorithmTest.java91
-rw-r--r--net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/feed/local/LocalFeedUpdaterTest.java308
52 files changed, 5700 insertions, 303 deletions
diff --git a/net/download/service-interface/build.gradle b/net/download/service-interface/build.gradle
index 784c1375f..a6ecd8c58 100644
--- a/net/download/service-interface/build.gradle
+++ b/net/download/service-interface/build.gradle
@@ -3,6 +3,7 @@ plugins {
id("java-test-fixtures")
}
apply from: "../../../common.gradle"
+apply from: "../../../playFlavor.gradle"
android {
namespace "de.danoeh.antennapod.net.download.serviceinterface"
@@ -15,8 +16,11 @@ android {
dependencies {
implementation project(':model')
implementation project(':net:common')
+ implementation project(':storage:preferences')
annotationProcessor "androidx.annotation:annotation:$annotationVersion"
+ implementation "org.apache.commons:commons-lang3:$commonslangVersion"
+ implementation "commons-io:commons-io:$commonsioVersion"
testImplementation "junit:junit:$junitVersion"
testImplementation "org.robolectric:robolectric:$robolectricVersion"
diff --git a/net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/AutoDownloadManager.java b/net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/AutoDownloadManager.java
new file mode 100644
index 000000000..2eb1d1b56
--- /dev/null
+++ b/net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/AutoDownloadManager.java
@@ -0,0 +1,39 @@
+package de.danoeh.antennapod.net.download.serviceinterface;
+
+import android.content.Context;
+
+import java.util.concurrent.Future;
+
+public abstract class AutoDownloadManager {
+ private static AutoDownloadManager instance;
+
+ public static AutoDownloadManager getInstance() {
+ return instance;
+ }
+
+ public static void setInstance(AutoDownloadManager instance) {
+ AutoDownloadManager.instance = instance;
+ }
+
+ /**
+ * 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
+ * This method is executed on an internal single thread executor.
+ *
+ * @param context Used for accessing the DB.
+ * @return A Future that can be used for waiting for the methods completion.
+ */
+ public abstract Future<?> autodownloadUndownloadedItems(final Context context);
+
+ /**
+ * Removed downloaded episodes outside of the queue if the episode cache is full. Episodes with a smaller
+ * 'playbackCompletionDate'-value will be deleted first.
+ * <p/>
+ * This method should NOT be executed on the GUI thread.
+ *
+ * @param context Used for accessing the DB.
+ */
+ public abstract void performAutoCleanup(final Context context);
+}
diff --git a/net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/DownloadRequest.java b/net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/DownloadRequest.java
deleted file mode 100644
index 962ecfc84..000000000
--- a/net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/DownloadRequest.java
+++ /dev/null
@@ -1,297 +0,0 @@
-package de.danoeh.antennapod.net.download.serviceinterface;
-
-import android.os.Bundle;
-import android.os.Parcel;
-import android.os.Parcelable;
-import android.text.TextUtils;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import de.danoeh.antennapod.model.feed.Feed;
-import de.danoeh.antennapod.net.common.UrlChecker;
-import de.danoeh.antennapod.model.feed.FeedMedia;
-
-public class DownloadRequest implements Parcelable {
- public static final String REQUEST_ARG_PAGE_NR = "page";
-
- private final String destination;
- private final String source;
- private final String title;
- private String username;
- private String password;
- private String lastModified;
- private final long feedfileId;
- private final int feedfileType;
- private final Bundle arguments;
-
- private int progressPercent;
- private long soFar;
- private long size;
- private int statusMsg;
- private boolean mediaEnqueued;
- private boolean initiatedByUser;
-
- public DownloadRequest(@NonNull String destination, @NonNull String source, @NonNull String title, long feedfileId,
- int feedfileType, String username, String password,
- Bundle arguments, boolean initiatedByUser) {
- this(destination, source, title, feedfileId, feedfileType, null, username, password, false,
- arguments, initiatedByUser);
- }
-
- private DownloadRequest(Builder builder) {
- this(builder.destination, builder.source, builder.title, builder.feedfileId, builder.feedfileType,
- builder.lastModified, builder.username, builder.password, false,
- builder.arguments, builder.initiatedByUser);
- }
-
- private DownloadRequest(Parcel in) {
- this(in.readString(), in.readString(), in.readString(), in.readLong(), in.readInt(), in.readString(),
- nullIfEmpty(in.readString()), nullIfEmpty(in.readString()), in.readByte() > 0,
- in.readBundle(), in.readByte() > 0);
- }
-
- private DownloadRequest(String destination, String source, String title, long feedfileId, int feedfileType,
- String lastModified, String username, String password,
- boolean mediaEnqueued, Bundle arguments, boolean initiatedByUser) {
- this.destination = destination;
- this.source = source;
- this.title = title;
- this.feedfileId = feedfileId;
- this.feedfileType = feedfileType;
- this.lastModified = lastModified;
- this.username = username;
- this.password = password;
- this.mediaEnqueued = mediaEnqueued;
- this.arguments = arguments;
- this.initiatedByUser = initiatedByUser;
- }
-
- @Override
- public int describeContents() {
- return 0;
- }
-
- @Override
- public void writeToParcel(Parcel dest, int flags) {
- dest.writeString(destination);
- dest.writeString(source);
- dest.writeString(title);
- dest.writeLong(feedfileId);
- dest.writeInt(feedfileType);
- dest.writeString(lastModified);
- // in case of null username/password, still write an empty string
- // (rather than skipping it). Otherwise, unmarshalling a collection
- // of them from a Parcel (from an Intent extra to submit a request to DownloadService) will fail.
- //
- // see: https://stackoverflow.com/a/22926342
- dest.writeString(nonNullString(username));
- dest.writeString(nonNullString(password));
- dest.writeByte((mediaEnqueued) ? (byte) 1 : 0);
- dest.writeBundle(arguments);
- dest.writeByte(initiatedByUser ? (byte) 1 : 0);
- }
-
- private static String nonNullString(String str) {
- return str != null ? str : "";
- }
-
- private static String nullIfEmpty(String str) {
- return TextUtils.isEmpty(str) ? null : str;
- }
-
- public static final Parcelable.Creator<DownloadRequest> CREATOR = new Parcelable.Creator<DownloadRequest>() {
- public DownloadRequest createFromParcel(Parcel in) {
- return new DownloadRequest(in);
- }
-
- public DownloadRequest[] newArray(int size) {
- return new DownloadRequest[size];
- }
- };
-
-
- @Override
- public boolean equals(Object o) {
- if (this == o) return true;
- if (!(o instanceof DownloadRequest)) return false;
-
- DownloadRequest that = (DownloadRequest) o;
-
- if (lastModified != null ? !lastModified.equals(that.lastModified) : that.lastModified != null)
- return false;
- if (feedfileId != that.feedfileId) return false;
- if (feedfileType != that.feedfileType) return false;
- if (progressPercent != that.progressPercent) return false;
- if (size != that.size) return false;
- if (soFar != that.soFar) return false;
- if (statusMsg != that.statusMsg) return false;
- if (!destination.equals(that.destination)) return false;
- if (password != null ? !password.equals(that.password) : that.password != null)
- return false;
- if (!source.equals(that.source)) return false;
- if (title != null ? !title.equals(that.title) : that.title != null) return false;
- if (username != null ? !username.equals(that.username) : that.username != null)
- return false;
- if (mediaEnqueued != that.mediaEnqueued) return false;
- if (initiatedByUser != that.initiatedByUser) return false;
- return true;
- }
-
- @Override
- public int hashCode() {
- int result = destination.hashCode();
- result = 31 * result + source.hashCode();
- result = 31 * result + (title != null ? title.hashCode() : 0);
- result = 31 * result + (username != null ? username.hashCode() : 0);
- result = 31 * result + (password != null ? password.hashCode() : 0);
- result = 31 * result + (lastModified != null ? lastModified.hashCode() : 0);
- result = 31 * result + (int) (feedfileId ^ (feedfileId >>> 32));
- result = 31 * result + feedfileType;
- result = 31 * result + arguments.hashCode();
- result = 31 * result + progressPercent;
- result = 31 * result + (int) (soFar ^ (soFar >>> 32));
- result = 31 * result + (int) (size ^ (size >>> 32));
- result = 31 * result + statusMsg;
- result = 31 * result + (mediaEnqueued ? 1 : 0);
- return result;
- }
-
- public String getDestination() {
- return destination;
- }
-
- public String getSource() {
- return source;
- }
-
- public String getTitle() {
- return title;
- }
-
- public long getFeedfileId() {
- return feedfileId;
- }
-
- public int getFeedfileType() {
- return feedfileType;
- }
-
- public int getProgressPercent() {
- return progressPercent;
- }
-
- public void setProgressPercent(int progressPercent) {
- this.progressPercent = progressPercent;
- }
-
- public long getSoFar() {
- return soFar;
- }
-
- public void setSoFar(long soFar) {
- this.soFar = soFar;
- }
-
- public long getSize() {
- return size;
- }
-
- public void setSize(long size) {
- this.size = size;
- }
-
- public void setStatusMsg(int statusMsg) {
- this.statusMsg = statusMsg;
- }
-
- public String getUsername() {
- return username;
- }
-
- public String getPassword() {
- return password;
- }
-
- public void setUsername(String username) {
- this.username = username;
- }
-
- public void setPassword(String password) {
- this.password = password;
- }
-
- public DownloadRequest setLastModified(@Nullable String lastModified) {
- this.lastModified = lastModified;
- return this;
- }
-
- @Nullable
- public String getLastModified() {
- return lastModified;
- }
-
- public Bundle getArguments() {
- return arguments;
- }
-
- public static class Builder {
- private final String destination;
- private String source;
- private final String title;
- private String username;
- private String password;
- private String lastModified;
- private final long feedfileId;
- private final int feedfileType;
- private final Bundle arguments = new Bundle();
- private boolean initiatedByUser = true;
-
- public Builder(@NonNull String destination, @NonNull FeedMedia media) {
- this.destination = destination;
- this.source = UrlChecker.prepareUrl(media.getDownload_url());
- this.title = media.getHumanReadableIdentifier();
- this.feedfileId = media.getId();
- this.feedfileType = media.getTypeAsInt();
- }
-
- public Builder(@NonNull String destination, @NonNull Feed feed) {
- this.destination = destination;
- this.source = feed.isLocalFeed() ? feed.getDownload_url() : UrlChecker.prepareUrl(feed.getDownload_url());
- this.title = feed.getHumanReadableIdentifier();
- this.feedfileId = feed.getId();
- this.feedfileType = feed.getTypeAsInt();
- arguments.putInt(REQUEST_ARG_PAGE_NR, feed.getPageNr());
- }
-
- public Builder withInitiatedByUser(boolean initiatedByUser) {
- this.initiatedByUser = initiatedByUser;
- return this;
- }
-
- public void setSource(String source) {
- this.source = source;
- }
-
- public void setForce(boolean force) {
- if (force) {
- lastModified = null;
- }
- }
-
- public Builder lastModified(String lastModified) {
- this.lastModified = lastModified;
- return this;
- }
-
- public Builder withAuthentication(String username, String password) {
- this.username = username;
- this.password = password;
- return this;
- }
-
- public DownloadRequest build() {
- return new DownloadRequest(this);
- }
- }
-}
diff --git a/net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/DownloadRequestBuilder.java b/net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/DownloadRequestBuilder.java
new file mode 100644
index 000000000..35a801537
--- /dev/null
+++ b/net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/DownloadRequestBuilder.java
@@ -0,0 +1,69 @@
+package de.danoeh.antennapod.net.download.serviceinterface;
+
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import de.danoeh.antennapod.model.download.DownloadRequest;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.net.common.UrlChecker;
+
+public class DownloadRequestBuilder {
+ private final String destination;
+ private String source;
+ private final String title;
+ private String username;
+ private String password;
+ private String lastModified;
+ private final long feedfileId;
+ private final int feedfileType;
+ private final Bundle arguments = new Bundle();
+ private boolean initiatedByUser = true;
+
+ public DownloadRequestBuilder(@NonNull String destination, @NonNull FeedMedia media) {
+ this.destination = destination;
+ this.source = UrlChecker.prepareUrl(media.getDownloadUrl());
+ this.title = media.getHumanReadableIdentifier();
+ this.feedfileId = media.getId();
+ this.feedfileType = FeedMedia.FEEDFILETYPE_FEEDMEDIA;
+ }
+
+ public DownloadRequestBuilder(@NonNull String destination, @NonNull Feed feed) {
+ this.destination = destination;
+ this.source = feed.isLocalFeed() ? feed.getDownloadUrl() : UrlChecker.prepareUrl(feed.getDownloadUrl());
+ this.title = feed.getHumanReadableIdentifier();
+ this.feedfileId = feed.getId();
+ this.feedfileType = Feed.FEEDFILETYPE_FEED;
+ arguments.putInt(DownloadRequest.REQUEST_ARG_PAGE_NR, feed.getPageNr());
+ }
+
+ public DownloadRequestBuilder withInitiatedByUser(boolean initiatedByUser) {
+ this.initiatedByUser = initiatedByUser;
+ return this;
+ }
+
+ public void setSource(String source) {
+ this.source = source;
+ }
+
+ public void setForce(boolean force) {
+ if (force) {
+ lastModified = null;
+ }
+ }
+
+ public DownloadRequestBuilder lastModified(String lastModified) {
+ this.lastModified = lastModified;
+ return this;
+ }
+
+ public DownloadRequestBuilder withAuthentication(String username, String password) {
+ this.username = username;
+ this.password = password;
+ return this;
+ }
+
+ public DownloadRequest build() {
+ return new DownloadRequest(destination, source, title, feedfileId, feedfileType,
+ lastModified, username, password, false, arguments, initiatedByUser);
+ }
+} \ No newline at end of file
diff --git a/net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/DownloadRequestCreator.java b/net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/DownloadRequestCreator.java
new file mode 100644
index 000000000..c0d70523c
--- /dev/null
+++ b/net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/DownloadRequestCreator.java
@@ -0,0 +1,122 @@
+package de.danoeh.antennapod.net.download.serviceinterface;
+
+import android.util.Log;
+import android.webkit.URLUtil;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import org.apache.commons.io.FilenameUtils;
+
+import java.io.File;
+
+/**
+ * Creates download requests that can be sent to the DownloadService.
+ */
+public class DownloadRequestCreator {
+ private static final String TAG = "DownloadRequestCreat";
+ private static final String FEED_DOWNLOADPATH = "cache/";
+ private static final String MEDIA_DOWNLOADPATH = "media/";
+
+ public static DownloadRequestBuilder create(Feed feed) {
+ File dest = new File(getFeedfilePath(), getFeedfileName(feed));
+ if (dest.exists()) {
+ boolean deleted = dest.delete();
+ Log.d(TAG, "deleted" + dest.getPath() + ": " + deleted);
+ }
+ Log.d(TAG, "Requesting download of url " + feed.getDownloadUrl());
+
+ String username = (feed.getPreferences() != null) ? feed.getPreferences().getUsername() : null;
+ String password = (feed.getPreferences() != null) ? feed.getPreferences().getPassword() : null;
+
+ return new DownloadRequestBuilder(dest.toString(), feed)
+ .withAuthentication(username, password)
+ .lastModified(feed.getLastModified());
+ }
+
+ public static DownloadRequestBuilder create(FeedMedia media) {
+ final boolean partiallyDownloadedFileExists =
+ media.getLocalFileUrl() != null && new File(media.getLocalFileUrl()).exists();
+ File dest;
+ if (partiallyDownloadedFileExists) {
+ dest = new File(media.getLocalFileUrl());
+ } else {
+ dest = new File(getMediafilePath(media), getMediafilename(media));
+ }
+
+ if (dest.exists() && !partiallyDownloadedFileExists) {
+ dest = findUnusedFile(dest);
+ }
+ Log.d(TAG, "Requesting download of url " + media.getDownloadUrl());
+
+ String username = (media.getItem().getFeed().getPreferences() != null)
+ ? media.getItem().getFeed().getPreferences().getUsername() : null;
+ String password = (media.getItem().getFeed().getPreferences() != null)
+ ? media.getItem().getFeed().getPreferences().getPassword() : null;
+
+ return new DownloadRequestBuilder(dest.toString(), media)
+ .withAuthentication(username, password);
+ }
+
+ private static File findUnusedFile(File dest) {
+ // find different name
+ File newDest = null;
+ for (int i = 1; i < Integer.MAX_VALUE; i++) {
+ String newName = FilenameUtils.getBaseName(dest
+ .getName())
+ + "-"
+ + i
+ + FilenameUtils.EXTENSION_SEPARATOR
+ + FilenameUtils.getExtension(dest.getName());
+ Log.d(TAG, "Testing filename " + newName);
+ newDest = new File(dest.getParent(), newName);
+ if (!newDest.exists()) {
+ Log.d(TAG, "File doesn't exist yet. Using " + newName);
+ break;
+ }
+ }
+ return newDest;
+ }
+
+ private static String getFeedfilePath() {
+ return UserPreferences.getDataFolder(FEED_DOWNLOADPATH).toString() + "/";
+ }
+
+ private static String getFeedfileName(Feed feed) {
+ String filename = feed.getDownloadUrl();
+ if (feed.getTitle() != null && !feed.getTitle().isEmpty()) {
+ filename = feed.getTitle();
+ }
+ return "feed-" + FileNameGenerator.generateFileName(filename) + feed.getId();
+ }
+
+ private static String getMediafilePath(FeedMedia media) {
+ String mediaPath = MEDIA_DOWNLOADPATH
+ + FileNameGenerator.generateFileName(media.getItem().getFeed().getTitle());
+ return UserPreferences.getDataFolder(mediaPath).toString() + "/";
+ }
+
+ private static String getMediafilename(FeedMedia media) {
+ String titleBaseFilename = "";
+
+ // Try to generate the filename by the item title
+ if (media.getItem() != null && media.getItem().getTitle() != null) {
+ String title = media.getItem().getTitle();
+ titleBaseFilename = FileNameGenerator.generateFileName(title);
+ }
+
+ String urlBaseFilename = URLUtil.guessFileName(media.getDownloadUrl(), null, media.getMimeType());
+
+ String baseFilename;
+ if (!titleBaseFilename.equals("")) {
+ baseFilename = titleBaseFilename;
+ } else {
+ baseFilename = urlBaseFilename;
+ }
+ final int filenameMaxLength = 220;
+ if (baseFilename.length() > filenameMaxLength) {
+ baseFilename = baseFilename.substring(0, filenameMaxLength);
+ }
+ return baseFilename + FilenameUtils.EXTENSION_SEPARATOR + media.getId()
+ + FilenameUtils.EXTENSION_SEPARATOR + FilenameUtils.getExtension(urlBaseFilename);
+ }
+}
diff --git a/net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/FeedUpdateManager.java b/net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/FeedUpdateManager.java
new file mode 100644
index 000000000..25a8f42d3
--- /dev/null
+++ b/net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/FeedUpdateManager.java
@@ -0,0 +1,30 @@
+package de.danoeh.antennapod.net.download.serviceinterface;
+
+import android.content.Context;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import de.danoeh.antennapod.model.feed.Feed;
+
+public abstract class FeedUpdateManager {
+ private static FeedUpdateManager instance;
+
+ public static FeedUpdateManager getInstance() {
+ return instance;
+ }
+
+ public static void setInstance(FeedUpdateManager instance) {
+ FeedUpdateManager.instance = instance;
+ }
+
+ public abstract void restartUpdateAlarm(Context context, boolean replace);
+
+ public abstract void runOnce(Context context);
+
+ public abstract void runOnce(Context context, Feed feed);
+
+ public abstract void runOnce(Context context, Feed feed, boolean nextPage);
+
+ public abstract void runOnceOrAsk(@NonNull Context context);
+
+ public abstract void runOnceOrAsk(@NonNull Context context, @Nullable Feed feed);
+}
diff --git a/net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/FileNameGenerator.java b/net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/FileNameGenerator.java
new file mode 100644
index 000000000..85e73836f
--- /dev/null
+++ b/net/download/service-interface/src/main/java/de/danoeh/antennapod/net/download/serviceinterface/FileNameGenerator.java
@@ -0,0 +1,76 @@
+package de.danoeh.antennapod.net.download.serviceinterface;
+
+import android.text.TextUtils;
+
+import androidx.annotation.VisibleForTesting;
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.StringUtils;
+
+import java.io.UnsupportedEncodingException;
+import java.security.NoSuchAlgorithmException;
+
+
+/** Generates valid filenames for a given string. */
+public class FileNameGenerator {
+ @VisibleForTesting
+ public static final int MAX_FILENAME_LENGTH = 242; // limited by CircleCI
+ private static final int MD5_HEX_LENGTH = 32;
+
+ private static final char[] validChars =
+ ("abcdefghijklmnopqrstuvwxyz"
+ + "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ + "0123456789"
+ + " _-").toCharArray();
+
+ private FileNameGenerator() {
+ }
+
+ /**
+ * This method will return a new string that doesn't contain any illegal
+ * characters of the given string.
+ */
+ public static String generateFileName(String string) {
+ string = StringUtils.stripAccents(string);
+ StringBuilder buf = new StringBuilder();
+ for (int i = 0; i < string.length(); i++) {
+ char c = string.charAt(i);
+ if (Character.isSpaceChar(c)
+ && (buf.length() == 0 || Character.isSpaceChar(buf.charAt(buf.length() - 1)))) {
+ continue;
+ }
+ if (ArrayUtils.contains(validChars, c)) {
+ buf.append(c);
+ }
+ }
+ String filename = buf.toString().trim();
+ if (TextUtils.isEmpty(filename)) {
+ return randomString(8);
+ } else if (filename.length() >= MAX_FILENAME_LENGTH) {
+ return filename.substring(0, MAX_FILENAME_LENGTH - MD5_HEX_LENGTH - 1) + "_" + md5(filename);
+ } else {
+ return filename;
+ }
+ }
+
+ private static String randomString(int length) {
+ StringBuilder sb = new StringBuilder(length);
+ for (int i = 0; i < length; i++) {
+ sb.append(validChars[(int) (Math.random() * validChars.length)]);
+ }
+ return sb.toString();
+ }
+
+ private static String md5(String md5) {
+ try {
+ java.security.MessageDigest md = java.security.MessageDigest.getInstance("MD5");
+ byte[] array = md.digest(md5.getBytes("UTF-8"));
+ StringBuilder sb = new StringBuilder();
+ for (byte b : array) {
+ sb.append(Integer.toHexString((b & 0xFF) | 0x100).substring(1, 3));
+ }
+ return sb.toString();
+ } catch (NoSuchAlgorithmException | UnsupportedEncodingException e) {
+ return null;
+ }
+ }
+}
diff --git a/net/download/service-interface/src/test/java/de/danoeh/antennapod/net/download/serviceinterface/DownloadRequestTest.java b/net/download/service-interface/src/test/java/de/danoeh/antennapod/net/download/serviceinterface/DownloadRequestBuilderTest.java
index a48934cfe..e9876a949 100644
--- a/net/download/service-interface/src/test/java/de/danoeh/antennapod/net/download/serviceinterface/DownloadRequestTest.java
+++ b/net/download/service-interface/src/test/java/de/danoeh/antennapod/net/download/serviceinterface/DownloadRequestBuilderTest.java
@@ -3,6 +3,7 @@ package de.danoeh.antennapod.net.download.serviceinterface;
import android.os.Bundle;
import android.os.Parcel;
+import de.danoeh.antennapod.model.download.DownloadRequest;
import de.danoeh.antennapod.model.feed.FeedMedia;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -14,7 +15,7 @@ import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotEquals;
@RunWith(RobolectricTestRunner.class)
-public class DownloadRequestTest {
+public class DownloadRequestBuilderTest {
@Test
public void parcelInArrayListTest_WithAuth() {
@@ -40,15 +41,15 @@ public class DownloadRequestTest {
String username = "testUser";
String password = "testPassword";
FeedMedia item = createFeedItem(1);
- DownloadRequest request1 = new DownloadRequest.Builder(destStr, item)
+ DownloadRequest request1 = new DownloadRequestBuilder(destStr, item)
.withAuthentication(username, password)
.build();
- DownloadRequest request2 = new DownloadRequest.Builder(destStr, item)
+ DownloadRequest request2 = new DownloadRequestBuilder(destStr, item)
.withAuthentication(username, password)
.build();
- DownloadRequest request3 = new DownloadRequest.Builder(destStr, item)
+ DownloadRequest request3 = new DownloadRequestBuilder(destStr, item)
.withAuthentication("diffUsername", "diffPassword")
.build();
@@ -65,12 +66,12 @@ public class DownloadRequestTest {
{ // test DownloadRequests to parcel
String destStr = "file://location/media.mp3";
FeedMedia item1 = createFeedItem(1);
- DownloadRequest request1 = new DownloadRequest.Builder(destStr, item1)
+ DownloadRequest request1 = new DownloadRequestBuilder(destStr, item1)
.withAuthentication(username1, password1)
.build();
FeedMedia item2 = createFeedItem(2);
- DownloadRequest request2 = new DownloadRequest.Builder(destStr, item2)
+ DownloadRequest request2 = new DownloadRequestBuilder(destStr, item2)
.withAuthentication(username2, password2)
.build();
diff --git a/net/download/service-interface/src/test/java/de/danoeh/antennapod/net/download/serviceinterface/FilenameGeneratorTest.java b/net/download/service-interface/src/test/java/de/danoeh/antennapod/net/download/serviceinterface/FilenameGeneratorTest.java
new file mode 100644
index 000000000..521fc2d36
--- /dev/null
+++ b/net/download/service-interface/src/test/java/de/danoeh/antennapod/net/download/serviceinterface/FilenameGeneratorTest.java
@@ -0,0 +1,98 @@
+package de.danoeh.antennapod.net.download.serviceinterface;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+import android.text.TextUtils;
+
+import java.io.File;
+
+import org.apache.commons.lang3.StringUtils;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(RobolectricTestRunner.class)
+public class FilenameGeneratorTest {
+
+ public FilenameGeneratorTest() {
+ super();
+ }
+
+ @Test
+ public void testGenerateFileName() throws Exception {
+ String result = FileNameGenerator.generateFileName("abc abc");
+ assertEquals(result, "abc abc");
+ createFiles(result);
+ }
+
+ @Test
+ public void testGenerateFileName1() throws Exception {
+ String result = FileNameGenerator.generateFileName("ab/c: <abc");
+ assertEquals(result, "abc abc");
+ createFiles(result);
+ }
+
+ @Test
+ public void testGenerateFileName2() throws Exception {
+ String result = FileNameGenerator.generateFileName("abc abc ");
+ assertEquals(result, "abc abc");
+ createFiles(result);
+ }
+
+ @Test
+ public void testFeedTitleContainsApostrophe() {
+ String result = FileNameGenerator.generateFileName("Feed's Title ...");
+ assertEquals("Feeds Title", result);
+ }
+
+ @Test
+ public void testFeedTitleContainsDash() {
+ String result = FileNameGenerator.generateFileName("Left - Right");
+ assertEquals("Left - Right", result);
+ }
+
+ @Test
+ public void testFeedTitleContainsAccents() {
+ String result = FileNameGenerator.generateFileName("Äàáâãå");
+ assertEquals("Aaaaaa", result);
+ }
+
+ @Test
+ public void testInvalidInput() {
+ String result = FileNameGenerator.generateFileName("???");
+ assertFalse(TextUtils.isEmpty(result));
+ }
+
+ @Test
+ public void testLongFilename() throws Exception {
+ String longName = StringUtils.repeat("x", 20 + FileNameGenerator.MAX_FILENAME_LENGTH);
+ String result = FileNameGenerator.generateFileName(longName);
+ assertTrue(result.length() <= FileNameGenerator.MAX_FILENAME_LENGTH);
+ createFiles(result);
+ }
+
+ @Test
+ public void testLongFilenameNotEquals() {
+ // Verify that the name is not just trimmed and different suffixes end up with the same name
+ String longName = StringUtils.repeat("x", 20 + FileNameGenerator.MAX_FILENAME_LENGTH);
+ String result1 = FileNameGenerator.generateFileName(longName + "a");
+ String result2 = FileNameGenerator.generateFileName(longName + "b");
+ assertNotEquals(result1, result2);
+ }
+
+ /**
+ * Tests if files can be created.
+ */
+ private void createFiles(String name) throws Exception {
+ File cache = InstrumentationRegistry.getInstrumentation().getTargetContext().getExternalCacheDir();
+ File testFile = new File(cache, name);
+ assertTrue(testFile.mkdir());
+ assertTrue(testFile.exists());
+ assertTrue(testFile.delete());
+ assertTrue(testFile.createNewFile());
+ }
+}
diff --git a/net/download/service/README.md b/net/download/service/README.md
new file mode 100644
index 000000000..ecee50402
--- /dev/null
+++ b/net/download/service/README.md
@@ -0,0 +1,3 @@
+# :net:download:service
+
+The download service.
diff --git a/net/download/service/build.gradle b/net/download/service/build.gradle
new file mode 100644
index 000000000..cebffc75c
--- /dev/null
+++ b/net/download/service/build.gradle
@@ -0,0 +1,47 @@
+plugins {
+ id("com.android.library")
+ id("java-test-fixtures")
+}
+apply from: "../../../common.gradle"
+apply from: "../../../playFlavor.gradle"
+
+android {
+ namespace "de.danoeh.antennapod.net.download.service"
+}
+
+dependencies {
+ implementation project(':event')
+ implementation project(':model')
+ implementation project(':net:common')
+ implementation project(':net:download:service-interface')
+ implementation project(':net:sync:service-interface')
+ implementation project(':parser:media')
+ implementation project(':parser:feed')
+ implementation project(':storage:database')
+ implementation project(':ui:notifications')
+ implementation project(':storage:preferences')
+ implementation project(':ui:app-start-intent')
+ implementation project(':ui:chapters')
+
+ annotationProcessor "androidx.annotation:annotation:$annotationVersion"
+ implementation "androidx.core:core:$coreVersion"
+ implementation 'androidx.documentfile:documentfile:1.0.1'
+ implementation "androidx.work:work-runtime:$workManagerVersion"
+ implementation "com.google.android.material:material:$googleMaterialVersion"
+
+ implementation "com.squareup.okhttp3:okhttp:$okhttpVersion"
+ implementation "com.squareup.okhttp3:okhttp-urlconnection:$okhttpVersion"
+ implementation "commons-io:commons-io:$commonsioVersion"
+ implementation "org.apache.commons:commons-lang3:$commonslangVersion"
+ implementation "org.greenrobot:eventbus:$eventbusVersion"
+ implementation "com.github.bumptech.glide:glide:$glideVersion"
+ implementation "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion"
+ implementation "io.reactivex.rxjava2:rxjava:$rxJavaVersion"
+ implementation "com.google.guava:guava:31.0.1-android"
+
+ testImplementation "junit:junit:$junitVersion"
+ testImplementation "org.robolectric:robolectric:$robolectricVersion"
+ testImplementation "org.awaitility:awaitility:$awaitilityVersion"
+ testImplementation "org.mockito:mockito-core:$mockitoVersion"
+ testImplementation "androidx.preference:preference:$preferenceVersion"
+}
diff --git a/net/download/service/src/main/AndroidManifest.xml b/net/download/service/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..0a1400010
--- /dev/null
+++ b/net/download/service/src/main/AndroidManifest.xml
@@ -0,0 +1,36 @@
+<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
+
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+ <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+
+ <application
+ android:allowBackup="true"
+ android:supportsRtl="true">
+
+ <receiver android:name=".feed.FeedUpdateReceiver"
+ android:label="@string/feed_update_receiver_name"
+ android:exported="true"
+ tools:ignore="ExportedReceiver" /> <!-- allow feeds update to be triggered by external apps -->
+
+ <receiver
+ android:name=".ConnectivityActionReceiver"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="android.net.conn.CONNECTIVITY_CHANGE" tools:ignore="BatteryLife"/>
+ </intent-filter>
+ </receiver>
+
+ <receiver
+ android:name=".PowerConnectionReceiver"
+ android:exported="true">
+ <intent-filter>
+ <action android:name="android.intent.action.ACTION_POWER_CONNECTED"/>
+ <action android:name="android.intent.action.ACTION_POWER_DISCONNECTED"/>
+ </intent-filter>
+ </receiver>
+
+ </application>
+
+</manifest>
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/ConnectivityActionReceiver.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/ConnectivityActionReceiver.java
new file mode 100644
index 000000000..3b733a482
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/ConnectivityActionReceiver.java
@@ -0,0 +1,34 @@
+package de.danoeh.antennapod.net.download.service;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.net.ConnectivityManager;
+import android.text.TextUtils;
+import android.util.Log;
+
+import de.danoeh.antennapod.net.common.NetworkUtils;
+import de.danoeh.antennapod.net.download.serviceinterface.AutoDownloadManager;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadServiceInterface;
+
+public class ConnectivityActionReceiver extends BroadcastReceiver {
+ private static final String TAG = "ConnectivityActionRecvr";
+
+ @Override
+ public void onReceive(final Context context, Intent intent) {
+ if (TextUtils.equals(intent.getAction(), ConnectivityManager.CONNECTIVITY_ACTION)) {
+ Log.d(TAG, "Received intent");
+
+ if (NetworkUtils.isAutoDownloadAllowed()) {
+ Log.d(TAG, "auto-dl network available, starting auto-download");
+ AutoDownloadManager.getInstance().autodownloadUndownloadedItems(context);
+ } else { // if new network is Wi-Fi, finish ongoing downloads,
+ // otherwise cancel all downloads
+ if (NetworkUtils.isNetworkRestricted()) {
+ Log.i(TAG, "Device is no longer connected to Wi-Fi. Cancelling ongoing downloads");
+ DownloadServiceInterface.get().cancelAll(context);
+ }
+ }
+ }
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/PowerConnectionReceiver.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/PowerConnectionReceiver.java
new file mode 100644
index 000000000..12a3f82e0
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/PowerConnectionReceiver.java
@@ -0,0 +1,46 @@
+package de.danoeh.antennapod.net.download.service;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import de.danoeh.antennapod.net.download.serviceinterface.AutoDownloadManager;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadServiceInterface;
+
+// modified from http://developer.android.com/training/monitoring-device-state/battery-monitoring.html
+// and ConnectivityActionReceiver.java
+// Updated based on http://stackoverflow.com/questions/20833241/android-charge-intent-has-no-extra-data
+// Since the intent doesn't have the EXTRA_STATUS like the android.com article says it does
+// (though it used to)
+public class PowerConnectionReceiver extends BroadcastReceiver {
+ private static final String TAG = "PowerConnectionReceiver";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ final String action = intent.getAction();
+
+ Log.d(TAG, "charging intent: " + action);
+
+ if (Intent.ACTION_POWER_CONNECTED.equals(action)) {
+ Log.d(TAG, "charging, starting auto-download");
+ // we're plugged in, this is a great time to auto-download if everything else is
+ // right. So, even if the user allows auto-dl on battery, let's still start
+ // downloading now. They shouldn't mind.
+ // autodownloadUndownloadedItems will make sure we're on the right wifi networks,
+ // etc... so we don't have to worry about it.
+ AutoDownloadManager.getInstance().autodownloadUndownloadedItems(context);
+ } else {
+ // if we're not supposed to be auto-downloading when we're not charging, stop it
+ if (!UserPreferences.isEnableAutodownloadOnBattery()) {
+ Log.d(TAG, "not charging anymore, canceling auto-download");
+ DownloadServiceInterface.get().cancelAll(context);
+ } else {
+ Log.d(TAG, "not charging anymore, but the user allows auto-download " +
+ "when on battery so we'll keep going");
+ }
+ }
+
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/EpisodeDownloadWorker.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/EpisodeDownloadWorker.java
new file mode 100644
index 000000000..ff548c039
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/EpisodeDownloadWorker.java
@@ -0,0 +1,311 @@
+package de.danoeh.antennapod.net.download.service.episode;
+
+import android.Manifest;
+import android.app.Notification;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.wifi.WifiManager;
+import android.os.Build;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.core.app.NotificationCompat;
+import androidx.core.content.ContextCompat;
+import androidx.work.Data;
+import androidx.work.ForegroundInfo;
+import androidx.work.Worker;
+import androidx.work.WorkerParameters;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import de.danoeh.antennapod.net.download.service.R;
+import de.danoeh.antennapod.net.download.service.feed.remote.DefaultDownloaderFactory;
+import de.danoeh.antennapod.net.download.service.feed.remote.Downloader;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadRequestCreator;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.event.MessageEvent;
+import de.danoeh.antennapod.model.download.DownloadError;
+import de.danoeh.antennapod.model.download.DownloadResult;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.model.download.DownloadRequest;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadServiceInterface;
+import de.danoeh.antennapod.ui.appstartintent.MainActivityStarter;
+import de.danoeh.antennapod.ui.notifications.NotificationUtils;
+import org.apache.commons.io.FileUtils;
+import org.greenrobot.eventbus.EventBus;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+
+public class EpisodeDownloadWorker extends Worker {
+ private static final String TAG = "EpisodeDownloadWorker";
+ private static final Map<String, Integer> notificationProgress = new HashMap<>();
+
+ private Downloader downloader = null;
+
+ public EpisodeDownloadWorker(@NonNull Context context, @NonNull WorkerParameters params) {
+ super(context, params);
+ }
+
+ @Override
+ @NonNull
+ public Result doWork() {
+ long mediaId = getInputData().getLong(DownloadServiceInterface.WORK_DATA_MEDIA_ID, 0);
+ FeedMedia media = DBReader.getFeedMedia(mediaId);
+ if (media == null) {
+ return Result.failure();
+ }
+
+ DownloadRequest request = DownloadRequestCreator.create(media).build();
+ Thread progressUpdaterThread = new Thread() {
+ @Override
+ public void run() {
+ while (true) {
+ try {
+ synchronized (notificationProgress) {
+ if (isInterrupted()) {
+ return;
+ }
+ notificationProgress.put(media.getEpisodeTitle(), request.getProgressPercent());
+ }
+ setProgressAsync(
+ new Data.Builder()
+ .putInt(DownloadServiceInterface.WORK_DATA_PROGRESS, request.getProgressPercent())
+ .build())
+ .get();
+ NotificationManager nm = (NotificationManager) getApplicationContext()
+ .getSystemService(Context.NOTIFICATION_SERVICE);
+ if (ContextCompat.checkSelfPermission(getApplicationContext(),
+ Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) {
+ nm.notify(R.id.notification_downloading, generateProgressNotification());
+ }
+ Thread.sleep(1000);
+ } catch (InterruptedException | ExecutionException e) {
+ return;
+ }
+ }
+ }
+ };
+ progressUpdaterThread.start();
+ Result result;
+ try {
+ result = performDownload(media, request);
+ } catch (Exception e) {
+ e.printStackTrace();
+ result = Result.failure();
+ }
+ if (result.equals(Result.failure()) && downloader != null) {
+ FileUtils.deleteQuietly(new File(downloader.getDownloadRequest().getDestination()));
+ }
+ progressUpdaterThread.interrupt();
+ try {
+ progressUpdaterThread.join();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+ synchronized (notificationProgress) {
+ notificationProgress.remove(media.getEpisodeTitle());
+ if (notificationProgress.isEmpty()) {
+ NotificationManager nm = (NotificationManager) getApplicationContext()
+ .getSystemService(Context.NOTIFICATION_SERVICE);
+ nm.cancel(R.id.notification_downloading);
+ }
+ }
+ Log.d(TAG, "Worker for " + media.getDownloadUrl() + " returned.");
+ return result;
+ }
+
+ @Override
+ public void onStopped() {
+ super.onStopped();
+ if (downloader != null) {
+ downloader.cancel();
+ }
+ }
+
+ @NonNull
+ @Override
+ public ListenableFuture<ForegroundInfo> getForegroundInfoAsync() {
+ return Futures.immediateFuture(
+ new ForegroundInfo(R.id.notification_downloading, generateProgressNotification()));
+ }
+
+ private Result performDownload(FeedMedia media, DownloadRequest request) {
+ File dest = new File(request.getDestination());
+ if (!dest.exists()) {
+ try {
+ dest.createNewFile();
+ } catch (IOException e) {
+ Log.e(TAG, "Unable to create file");
+ }
+ }
+
+ if (dest.exists()) {
+ media.setLocalFileUrl(request.getDestination());
+ try {
+ DBWriter.setFeedMedia(media).get();
+ } catch (Exception e) {
+ Log.e(TAG, "ExecutionException in writeFileUrl: " + e.getMessage());
+ }
+ }
+
+ downloader = new DefaultDownloaderFactory().create(request);
+ if (downloader == null) {
+ Log.d(TAG, "Unable to create downloader");
+ return Result.failure();
+ }
+
+ WifiManager wifiManager = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE);
+ WifiManager.WifiLock wifiLock = null;
+ if (wifiManager != null) {
+ wifiLock = wifiManager.createWifiLock(TAG);
+ wifiLock.acquire();
+ }
+ try {
+ downloader.call();
+ } catch (Exception e) {
+ DBWriter.addDownloadStatus(downloader.getResult());
+ sendErrorNotification(request.getTitle());
+ return Result.failure();
+ } finally {
+ if (wifiLock != null) {
+ wifiLock.release();
+ }
+ }
+
+ if (downloader.cancelled) {
+ // This also happens when the worker was preempted, not just when the user cancelled it
+ return Result.success();
+ }
+
+ DownloadResult status = downloader.getResult();
+ if (status.isSuccessful()) {
+ MediaDownloadedHandler handler = new MediaDownloadedHandler(
+ getApplicationContext(), downloader.getResult(), request);
+ handler.run();
+ DBWriter.addDownloadStatus(handler.getUpdatedStatus());
+ return Result.success();
+ }
+
+ if (status.getReason() == DownloadError.ERROR_HTTP_DATA_ERROR
+ && Integer.parseInt(status.getReasonDetailed()) == 416) {
+ Log.d(TAG, "Requested invalid range, restarting download from the beginning");
+ FileUtils.deleteQuietly(new File(downloader.getDownloadRequest().getDestination()));
+ sendMessage(request.getTitle(), false);
+ return retry3times();
+ }
+
+ Log.e(TAG, "Download failed");
+ DBWriter.addDownloadStatus(status);
+ if (status.getReason() == DownloadError.ERROR_FORBIDDEN
+ || status.getReason() == DownloadError.ERROR_NOT_FOUND
+ || status.getReason() == DownloadError.ERROR_UNAUTHORIZED
+ || status.getReason() == DownloadError.ERROR_IO_BLOCKED) {
+ // Fail fast, these are probably unrecoverable
+ sendErrorNotification(request.getTitle());
+ return Result.failure();
+ }
+ sendMessage(request.getTitle(), false);
+ return retry3times();
+ }
+
+ private Result retry3times() {
+ if (isLastRunAttempt()) {
+ sendErrorNotification(downloader.getDownloadRequest().getTitle());
+ return Result.failure();
+ } else {
+ return Result.retry();
+ }
+ }
+
+ private boolean isLastRunAttempt() {
+ return getRunAttemptCount() >= 2;
+ }
+
+ private void sendMessage(String episodeTitle, boolean isImmediateFail) {
+ boolean retrying = !isLastRunAttempt() && !isImmediateFail;
+ if (episodeTitle.length() > 20) {
+ episodeTitle = episodeTitle.substring(0, 19) + "…";
+ }
+ EventBus.getDefault().post(new MessageEvent(
+ getApplicationContext().getString(
+ retrying ? R.string.download_error_retrying : R.string.download_error_not_retrying,
+ episodeTitle), (ctx) -> new MainActivityStarter(ctx).withDownloadLogsOpen().start(),
+ getApplicationContext().getString(R.string.download_error_details)));
+ }
+
+ private PendingIntent getDownloadLogsIntent(Context context) {
+ Intent intent = new MainActivityStarter(context).withDownloadLogsOpen().getIntent();
+ return PendingIntent.getActivity(context, R.id.pending_intent_download_service_report, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0));
+ }
+
+ private PendingIntent getDownloadsIntent(Context context) {
+ Intent intent = new MainActivityStarter(context).withFragmentLoaded("DownloadsFragment").getIntent();
+ return PendingIntent.getActivity(context, R.id.pending_intent_download_service_notification, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0));
+ }
+
+ private void sendErrorNotification(String title) {
+ if (EventBus.getDefault().hasSubscriberForEvent(MessageEvent.class)) {
+ sendMessage(title, false);
+ return;
+ }
+
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(getApplicationContext(),
+ NotificationUtils.CHANNEL_ID_DOWNLOAD_ERROR);
+ builder.setTicker(getApplicationContext().getString(R.string.download_report_title))
+ .setContentTitle(getApplicationContext().getString(R.string.download_report_title))
+ .setContentText(getApplicationContext().getString(R.string.download_error_tap_for_details))
+ .setSmallIcon(R.drawable.ic_notification_sync_error)
+ .setContentIntent(getDownloadLogsIntent(getApplicationContext()))
+ .setAutoCancel(true);
+ builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC);
+ NotificationManager nm = (NotificationManager) getApplicationContext()
+ .getSystemService(Context.NOTIFICATION_SERVICE);
+ if (ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.POST_NOTIFICATIONS)
+ == PackageManager.PERMISSION_GRANTED) {
+ nm.notify(R.id.notification_download_report, builder.build());
+ }
+ }
+
+ private Notification generateProgressNotification() {
+ StringBuilder bigTextB = new StringBuilder();
+ Map<String, Integer> progressCopy;
+ synchronized (notificationProgress) {
+ progressCopy = new HashMap<>(notificationProgress);
+ }
+ for (Map.Entry<String, Integer> entry : progressCopy.entrySet()) {
+ bigTextB.append(String.format(Locale.getDefault(), "%s (%d%%)\n", entry.getKey(), entry.getValue()));
+ }
+ String bigText = bigTextB.toString().trim();
+ String contentText;
+ if (progressCopy.size() == 1) {
+ contentText = bigText;
+ } else {
+ contentText = getApplicationContext().getResources().getQuantityString(R.plurals.downloads_left,
+ progressCopy.size(), progressCopy.size());
+ }
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(getApplicationContext(),
+ NotificationUtils.CHANNEL_ID_DOWNLOADING);
+ builder.setTicker(getApplicationContext().getString(R.string.download_notification_title_episodes))
+ .setContentTitle(getApplicationContext().getString(R.string.download_notification_title_episodes))
+ .setContentText(contentText)
+ .setStyle(new NotificationCompat.BigTextStyle().bigText(bigText))
+ .setContentIntent(getDownloadsIntent(getApplicationContext()))
+ .setAutoCancel(false)
+ .setOngoing(true)
+ .setWhen(0)
+ .setOnlyAlertOnce(true)
+ .setShowWhen(false)
+ .setSmallIcon(R.drawable.ic_notification_sync)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC);
+ return builder.build();
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/MediaDownloadedHandler.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/MediaDownloadedHandler.java
new file mode 100644
index 000000000..406149100
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/MediaDownloadedHandler.java
@@ -0,0 +1,119 @@
+package de.danoeh.antennapod.net.download.service.episode;
+
+import android.content.Context;
+import android.media.MediaMetadataRetriever;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import de.danoeh.antennapod.model.MediaMetadataRetrieverCompat;
+import de.danoeh.antennapod.net.sync.serviceinterface.SynchronizationQueueSink;
+import de.danoeh.antennapod.ui.chapters.ChapterUtils;
+import org.greenrobot.eventbus.EventBus;
+
+import java.io.File;
+import java.io.InterruptedIOException;
+import java.util.concurrent.ExecutionException;
+
+import de.danoeh.antennapod.event.UnreadItemsUpdateEvent;
+import de.danoeh.antennapod.model.download.DownloadRequest;
+import de.danoeh.antennapod.model.download.DownloadResult;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.model.download.DownloadError;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.net.sync.serviceinterface.EpisodeAction;
+
+/**
+ * Handles a completed media download.
+ */
+public class MediaDownloadedHandler implements Runnable {
+ private static final String TAG = "MediaDownloadedHandler";
+ private final DownloadRequest request;
+ private final Context context;
+ private DownloadResult updatedStatus;
+
+ public MediaDownloadedHandler(@NonNull Context context, @NonNull DownloadResult status,
+ @NonNull DownloadRequest request) {
+ this.request = request;
+ this.context = context;
+ this.updatedStatus = status;
+ }
+
+ @Override
+ public void run() {
+ FeedMedia media = DBReader.getFeedMedia(request.getFeedfileId());
+ if (media == null) {
+ Log.e(TAG, "Could not find downloaded media object in database");
+ return;
+ }
+ // media.setDownloaded modifies played state
+ boolean broadcastUnreadStateUpdate = media.getItem() != null && media.getItem().isNew();
+ media.setDownloaded(true);
+ media.setLocalFileUrl(request.getDestination());
+ media.setSize(new File(request.getDestination()).length());
+ media.checkEmbeddedPicture(); // enforce check
+
+ try {
+ // Cache chapters if file has them
+ if (media.getItem() != null && !media.getItem().hasChapters()) {
+ media.setChapters(ChapterUtils.loadChaptersFromMediaFile(media, context));
+ }
+ if (media.getItem() != null && media.getItem().getPodcastIndexChapterUrl() != null) {
+ ChapterUtils.loadChaptersFromUrl(media.getItem().getPodcastIndexChapterUrl(), false);
+ }
+ } catch (InterruptedIOException ignore) {
+ // Ignore
+ }
+
+ // Get duration
+ String durationStr = null;
+ try (MediaMetadataRetrieverCompat mmr = new MediaMetadataRetrieverCompat()) {
+ mmr.setDataSource(media.getLocalFileUrl());
+ durationStr = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION);
+ media.setDuration(Integer.parseInt(durationStr));
+ Log.d(TAG, "Duration of file is " + media.getDuration());
+ } catch (NumberFormatException e) {
+ Log.d(TAG, "Invalid file duration: " + durationStr);
+ } catch (Exception e) {
+ Log.e(TAG, "Get duration failed", e);
+ }
+
+ final FeedItem item = media.getItem();
+
+ try {
+ DBWriter.setFeedMedia(media).get();
+
+ // we've received the media, we don't want to autodownload it again
+ if (item != null) {
+ item.disableAutoDownload();
+ // setFeedItem() signals (via EventBus) that the item has been updated,
+ // so we do it after the enclosing media has been updated above,
+ // to ensure subscribers will get the updated FeedMedia as well
+ DBWriter.setFeedItem(item).get();
+ if (broadcastUnreadStateUpdate) {
+ EventBus.getDefault().post(new UnreadItemsUpdateEvent());
+ }
+ }
+ } catch (InterruptedException e) {
+ Log.e(TAG, "MediaHandlerThread was interrupted");
+ } catch (ExecutionException e) {
+ Log.e(TAG, "ExecutionException in MediaHandlerThread: " + e.getMessage());
+ updatedStatus = new DownloadResult(media.getEpisodeTitle(), media.getId(),
+ FeedMedia.FEEDFILETYPE_FEEDMEDIA, false, DownloadError.ERROR_DB_ACCESS_ERROR, e.getMessage());
+ }
+
+ if (item != null) {
+ EpisodeAction action = new EpisodeAction.Builder(item, EpisodeAction.DOWNLOAD)
+ .currentTimestamp()
+ .build();
+ SynchronizationQueueSink.enqueueEpisodeActionIfSynchronizationIsActive(context, action);
+ }
+ }
+
+ @NonNull
+ public DownloadResult getUpdatedStatus() {
+ return updatedStatus;
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/APCleanupAlgorithm.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/APCleanupAlgorithm.java
new file mode 100644
index 000000000..bc50c8c1f
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/APCleanupAlgorithm.java
@@ -0,0 +1,135 @@
+package de.danoeh.antennapod.net.download.service.episode.autodownload;
+
+import android.content.Context;
+import androidx.annotation.NonNull;
+import androidx.annotation.VisibleForTesting;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.Calendar;
+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.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedItemFilter;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.model.feed.SortOrder;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.DBWriter;
+
+/**
+ * Implementation of the EpisodeCleanupAlgorithm interface used by AntennaPod.
+ */
+public class APCleanupAlgorithm extends EpisodeCleanupAlgorithm {
+
+ private static final String TAG = "APCleanupAlgorithm";
+ /** the number of days after playback to wait before an item is eligible to be cleaned up.
+ Fractional for number of hours, e.g., 0.5 = 12 hours, 0.0416 = 1 hour. */
+ private final int numberOfHoursAfterPlayback;
+
+ public APCleanupAlgorithm(int numberOfHoursAfterPlayback) {
+ this.numberOfHoursAfterPlayback = numberOfHoursAfterPlayback;
+ }
+
+ /**
+ * @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;
+
+ Collections.sort(candidates, (lhs, rhs) -> {
+ Date l = lhs.getMedia().getPlaybackCompletionDate();
+ Date r = rhs.getMedia().getPlaybackCompletionDate();
+
+ if (l == null) {
+ l = new Date();
+ }
+ if (r == null) {
+ r = new Date();
+ }
+ return l.compareTo(r);
+ });
+
+ if (candidates.size() > numberOfEpisodesToDelete) {
+ delete = candidates.subList(0, numberOfEpisodesToDelete);
+ } else {
+ delete = candidates;
+ }
+
+ for (FeedItem item : delete) {
+ try {
+ DBWriter.deleteFeedMediaOfItem(context, item.getMedia()).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;
+ }
+
+ @VisibleForTesting
+ Date calcMostRecentDateForDeletion(@NonNull Date currentDate) {
+ return minusHours(currentDate, numberOfHoursAfterPlayback);
+ }
+
+ @NonNull
+ private List<FeedItem> getCandidates() {
+ List<FeedItem> candidates = new ArrayList<>();
+ List<FeedItem> downloadedItems = DBReader.getEpisodes(0, Integer.MAX_VALUE,
+ new FeedItemFilter(FeedItemFilter.DOWNLOADED), SortOrder.DATE_NEW_OLD);
+
+ Date mostRecentDateForDeletion = calcMostRecentDateForDeletion(new Date());
+ for (FeedItem item : downloadedItems) {
+ if (item.hasMedia()
+ && item.getMedia().isDownloaded()
+ && !item.isTagged(FeedItem.TAG_QUEUE)
+ && item.isPlayed()
+ && !item.isTagged(FeedItem.TAG_FAVORITE)) {
+ FeedMedia media = item.getMedia();
+ // make sure this candidate was played at least the proper amount of days prior
+ // to now
+ if (media != null
+ && media.getPlaybackCompletionDate() != null
+ && media.getPlaybackCompletionDate().before(mostRecentDateForDeletion)) {
+ candidates.add(item);
+ }
+ }
+ }
+ return candidates;
+ }
+
+ @Override
+ public int getDefaultCleanupParameter() {
+ return getNumEpisodesToCleanup(0);
+ }
+
+ @VisibleForTesting
+ public int getNumberOfHoursAfterPlayback() { return numberOfHoursAfterPlayback; }
+
+ private static Date minusHours(Date baseDate, int numberOfHours) {
+ Calendar cal = Calendar.getInstance();
+ cal.setTime(baseDate);
+
+ cal.add(Calendar.HOUR_OF_DAY, -1 * numberOfHours);
+
+ return cal.getTime();
+ }
+
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/APNullCleanupAlgorithm.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/APNullCleanupAlgorithm.java
new file mode 100644
index 000000000..f550cecf8
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/APNullCleanupAlgorithm.java
@@ -0,0 +1,29 @@
+package de.danoeh.antennapod.net.download.service.episode.autodownload;
+
+import android.content.Context;
+import android.util.Log;
+
+/**
+ * A cleanup algorithm that never removes anything
+ */
+public class APNullCleanupAlgorithm extends EpisodeCleanupAlgorithm {
+
+ private static final String TAG = "APNullCleanupAlgorithm";
+
+ @Override
+ public int performCleanup(Context context, int parameter) {
+ // never clean anything up
+ Log.i(TAG, "performCleanup: Not removing anything");
+ return 0;
+ }
+
+ @Override
+ public int getDefaultCleanupParameter() {
+ return 0;
+ }
+
+ @Override
+ public int getReclaimableItems() {
+ return 0;
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/APQueueCleanupAlgorithm.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/APQueueCleanupAlgorithm.java
new file mode 100644
index 000000000..ea550599b
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/APQueueCleanupAlgorithm.java
@@ -0,0 +1,99 @@
+package de.danoeh.antennapod.net.download.service.episode.autodownload;
+
+import android.content.Context;
+import androidx.annotation.NonNull;
+import android.util.Log;
+
+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.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedItemFilter;
+import de.danoeh.antennapod.model.feed.SortOrder;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.DBWriter;
+
+/**
+ * A cleanup algorithm that removes any item that isn't in the queue and isn't a favorite
+ * but only if space is needed.
+ */
+public class APQueueCleanupAlgorithm extends EpisodeCleanupAlgorithm {
+
+ private static final String TAG = "APQueueCleanupAlgorithm";
+
+ /**
+ * @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) {
+ l = new Date();
+ }
+ if (r == null) {
+ r = new Date();
+ }
+ return l.compareTo(r);
+ });
+
+ if (candidates.size() > numberOfEpisodesToDelete) {
+ delete = candidates.subList(0, numberOfEpisodesToDelete);
+ } else {
+ delete = candidates;
+ }
+
+ for (FeedItem item : delete) {
+ try {
+ DBWriter.deleteFeedMediaOfItem(context, item.getMedia()).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.getEpisodes(0, Integer.MAX_VALUE,
+ new FeedItemFilter(FeedItemFilter.DOWNLOADED), SortOrder.DATE_NEW_OLD);
+ for (FeedItem item : downloadedItems) {
+ if (item.hasMedia()
+ && item.getMedia().isDownloaded()
+ && !item.isTagged(FeedItem.TAG_QUEUE)
+ && !item.isTagged(FeedItem.TAG_FAVORITE)) {
+ candidates.add(item);
+ }
+ }
+ return candidates;
+ }
+
+ @Override
+ public int getDefaultCleanupParameter() {
+ return getNumEpisodesToCleanup(0);
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/AutoDownloadManagerImpl.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/AutoDownloadManagerImpl.java
new file mode 100644
index 000000000..2b0eb4d62
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/AutoDownloadManagerImpl.java
@@ -0,0 +1,55 @@
+package de.danoeh.antennapod.net.download.service.episode.autodownload;
+
+import android.content.Context;
+import android.util.Log;
+import de.danoeh.antennapod.net.download.serviceinterface.AutoDownloadManager;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+
+public class AutoDownloadManagerImpl extends AutoDownloadManager {
+ private static final String TAG = "AutoDownloadManager";
+
+ /**
+ * Executor service used by the autodownloadUndownloadedEpisodes method.
+ */
+ private static final ExecutorService autodownloadExec;
+
+ private static AutomaticDownloadAlgorithm downloadAlgorithm = new AutomaticDownloadAlgorithm();
+
+ static {
+ autodownloadExec = Executors.newSingleThreadExecutor(r -> {
+ Thread t = new Thread(r);
+ t.setPriority(Thread.MIN_PRIORITY);
+ return t;
+ });
+ }
+
+ /**
+ * 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
+ * This method is executed on an internal single thread executor.
+ *
+ * @param context Used for accessing the DB.
+ * @return A Future that can be used for waiting for the methods completion.
+ */
+ public Future<?> autodownloadUndownloadedItems(final Context context) {
+ Log.d(TAG, "autodownloadUndownloadedItems");
+ return autodownloadExec.submit(downloadAlgorithm.autoDownloadUndownloadedItems(context));
+ }
+
+ /**
+ * Removed downloaded episodes outside of the queue if the episode cache is full. Episodes with a smaller
+ * 'playbackCompletionDate'-value will be deleted first.
+ * <p/>
+ * This method should NOT be executed on the GUI thread.
+ *
+ * @param context Used for accessing the DB.
+ */
+ public void performAutoCleanup(final Context context) {
+ EpisodeCleanupAlgorithmFactory.build().performCleanup(context);
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/AutomaticDownloadAlgorithm.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/AutomaticDownloadAlgorithm.java
new file mode 100644
index 000000000..828211ba1
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/AutomaticDownloadAlgorithm.java
@@ -0,0 +1,121 @@
+package de.danoeh.antennapod.net.download.service.episode.autodownload;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.os.BatteryManager;
+import android.util.Log;
+
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import de.danoeh.antennapod.model.feed.FeedItemFilter;
+import de.danoeh.antennapod.model.feed.SortOrder;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedPreferences;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadServiceInterface;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import de.danoeh.antennapod.net.common.NetworkUtils;
+
+/**
+ * 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 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.
+ */
+ public Runnable autoDownloadUndownloadedItems(final Context context) {
+ return () -> {
+
+ // true if we should auto download based on network status
+ boolean networkShouldAutoDl = NetworkUtils.isAutoDownloadAllowed()
+ && UserPreferences.isEnableAutodownload();
+
+ // true if we should auto download based on power status
+ boolean powerShouldAutoDl = 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.getEpisodes(0, Integer.MAX_VALUE,
+ new FeedItemFilter(FeedItemFilter.NEW), SortOrder.DATE_NEW_OLD);
+ candidates = new ArrayList<>(queue.size() + newItems.size());
+ candidates.addAll(queue);
+ for (FeedItem newItem : newItems) {
+ FeedPreferences feedPrefs = newItem.getFeed().getPreferences();
+ if (feedPrefs.getAutoDownload()
+ && !candidates.contains(newItem)
+ && feedPrefs.getFilter().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.isAutoDownloadEnabled()
+ || item.isDownloaded()
+ || !item.hasMedia()
+ || item.getFeed().isLocalFeed()) {
+ it.remove();
+ }
+ }
+
+ int autoDownloadableEpisodes = candidates.size();
+ int downloadedEpisodes = DBReader.getTotalEpisodeCount(new FeedItemFilter(FeedItemFilter.DOWNLOADED));
+ int deletedEpisodes = EpisodeCleanupAlgorithmFactory.build()
+ .makeRoomForEpisodes(context, autoDownloadableEpisodes);
+ boolean cacheIsUnlimited =
+ UserPreferences.getEpisodeCacheSize() == UserPreferences.EPISODE_CACHE_SIZE_UNLIMITED;
+ int episodeCacheSize = UserPreferences.getEpisodeCacheSize();
+
+ int episodeSpaceLeft;
+ if (cacheIsUnlimited || episodeCacheSize >= downloadedEpisodes + autoDownloadableEpisodes) {
+ episodeSpaceLeft = autoDownloadableEpisodes;
+ } else {
+ episodeSpaceLeft = episodeCacheSize - (downloadedEpisodes - deletedEpisodes);
+ }
+
+ List<FeedItem> itemsToDownload = candidates.subList(0, episodeSpaceLeft);
+ if (itemsToDownload.size() > 0) {
+ Log.d(TAG, "Enqueueing " + itemsToDownload.size() + " items for download");
+
+ for (FeedItem episode : itemsToDownload) {
+ DownloadServiceInterface.get().download(context, episode);
+ }
+ }
+ }
+ };
+ }
+
+ /**
+ * @return true if the device is charging
+ */
+ public static boolean deviceCharging(Context context) {
+ // from http://developer.android.com/training/monitoring-device-state/battery-monitoring.html
+ IntentFilter intentFilter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
+ Intent batteryStatus = context.registerReceiver(null, intentFilter);
+
+ int status = batteryStatus.getIntExtra(BatteryManager.EXTRA_STATUS, -1);
+ return (status == BatteryManager.BATTERY_STATUS_CHARGING
+ || status == BatteryManager.BATTERY_STATUS_FULL);
+
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/EpisodeCleanupAlgorithm.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/EpisodeCleanupAlgorithm.java
new file mode 100644
index 000000000..eb582a19a
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/EpisodeCleanupAlgorithm.java
@@ -0,0 +1,66 @@
+package de.danoeh.antennapod.net.download.service.episode.autodownload;
+
+import android.content.Context;
+
+import de.danoeh.antennapod.model.feed.FeedItemFilter;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+
+public abstract class EpisodeCleanupAlgorithm {
+
+ /**
+ * Deletes downloaded episodes that are no longer needed. What episodes are deleted and how many
+ * of them depends on the implementation.
+ *
+ * @param context Can be used for accessing the database
+ * @param numToRemove An additional parameter. This parameter is either returned by getDefaultCleanupParameter
+ * or getPerformCleanupParameter.
+ * @return The number of episodes that were deleted.
+ */
+ protected abstract int performCleanup(Context context, int numToRemove);
+
+ public int performCleanup(Context context) {
+ return performCleanup(context, getDefaultCleanupParameter());
+ }
+
+ /**
+ * Returns a parameter for performCleanup. The implementation of this interface should decide how much
+ * space to free to satisfy the episode cache conditions. If the conditions are already satisfied, this
+ * method should not have any effects.
+ */
+ protected abstract int getDefaultCleanupParameter();
+
+ /**
+ * Cleans up just enough episodes to make room for the requested number
+ *
+ * @param context Can be used for accessing the database
+ * @param amountOfRoomNeeded the number of episodes we need space for
+ * @return The number of epiosdes that were deleted
+ */
+ public int makeRoomForEpisodes(Context context, int amountOfRoomNeeded) {
+ return performCleanup(context, getNumEpisodesToCleanup(amountOfRoomNeeded));
+ }
+
+ /**
+ * @return the number of episodes/items that *could* be cleaned up, if needed
+ */
+ public abstract int getReclaimableItems();
+
+ /**
+ * @param amountOfRoomNeeded the number of episodes we want to download
+ * @return the number of episodes to delete in order to make room
+ */
+ int getNumEpisodesToCleanup(final int amountOfRoomNeeded) {
+ if (amountOfRoomNeeded >= 0
+ && UserPreferences.getEpisodeCacheSize() != UserPreferences.EPISODE_CACHE_SIZE_UNLIMITED) {
+ int downloadedEpisodes = DBReader.getTotalEpisodeCount(new FeedItemFilter(FeedItemFilter.DOWNLOADED));
+ if (downloadedEpisodes + amountOfRoomNeeded >= UserPreferences
+ .getEpisodeCacheSize()) {
+
+ return downloadedEpisodes + amountOfRoomNeeded
+ - UserPreferences.getEpisodeCacheSize();
+ }
+ }
+ return 0;
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/EpisodeCleanupAlgorithmFactory.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/EpisodeCleanupAlgorithmFactory.java
new file mode 100644
index 000000000..de8a2feda
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/EpisodeCleanupAlgorithmFactory.java
@@ -0,0 +1,22 @@
+package de.danoeh.antennapod.net.download.service.episode.autodownload;
+
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+
+public abstract class EpisodeCleanupAlgorithmFactory {
+ public static EpisodeCleanupAlgorithm build() {
+ if (!UserPreferences.isEnableAutodownload()) {
+ return new APNullCleanupAlgorithm();
+ }
+ int cleanupValue = UserPreferences.getEpisodeCleanupValue();
+ switch (cleanupValue) {
+ case UserPreferences.EPISODE_CLEANUP_EXCEPT_FAVORITE:
+ return new ExceptFavoriteCleanupAlgorithm();
+ case UserPreferences.EPISODE_CLEANUP_QUEUE:
+ return new APQueueCleanupAlgorithm();
+ case UserPreferences.EPISODE_CLEANUP_NULL:
+ return new APNullCleanupAlgorithm();
+ default:
+ return new APCleanupAlgorithm(cleanupValue);
+ }
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/ExceptFavoriteCleanupAlgorithm.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/ExceptFavoriteCleanupAlgorithm.java
new file mode 100644
index 000000000..46dfcffdc
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/episode/autodownload/ExceptFavoriteCleanupAlgorithm.java
@@ -0,0 +1,104 @@
+package de.danoeh.antennapod.net.download.service.episode.autodownload;
+
+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.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedItemFilter;
+import de.danoeh.antennapod.model.feed.SortOrder;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.storage.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()).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.getEpisodes(0, Integer.MAX_VALUE,
+ new FeedItemFilter(FeedItemFilter.DOWNLOADED), SortOrder.DATE_NEW_OLD);
+ 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.EPISODE_CACHE_SIZE_UNLIMITED) {
+ int downloadedEpisodes = DBReader.getTotalEpisodeCount(new FeedItemFilter(FeedItemFilter.DOWNLOADED));
+ if (downloadedEpisodes > cacheSize) {
+ return downloadedEpisodes - cacheSize;
+ }
+ }
+ return 0;
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/DownloadServiceInterfaceImpl.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/DownloadServiceInterfaceImpl.java
new file mode 100644
index 000000000..37a7f30e0
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/DownloadServiceInterfaceImpl.java
@@ -0,0 +1,97 @@
+package de.danoeh.antennapod.net.download.service.feed;
+
+import android.content.Context;
+import androidx.work.Constraints;
+import androidx.work.Data;
+import androidx.work.ExistingWorkPolicy;
+import androidx.work.NetworkType;
+import androidx.work.OneTimeWorkRequest;
+import androidx.work.OutOfQuotaPolicy;
+import androidx.work.WorkInfo;
+import androidx.work.WorkManager;
+import de.danoeh.antennapod.net.download.service.episode.EpisodeDownloadWorker;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadServiceInterface;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import io.reactivex.Observable;
+import io.reactivex.schedulers.Schedulers;
+
+import java.util.List;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+public class DownloadServiceInterfaceImpl extends DownloadServiceInterface {
+ public void downloadNow(Context context, FeedItem item, boolean ignoreConstraints) {
+ OneTimeWorkRequest.Builder workRequest = getRequest(context, item);
+ workRequest.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST);
+ if (ignoreConstraints) {
+ workRequest.setConstraints(new Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build());
+ } else {
+ workRequest.setConstraints(getConstraints());
+ }
+ WorkManager.getInstance(context).enqueueUniqueWork(item.getMedia().getDownloadUrl(),
+ ExistingWorkPolicy.KEEP, workRequest.build());
+ }
+
+ public void download(Context context, FeedItem item) {
+ OneTimeWorkRequest.Builder workRequest = getRequest(context, item);
+ workRequest.setConstraints(getConstraints());
+ WorkManager.getInstance(context).enqueueUniqueWork(item.getMedia().getDownloadUrl(),
+ ExistingWorkPolicy.KEEP, workRequest.build());
+ }
+
+ private static OneTimeWorkRequest.Builder getRequest(Context context, FeedItem item) {
+ OneTimeWorkRequest.Builder workRequest = new OneTimeWorkRequest.Builder(EpisodeDownloadWorker.class)
+ .setInitialDelay(0L, TimeUnit.MILLISECONDS)
+ .addTag(DownloadServiceInterface.WORK_TAG)
+ .addTag(DownloadServiceInterface.WORK_TAG_EPISODE_URL + item.getMedia().getDownloadUrl());
+ if (!item.isTagged(FeedItem.TAG_QUEUE) && UserPreferences.enqueueDownloadedEpisodes()) {
+ DBWriter.addQueueItem(context, false, item.getId());
+ workRequest.addTag(DownloadServiceInterface.WORK_DATA_WAS_QUEUED);
+ }
+ workRequest.setInputData(new Data.Builder().putLong(WORK_DATA_MEDIA_ID, item.getMedia().getId()).build());
+ return workRequest;
+ }
+
+ private static Constraints getConstraints() {
+ Constraints.Builder constraints = new Constraints.Builder();
+ if (UserPreferences.isAllowMobileEpisodeDownload()) {
+ constraints.setRequiredNetworkType(NetworkType.CONNECTED);
+ } else {
+ constraints.setRequiredNetworkType(NetworkType.UNMETERED);
+ }
+ return constraints.build();
+ }
+
+ @Override
+ public void cancel(Context context, FeedMedia media) {
+ // This needs to be done here, not in the worker. Reason: The worker might or might not be running.
+ if (media.fileExists()) {
+ DBWriter.deleteFeedMediaOfItem(context, media); // Remove partially downloaded file
+ }
+ String tag = WORK_TAG_EPISODE_URL + media.getDownloadUrl();
+ Future<List<WorkInfo>> future = WorkManager.getInstance(context).getWorkInfosByTag(tag);
+ Observable.fromFuture(future)
+ .subscribeOn(Schedulers.io())
+ .observeOn(Schedulers.io())
+ .subscribe(
+ workInfos -> {
+ for (WorkInfo info : workInfos) {
+ if (info.getTags().contains(DownloadServiceInterface.WORK_DATA_WAS_QUEUED)) {
+ DBWriter.removeQueueItem(context, false, media.getItem());
+ }
+ }
+ WorkManager.getInstance(context).cancelAllWorkByTag(tag);
+ }, exception -> {
+ WorkManager.getInstance(context).cancelAllWorkByTag(tag);
+ exception.printStackTrace();
+ });
+ }
+
+ @Override
+ public void cancelAll(Context context) {
+ WorkManager.getInstance(context).cancelAllWorkByTag(WORK_TAG);
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateManagerImpl.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateManagerImpl.java
new file mode 100644
index 000000000..2ce5f7006
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateManagerImpl.java
@@ -0,0 +1,117 @@
+package de.danoeh.antennapod.net.download.service.feed;
+
+import android.content.Context;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.work.Constraints;
+import androidx.work.Data;
+import androidx.work.ExistingPeriodicWorkPolicy;
+import androidx.work.ExistingWorkPolicy;
+import androidx.work.NetworkType;
+import androidx.work.OneTimeWorkRequest;
+import androidx.work.OutOfQuotaPolicy;
+import androidx.work.PeriodicWorkRequest;
+import androidx.work.WorkManager;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import de.danoeh.antennapod.net.common.NetworkUtils;
+import de.danoeh.antennapod.event.MessageEvent;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.net.download.service.R;
+import de.danoeh.antennapod.net.download.serviceinterface.FeedUpdateManager;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import org.greenrobot.eventbus.EventBus;
+
+import java.util.concurrent.TimeUnit;
+
+public class FeedUpdateManagerImpl extends FeedUpdateManager {
+ public static final String WORK_TAG_FEED_UPDATE = "feedUpdate";
+ private static final String WORK_ID_FEED_UPDATE = "de.danoeh.antennapod.core.service.FeedUpdateWorker";
+ private static final String WORK_ID_FEED_UPDATE_MANUAL = "feedUpdateManual";
+ public static final String EXTRA_FEED_ID = "feed_id";
+ public static final String EXTRA_NEXT_PAGE = "next_page";
+ public static final String EXTRA_EVEN_ON_MOBILE = "even_on_mobile";
+ private static final String TAG = "AutoUpdateManager";
+
+ /**
+ * Start / restart periodic auto feed refresh
+ * @param context Context
+ */
+ public void restartUpdateAlarm(Context context, boolean replace) {
+ if (UserPreferences.isAutoUpdateDisabled()) {
+ WorkManager.getInstance(context).cancelUniqueWork(WORK_ID_FEED_UPDATE);
+ } else {
+ PeriodicWorkRequest workRequest = new PeriodicWorkRequest.Builder(
+ FeedUpdateWorker.class, UserPreferences.getUpdateInterval(), TimeUnit.HOURS)
+ .setConstraints(new Constraints.Builder()
+ .setRequiredNetworkType(UserPreferences.isAllowMobileFeedRefresh()
+ ? NetworkType.CONNECTED : NetworkType.UNMETERED).build())
+ .build();
+ WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK_ID_FEED_UPDATE,
+ replace ? ExistingPeriodicWorkPolicy.REPLACE : ExistingPeriodicWorkPolicy.KEEP, workRequest);
+ }
+ }
+
+ public void runOnce(Context context) {
+ runOnce(context, null, false);
+ }
+
+ public void runOnce(Context context, Feed feed) {
+ runOnce(context, feed, false);
+ }
+
+ public void runOnce(Context context, Feed feed, boolean nextPage) {
+ OneTimeWorkRequest.Builder workRequest = new OneTimeWorkRequest.Builder(FeedUpdateWorker.class)
+ .setInitialDelay(0L, TimeUnit.MILLISECONDS)
+ .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
+ .addTag(WORK_TAG_FEED_UPDATE);
+ if (feed == null || !feed.isLocalFeed()) {
+ workRequest.setConstraints(new Constraints.Builder()
+ .setRequiredNetworkType(NetworkType.CONNECTED).build());
+ }
+ Data.Builder builder = new Data.Builder();
+ builder.putBoolean(EXTRA_EVEN_ON_MOBILE, true);
+ if (feed != null) {
+ builder.putLong(EXTRA_FEED_ID, feed.getId());
+ builder.putBoolean(EXTRA_NEXT_PAGE, nextPage);
+ }
+ workRequest.setInputData(builder.build());
+ WorkManager.getInstance(context).enqueueUniqueWork(WORK_ID_FEED_UPDATE_MANUAL,
+ ExistingWorkPolicy.REPLACE, workRequest.build());
+ }
+
+ public void runOnceOrAsk(@NonNull Context context) {
+ runOnceOrAsk(context, null);
+ }
+
+ public void runOnceOrAsk(@NonNull Context context, @Nullable Feed feed) {
+ Log.d(TAG, "Run auto update immediately in background.");
+ if (feed != null && feed.isLocalFeed()) {
+ runOnce(context, feed);
+ } else if (!NetworkUtils.networkAvailable()) {
+ EventBus.getDefault().post(new MessageEvent(context.getString(R.string.download_error_no_connection)));
+ } else if (NetworkUtils.isFeedRefreshAllowed()) {
+ runOnce(context, feed);
+ } else {
+ confirmMobileRefresh(context, feed);
+ }
+ }
+
+ private void confirmMobileRefresh(final Context context, @Nullable Feed feed) {
+ MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(context)
+ .setTitle(R.string.feed_refresh_title)
+ .setPositiveButton(R.string.confirm_mobile_streaming_button_once,
+ (dialog, which) -> runOnce(context, feed))
+ .setNeutralButton(R.string.confirm_mobile_streaming_button_always, (dialog, which) -> {
+ UserPreferences.setAllowMobileFeedRefresh(true);
+ runOnce(context, feed);
+ })
+ .setNegativeButton(R.string.no, null);
+ if (NetworkUtils.isNetworkRestricted() && NetworkUtils.isVpnOverWifi()) {
+ builder.setMessage(R.string.confirm_mobile_feed_refresh_dialog_message_vpn);
+ } else {
+ builder.setMessage(R.string.confirm_mobile_feed_refresh_dialog_message);
+ }
+ builder.show();
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateReceiver.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateReceiver.java
new file mode 100644
index 000000000..a230497d0
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateReceiver.java
@@ -0,0 +1,23 @@
+package de.danoeh.antennapod.net.download.service.feed;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.util.Log;
+
+import de.danoeh.antennapod.net.download.serviceinterface.FeedUpdateManager;
+
+/**
+ * Refreshes all feeds when it receives an intent
+ */
+public class FeedUpdateReceiver extends BroadcastReceiver {
+
+ private static final String TAG = "FeedUpdateReceiver";
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ Log.d(TAG, "Received intent");
+ FeedUpdateManager.getInstance().runOnce(context);
+ }
+
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateWorker.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateWorker.java
new file mode 100644
index 000000000..d12249f11
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/FeedUpdateWorker.java
@@ -0,0 +1,217 @@
+package de.danoeh.antennapod.net.download.service.feed;
+
+import android.Manifest;
+import android.app.Notification;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationManagerCompat;
+import androidx.core.content.ContextCompat;
+import androidx.work.ForegroundInfo;
+import androidx.work.WorkManager;
+import androidx.work.Worker;
+import androidx.work.WorkerParameters;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import de.danoeh.antennapod.net.download.service.R;
+import de.danoeh.antennapod.net.download.service.feed.local.LocalFeedUpdater;
+import de.danoeh.antennapod.net.download.service.feed.remote.DefaultDownloaderFactory;
+import de.danoeh.antennapod.net.download.service.feed.remote.Downloader;
+import de.danoeh.antennapod.net.download.service.feed.remote.FeedParserTask;
+import de.danoeh.antennapod.net.download.serviceinterface.AutoDownloadManager;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadRequestCreator;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.FeedDatabaseWriter;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.net.common.NetworkUtils;
+import de.danoeh.antennapod.model.download.DownloadError;
+import de.danoeh.antennapod.model.download.DownloadResult;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.download.DownloadRequest;
+
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadRequestBuilder;
+import de.danoeh.antennapod.parser.feed.FeedHandlerResult;
+import de.danoeh.antennapod.ui.notifications.NotificationUtils;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+
+public class FeedUpdateWorker extends Worker {
+ private static final String TAG = "FeedUpdateWorker";
+
+ private final NewEpisodesNotification newEpisodesNotification;
+ private final NotificationManagerCompat notificationManager;
+
+ public FeedUpdateWorker(@NonNull Context context, @NonNull WorkerParameters params) {
+ super(context, params);
+ newEpisodesNotification = new NewEpisodesNotification();
+ notificationManager = NotificationManagerCompat.from(context);
+ }
+
+ @Override
+ @NonNull
+ public Result doWork() {
+ newEpisodesNotification.loadCountersBeforeRefresh();
+
+ List<Feed> toUpdate;
+ long feedId = getInputData().getLong(FeedUpdateManagerImpl.EXTRA_FEED_ID, -1);
+ boolean allAreLocal = true;
+ boolean force = false;
+ if (feedId == -1) { // Update all
+ toUpdate = DBReader.getFeedList();
+ Iterator<Feed> itr = toUpdate.iterator();
+ while (itr.hasNext()) {
+ Feed feed = itr.next();
+ if (!feed.getPreferences().getKeepUpdated()) {
+ itr.remove();
+ }
+ if (!feed.isLocalFeed()) {
+ allAreLocal = false;
+ }
+ }
+ Collections.shuffle(toUpdate); // If the worker gets cancelled early, every feed has a chance to be updated
+ } else {
+ Feed feed = DBReader.getFeed(feedId);
+ if (feed == null) {
+ return Result.success();
+ }
+ if (!feed.isLocalFeed()) {
+ allAreLocal = false;
+ }
+ toUpdate = new ArrayList<>();
+ toUpdate.add(feed); // Needs to be updatable, so no singletonList
+ force = true;
+ }
+
+ if (!getInputData().getBoolean(FeedUpdateManagerImpl.EXTRA_EVEN_ON_MOBILE, false) && !allAreLocal) {
+ if (!NetworkUtils.networkAvailable() || !NetworkUtils.isFeedRefreshAllowed()) {
+ Log.d(TAG, "Blocking automatic update");
+ return Result.retry();
+ }
+ }
+ refreshFeeds(toUpdate, force);
+
+ notificationManager.cancel(R.id.notification_updating_feeds);
+ AutoDownloadManager.getInstance().autodownloadUndownloadedItems(getApplicationContext());
+ return Result.success();
+ }
+
+ @NonNull
+ private Notification createNotification(@Nullable List<Feed> toUpdate) {
+ Context context = getApplicationContext();
+ String contentText = "";
+ StringBuilder bigText = new StringBuilder();
+ if (toUpdate != null) {
+ contentText = context.getResources().getQuantityString(R.plurals.downloads_left,
+ toUpdate.size(), toUpdate.size());
+ for (int i = 0; i < toUpdate.size(); i++) {
+ bigText.append("• ").append(toUpdate.get(i).getTitle());
+ if (i != toUpdate.size() - 1) {
+ bigText.append("\n");
+ }
+ }
+ }
+ return new NotificationCompat.Builder(context, NotificationUtils.CHANNEL_ID_DOWNLOADING)
+ .setContentTitle(context.getString(R.string.download_notification_title_feeds))
+ .setContentText(contentText)
+ .setStyle(new NotificationCompat.BigTextStyle().bigText(bigText))
+ .setSmallIcon(R.drawable.ic_notification_sync)
+ .setOngoing(true)
+ .addAction(R.drawable.ic_notification_cancel, context.getString(R.string.cancel_label),
+ WorkManager.getInstance(context).createCancelPendingIntent(getId()))
+ .build();
+ }
+
+ @NonNull
+ @Override
+ public ListenableFuture getForegroundInfoAsync() {
+ return Futures.immediateFuture(new ForegroundInfo(R.id.notification_updating_feeds, createNotification(null)));
+ }
+
+ private void refreshFeeds(List<Feed> toUpdate, boolean force) {
+ while (!toUpdate.isEmpty()) {
+ if (isStopped()) {
+ return;
+ }
+ if (ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.POST_NOTIFICATIONS)
+ == PackageManager.PERMISSION_GRANTED) {
+ notificationManager.notify(R.id.notification_updating_feeds, createNotification(toUpdate));
+ }
+ Feed feed = toUpdate.get(0);
+ try {
+ if (feed.isLocalFeed()) {
+ LocalFeedUpdater.updateFeed(feed, getApplicationContext(), null);
+ } else {
+ refreshFeed(feed, force);
+ }
+ } catch (Exception e) {
+ DBWriter.setFeedLastUpdateFailed(feed.getId(), true);
+ DownloadResult status = new DownloadResult(feed.getTitle(),
+ feed.getId(), Feed.FEEDFILETYPE_FEED, false,
+ DownloadError.ERROR_IO_ERROR, e.getMessage());
+ DBWriter.addDownloadStatus(status);
+ }
+ toUpdate.remove(0);
+ }
+ }
+
+ void refreshFeed(Feed feed, boolean force) throws Exception {
+ boolean nextPage = getInputData().getBoolean(FeedUpdateManagerImpl.EXTRA_NEXT_PAGE, false)
+ && feed.getNextPageLink() != null;
+ if (nextPage) {
+ feed.setPageNr(feed.getPageNr() + 1);
+ }
+ DownloadRequestBuilder builder = DownloadRequestCreator.create(feed);
+ builder.setForce(force || feed.hasLastUpdateFailed());
+ if (nextPage) {
+ builder.setSource(feed.getNextPageLink());
+ }
+ DownloadRequest request = builder.build();
+
+ Downloader downloader = new DefaultDownloaderFactory().create(request);
+ if (downloader == null) {
+ throw new Exception("Unable to create downloader");
+ }
+
+ downloader.call();
+
+ if (!downloader.getResult().isSuccessful()) {
+ if (downloader.cancelled || downloader.getResult().getReason() == DownloadError.ERROR_DOWNLOAD_CANCELLED) {
+ return;
+ }
+ DBWriter.setFeedLastUpdateFailed(request.getFeedfileId(), true);
+ DBWriter.addDownloadStatus(downloader.getResult());
+ return;
+ }
+
+ FeedParserTask parserTask = new FeedParserTask(request);
+ FeedHandlerResult feedHandlerResult = parserTask.call();
+ if (!parserTask.isSuccessful()) {
+ DBWriter.setFeedLastUpdateFailed(request.getFeedfileId(), true);
+ DBWriter.addDownloadStatus(parserTask.getDownloadStatus());
+ return;
+ }
+ feedHandlerResult.feed.setLastRefreshAttempt(System.currentTimeMillis());
+ Feed savedFeed = FeedDatabaseWriter.updateFeed(getApplicationContext(), feedHandlerResult.feed, false);
+
+ if (request.getFeedfileId() == 0) {
+ return; // No download logs for new subscriptions
+ }
+ // we create a 'successful' download log if the feed's last refresh failed
+ List<DownloadResult> log = DBReader.getFeedDownloadLog(request.getFeedfileId());
+ if (!log.isEmpty() && !log.get(0).isSuccessful()) {
+ DBWriter.addDownloadStatus(parserTask.getDownloadStatus());
+ }
+ newEpisodesNotification.showIfNeeded(getApplicationContext(), savedFeed);
+ if (downloader.permanentRedirectUrl != null) {
+ DBWriter.updateFeedDownloadURL(request.getSource(), downloader.permanentRedirectUrl);
+ } else if (feedHandlerResult.redirectUrl != null
+ && !feedHandlerResult.redirectUrl.equals(request.getSource())) {
+ DBWriter.updateFeedDownloadURL(request.getSource(), feedHandlerResult.redirectUrl);
+ }
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/NewEpisodesNotification.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/NewEpisodesNotification.java
new file mode 100644
index 000000000..b95111b93
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/NewEpisodesNotification.java
@@ -0,0 +1,147 @@
+package de.danoeh.antennapod.net.download.service.feed;
+
+import android.Manifest;
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.res.Resources;
+
+import android.graphics.Bitmap;
+import android.os.Build;
+import android.util.Log;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationManagerCompat;
+import androidx.core.content.ContextCompat;
+
+import com.bumptech.glide.Glide;
+import com.bumptech.glide.request.RequestOptions;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedCounter;
+import de.danoeh.antennapod.model.feed.FeedPreferences;
+import de.danoeh.antennapod.net.download.service.R;
+import de.danoeh.antennapod.storage.database.PodDBAdapter;
+
+import de.danoeh.antennapod.ui.notifications.NotificationUtils;
+import java.util.Map;
+
+public class NewEpisodesNotification {
+ private static final String TAG = "NewEpisodesNotification";
+ private static final String GROUP_KEY = "de.danoeh.antennapod.EPISODES";
+
+ private Map<Long, Integer> countersBefore;
+
+ public NewEpisodesNotification() {
+ }
+
+ public void loadCountersBeforeRefresh() {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ countersBefore = adapter.getFeedCounters(FeedCounter.SHOW_NEW);
+ adapter.close();
+ }
+
+ public void showIfNeeded(Context context, Feed feed) {
+ FeedPreferences prefs = feed.getPreferences();
+ if (!prefs.getKeepUpdated() || !prefs.getShowEpisodeNotification()) {
+ return;
+ }
+
+ int newEpisodesBefore = countersBefore.containsKey(feed.getId()) ? countersBefore.get(feed.getId()) : 0;
+ 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,
+ (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 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)
+ .setOnlyAlertOnce(true)
+ .setAutoCancel(true)
+ .build();
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
+ == PackageManager.PERMISSION_GRANTED) {
+ 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", "NewEpisodesFragment");
+ PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent,
+ (Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 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)
+ .setOnlyAlertOnce(true)
+ .setAutoCancel(true)
+ .build();
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS)
+ == PackageManager.PERMISSION_GRANTED) {
+ 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(new RequestOptions().centerCrop())
+ .submit(iconSize, iconSize)
+ .get();
+ } catch (Throwable tr) {
+ return null;
+ }
+ }
+
+ private static int getNewEpisodeCount(long feedId) {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ Map<Long, Integer> counters = adapter.getFeedCounters(FeedCounter.SHOW_NEW, feedId);
+ int episodeCount = counters.containsKey(feedId) ? counters.get(feedId) : 0;
+ adapter.close();
+ return episodeCount;
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/local/FastDocumentFile.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/local/FastDocumentFile.java
new file mode 100644
index 000000000..13cfaa4bf
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/local/FastDocumentFile.java
@@ -0,0 +1,72 @@
+package de.danoeh.antennapod.net.download.service.feed.local;
+
+import android.content.Context;
+import android.database.Cursor;
+import android.net.Uri;
+import android.provider.DocumentsContract;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Android's DocumentFile is slow because every single method call queries the ContentResolver.
+ * This queries the ContentResolver a single time with all the information.
+ */
+public class FastDocumentFile {
+ private final String name;
+ private final String type;
+ private final Uri uri;
+ private final long length;
+ private final long lastModified;
+
+ public static List<FastDocumentFile> list(Context context, Uri folderUri) {
+ Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri,
+ DocumentsContract.getDocumentId(folderUri));
+ Cursor cursor = context.getContentResolver().query(childrenUri, new String[] {
+ DocumentsContract.Document.COLUMN_DOCUMENT_ID,
+ DocumentsContract.Document.COLUMN_DISPLAY_NAME,
+ DocumentsContract.Document.COLUMN_SIZE,
+ DocumentsContract.Document.COLUMN_LAST_MODIFIED,
+ DocumentsContract.Document.COLUMN_MIME_TYPE}, null, null, null);
+ ArrayList<FastDocumentFile> list = new ArrayList<>();
+ while (cursor.moveToNext()) {
+ String id = cursor.getString(0);
+ Uri uri = DocumentsContract.buildDocumentUriUsingTree(folderUri, id);
+ String name = cursor.getString(1);
+ long size = cursor.getLong(2);
+ long lastModified = cursor.getLong(3);
+ String mimeType = cursor.getString(4);
+ list.add(new FastDocumentFile(name, mimeType, uri, size, lastModified));
+ }
+ cursor.close();
+ return list;
+ }
+
+ public FastDocumentFile(String name, String type, Uri uri, long length, long lastModified) {
+ this.name = name;
+ this.type = type;
+ this.uri = uri;
+ this.length = length;
+ this.lastModified = lastModified;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public Uri getUri() {
+ return uri;
+ }
+
+ public long getLength() {
+ return length;
+ }
+
+ public long getLastModified() {
+ return lastModified;
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/local/LocalFeedUpdater.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/local/LocalFeedUpdater.java
new file mode 100644
index 000000000..015cd9146
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/local/LocalFeedUpdater.java
@@ -0,0 +1,285 @@
+package de.danoeh.antennapod.net.download.service.feed.local;
+
+import android.content.Context;
+import android.media.MediaMetadataRetriever;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.UUID;
+
+import androidx.annotation.VisibleForTesting;
+import androidx.documentfile.provider.DocumentFile;
+import de.danoeh.antennapod.model.MediaMetadataRetrieverCompat;
+import de.danoeh.antennapod.model.download.DownloadResult;
+import de.danoeh.antennapod.net.download.service.R;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.FeedDatabaseWriter;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.parser.feed.util.DateUtils;
+import de.danoeh.antennapod.model.download.DownloadError;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.model.playback.MediaType;
+import de.danoeh.antennapod.parser.feed.util.MimeTypeUtils;
+import de.danoeh.antennapod.parser.media.id3.ID3ReaderException;
+import de.danoeh.antennapod.parser.media.id3.Id3MetadataReader;
+import de.danoeh.antennapod.parser.media.vorbis.VorbisCommentMetadataReader;
+import de.danoeh.antennapod.parser.media.vorbis.VorbisCommentReaderException;
+import org.apache.commons.io.input.CountingInputStream;
+
+public class LocalFeedUpdater {
+ private static final String TAG = "LocalFeedUpdater";
+
+ static final String[] PREFERRED_FEED_IMAGE_FILENAMES = {"folder.jpg", "Folder.jpg", "folder.png", "Folder.png"};
+
+ public static void updateFeed(Feed feed, Context context,
+ @Nullable UpdaterProgressListener updaterProgressListener) {
+ try {
+ String uriString = feed.getDownloadUrl().replace(Feed.PREFIX_LOCAL_FOLDER, "");
+ DocumentFile documentFolder = DocumentFile.fromTreeUri(context, Uri.parse(uriString));
+ if (documentFolder == null) {
+ throw new IOException("Unable to retrieve document tree. "
+ + "Try re-connecting the folder on the podcast info page.");
+ }
+ if (!documentFolder.exists() || !documentFolder.canRead()) {
+ throw new IOException("Cannot read local directory. "
+ + "Try re-connecting the folder on the podcast info page.");
+ }
+ tryUpdateFeed(feed, context, documentFolder.getUri(), updaterProgressListener);
+
+ if (mustReportDownloadSuccessful(feed)) {
+ reportSuccess(feed);
+ }
+ } catch (Exception e) {
+ e.printStackTrace();
+ reportError(feed, e.getMessage());
+ }
+ }
+
+ @VisibleForTesting
+ static void tryUpdateFeed(Feed feed, Context context, Uri folderUri,
+ UpdaterProgressListener updaterProgressListener) throws IOException {
+ if (feed.getItems() == null) {
+ feed.setItems(new ArrayList<>());
+ }
+ //make sure it is the latest 'version' of this feed from the db (all items etc)
+ feed = FeedDatabaseWriter.updateFeed(context, feed, false);
+
+ // list files in feed folder
+ List<FastDocumentFile> allFiles = FastDocumentFile.list(context, folderUri);
+ List<FastDocumentFile> mediaFiles = new ArrayList<>();
+ Set<String> mediaFileNames = new HashSet<>();
+ for (FastDocumentFile file : allFiles) {
+ String mimeType = MimeTypeUtils.getMimeType(file.getType(), file.getUri().toString());
+ MediaType mediaType = MediaType.fromMimeType(mimeType);
+ if (mediaType == MediaType.AUDIO || mediaType == MediaType.VIDEO) {
+ mediaFiles.add(file);
+ mediaFileNames.add(file.getName());
+ }
+ }
+
+ // add new files to feed and update item data
+ List<FeedItem> newItems = feed.getItems();
+ for (int i = 0; i < mediaFiles.size(); i++) {
+ FeedItem oldItem = feedContainsFile(feed, mediaFiles.get(i).getName());
+ FeedItem newItem = createFeedItem(feed, mediaFiles.get(i), context);
+ if (oldItem == null) {
+ newItems.add(newItem);
+ } else {
+ oldItem.updateFromOther(newItem);
+ }
+ if (updaterProgressListener != null) {
+ updaterProgressListener.onLocalFileScanned(i, mediaFiles.size());
+ }
+ }
+
+ // remove feed items without corresponding file
+ Iterator<FeedItem> it = newItems.iterator();
+ while (it.hasNext()) {
+ FeedItem feedItem = it.next();
+ if (!mediaFileNames.contains(feedItem.getLink())) {
+ it.remove();
+ }
+ }
+
+ feed.setImageUrl(getImageUrl(allFiles, folderUri));
+
+ feed.getPreferences().setAutoDownload(false);
+ feed.setDescription(context.getString(R.string.local_feed_description));
+ feed.setAuthor(context.getString(R.string.local_folder));
+
+ FeedDatabaseWriter.updateFeed(context, feed, true);
+ }
+
+ /**
+ * Returns the image URL for the local feed.
+ */
+ @NonNull
+ static String getImageUrl(List<FastDocumentFile> files, Uri folderUri) {
+ // look for special file names
+ for (String iconLocation : PREFERRED_FEED_IMAGE_FILENAMES) {
+ for (FastDocumentFile file : files) {
+ if (iconLocation.equals(file.getName())) {
+ return file.getUri().toString();
+ }
+ }
+ }
+
+ // use the first image in the folder if existing
+ for (FastDocumentFile file : files) {
+ 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 Feed.PREFIX_GENERATIVE_COVER + folderUri;
+ }
+
+ private static FeedItem feedContainsFile(Feed feed, String filename) {
+ List<FeedItem> items = feed.getItems();
+ for (FeedItem i : items) {
+ if (i.getMedia() != null && i.getLink().equals(filename)) {
+ return i;
+ }
+ }
+ return null;
+ }
+
+ private static FeedItem createFeedItem(Feed feed, FastDocumentFile file, Context context) {
+ FeedItem item = new FeedItem(0, file.getName(), UUID.randomUUID().toString(),
+ file.getName(), new Date(file.getLastModified()), FeedItem.UNPLAYED, feed);
+ item.disableAutoDownload();
+
+ long size = file.getLength();
+ FeedMedia media = new FeedMedia(0, item, 0, 0, size, file.getType(),
+ file.getUri().toString(), file.getUri().toString(), false, null, 0, 0);
+ item.setMedia(media);
+
+ for (FeedItem existingItem : feed.getItems()) {
+ if (existingItem.getMedia() != null
+ && existingItem.getMedia().getDownloadUrl().equals(file.getUri().toString())
+ && file.getLength() == existingItem.getMedia().getSize()) {
+ // We found an old file that we already scanned. Re-use metadata.
+ item.updateFromOther(existingItem);
+ return item;
+ }
+ }
+
+ // Did not find existing item. Scan metadata.
+ try {
+ loadMetadata(item, file, context);
+ } catch (Exception e) {
+ item.setDescriptionIfLonger(e.getMessage());
+ }
+ return item;
+ }
+
+ private static void loadMetadata(FeedItem item, FastDocumentFile file, Context context) {
+ try (MediaMetadataRetrieverCompat mediaMetadataRetriever = new MediaMetadataRetrieverCompat()) {
+ mediaMetadataRetriever.setDataSource(context, file.getUri());
+
+ String dateStr = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE);
+ if (!TextUtils.isEmpty(dateStr) && !"19040101T000000.000Z".equals(dateStr)) {
+ try {
+ SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.getDefault());
+ item.setPubDate(simpleDateFormat.parse(dateStr));
+ } catch (ParseException parseException) {
+ Date date = DateUtils.parse(dateStr);
+ if (date != null) {
+ item.setPubDate(date);
+ }
+ }
+ }
+
+ String title = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE);
+ if (!TextUtils.isEmpty(title)) {
+ item.setTitle(title);
+ }
+
+ String durationStr = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION);
+ item.getMedia().setDuration((int) Long.parseLong(durationStr));
+
+ item.getMedia().setHasEmbeddedPicture(mediaMetadataRetriever.getEmbeddedPicture() != null);
+
+ try (InputStream inputStream = context.getContentResolver().openInputStream(file.getUri())) {
+ Id3MetadataReader reader = new Id3MetadataReader(
+ new CountingInputStream(new BufferedInputStream(inputStream)));
+ reader.readInputStream();
+ item.setDescriptionIfLonger(reader.getComment());
+ } catch (IOException | ID3ReaderException e) {
+ Log.d(TAG, "Unable to parse ID3 of " + file.getUri() + ": " + e.getMessage());
+
+ try (InputStream inputStream = context.getContentResolver().openInputStream(file.getUri())) {
+ VorbisCommentMetadataReader reader = new VorbisCommentMetadataReader(inputStream);
+ reader.readInputStream();
+ item.setDescriptionIfLonger(reader.getDescription());
+ } catch (IOException | VorbisCommentReaderException e2) {
+ Log.d(TAG, "Unable to parse vorbis comments of " + file.getUri() + ": " + e2.getMessage());
+ }
+ }
+ }
+ }
+
+ private static void reportError(Feed feed, String reasonDetailed) {
+ DownloadResult status = new DownloadResult(feed.getTitle(), feed.getId(),
+ Feed.FEEDFILETYPE_FEED, false, DownloadError.ERROR_IO_ERROR, reasonDetailed);
+ DBWriter.addDownloadStatus(status);
+ DBWriter.setFeedLastUpdateFailed(feed.getId(), true);
+ }
+
+ /**
+ * Reports a successful download status.
+ */
+ private static void reportSuccess(Feed feed) {
+ DownloadResult status = new DownloadResult(feed.getTitle(), feed.getId(),
+ Feed.FEEDFILETYPE_FEED, true, DownloadError.SUCCESS, null);
+ DBWriter.addDownloadStatus(status);
+ DBWriter.setFeedLastUpdateFailed(feed.getId(), false);
+ }
+
+ /**
+ * Answers if reporting success is needed for the given feed.
+ */
+ private static boolean mustReportDownloadSuccessful(Feed feed) {
+ List<DownloadResult> downloadResults = DBReader.getFeedDownloadLog(feed.getId());
+
+ if (downloadResults.isEmpty()) {
+ // report success if never reported before
+ return true;
+ }
+
+ Collections.sort(downloadResults, (downloadStatus1, downloadStatus2) ->
+ downloadStatus1.getCompletionDate().compareTo(downloadStatus2.getCompletionDate()));
+
+ DownloadResult lastDownloadResult = downloadResults.get(downloadResults.size() - 1);
+
+ // report success if the last update was not successful
+ // (avoid logging success again if the last update was ok)
+ return !lastDownloadResult.isSuccessful();
+ }
+
+ @FunctionalInterface
+ public interface UpdaterProgressListener {
+ void onLocalFileScanned(int scanned, int totalFiles);
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/DefaultDownloaderFactory.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/DefaultDownloaderFactory.java
new file mode 100644
index 000000000..b87e339e3
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/DefaultDownloaderFactory.java
@@ -0,0 +1,21 @@
+package de.danoeh.antennapod.net.download.service.feed.remote;
+
+import android.util.Log;
+import android.webkit.URLUtil;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import de.danoeh.antennapod.model.download.DownloadRequest;
+
+public class DefaultDownloaderFactory implements DownloaderFactory {
+ private static final String TAG = "DefaultDwnldrFactory";
+
+ @Nullable
+ @Override
+ public Downloader create(@NonNull DownloadRequest request) {
+ if (!URLUtil.isHttpUrl(request.getSource()) && !URLUtil.isHttpsUrl(request.getSource())) {
+ Log.e(TAG, "Could not find appropriate downloader for " + request.getSource());
+ return null;
+ }
+ return new HttpDownloader(request);
+ }
+} \ No newline at end of file
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/Downloader.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/Downloader.java
new file mode 100644
index 000000000..329b42805
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/Downloader.java
@@ -0,0 +1,62 @@
+package de.danoeh.antennapod.net.download.service.feed.remote;
+
+import androidx.annotation.NonNull;
+
+import java.util.Date;
+import java.util.concurrent.Callable;
+
+import de.danoeh.antennapod.model.download.DownloadResult;
+import de.danoeh.antennapod.model.download.DownloadRequest;
+import de.danoeh.antennapod.net.download.service.R;
+
+/**
+ * Downloads files
+ */
+public abstract class Downloader implements Callable<Downloader> {
+ private static final String TAG = "Downloader";
+
+ private volatile boolean finished;
+ public volatile boolean cancelled;
+ public String permanentRedirectUrl = null;
+
+ @NonNull
+ final DownloadRequest request;
+ @NonNull
+ final DownloadResult result;
+
+ Downloader(@NonNull DownloadRequest request) {
+ super();
+ this.request = request;
+ this.request.setStatusMsg(R.string.download_pending);
+ this.cancelled = false;
+ this.result = new DownloadResult(0, request.getTitle(), request.getFeedfileId(), request.getFeedfileType(),
+ false, null, new Date(), null);
+ }
+
+ protected abstract void download();
+
+ public final Downloader call() {
+ download();
+ finished = true;
+ return this;
+ }
+
+ @NonNull
+ public DownloadRequest getDownloadRequest() {
+ return request;
+ }
+
+ @NonNull
+ public DownloadResult getResult() {
+ return result;
+ }
+
+ public boolean isFinished() {
+ return finished;
+ }
+
+ public void cancel() {
+ cancelled = true;
+ }
+
+} \ No newline at end of file
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/DownloaderFactory.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/DownloaderFactory.java
new file mode 100644
index 000000000..cdf32b4a9
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/DownloaderFactory.java
@@ -0,0 +1,10 @@
+package de.danoeh.antennapod.net.download.service.feed.remote;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import de.danoeh.antennapod.model.download.DownloadRequest;
+
+public interface DownloaderFactory {
+ @Nullable
+ Downloader create(@NonNull DownloadRequest request);
+} \ No newline at end of file
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/FeedParserTask.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/FeedParserTask.java
new file mode 100644
index 000000000..dde7bc8b5
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/FeedParserTask.java
@@ -0,0 +1,124 @@
+package de.danoeh.antennapod.net.download.service.feed.remote;
+
+import android.text.TextUtils;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedPreferences;
+import de.danoeh.antennapod.model.feed.VolumeAdaptionSetting;
+import de.danoeh.antennapod.model.download.DownloadRequest;
+import de.danoeh.antennapod.model.download.DownloadResult;
+import de.danoeh.antennapod.parser.feed.FeedHandler;
+import de.danoeh.antennapod.parser.feed.FeedHandlerResult;
+import de.danoeh.antennapod.parser.feed.UnsupportedFeedtypeException;
+import de.danoeh.antennapod.model.download.DownloadError;
+import org.xml.sax.SAXException;
+
+import javax.xml.parsers.ParserConfigurationException;
+import java.io.File;
+import java.io.IOException;
+import java.util.Date;
+import java.util.concurrent.Callable;
+
+public class FeedParserTask implements Callable<FeedHandlerResult> {
+ private static final String TAG = "FeedParserTask";
+ private final DownloadRequest request;
+ private DownloadResult downloadResult;
+ private boolean successful = true;
+
+ public FeedParserTask(DownloadRequest request) {
+ this.request = request;
+ downloadResult = new DownloadResult(
+ 0, request.getTitle(), 0, request.getFeedfileType(), false,
+ DownloadError.ERROR_REQUEST_ERROR, new Date(),
+ "Unknown error: Status not set");
+ }
+
+ @Override
+ public FeedHandlerResult call() {
+ Feed feed = new Feed(request.getSource(), request.getLastModified());
+ feed.setLocalFileUrl(request.getDestination());
+ feed.setId(request.getFeedfileId());
+ feed.setPreferences(new FeedPreferences(0, true, FeedPreferences.AutoDeleteAction.GLOBAL,
+ VolumeAdaptionSetting.OFF, FeedPreferences.NewEpisodesAction.GLOBAL, request.getUsername(),
+ request.getPassword()));
+ feed.setPageNr(request.getArguments().getInt(DownloadRequest.REQUEST_ARG_PAGE_NR, 0));
+
+ DownloadError reason = null;
+ String reasonDetailed = null;
+ FeedHandler feedHandler = new FeedHandler();
+
+ FeedHandlerResult result = null;
+ try {
+ result = feedHandler.parseFeed(feed);
+ Log.d(TAG, feed.getTitle() + " parsed");
+ checkFeedData(feed);
+ if (TextUtils.isEmpty(feed.getImageUrl())) {
+ feed.setImageUrl(Feed.PREFIX_GENERATIVE_COVER + feed.getDownloadUrl());
+ }
+ } catch (SAXException | IOException | ParserConfigurationException e) {
+ successful = false;
+ e.printStackTrace();
+ reason = DownloadError.ERROR_PARSER_EXCEPTION;
+ reasonDetailed = e.getMessage();
+ } catch (UnsupportedFeedtypeException e) {
+ 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();
+ successful = false;
+ reason = DownloadError.ERROR_PARSER_EXCEPTION;
+ reasonDetailed = e.getMessage();
+ } finally {
+ File feedFile = new File(request.getDestination());
+ if (feedFile.exists()) {
+ boolean deleted = feedFile.delete();
+ Log.d(TAG, "Deletion of file '" + feedFile.getAbsolutePath() + "' "
+ + (deleted ? "successful" : "FAILED"));
+ }
+ }
+
+ if (successful) {
+ downloadResult = new DownloadResult(feed.getHumanReadableIdentifier(), feed.getId(),
+ Feed.FEEDFILETYPE_FEED, true, DownloadError.SUCCESS, reasonDetailed);
+ return result;
+ } else {
+ downloadResult = new DownloadResult(feed.getHumanReadableIdentifier(), feed.getId(),
+ Feed.FEEDFILETYPE_FEED, false, reason, reasonDetailed);
+ return null;
+ }
+ }
+
+ public boolean isSuccessful() {
+ return successful;
+ }
+
+ /**
+ * Checks if the feed was parsed correctly.
+ */
+ private void checkFeedData(Feed feed) throws InvalidFeedException {
+ if (feed.getTitle() == null) {
+ throw new InvalidFeedException("Feed has no title");
+ }
+ checkFeedItems(feed);
+ }
+
+ private void checkFeedItems(Feed feed) throws InvalidFeedException {
+ for (FeedItem item : feed.getItems()) {
+ if (item.getTitle() == null) {
+ throw new InvalidFeedException("Item has no title: " + item);
+ }
+ }
+ }
+
+ @NonNull
+ public DownloadResult getDownloadStatus() {
+ return downloadResult;
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/HttpDownloader.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/HttpDownloader.java
new file mode 100644
index 000000000..2e983d88e
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/HttpDownloader.java
@@ -0,0 +1,328 @@
+package de.danoeh.antennapod.net.download.service.feed.remote;
+
+import android.os.StatFs;
+import androidx.annotation.NonNull;
+import android.text.TextUtils;
+import android.util.Log;
+
+import de.danoeh.antennapod.net.common.NetworkUtils;
+import de.danoeh.antennapod.model.download.DownloadResult;
+import de.danoeh.antennapod.model.download.DownloadRequest;
+import de.danoeh.antennapod.net.common.AntennapodHttpClient;
+import de.danoeh.antennapod.net.download.service.R;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import okhttp3.CacheControl;
+import okhttp3.internal.http.StatusLine;
+import org.apache.commons.io.IOUtils;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.RandomAccessFile;
+import java.net.HttpURLConnection;
+import java.net.SocketTimeoutException;
+import java.net.URI;
+import java.net.UnknownHostException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Locale;
+
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.parser.feed.util.DateUtils;
+import de.danoeh.antennapod.model.download.DownloadError;
+import de.danoeh.antennapod.net.common.UriUtil;
+import okhttp3.OkHttpClient;
+import okhttp3.Protocol;
+import okhttp3.Request;
+import okhttp3.Response;
+import okhttp3.ResponseBody;
+
+public class HttpDownloader extends Downloader {
+ private static final String TAG = "HttpDownloader";
+ private static final int BUFFER_SIZE = 8 * 1024;
+
+ public HttpDownloader(@NonNull DownloadRequest request) {
+ super(request);
+ }
+
+ @Override
+ protected void download() {
+ File destination = new File(request.getDestination());
+ final boolean fileExists = destination.exists();
+
+ RandomAccessFile out = null;
+ InputStream connection;
+ ResponseBody responseBody = null;
+
+ try {
+ final URI uri = UriUtil.getURIFromRequestUrl(request.getSource());
+ Request.Builder httpReq = new Request.Builder().url(uri.toURL());
+ httpReq.tag(request);
+ httpReq.cacheControl(new CacheControl.Builder().noStore().build());
+
+ if (request.getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA) {
+ // set header explicitly so that okhttp doesn't do transparent gzip
+ Log.d(TAG, "addHeader(\"Accept-Encoding\", \"identity\")");
+ httpReq.addHeader("Accept-Encoding", "identity");
+ httpReq.cacheControl(new CacheControl.Builder().noCache().build()); // noStore breaks CDNs
+ }
+
+ if (uri.getScheme().equals("http")) {
+ httpReq.addHeader("Upgrade-Insecure-Requests", "1");
+ }
+
+ if (!TextUtils.isEmpty(request.getLastModified())) {
+ String lastModified = request.getLastModified();
+ Date lastModifiedDate = DateUtils.parse(lastModified);
+ if (lastModifiedDate != null) {
+ long threeDaysAgo = System.currentTimeMillis() - 1000 * 60 * 60 * 24 * 3;
+ if (lastModifiedDate.getTime() > threeDaysAgo) {
+ Log.d(TAG, "addHeader(\"If-Modified-Since\", \"" + lastModified + "\")");
+ httpReq.addHeader("If-Modified-Since", lastModified);
+ }
+ } else {
+ Log.d(TAG, "addHeader(\"If-None-Match\", \"" + lastModified + "\")");
+ httpReq.addHeader("If-None-Match", lastModified);
+ }
+ }
+
+ // add range header if necessary
+ if (fileExists && destination.length() > 0) {
+ request.setSoFar(destination.length());
+ httpReq.addHeader("Range", "bytes=" + request.getSoFar() + "-");
+ Log.d(TAG, "Adding range header: " + request.getSoFar());
+ }
+
+ Response response = newCall(httpReq);
+ responseBody = response.body();
+ String contentEncodingHeader = response.header("Content-Encoding");
+ boolean isGzip = false;
+ if (!TextUtils.isEmpty(contentEncodingHeader)) {
+ isGzip = TextUtils.equals(contentEncodingHeader.toLowerCase(Locale.US), "gzip");
+ }
+
+ Log.d(TAG, "Response code is " + response.code());
+ if (!response.isSuccessful() && response.code() == HttpURLConnection.HTTP_NOT_MODIFIED) {
+ Log.d(TAG, "Feed '" + request.getSource() + "' not modified since last update, Download canceled");
+ onCancelled();
+ return;
+ } else if (!response.isSuccessful() || response.body() == null) {
+ callOnFailByResponseCode(response);
+ return;
+ } else if (request.getFeedfileType() == FeedMedia.FEEDFILETYPE_FEEDMEDIA
+ && isContentTypeTextAndSmallerThan100kb(response)) {
+ onFail(DownloadError.ERROR_FILE_TYPE, null);
+ return;
+ }
+ checkIfRedirect(response);
+
+ connection = new BufferedInputStream(responseBody.byteStream());
+
+ String contentRangeHeader = (fileExists) ? response.header("Content-Range") : null;
+ if (fileExists && response.code() == HttpURLConnection.HTTP_PARTIAL
+ && !TextUtils.isEmpty(contentRangeHeader)) {
+ String start = contentRangeHeader.substring("bytes ".length(),
+ contentRangeHeader.indexOf("-"));
+ request.setSoFar(Long.parseLong(start));
+ Log.d(TAG, "Starting download at position " + request.getSoFar());
+
+ out = new RandomAccessFile(destination, "rw");
+ out.seek(request.getSoFar());
+ } else {
+ boolean success = destination.delete();
+ success |= destination.createNewFile();
+ if (!success) {
+ throw new IOException("Unable to recreate partially downloaded file");
+ }
+ out = new RandomAccessFile(destination, "rw");
+ }
+
+ byte[] buffer = new byte[BUFFER_SIZE];
+ int count;
+ request.setStatusMsg(R.string.download_running);
+ Log.d(TAG, "Getting size of download");
+ request.setSize(responseBody.contentLength() + request.getSoFar());
+ Log.d(TAG, "Size is " + request.getSize());
+ if (request.getSize() < 0) {
+ request.setSize(DownloadResult.SIZE_UNKNOWN);
+ }
+
+ long freeSpace = getFreeSpaceAvailable();
+ Log.d(TAG, "Free space is " + freeSpace);
+ if (request.getSize() != DownloadResult.SIZE_UNKNOWN && request.getSize() > freeSpace) {
+ onFail(DownloadError.ERROR_NOT_ENOUGH_SPACE, null);
+ return;
+ }
+
+ Log.d(TAG, "Starting download");
+ try {
+ while (!cancelled && (count = connection.read(buffer)) != -1) {
+ out.write(buffer, 0, count);
+ request.setSoFar(request.getSoFar() + count);
+ int progressPercent = (int) (100.0 * request.getSoFar() / request.getSize());
+ request.setProgressPercent(progressPercent);
+ }
+ } catch (IOException e) {
+ Log.e(TAG, Log.getStackTraceString(e));
+ }
+ if (cancelled) {
+ onCancelled();
+ } else {
+ // check if size specified in the response header is the same as the size of the
+ // written file. This check cannot be made if compression was used
+ if (!isGzip && request.getSize() != DownloadResult.SIZE_UNKNOWN
+ && request.getSoFar() != request.getSize()) {
+ 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) {
+ onFail(DownloadError.ERROR_IO_ERROR, "Download completed, but nothing was read");
+ return;
+ }
+ String lastModified = response.header("Last-Modified");
+ if (lastModified != null) {
+ request.setLastModified(lastModified);
+ } else {
+ request.setLastModified(response.header("ETag"));
+ }
+ onSuccess();
+ }
+
+ } catch (IllegalArgumentException e) {
+ e.printStackTrace();
+ onFail(DownloadError.ERROR_MALFORMED_URL, e.getMessage());
+ } catch (SocketTimeoutException e) {
+ e.printStackTrace();
+ onFail(DownloadError.ERROR_CONNECTION_ERROR, e.getMessage());
+ } catch (UnknownHostException e) {
+ e.printStackTrace();
+ onFail(DownloadError.ERROR_UNKNOWN_HOST, e.getMessage());
+ } catch (IOException e) {
+ e.printStackTrace();
+ if (NetworkUtils.wasDownloadBlocked(e)) {
+ onFail(DownloadError.ERROR_IO_BLOCKED, e.getMessage());
+ return;
+ }
+ String message = e.getMessage();
+ if (message != null && 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()
+ e.printStackTrace();
+ onFail(DownloadError.ERROR_CONNECTION_ERROR, request.getSource());
+ } finally {
+ IOUtils.closeQuietly(out);
+ IOUtils.closeQuietly(responseBody);
+ }
+ }
+
+ private Response newCall(Request.Builder httpReq) throws IOException {
+ OkHttpClient httpClient = AntennapodHttpClient.getHttpClient();
+ try {
+ return httpClient.newCall(httpReq.build()).execute();
+ } catch (IOException e) {
+ Log.e(TAG, e.toString());
+ if (e.getMessage() != null && e.getMessage().contains("PROTOCOL_ERROR")) {
+ // Apparently some servers announce they support SPDY but then actually don't.
+ httpClient = httpClient.newBuilder()
+ .protocols(Collections.singletonList(Protocol.HTTP_1_1))
+ .build();
+ return httpClient.newCall(httpReq.build()).execute();
+ } else {
+ throw e;
+ }
+ }
+ }
+
+ private boolean isContentTypeTextAndSmallerThan100kb(Response response) {
+ int contentLength = -1;
+ String contentLen = response.header("Content-Length");
+ if (contentLen != null) {
+ try {
+ contentLength = Integer.parseInt(contentLen);
+ } catch (NumberFormatException e) {
+ e.printStackTrace();
+ }
+ }
+ Log.d(TAG, "content length: " + contentLength);
+ String contentType = response.header("Content-Type");
+ Log.d(TAG, "content type: " + contentType);
+ return contentType != null && contentType.startsWith("text/") && contentLength < 100 * 1024;
+ }
+
+ private void callOnFailByResponseCode(Response response) {
+ final DownloadError error;
+ final String details;
+ if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) {
+ error = DownloadError.ERROR_UNAUTHORIZED;
+ details = String.valueOf(response.code());
+ } else if (response.code() == HttpURLConnection.HTTP_FORBIDDEN) {
+ error = DownloadError.ERROR_FORBIDDEN;
+ details = String.valueOf(response.code());
+ } else if (response.code() == HttpURLConnection.HTTP_NOT_FOUND
+ || response.code() == HttpURLConnection.HTTP_GONE) {
+ error = DownloadError.ERROR_NOT_FOUND;
+ details = String.valueOf(response.code());
+ } else {
+ error = DownloadError.ERROR_HTTP_DATA_ERROR;
+ details = String.valueOf(response.code());
+ }
+ onFail(error, details);
+ }
+
+ private void checkIfRedirect(Response response) {
+ // detect 301 Moved permanently and 308 Permanent Redirect
+ ArrayList<Response> responses = new ArrayList<>();
+ while (response != null) {
+ responses.add(response);
+ response = response.priorResponse();
+ }
+ if (responses.size() < 2) {
+ return;
+ }
+ Collections.reverse(responses);
+ int firstCode = responses.get(0).code();
+ String firstUrl = responses.get(0).request().url().toString();
+ String secondUrl = responses.get(1).request().url().toString();
+ if (firstCode == HttpURLConnection.HTTP_MOVED_PERM || firstCode == StatusLine.HTTP_PERM_REDIRECT) {
+ Log.d(TAG, "Detected permanent redirect from " + request.getSource() + " to " + secondUrl);
+ permanentRedirectUrl = secondUrl;
+ } else if (secondUrl.equals(firstUrl.replace("http://", "https://"))) {
+ Log.d(TAG, "Treating http->https non-permanent redirect as permanent: " + firstUrl);
+ permanentRedirectUrl = secondUrl;
+ }
+ }
+
+ private static long getFreeSpaceAvailable() {
+ File dataFolder = UserPreferences.getDataFolder(null);
+ if (dataFolder != null) {
+ StatFs stat = new StatFs(dataFolder.getAbsolutePath());
+ long availableBlocks = stat.getAvailableBlocksLong();
+ long blockSize = stat.getBlockSizeLong();
+ return availableBlocks * blockSize;
+ } else {
+ return 0;
+ }
+ }
+
+ private void onSuccess() {
+ Log.d(TAG, "Download was successful");
+ result.setSuccessful();
+ }
+
+ private void onFail(DownloadError reason, String reasonDetailed) {
+ Log.d(TAG, "onFail() called with: " + "reason = [" + reason + "], reasonDetailed = [" + reasonDetailed + "]");
+ result.setFailed(reason, reasonDetailed);
+ }
+
+ private void onCancelled() {
+ Log.d(TAG, "Download was cancelled");
+ result.setCancelled();
+ cancelled = true;
+ }
+}
diff --git a/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/InvalidFeedException.java b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/InvalidFeedException.java
new file mode 100644
index 000000000..353b86406
--- /dev/null
+++ b/net/download/service/src/main/java/de/danoeh/antennapod/net/download/service/feed/remote/InvalidFeedException.java
@@ -0,0 +1,12 @@
+package de.danoeh.antennapod.net.download.service.feed.remote;
+
+/**
+ * Thrown if a feed has invalid attribute values.
+ */
+public class InvalidFeedException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ public InvalidFeedException(String message) {
+ super(message);
+ }
+}
diff --git a/net/download/service/src/main/res/values/ids.xml b/net/download/service/src/main/res/values/ids.xml
new file mode 100644
index 000000000..cfd4c941b
--- /dev/null
+++ b/net/download/service/src/main/res/values/ids.xml
@@ -0,0 +1,6 @@
+<resources>
+ <item name="notification_downloading" type="id"/>
+ <item name="notification_updating_feeds" type="id"/>
+ <item name="notification_download_report" type="id"/>
+ <item name="notification_auto_download_report" type="id"/>
+</resources>
diff --git a/net/download/service/src/test/assets/local-feed1/track1.mp3 b/net/download/service/src/test/assets/local-feed1/track1.mp3
new file mode 100644
index 000000000..b1f993c3f
--- /dev/null
+++ b/net/download/service/src/test/assets/local-feed1/track1.mp3
Binary files differ
diff --git a/net/download/service/src/test/assets/local-feed2/folder.png b/net/download/service/src/test/assets/local-feed2/folder.png
new file mode 100644
index 000000000..9e522a986
--- /dev/null
+++ b/net/download/service/src/test/assets/local-feed2/folder.png
Binary files differ
diff --git a/net/download/service/src/test/assets/local-feed2/track1.mp3 b/net/download/service/src/test/assets/local-feed2/track1.mp3
new file mode 100644
index 000000000..b1f993c3f
--- /dev/null
+++ b/net/download/service/src/test/assets/local-feed2/track1.mp3
Binary files differ
diff --git a/net/download/service/src/test/assets/local-feed2/track2.mp3 b/net/download/service/src/test/assets/local-feed2/track2.mp3
new file mode 100644
index 000000000..310cddd6b
--- /dev/null
+++ b/net/download/service/src/test/assets/local-feed2/track2.mp3
Binary files differ
diff --git a/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/APCleanupAlgorithmTest.java b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/APCleanupAlgorithmTest.java
new file mode 100644
index 000000000..0072e7ac0
--- /dev/null
+++ b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/APCleanupAlgorithmTest.java
@@ -0,0 +1,20 @@
+package de.danoeh.antennapod.net.download.service.episode.autodownload;
+
+import org.junit.Test;
+
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+import static org.junit.Assert.assertEquals;
+
+public class APCleanupAlgorithmTest {
+
+ @Test
+ public void testCalcMostRecentDateForDeletion() throws Exception {
+ APCleanupAlgorithm algo = new APCleanupAlgorithm(24);
+ Date curDateForTest = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse("2018-11-13T14:08:56-0800");
+ Date resExpected = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse("2018-11-12T14:08:56-0800");
+ Date resActual = algo.calcMostRecentDateForDeletion(curDateForTest);
+ assertEquals("cutoff for retaining most recent 1 day", resExpected, resActual);
+ }
+}
diff --git a/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbCleanupTests.java b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbCleanupTests.java
new file mode 100644
index 000000000..dc9c8749a
--- /dev/null
+++ b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbCleanupTests.java
@@ -0,0 +1,234 @@
+package de.danoeh.antennapod.net.download.service.episode.autodownload;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import androidx.preference.PreferenceManager;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.net.download.serviceinterface.AutoDownloadManager;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.storage.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.storage.preferences.SynchronizationSettings;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+
+import de.danoeh.antennapod.storage.database.PodDBAdapter;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import static de.danoeh.antennapod.net.download.service.episode.autodownload.DbTestUtils.saveFeedlist;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Test class for DBTasks.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class DbCleanupTests {
+
+ static final int EPISODE_CACHE_SIZE = 5;
+ private int cleanupAlgorithm;
+
+ Context context;
+
+ private File destFolder;
+
+ public DbCleanupTests() {
+ setCleanupAlgorithm(UserPreferences.EPISODE_CLEANUP_DEFAULT);
+ }
+
+ protected void setCleanupAlgorithm(int cleanupAlgorithm) {
+ this.cleanupAlgorithm = cleanupAlgorithm;
+ }
+
+ @Before
+ public void setUp() {
+ context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ destFolder = new File(context.getCacheDir(), "DbCleanupTests");
+ //noinspection ResultOfMethodCallIgnored
+ destFolder.mkdir();
+ cleanupDestFolder(destFolder);
+ assertNotNull(destFolder);
+ assertTrue(destFolder.exists());
+ assertTrue(destFolder.canWrite());
+
+ // create new database
+ PodDBAdapter.init(context);
+ PodDBAdapter.deleteDatabase();
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.close();
+
+ SharedPreferences.Editor prefEdit = PreferenceManager
+ .getDefaultSharedPreferences(context.getApplicationContext()).edit();
+ prefEdit.putString(UserPreferences.PREF_EPISODE_CACHE_SIZE, Integer.toString(EPISODE_CACHE_SIZE));
+ prefEdit.putString(UserPreferences.PREF_EPISODE_CLEANUP, Integer.toString(cleanupAlgorithm));
+ prefEdit.putBoolean(UserPreferences.PREF_ENABLE_AUTODL, true);
+ prefEdit.commit();
+
+ UserPreferences.init(context);
+ PlaybackPreferences.init(context);
+ SynchronizationSettings.init(context);
+ AutoDownloadManager.setInstance(new AutoDownloadManagerImpl());
+ }
+
+ @After
+ public void tearDown() {
+ cleanupDestFolder(destFolder);
+ assertTrue(destFolder.delete());
+
+ DBWriter.tearDownTests();
+ PodDBAdapter.tearDownTests();
+ }
+
+ private void cleanupDestFolder(File destFolder) {
+ //noinspection ConstantConditions
+ for (File f : destFolder.listFiles()) {
+ assertTrue(f.delete());
+ }
+ }
+
+ @Test
+ public void testPerformAutoCleanupShouldDelete() throws IOException {
+ final int numItems = EPISODE_CACHE_SIZE * 2;
+
+ Feed feed = new Feed("url", null, "title");
+ List<FeedItem> items = new ArrayList<>();
+ feed.setItems(items);
+ List<File> files = new ArrayList<>();
+ populateItems(numItems, feed, items, files, FeedItem.PLAYED, false, false);
+
+ AutoDownloadManager.getInstance().performAutoCleanup(context);
+ for (int i = 0; i < files.size(); i++) {
+ if (i < EPISODE_CACHE_SIZE) {
+ assertTrue(files.get(i).exists());
+ } else {
+ assertFalse(files.get(i).exists());
+ }
+ }
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ void populateItems(final int numItems, Feed feed, List<FeedItem> items,
+ List<File> files, int itemState, boolean addToQueue,
+ boolean addToFavorites) throws IOException {
+ for (int i = 0; i < numItems; i++) {
+ Date itemDate = new Date(numItems - i);
+ Date playbackCompletionDate = null;
+ if (itemState == FeedItem.PLAYED) {
+ playbackCompletionDate = itemDate;
+ }
+ FeedItem item = new FeedItem(0, "title", "id" + i, "link", itemDate, itemState, feed);
+
+ File f = new File(destFolder, "file " + i);
+ assertTrue(f.createNewFile());
+ files.add(f);
+ item.setMedia(new FeedMedia(0, item, 1, 0, 1L, "m",
+ f.getAbsolutePath(), "url", true, playbackCompletionDate, 0, 0));
+ items.add(item);
+ }
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ if (addToQueue) {
+ adapter.setQueue(items);
+ }
+ if (addToFavorites) {
+ adapter.setFavorites(items);
+ }
+ adapter.close();
+
+ assertTrue(feed.getId() != 0);
+ for (FeedItem item : items) {
+ assertTrue(item.getId() != 0);
+ //noinspection ConstantConditions
+ assertTrue(item.getMedia().getId() != 0);
+ }
+ }
+
+ @Test
+ public void testPerformAutoCleanupHandleUnplayed() throws IOException {
+ final int numItems = EPISODE_CACHE_SIZE * 2;
+
+ Feed feed = new Feed("url", null, "title");
+ List<FeedItem> items = new ArrayList<>();
+ feed.setItems(items);
+ List<File> files = new ArrayList<>();
+ populateItems(numItems, feed, items, files, FeedItem.UNPLAYED, false, false);
+
+ AutoDownloadManager.getInstance().performAutoCleanup(context);
+ for (File file : files) {
+ assertTrue(file.exists());
+ }
+ }
+
+ @Test
+ public void testPerformAutoCleanupShouldNotDeleteBecauseInQueue() throws IOException {
+ final int numItems = EPISODE_CACHE_SIZE * 2;
+
+ Feed feed = new Feed("url", null, "title");
+ List<FeedItem> items = new ArrayList<>();
+ feed.setItems(items);
+ List<File> files = new ArrayList<>();
+ populateItems(numItems, feed, items, files, FeedItem.PLAYED, true, false);
+
+ AutoDownloadManager.getInstance().performAutoCleanup(context);
+ for (File file : files) {
+ assertTrue(file.exists());
+ }
+ }
+
+ /**
+ * Reproduces a bug where DBTasks.performAutoCleanup(android.content.Context) would use the ID
+ * of the FeedItem in the call to DBWriter.deleteFeedMediaOfItem instead of the ID of the FeedMedia.
+ * This would cause the wrong item to be deleted.
+ */
+ @Test
+ public void testPerformAutoCleanupShouldNotDeleteBecauseInQueue_withFeedsWithNoMedia() throws IOException {
+ // add feed with no enclosures so that item ID != media ID
+ saveFeedlist(1, 10, false);
+
+ // add candidate for performAutoCleanup
+ List<Feed> feeds = saveFeedlist(1, 1, true);
+ FeedMedia m = feeds.get(0).getItems().get(0).getMedia();
+ //noinspection ConstantConditions
+ m.setDownloaded(true);
+ m.setLocalFileUrl("file");
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setMedia(m);
+ adapter.close();
+
+ testPerformAutoCleanupShouldNotDeleteBecauseInQueue();
+ }
+
+ @Test
+ public void testPerformAutoCleanupShouldNotDeleteBecauseFavorite() throws IOException {
+ final int numItems = EPISODE_CACHE_SIZE * 2;
+
+ Feed feed = new Feed("url", null, "title");
+ List<FeedItem> items = new ArrayList<>();
+ feed.setItems(items);
+ List<File> files = new ArrayList<>();
+ populateItems(numItems, feed, items, files, FeedItem.PLAYED, false, true);
+
+ AutoDownloadManager.getInstance().performAutoCleanup(context);
+ for (File file : files) {
+ assertTrue(file.exists());
+ }
+ }
+}
diff --git a/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbNullCleanupAlgorithmTest.java b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbNullCleanupAlgorithmTest.java
new file mode 100644
index 000000000..032fc2013
--- /dev/null
+++ b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbNullCleanupAlgorithmTest.java
@@ -0,0 +1,125 @@
+package de.danoeh.antennapod.net.download.service.episode.autodownload;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import androidx.preference.PreferenceManager;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.net.download.serviceinterface.AutoDownloadManager;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import de.danoeh.antennapod.storage.database.PodDBAdapter;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests that the APNullCleanupAlgorithm is working correctly.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class DbNullCleanupAlgorithmTest {
+
+ private static final int EPISODE_CACHE_SIZE = 5;
+
+ private Context context;
+
+ private File destFolder;
+
+ @Before
+ public void setUp() {
+ context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ destFolder = context.getExternalCacheDir();
+ cleanupDestFolder(destFolder);
+ assertNotNull(destFolder);
+ assertTrue(destFolder.exists());
+ assertTrue(destFolder.canWrite());
+
+ // create new database
+ PodDBAdapter.init(context);
+ PodDBAdapter.deleteDatabase();
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.close();
+
+ SharedPreferences.Editor prefEdit = PreferenceManager.getDefaultSharedPreferences(context
+ .getApplicationContext()).edit();
+ prefEdit.putString(UserPreferences.PREF_EPISODE_CACHE_SIZE, Integer.toString(EPISODE_CACHE_SIZE));
+ prefEdit.putString(UserPreferences.PREF_EPISODE_CLEANUP,
+ Integer.toString(UserPreferences.EPISODE_CLEANUP_NULL));
+ prefEdit.commit();
+
+ UserPreferences.init(context);
+ AutoDownloadManager.setInstance(new AutoDownloadManagerImpl());
+ }
+
+ @After
+ public void tearDown() {
+ DBWriter.tearDownTests();
+ PodDBAdapter.deleteDatabase();
+ PodDBAdapter.tearDownTests();
+
+ cleanupDestFolder(destFolder);
+ assertTrue(destFolder.delete());
+ }
+
+ private void cleanupDestFolder(File destFolder) {
+ //noinspection ConstantConditions
+ for (File f : destFolder.listFiles()) {
+ assertTrue(f.delete());
+ }
+ }
+
+ /**
+ * A test with no items in the queue, but multiple items downloaded.
+ * The null algorithm should never delete any items, even if they're played and not in the queue.
+ */
+ @Test
+ public void testPerformAutoCleanupShouldNotDelete() throws IOException {
+ final int numItems = EPISODE_CACHE_SIZE * 2;
+
+ Feed feed = new Feed("url", null, "title");
+ List<FeedItem> items = new ArrayList<>();
+ feed.setItems(items);
+ List<File> files = new ArrayList<>();
+ for (int i = 0; i < numItems; i++) {
+ FeedItem item = new FeedItem(0, "title", "id" + i, "link", new Date(), FeedItem.PLAYED, feed);
+
+ File f = new File(destFolder, "file " + i);
+ assertTrue(f.createNewFile());
+ files.add(f);
+ item.setMedia(new FeedMedia(0, item, 1, 0, 1L, "m", f.getAbsolutePath(), "url", true,
+ new Date(numItems - i), 0, 0));
+ items.add(item);
+ }
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+
+ assertTrue(feed.getId() != 0);
+ for (FeedItem item : items) {
+ assertTrue(item.getId() != 0);
+ //noinspection ConstantConditions
+ assertTrue(item.getMedia().getId() != 0);
+ }
+ AutoDownloadManager.getInstance().performAutoCleanup(context);
+ for (int i = 0; i < files.size(); i++) {
+ assertTrue(files.get(i).exists());
+ }
+ }
+}
diff --git a/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbQueueCleanupAlgorithmTest.java b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbQueueCleanupAlgorithmTest.java
new file mode 100644
index 000000000..b6d9a8f66
--- /dev/null
+++ b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbQueueCleanupAlgorithmTest.java
@@ -0,0 +1,54 @@
+package de.danoeh.antennapod.net.download.service.episode.autodownload;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.net.download.serviceinterface.AutoDownloadManager;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests that the APQueueCleanupAlgorithm is working correctly.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class DbQueueCleanupAlgorithmTest extends DbCleanupTests {
+
+ public DbQueueCleanupAlgorithmTest() {
+ setCleanupAlgorithm(UserPreferences.EPISODE_CLEANUP_QUEUE);
+ AutoDownloadManager.setInstance(new AutoDownloadManagerImpl());
+ }
+
+ /**
+ * For APQueueCleanupAlgorithm we expect even unplayed episodes to be deleted if needed
+ * if they aren't in the queue.
+ */
+ @Test
+ public void testPerformAutoCleanupHandleUnplayed() throws IOException {
+ final int numItems = EPISODE_CACHE_SIZE * 2;
+
+ Feed feed = new Feed("url", null, "title");
+ List<FeedItem> items = new ArrayList<>();
+ feed.setItems(items);
+ List<File> files = new ArrayList<>();
+ populateItems(numItems, feed, items, files, FeedItem.UNPLAYED, false, false);
+
+ AutoDownloadManager.getInstance().performAutoCleanup(context);
+ for (int i = 0; i < files.size(); i++) {
+ if (i < EPISODE_CACHE_SIZE) {
+ assertTrue(files.get(i).exists());
+ } else {
+ assertFalse(files.get(i).exists());
+ }
+ }
+ }
+}
diff --git a/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbReaderTest.java b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbReaderTest.java
new file mode 100644
index 000000000..f36408957
--- /dev/null
+++ b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbReaderTest.java
@@ -0,0 +1,526 @@
+package de.danoeh.antennapod.net.download.service.episode.autodownload;
+
+import android.content.Context;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Date;
+import java.util.List;
+import java.util.Random;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedCounter;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedItemFilter;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.model.feed.FeedOrder;
+import de.danoeh.antennapod.model.feed.SortOrder;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.storage.database.NavDrawerData;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import de.danoeh.antennapod.storage.database.LongList;
+import de.danoeh.antennapod.storage.database.PodDBAdapter;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Ignore;
+import org.junit.Test;
+import org.junit.experimental.runners.Enclosed;
+import org.junit.runner.RunWith;
+import org.robolectric.ParameterizedRobolectricTestRunner;
+import org.robolectric.RobolectricTestRunner;
+
+import static de.danoeh.antennapod.net.download.service.episode.autodownload.DbTestUtils.saveFeedlist;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Test class for DBReader.
+ */
+@SuppressWarnings("ConstantConditions")
+@RunWith(Enclosed.class)
+public class DbReaderTest {
+ @Ignore("Not a test")
+ public static class TestBase {
+ @Before
+ public void setUp() {
+ Context context = InstrumentationRegistry.getInstrumentation().getContext();
+ UserPreferences.init(context);
+
+ PodDBAdapter.init(context);
+ PodDBAdapter.deleteDatabase();
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.close();
+ }
+
+ @After
+ public void tearDown() {
+ PodDBAdapter.tearDownTests();
+ DBWriter.tearDownTests();
+ }
+ }
+
+ @RunWith(RobolectricTestRunner.class)
+ public static class SingleTests extends TestBase {
+ @Test
+ public void testGetFeedList() {
+ List<Feed> feeds = saveFeedlist(10, 0, false);
+ List<Feed> savedFeeds = DBReader.getFeedList();
+ assertNotNull(savedFeeds);
+ assertEquals(feeds.size(), savedFeeds.size());
+ for (int i = 0; i < feeds.size(); i++) {
+ assertEquals(feeds.get(i).getId(), savedFeeds.get(i).getId());
+ }
+ }
+
+ @Test
+ public void testGetFeedListSortOrder() {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+
+ final long lastRefreshed = System.currentTimeMillis();
+ Feed feed1 = new Feed(0, null, "A", "link", "d", null, null, null, "rss", "A", null, "", "", lastRefreshed);
+ Feed feed2 = new Feed(0, null, "b", "link", "d", null, null, null, "rss", "b", null, "", "", lastRefreshed);
+ Feed feed3 = new Feed(0, null, "C", "link", "d", null, null, null, "rss", "C", null, "", "", lastRefreshed);
+ Feed feed4 = new Feed(0, null, "d", "link", "d", null, null, null, "rss", "d", null, "", "", lastRefreshed);
+ adapter.setCompleteFeed(feed1);
+ adapter.setCompleteFeed(feed2);
+ adapter.setCompleteFeed(feed3);
+ adapter.setCompleteFeed(feed4);
+ assertTrue(feed1.getId() != 0);
+ assertTrue(feed2.getId() != 0);
+ assertTrue(feed3.getId() != 0);
+ assertTrue(feed4.getId() != 0);
+
+ adapter.close();
+
+ List<Feed> saved = DBReader.getFeedList();
+ assertNotNull(saved);
+ assertEquals("Wrong size: ", 4, saved.size());
+
+ assertEquals("Wrong id of feed 1: ", feed1.getId(), saved.get(0).getId());
+ assertEquals("Wrong id of feed 2: ", feed2.getId(), saved.get(1).getId());
+ assertEquals("Wrong id of feed 3: ", feed3.getId(), saved.get(2).getId());
+ assertEquals("Wrong id of feed 4: ", feed4.getId(), saved.get(3).getId());
+ }
+
+ @Test
+ public void testFeedListDownloadUrls() {
+ List<Feed> feeds = saveFeedlist(10, 0, false);
+ List<String> urls = DBReader.getFeedListDownloadUrls();
+ assertNotNull(urls);
+ assertEquals(feeds.size(), urls.size());
+ for (int i = 0; i < urls.size(); i++) {
+ assertEquals(urls.get(i), feeds.get(i).getDownloadUrl());
+ }
+ }
+
+ @Test
+ public void testLoadFeedDataOfFeedItemlist() {
+ final int numFeeds = 10;
+ final int numItems = 1;
+ List<Feed> feeds = saveFeedlist(numFeeds, numItems, false);
+ List<FeedItem> items = new ArrayList<>();
+ for (Feed f : feeds) {
+ for (FeedItem item : f.getItems()) {
+ item.setFeed(null);
+ item.setFeedId(f.getId());
+ items.add(item);
+ }
+ }
+ DBReader.loadAdditionalFeedItemListData(items);
+ for (int i = 0; i < numFeeds; i++) {
+ for (int j = 0; j < numItems; j++) {
+ FeedItem item = feeds.get(i).getItems().get(j);
+ assertNotNull(item.getFeed());
+ assertEquals(feeds.get(i).getId(), item.getFeed().getId());
+ assertEquals(item.getFeed().getId(), item.getFeedId());
+ }
+ }
+ }
+
+ @Test
+ public void testGetFeedItemList() {
+ final int numFeeds = 1;
+ final int numItems = 10;
+ Feed feed = saveFeedlist(numFeeds, numItems, false).get(0);
+ List<FeedItem> items = feed.getItems();
+ feed.setItems(null);
+ List<FeedItem> savedItems = DBReader.getFeedItemList(feed,
+ FeedItemFilter.unfiltered(), SortOrder.DATE_NEW_OLD);
+ assertNotNull(savedItems);
+ assertEquals(items.size(), savedItems.size());
+ for (int i = 0; i < savedItems.size(); i++) {
+ assertEquals(savedItems.get(i).getId(), items.get(i).getId());
+ }
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ private List<FeedItem> saveQueue(int numItems) {
+ if (numItems <= 0) {
+ throw new IllegalArgumentException("numItems<=0");
+ }
+ List<Feed> feeds = saveFeedlist(numItems, numItems, false);
+ List<FeedItem> allItems = new ArrayList<>();
+ for (Feed f : feeds) {
+ allItems.addAll(f.getItems());
+ }
+ // take random items from every feed
+ Random random = new Random();
+ List<FeedItem> queue = new ArrayList<>();
+ while (queue.size() < numItems) {
+ int index = random.nextInt(numItems);
+ if (!queue.contains(allItems.get(index))) {
+ queue.add(allItems.get(index));
+ }
+ }
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setQueue(queue);
+ adapter.close();
+ return queue;
+ }
+
+ @Test
+ public void testGetQueueIdList() {
+ final int numItems = 10;
+ List<FeedItem> queue = saveQueue(numItems);
+ LongList ids = DBReader.getQueueIDList();
+ assertNotNull(ids);
+ assertEquals(ids.size(), queue.size());
+ for (int i = 0; i < queue.size(); i++) {
+ assertTrue(ids.get(i) != 0);
+ assertEquals(ids.get(i), queue.get(i).getId());
+ }
+ }
+
+ @Test
+ public void testGetQueue() {
+ final int numItems = 10;
+ List<FeedItem> queue = saveQueue(numItems);
+ List<FeedItem> savedQueue = DBReader.getQueue();
+ assertNotNull(savedQueue);
+ assertEquals(savedQueue.size(), queue.size());
+ for (int i = 0; i < queue.size(); i++) {
+ assertTrue(savedQueue.get(i).getId() != 0);
+ assertEquals(savedQueue.get(i).getId(), queue.get(i).getId());
+ }
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ private List<FeedItem> saveDownloadedItems(int numItems) {
+ if (numItems <= 0) {
+ throw new IllegalArgumentException("numItems<=0");
+ }
+ List<Feed> feeds = saveFeedlist(numItems, numItems, true);
+ List<FeedItem> items = new ArrayList<>();
+ for (Feed f : feeds) {
+ items.addAll(f.getItems());
+ }
+ List<FeedItem> downloaded = new ArrayList<>();
+ Random random = new Random();
+
+ while (downloaded.size() < numItems) {
+ int i = random.nextInt(numItems);
+ if (!downloaded.contains(items.get(i))) {
+ FeedItem item = items.get(i);
+ item.getMedia().setDownloaded(true);
+ item.getMedia().setLocalFileUrl("file" + i);
+ downloaded.add(item);
+ }
+ }
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.storeFeedItemlist(downloaded);
+ adapter.close();
+ return downloaded;
+ }
+
+ @Test
+ public void testGetDownloadedItems() {
+ final int numItems = 10;
+ List<FeedItem> downloaded = saveDownloadedItems(numItems);
+ List<FeedItem> downloadedSaved = DBReader.getEpisodes(0, Integer.MAX_VALUE,
+ new FeedItemFilter(FeedItemFilter.DOWNLOADED), SortOrder.DATE_NEW_OLD);
+ assertNotNull(downloadedSaved);
+ assertEquals(downloaded.size(), downloadedSaved.size());
+ for (FeedItem item : downloadedSaved) {
+ assertNotNull(item.getMedia());
+ assertTrue(item.getMedia().isDownloaded());
+ assertNotNull(item.getMedia().getDownloadUrl());
+ }
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ private List<FeedItem> saveNewItems(int numItems) {
+ List<Feed> feeds = saveFeedlist(numItems, numItems, true);
+ List<FeedItem> items = new ArrayList<>();
+ for (Feed f : feeds) {
+ items.addAll(f.getItems());
+ }
+ List<FeedItem> newItems = new ArrayList<>();
+ Random random = new Random();
+
+ while (newItems.size() < numItems) {
+ int i = random.nextInt(numItems);
+ if (!newItems.contains(items.get(i))) {
+ FeedItem item = items.get(i);
+ item.setNew();
+ newItems.add(item);
+ }
+ }
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.storeFeedItemlist(newItems);
+ adapter.close();
+ return newItems;
+ }
+
+ @Test
+ public void testGetNewItemIds() {
+ final int numItems = 10;
+
+ List<FeedItem> newItems = saveNewItems(numItems);
+ long[] unreadIds = new long[newItems.size()];
+ for (int i = 0; i < newItems.size(); i++) {
+ unreadIds[i] = newItems.get(i).getId();
+ }
+ List<FeedItem> newItemsSaved = DBReader.getEpisodes(0, Integer.MAX_VALUE,
+ new FeedItemFilter(FeedItemFilter.NEW), SortOrder.DATE_NEW_OLD);
+ assertNotNull(newItemsSaved);
+ assertEquals(newItemsSaved.size(), newItems.size());
+ for (FeedItem feedItem : newItemsSaved) {
+ long savedId = feedItem.getId();
+ boolean found = false;
+ for (long id : unreadIds) {
+ if (id == savedId) {
+ found = true;
+ break;
+ }
+ }
+ assertTrue(found);
+ }
+ }
+
+ @Test
+ public void testGetPlaybackHistoryLength() {
+ final int totalItems = 100;
+
+ Feed feed = DbTestUtils.saveFeedlist(1, totalItems, true).get(0);
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ for (int playedItems : Arrays.asList(0, 1, 20, 100)) {
+ adapter.open();
+ for (int i = 0; i < playedItems; ++i) {
+ FeedMedia m = feed.getItems().get(i).getMedia();
+ m.setPlaybackCompletionDate(new Date(i + 1));
+
+ adapter.setFeedMediaPlaybackCompletionDate(m);
+ }
+ adapter.close();
+
+ long len = DBReader.getTotalEpisodeCount(new FeedItemFilter(FeedItemFilter.IS_IN_HISTORY));
+ assertEquals("Wrong size: ", (int) len, playedItems);
+ }
+
+ }
+
+ @Test
+ public void testGetNavDrawerDataQueueEmptyNoUnreadItems() {
+ final int numFeeds = 10;
+ final int numItems = 10;
+ DbTestUtils.saveFeedlist(numFeeds, numItems, true);
+ NavDrawerData navDrawerData = DBReader.getNavDrawerData(
+ UserPreferences.getSubscriptionsFilter(), FeedOrder.COUNTER, FeedCounter.SHOW_NEW);
+ assertEquals(numFeeds, navDrawerData.items.size());
+ assertEquals(0, navDrawerData.numNewItems);
+ assertEquals(0, navDrawerData.queueSize);
+ }
+
+ @Test
+ public void testGetNavDrawerDataQueueNotEmptyWithUnreadItems() {
+ final int numFeeds = 10;
+ final int numItems = 10;
+ final int numQueue = 1;
+ final int numNew = 2;
+ List<Feed> feeds = DbTestUtils.saveFeedlist(numFeeds, numItems, true);
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ for (int i = 0; i < numNew; i++) {
+ FeedItem item = feeds.get(0).getItems().get(i);
+ item.setNew();
+ adapter.setSingleFeedItem(item);
+ }
+ List<FeedItem> queue = new ArrayList<>();
+ for (int i = 0; i < numQueue; i++) {
+ FeedItem item = feeds.get(1).getItems().get(i);
+ queue.add(item);
+ }
+ adapter.setQueue(queue);
+
+ adapter.close();
+
+ NavDrawerData navDrawerData = DBReader.getNavDrawerData(
+ UserPreferences.getSubscriptionsFilter(), FeedOrder.COUNTER, FeedCounter.SHOW_NEW);
+ assertEquals(numFeeds, navDrawerData.items.size());
+ assertEquals(numNew, navDrawerData.numNewItems);
+ assertEquals(numQueue, navDrawerData.queueSize);
+ }
+
+ @Test
+ public void testGetFeedItemlistCheckChaptersFalse() {
+ List<Feed> feeds = DbTestUtils.saveFeedlist(10, 10, false, false, 0);
+ for (Feed feed : feeds) {
+ for (FeedItem item : feed.getItems()) {
+ assertFalse(item.hasChapters());
+ }
+ }
+ }
+
+ @Test
+ public void testGetFeedItemlistCheckChaptersTrue() {
+ List<Feed> feeds = saveFeedlist(10, 10, false, true, 10);
+ for (Feed feed : feeds) {
+ for (FeedItem item : feed.getItems()) {
+ assertTrue(item.hasChapters());
+ }
+ }
+ }
+
+ @Test
+ public void testLoadChaptersOfFeedItemNoChapters() {
+ List<Feed> feeds = saveFeedlist(1, 3, false, false, 0);
+ saveFeedlist(1, 3, false, true, 3);
+ for (Feed feed : feeds) {
+ for (FeedItem item : feed.getItems()) {
+ assertFalse(item.hasChapters());
+ item.setChapters(DBReader.loadChaptersOfFeedItem(item));
+ assertFalse(item.hasChapters());
+ assertNull(item.getChapters());
+ }
+ }
+ }
+
+ @Test
+ public void testLoadChaptersOfFeedItemWithChapters() {
+ final int numChapters = 3;
+ DbTestUtils.saveFeedlist(1, 3, false, false, 0);
+ List<Feed> feeds = saveFeedlist(1, 3, false, true, numChapters);
+ for (Feed feed : feeds) {
+ for (FeedItem item : feed.getItems()) {
+ assertTrue(item.hasChapters());
+ item.setChapters(DBReader.loadChaptersOfFeedItem(item));
+ assertTrue(item.hasChapters());
+ assertNotNull(item.getChapters());
+ assertEquals(numChapters, item.getChapters().size());
+ }
+ }
+ }
+
+ @Test
+ public void testGetItemWithChapters() {
+ final int numChapters = 3;
+ List<Feed> feeds = saveFeedlist(1, 1, false, true, numChapters);
+ FeedItem item1 = feeds.get(0).getItems().get(0);
+ FeedItem item2 = DBReader.getFeedItem(item1.getId());
+ item2.setChapters(DBReader.loadChaptersOfFeedItem(item2));
+ assertTrue(item2.hasChapters());
+ assertEquals(item1.getChapters().size(), item2.getChapters().size());
+ for (int i = 0; i < item1.getChapters().size(); i++) {
+ assertEquals(item1.getChapters().get(i).getId(), item2.getChapters().get(i).getId());
+ }
+ }
+
+ @Test
+ public void testGetItemByEpisodeUrl() {
+ List<Feed> feeds = saveFeedlist(1, 1, true);
+ FeedItem item1 = feeds.get(0).getItems().get(0);
+ FeedItem feedItemByEpisodeUrl = DBReader.getFeedItemByGuidOrEpisodeUrl(null,
+ item1.getMedia().getDownloadUrl());
+ assertEquals(item1.getItemIdentifier(), feedItemByEpisodeUrl.getItemIdentifier());
+ }
+
+ @Test
+ public void testGetItemByGuid() {
+ List<Feed> feeds = saveFeedlist(1, 1, true);
+ FeedItem item1 = feeds.get(0).getItems().get(0);
+
+ FeedItem feedItemByGuid = DBReader.getFeedItemByGuidOrEpisodeUrl(item1.getItemIdentifier(),
+ item1.getMedia().getDownloadUrl());
+ assertEquals(item1.getItemIdentifier(), feedItemByGuid.getItemIdentifier());
+ }
+
+ }
+
+ @RunWith(ParameterizedRobolectricTestRunner.class)
+ public static class PlaybackHistoryTest extends TestBase {
+
+ private int paramOffset;
+ private int paramLimit;
+
+ @ParameterizedRobolectricTestRunner.Parameters
+ public static Collection<Object[]> data() {
+ List<Integer> limits = Arrays.asList(1, 20, 100);
+ List<Integer> offsets = Arrays.asList(0, 10, 20);
+ Object[][] rv = new Object[limits.size() * offsets.size()][2];
+ int i = 0;
+ for (int offset : offsets) {
+ for (int limit : limits) {
+ rv[i][0] = offset;
+ rv[i][1] = limit;
+ i++;
+ }
+ }
+
+ return Arrays.asList(rv);
+ }
+
+ public PlaybackHistoryTest(int offset, int limit) {
+ this.paramOffset = offset;
+ this.paramLimit = limit;
+
+ }
+
+ @Test
+ public void testGetPlaybackHistory() {
+ final int numItems = (paramLimit + 1) * 2;
+ final int playedItems = paramLimit + 1;
+ final int numReturnedItems = Math.min(Math.max(playedItems - paramOffset, 0), paramLimit);
+ final int numFeeds = 1;
+
+ Feed feed = DbTestUtils.saveFeedlist(numFeeds, numItems, true).get(0);
+ long[] ids = new long[playedItems];
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ for (int i = 0; i < playedItems; i++) {
+ FeedMedia m = feed.getItems().get(i).getMedia();
+ m.setPlaybackCompletionDate(new Date(i + 1));
+ adapter.setFeedMediaPlaybackCompletionDate(m);
+ ids[ids.length - 1 - i] = m.getItem().getId();
+ }
+ adapter.close();
+
+ List<FeedItem> saved = DBReader.getEpisodes(paramOffset, paramLimit,
+ new FeedItemFilter(FeedItemFilter.IS_IN_HISTORY), SortOrder.COMPLETION_DATE_NEW_OLD);
+ assertNotNull(saved);
+ assertEquals(String.format("Wrong size with offset %d and limit %d: ",
+ paramOffset, paramLimit),
+ numReturnedItems, saved.size());
+ for (int i = 0; i < numReturnedItems; i++) {
+ FeedItem item = saved.get(i);
+ assertNotNull(item.getMedia().getPlaybackCompletionDate());
+ assertEquals(String.format("Wrong sort order with offset %d and limit %d: ",
+ paramOffset, paramLimit),
+ item.getId(), ids[paramOffset + i]);
+ }
+ }
+ }
+}
diff --git a/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbTasksTest.java b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbTasksTest.java
new file mode 100644
index 000000000..776319acf
--- /dev/null
+++ b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbTasksTest.java
@@ -0,0 +1,249 @@
+package de.danoeh.antennapod.net.download.service.episode.autodownload;
+
+import android.content.Context;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.storage.database.FeedDatabaseWriter;
+import de.danoeh.antennapod.storage.database.PodDBAdapter;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.storage.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+
+import static java.util.Collections.singletonList;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Test class for {@link FeedDatabaseWriter}.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class DbTasksTest {
+ private Context context;
+
+ @Before
+ public void setUp() {
+ context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ UserPreferences.init(context);
+ PlaybackPreferences.init(context);
+
+ // create new database
+ PodDBAdapter.init(context);
+ PodDBAdapter.deleteDatabase();
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.close();
+ }
+
+ @After
+ public void tearDown() {
+ DBWriter.tearDownTests();
+ PodDBAdapter.tearDownTests();
+ }
+
+ @Test
+ public void testUpdateFeedNewFeed() {
+ final int numItems = 10;
+
+ Feed feed = new Feed("url", null, "title");
+ feed.setItems(new ArrayList<>());
+ for (int i = 0; i < numItems; i++) {
+ feed.getItems().add(new FeedItem(0, "item " + i, "id " + i, "link " + i,
+ new Date(), FeedItem.UNPLAYED, feed));
+ }
+ Feed newFeed = FeedDatabaseWriter.updateFeed(context, feed, false);
+
+ assertEquals(feed.getId(), newFeed.getId());
+ assertTrue(feed.getId() != 0);
+ for (FeedItem item : feed.getItems()) {
+ assertFalse(item.isPlayed());
+ assertTrue(item.getId() != 0);
+ }
+ }
+
+ /** Two feeds with the same title, but different download URLs should be treated as different feeds. */
+ @Test
+ public void testUpdateFeedSameTitle() {
+
+ Feed feed1 = new Feed("url1", null, "title");
+ Feed feed2 = new Feed("url2", null, "title");
+
+ feed1.setItems(new ArrayList<>());
+ feed2.setItems(new ArrayList<>());
+
+ Feed savedFeed1 = FeedDatabaseWriter.updateFeed(context, feed1, false);
+ Feed savedFeed2 = FeedDatabaseWriter.updateFeed(context, feed2, false);
+
+ assertTrue(savedFeed1.getId() != savedFeed2.getId());
+ }
+
+ @Test
+ public void testUpdateFeedUpdatedFeed() {
+ final int numItemsOld = 10;
+ final int numItemsNew = 10;
+
+ final Feed feed = new Feed("url", null, "title");
+ feed.setItems(new ArrayList<>());
+ for (int i = 0; i < numItemsOld; i++) {
+ feed.getItems().add(new FeedItem(0, "item " + i, "id " + i, "link " + i,
+ new Date(i), FeedItem.PLAYED, feed));
+ }
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+
+ // ensure that objects have been saved in db, then reset
+ assertTrue(feed.getId() != 0);
+ final long feedID = feed.getId();
+ feed.setId(0);
+ List<Long> itemIDs = new ArrayList<>();
+ for (FeedItem item : feed.getItems()) {
+ assertTrue(item.getId() != 0);
+ itemIDs.add(item.getId());
+ item.setId(0);
+ }
+
+ for (int i = numItemsOld; i < numItemsNew + numItemsOld; i++) {
+ feed.getItems().add(0, new FeedItem(0, "item " + i, "id " + i, "link " + i,
+ new Date(i), FeedItem.UNPLAYED, feed));
+ }
+
+ final Feed newFeed = FeedDatabaseWriter.updateFeed(context, feed, false);
+ assertNotSame(newFeed, feed);
+
+ updatedFeedTest(newFeed, feedID, itemIDs, numItemsOld, numItemsNew);
+
+ final Feed feedFromDB = DBReader.getFeed(newFeed.getId());
+ assertNotNull(feedFromDB);
+ assertEquals(newFeed.getId(), feedFromDB.getId());
+ updatedFeedTest(feedFromDB, feedID, itemIDs, numItemsOld, numItemsNew);
+ }
+
+ @Test
+ public void testUpdateFeedMediaUrlResetState() {
+ final Feed feed = new Feed("url", null, "title");
+ FeedItem item = new FeedItem(0, "item", "id", "link", new Date(), FeedItem.PLAYED, feed);
+ feed.setItems(singletonList(item));
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+
+ // ensure that objects have been saved in db, then reset
+ assertTrue(feed.getId() != 0);
+ assertTrue(item.getId() != 0);
+
+ FeedMedia media = new FeedMedia(item, "url", 1024, "mime/type");
+ item.setMedia(media);
+ List<FeedItem> list = new ArrayList<>();
+ list.add(item);
+ feed.setItems(list);
+
+ final Feed newFeed = FeedDatabaseWriter.updateFeed(context, feed, false);
+ assertNotSame(newFeed, feed);
+
+ final Feed feedFromDB = DBReader.getFeed(newFeed.getId());
+ final FeedItem feedItemFromDB = feedFromDB.getItems().get(0);
+ assertTrue(feedItemFromDB.isNew());
+ }
+
+ @Test
+ public void testUpdateFeedRemoveUnlistedItems() {
+ final Feed feed = new Feed("url", null, "title");
+ feed.setItems(new ArrayList<>());
+ for (int i = 0; i < 10; i++) {
+ feed.getItems().add(
+ new FeedItem(0, "item " + i, "id " + i, "link " + i, new Date(i), FeedItem.PLAYED, feed));
+ }
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+
+ // delete some items
+ feed.getItems().subList(0, 2).clear();
+ Feed newFeed = FeedDatabaseWriter.updateFeed(context, feed, true);
+ assertEquals(8, newFeed.getItems().size()); // 10 - 2 = 8 items
+
+ Feed feedFromDB = DBReader.getFeed(newFeed.getId());
+ assertEquals(8, feedFromDB.getItems().size()); // 10 - 2 = 8 items
+ }
+
+ @Test
+ public void testUpdateFeedSetDuplicate() {
+ final Feed feed = new Feed("url", null, "title");
+ feed.setItems(new ArrayList<>());
+ for (int i = 0; i < 10; i++) {
+ FeedItem item =
+ new FeedItem(0, "item " + i, "id " + i, "link " + i, new Date(i), FeedItem.PLAYED, feed);
+ FeedMedia media = new FeedMedia(item, "download url " + i, 123, "media/mp3");
+ item.setMedia(media);
+ feed.getItems().add(item);
+ }
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+
+ // change the guid of the first item, but leave the download url the same
+ FeedItem item = feed.getItemAtIndex(0);
+ item.setItemIdentifier("id 0-duplicate");
+ item.setTitle("item 0 duplicate");
+ Feed newFeed = FeedDatabaseWriter.updateFeed(context, feed, false);
+ assertEquals(10, newFeed.getItems().size()); // id 1-duplicate replaces because the stream url is the same
+
+ Feed feedFromDB = DBReader.getFeed(newFeed.getId());
+ assertEquals(10, feedFromDB.getItems().size()); // id1-duplicate should override id 1
+
+ FeedItem updatedItem = feedFromDB.getItemAtIndex(9);
+ assertEquals("item 0 duplicate", updatedItem.getTitle());
+ assertEquals("id 0-duplicate", updatedItem.getItemIdentifier()); // Should use the new ID for sync etc
+ }
+
+
+ @SuppressWarnings("SameParameterValue")
+ private void updatedFeedTest(final Feed newFeed, long feedID, List<Long> itemIDs,
+ int numItemsOld, int numItemsNew) {
+ assertEquals(feedID, newFeed.getId());
+ assertEquals(numItemsNew + numItemsOld, newFeed.getItems().size());
+ Collections.reverse(newFeed.getItems());
+ Date lastDate = new Date(0);
+ for (int i = 0; i < numItemsOld; i++) {
+ FeedItem item = newFeed.getItems().get(i);
+ assertSame(newFeed, item.getFeed());
+ assertEquals((long) itemIDs.get(i), item.getId());
+ assertTrue(item.isPlayed());
+ assertTrue(item.getPubDate().getTime() >= lastDate.getTime());
+ lastDate = item.getPubDate();
+ }
+ for (int i = numItemsOld; i < numItemsNew + numItemsOld; i++) {
+ FeedItem item = newFeed.getItems().get(i);
+ assertSame(newFeed, item.getFeed());
+ assertTrue(item.getId() != 0);
+ assertFalse(item.isPlayed());
+ assertTrue(item.getPubDate().getTime() >= lastDate.getTime());
+ lastDate = item.getPubDate();
+ }
+ }
+}
diff --git a/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbTestUtils.java b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbTestUtils.java
new file mode 100644
index 000000000..c104df9e8
--- /dev/null
+++ b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbTestUtils.java
@@ -0,0 +1,74 @@
+package de.danoeh.antennapod.net.download.service.episode.autodownload;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+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 de.danoeh.antennapod.storage.database.PodDBAdapter;
+
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Utility methods for DB* tests.
+ */
+abstract class DbTestUtils {
+
+ /**
+ * Use this method when tests don't involve chapters.
+ */
+ public static List<Feed> saveFeedlist(int numFeeds, int numItems, boolean withMedia) {
+ return saveFeedlist(numFeeds, numItems, withMedia, false, 0);
+ }
+
+ /**
+ * Use this method when tests involve chapters.
+ */
+ public static List<Feed> saveFeedlist(int numFeeds, int numItems, boolean withMedia,
+ boolean withChapters, int numChapters) {
+ if (numFeeds <= 0) {
+ throw new IllegalArgumentException("numFeeds<=0");
+ }
+ if (numItems < 0) {
+ throw new IllegalArgumentException("numItems<0");
+ }
+
+ List<Feed> feeds = new ArrayList<>();
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ for (int i = 0; i < numFeeds; i++) {
+ Feed f = new Feed(0, null, "feed " + i, "link" + i, "descr", null, null,
+ null, null, "id" + i, null, null, "url" + i, System.currentTimeMillis());
+ f.setItems(new ArrayList<>());
+ for (int j = 0; j < numItems; j++) {
+ FeedItem item = new FeedItem(0, "item " + j, "id" + j, "link" + j, new Date(),
+ FeedItem.PLAYED, f, withChapters);
+ if (withMedia) {
+ FeedMedia media = new FeedMedia(item, "url" + j, 1, "audio/mp3");
+ item.setMedia(media);
+ }
+ if (withChapters) {
+ List<Chapter> chapters = new ArrayList<>();
+ item.setChapters(chapters);
+ for (int k = 0; k < numChapters; k++) {
+ chapters.add(new Chapter(k, "item " + j + " chapter " + k,
+ "http://example.com", "http://example.com/image.png"));
+ }
+ }
+ f.getItems().add(item);
+ }
+ adapter.setCompleteFeed(f);
+ assertTrue(f.getId() != 0);
+ for (FeedItem item : f.getItems()) {
+ assertTrue(item.getId() != 0);
+ }
+ feeds.add(f);
+ }
+ adapter.close();
+
+ return feeds;
+ }
+}
diff --git a/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbWriterTest.java b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbWriterTest.java
new file mode 100644
index 000000000..ccb822a91
--- /dev/null
+++ b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/DbWriterTest.java
@@ -0,0 +1,826 @@
+package de.danoeh.antennapod.net.download.service.episode.autodownload;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+import android.util.Log;
+
+import androidx.core.util.Consumer;
+import androidx.preference.PreferenceManager;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import de.danoeh.antennapod.model.feed.FeedItemFilter;
+import de.danoeh.antennapod.model.feed.SortOrder;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadServiceInterface;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadServiceInterfaceStub;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.DBWriter;
+import de.danoeh.antennapod.storage.database.PodDBAdapter;
+import org.awaitility.Awaitility;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.model.feed.FeedMedia;
+import de.danoeh.antennapod.storage.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Test class for {@link DBWriter}.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class DbWriterTest {
+
+ private static final String TAG = "DBWriterTest";
+ private static final String TEST_FOLDER = "testDBWriter";
+ private static final long TIMEOUT = 5L;
+
+ private Context context;
+
+ @Before
+ public void setUp() {
+ context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ UserPreferences.init(context);
+ PlaybackPreferences.init(context);
+ DownloadServiceInterface.setImpl(new DownloadServiceInterfaceStub());
+
+ // create new database
+ PodDBAdapter.init(context);
+ PodDBAdapter.deleteDatabase();
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.close();
+
+ SharedPreferences.Editor prefEdit = PreferenceManager.getDefaultSharedPreferences(
+ context.getApplicationContext()).edit();
+ prefEdit.putBoolean(UserPreferences.PREF_DELETE_REMOVES_FROM_QUEUE, true).commit();
+ }
+
+ @After
+ public void tearDown() {
+ PodDBAdapter.tearDownTests();
+ DBWriter.tearDownTests();
+
+ File testDir = context.getExternalFilesDir(TEST_FOLDER);
+ assertNotNull(testDir);
+ for (File f : testDir.listFiles()) {
+ //noinspection ResultOfMethodCallIgnored
+ f.delete();
+ }
+ }
+
+ @Test
+ public void testSetFeedMediaPlaybackInformation() throws Exception {
+ final int position = 50;
+ final long lastPlayedTime = 1000;
+ final int playedDuration = 60;
+ final int duration = 100;
+
+ Feed feed = new Feed("url", null, "title");
+ List<FeedItem> items = new ArrayList<>();
+ feed.setItems(items);
+ FeedItem item = new FeedItem(0, "Item", "Item", "url", new Date(), FeedItem.PLAYED, feed);
+ items.add(item);
+ FeedMedia media = new FeedMedia(0, item, duration, 1, 1, "mime_type",
+ "dummy path", "download_url", true, null, 0, 0);
+ item.setMedia(media);
+
+ DBWriter.setFeedItem(item).get(TIMEOUT, TimeUnit.SECONDS);
+
+ media.setPosition(position);
+ media.setLastPlayedTime(lastPlayedTime);
+ media.setPlayedDuration(playedDuration);
+
+ DBWriter.setFeedMediaPlaybackInformation(item.getMedia()).get(TIMEOUT, TimeUnit.SECONDS);
+
+ FeedItem itemFromDb = DBReader.getFeedItem(item.getId());
+ FeedMedia mediaFromDb = itemFromDb.getMedia();
+
+ assertEquals(position, mediaFromDb.getPosition());
+ assertEquals(lastPlayedTime, mediaFromDb.getLastPlayedTime());
+ assertEquals(playedDuration, mediaFromDb.getPlayedDuration());
+ assertEquals(duration, mediaFromDb.getDuration());
+ }
+
+ @Test
+ public void testDeleteFeedMediaOfItemFileExists() throws Exception {
+ File dest = new File(context.getExternalFilesDir(TEST_FOLDER), "testFile");
+
+ assertTrue(dest.createNewFile());
+
+ Feed feed = new Feed("url", null, "title");
+ List<FeedItem> items = new ArrayList<>();
+ feed.setItems(items);
+ FeedItem item = new FeedItem(0, "Item", "Item", "url", new Date(), FeedItem.PLAYED, feed);
+
+ FeedMedia media = new FeedMedia(0, item, 1, 1, 1, "mime_type",
+ dest.getAbsolutePath(), "download_url", true, null, 0, 0);
+ item.setMedia(media);
+
+ items.add(item);
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+ assertTrue(media.getId() != 0);
+ assertTrue(item.getId() != 0);
+
+ DBWriter.deleteFeedMediaOfItem(context, media)
+ .get(TIMEOUT, TimeUnit.SECONDS);
+ media = DBReader.getFeedMedia(media.getId());
+ assertNotNull(media);
+ assertFalse(dest.exists());
+ assertFalse(media.isDownloaded());
+ assertNull(media.getLocalFileUrl());
+ }
+
+ @Test
+ public void testDeleteFeedMediaOfItemRemoveFromQueue() throws Exception {
+ assertTrue(UserPreferences.shouldDeleteRemoveFromQueue());
+
+ File dest = new File(context.getExternalFilesDir(TEST_FOLDER), "testFile");
+
+ assertTrue(dest.createNewFile());
+
+ Feed feed = new Feed("url", null, "title");
+ List<FeedItem> items = new ArrayList<>();
+ feed.setItems(items);
+ FeedItem item = new FeedItem(0, "Item", "Item", "url", new Date(), FeedItem.UNPLAYED, feed);
+
+ FeedMedia media = new FeedMedia(0, item, 1, 1, 1, "mime_type",
+ dest.getAbsolutePath(), "download_url", true, null, 0, 0);
+ item.setMedia(media);
+
+ items.add(item);
+ List<FeedItem> queue = new ArrayList<>();
+ queue.add(item);
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.setQueue(queue);
+ adapter.close();
+ assertTrue(media.getId() != 0);
+ assertTrue(item.getId() != 0);
+ queue = DBReader.getQueue();
+ assertFalse(queue.isEmpty());
+
+ DBWriter.deleteFeedMediaOfItem(context, media);
+ Awaitility.await().timeout(2, TimeUnit.SECONDS).until(() -> !dest.exists());
+ media = DBReader.getFeedMedia(media.getId());
+ assertNotNull(media);
+ assertFalse(dest.exists());
+ assertFalse(media.isDownloaded());
+ assertNull(media.getLocalFileUrl());
+ Awaitility.await().timeout(2, TimeUnit.SECONDS).until(() -> DBReader.getQueue().isEmpty());
+ }
+
+ @Test
+ public void testDeleteFeed() throws Exception {
+ File destFolder = context.getExternalFilesDir(TEST_FOLDER);
+ assertNotNull(destFolder);
+
+ Feed feed = new Feed("url", null, "title");
+ feed.setItems(new ArrayList<>());
+
+ List<File> itemFiles = new ArrayList<>();
+ // create items with downloaded media files
+ for (int i = 0; i < 10; i++) {
+ FeedItem item = new FeedItem(0, "Item " + i, "Item" + i, "url", new Date(), FeedItem.PLAYED, feed);
+ feed.getItems().add(item);
+
+ File enc = new File(destFolder, "file " + i);
+ assertTrue(enc.createNewFile());
+
+ itemFiles.add(enc);
+ FeedMedia media = new FeedMedia(0, item, 1, 1, 1, "mime_type",
+ enc.getAbsolutePath(), "download_url", true, null, 0, 0);
+ item.setMedia(media);
+ }
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+
+ assertTrue(feed.getId() != 0);
+ for (FeedItem item : feed.getItems()) {
+ assertTrue(item.getId() != 0);
+ assertTrue(item.getMedia().getId() != 0);
+ }
+
+ DBWriter.deleteFeed(context, feed.getId()).get(TIMEOUT, TimeUnit.SECONDS);
+
+ // check if files still exist
+ for (File f : itemFiles) {
+ assertFalse(f.exists());
+ }
+
+ adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ Cursor c = adapter.getFeedCursor(feed.getId());
+ assertEquals(0, c.getCount());
+ c.close();
+ for (FeedItem item : feed.getItems()) {
+ c = adapter.getFeedItemCursor(String.valueOf(item.getId()));
+ assertEquals(0, c.getCount());
+ c.close();
+ c = adapter.getFeedItemFromMediaIdCursor(item.getMedia().getId());
+ assertEquals(0, c.getCount());
+ c.close();
+ }
+ adapter.close();
+ }
+
+ @Test
+ public void testDeleteFeedNoItems() throws Exception {
+ File destFolder = context.getExternalFilesDir(TEST_FOLDER);
+ assertNotNull(destFolder);
+
+ Feed feed = new Feed("url", null, "title");
+ feed.setItems(null);
+ feed.setImageUrl("url");
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+
+ assertTrue(feed.getId() != 0);
+
+ DBWriter.deleteFeed(context, feed.getId()).get(TIMEOUT, TimeUnit.SECONDS);
+
+ adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ Cursor c = adapter.getFeedCursor(feed.getId());
+ assertEquals(0, c.getCount());
+ c.close();
+ adapter.close();
+ }
+
+ @Test
+ public void testDeleteFeedNoFeedMedia() throws Exception {
+ File destFolder = context.getExternalFilesDir(TEST_FOLDER);
+ assertNotNull(destFolder);
+
+ Feed feed = new Feed("url", null, "title");
+ feed.setItems(new ArrayList<>());
+
+ feed.setImageUrl("url");
+
+ // create items
+ for (int i = 0; i < 10; i++) {
+ FeedItem item = new FeedItem(0, "Item " + i, "Item" + i, "url", new Date(), FeedItem.PLAYED, feed);
+ feed.getItems().add(item);
+ }
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+
+ assertTrue(feed.getId() != 0);
+ for (FeedItem item : feed.getItems()) {
+ assertTrue(item.getId() != 0);
+ }
+
+ DBWriter.deleteFeed(context, feed.getId()).get(TIMEOUT, TimeUnit.SECONDS);
+
+ adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ Cursor c = adapter.getFeedCursor(feed.getId());
+ assertEquals(0, c.getCount());
+ c.close();
+ for (FeedItem item : feed.getItems()) {
+ c = adapter.getFeedItemCursor(String.valueOf(item.getId()));
+ assertEquals(0, c.getCount());
+ c.close();
+ }
+ adapter.close();
+ }
+
+ @Test
+ public void testDeleteFeedWithQueueItems() throws Exception {
+ File destFolder = context.getExternalFilesDir(TEST_FOLDER);
+ assertNotNull(destFolder);
+
+ Feed feed = new Feed("url", null, "title");
+ feed.setItems(new ArrayList<>());
+
+ feed.setImageUrl("url");
+
+ // create items with downloaded media files
+ for (int i = 0; i < 10; i++) {
+ FeedItem item = new FeedItem(0, "Item " + i, "Item" + i, "url", new Date(), FeedItem.PLAYED, feed);
+ feed.getItems().add(item);
+ File enc = new File(destFolder, "file " + i);
+ FeedMedia media = new FeedMedia(0, item, 1, 1, 1, "mime_type",
+ enc.getAbsolutePath(), "download_url", false, null, 0, 0);
+ item.setMedia(media);
+ }
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+
+ assertTrue(feed.getId() != 0);
+ for (FeedItem item : feed.getItems()) {
+ assertTrue(item.getId() != 0);
+ assertTrue(item.getMedia().getId() != 0);
+ }
+
+ List<FeedItem> queue = new ArrayList<>(feed.getItems());
+ adapter.open();
+ adapter.setQueue(queue);
+
+ Cursor queueCursor = adapter.getQueueIDCursor();
+ assertEquals(queue.size(), queueCursor.getCount());
+ queueCursor.close();
+
+ adapter.close();
+ DBWriter.deleteFeed(context, feed.getId()).get(TIMEOUT, TimeUnit.SECONDS);
+ adapter.open();
+
+ Cursor c = adapter.getFeedCursor(feed.getId());
+ assertEquals(0, c.getCount());
+ c.close();
+ for (FeedItem item : feed.getItems()) {
+ c = adapter.getFeedItemCursor(String.valueOf(item.getId()));
+ assertEquals(0, c.getCount());
+ c.close();
+ c = adapter.getFeedItemFromMediaIdCursor(item.getMedia().getId());
+ assertEquals(0, c.getCount());
+ c.close();
+ }
+ c = adapter.getQueueCursor();
+ assertEquals(0, c.getCount());
+ c.close();
+ adapter.close();
+ }
+
+ @Test
+ public void testDeleteFeedNoDownloadedFiles() throws Exception {
+ File destFolder = context.getExternalFilesDir(TEST_FOLDER);
+ assertNotNull(destFolder);
+
+ Feed feed = new Feed("url", null, "title");
+ feed.setItems(new ArrayList<>());
+
+ feed.setImageUrl("url");
+
+ // create items with downloaded media files
+ for (int i = 0; i < 10; i++) {
+ FeedItem item = new FeedItem(0, "Item " + i, "Item" + i, "url", new Date(), FeedItem.PLAYED, feed);
+ feed.getItems().add(item);
+ File enc = new File(destFolder, "file " + i);
+ FeedMedia media = new FeedMedia(0, item, 1, 1, 1, "mime_type",
+ enc.getAbsolutePath(), "download_url", false, null, 0, 0);
+ item.setMedia(media);
+ }
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+
+ assertTrue(feed.getId() != 0);
+ for (FeedItem item : feed.getItems()) {
+ assertTrue(item.getId() != 0);
+ assertTrue(item.getMedia().getId() != 0);
+ }
+
+ DBWriter.deleteFeed(context, feed.getId()).get(TIMEOUT, TimeUnit.SECONDS);
+
+ adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ Cursor c = adapter.getFeedCursor(feed.getId());
+ assertEquals(0, c.getCount());
+ c.close();
+ for (FeedItem item : feed.getItems()) {
+ c = adapter.getFeedItemCursor(String.valueOf(item.getId()));
+ assertEquals(0, c.getCount());
+ c.close();
+ c = adapter.getFeedItemFromMediaIdCursor(item.getMedia().getId());
+ assertEquals(0, c.getCount());
+ c.close();
+ }
+ adapter.close();
+ }
+
+ @Test
+ public void testDeleteFeedItems() throws Exception {
+ Feed feed = new Feed("url", null, "title");
+ feed.setItems(new ArrayList<>());
+ feed.setImageUrl("url");
+
+ // create items
+ for (int i = 0; i < 10; i++) {
+ FeedItem item = new FeedItem(0, "Item " + i, "Item" + i, "url", new Date(), FeedItem.PLAYED, feed);
+ item.setMedia(new FeedMedia(item, "", 0, ""));
+ feed.getItems().add(item);
+ }
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+
+ List<FeedItem> itemsToDelete = feed.getItems().subList(0, 2);
+ DBWriter.deleteFeedItems(context, itemsToDelete).get(TIMEOUT, TimeUnit.SECONDS);
+
+ adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ for (int i = 0; i < feed.getItems().size(); i++) {
+ FeedItem feedItem = feed.getItems().get(i);
+ Cursor c = adapter.getFeedItemCursor(String.valueOf(feedItem.getId()));
+ if (i < 2) {
+ assertEquals(0, c.getCount());
+ } else {
+ assertEquals(1, c.getCount());
+ }
+ c.close();
+ }
+ adapter.close();
+ }
+
+ private FeedMedia playbackHistorySetup(Date playbackCompletionDate) {
+ Feed feed = new Feed("url", null, "title");
+ feed.setItems(new ArrayList<>());
+ FeedItem item = new FeedItem(0, "title", "id", "link", new Date(), FeedItem.PLAYED, feed);
+ FeedMedia media = new FeedMedia(0, item, 10, 0, 1, "mime", null,
+ "url", false, playbackCompletionDate, 0, 0);
+ feed.getItems().add(item);
+ item.setMedia(media);
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+ assertTrue(media.getId() != 0);
+ return media;
+ }
+
+ @Test
+ public void testAddItemToPlaybackHistoryNotPlayedYet() throws Exception {
+ FeedMedia media = playbackHistorySetup(null);
+ DBWriter.addItemToPlaybackHistory(media).get(TIMEOUT, TimeUnit.SECONDS);
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ media = DBReader.getFeedMedia(media.getId());
+ adapter.close();
+
+ assertNotNull(media);
+ assertNotNull(media.getPlaybackCompletionDate());
+ }
+
+ @Test
+ public void testAddItemToPlaybackHistoryAlreadyPlayed() throws Exception {
+ final long oldDate = 0;
+
+ FeedMedia media = playbackHistorySetup(new Date(oldDate));
+ DBWriter.addItemToPlaybackHistory(media).get(TIMEOUT, TimeUnit.SECONDS);
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ media = DBReader.getFeedMedia(media.getId());
+ adapter.close();
+
+ assertNotNull(media);
+ assertNotNull(media.getPlaybackCompletionDate());
+ assertNotEquals(media.getPlaybackCompletionDate().getTime(), oldDate);
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ private Feed queueTestSetupMultipleItems(final int numItems) throws Exception {
+ UserPreferences.setEnqueueLocation(UserPreferences.EnqueueLocation.BACK);
+ Feed feed = new Feed("url", null, "title");
+ feed.setItems(new ArrayList<>());
+ for (int i = 0; i < numItems; i++) {
+ FeedItem item = new FeedItem(0, "title " + i, "id " + i, "link " + i, new Date(), FeedItem.PLAYED, feed);
+ item.setMedia(new FeedMedia(item, "", 0, ""));
+ feed.getItems().add(item);
+ }
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+
+ for (FeedItem item : feed.getItems()) {
+ assertTrue(item.getId() != 0);
+ }
+ List<Future<?>> futures = new ArrayList<>();
+ for (FeedItem item : feed.getItems()) {
+ futures.add(DBWriter.addQueueItem(context, item));
+ }
+ for (Future<?> f : futures) {
+ f.get(TIMEOUT, TimeUnit.SECONDS);
+ }
+ return feed;
+ }
+
+ @Test
+ public void testAddQueueItemSingleItem() throws Exception {
+ Feed feed = new Feed("url", null, "title");
+ feed.setItems(new ArrayList<>());
+ FeedItem item = new FeedItem(0, "title", "id", "link", new Date(), FeedItem.PLAYED, feed);
+ item.setMedia(new FeedMedia(item, "", 0, ""));
+ feed.getItems().add(item);
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+
+ assertTrue(item.getId() != 0);
+ DBWriter.addQueueItem(context, item).get(TIMEOUT, TimeUnit.SECONDS);
+
+ adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ Cursor cursor = adapter.getQueueIDCursor();
+ assertTrue(cursor.moveToFirst());
+ assertEquals(item.getId(), cursor.getLong(0));
+ cursor.close();
+ adapter.close();
+ }
+
+ @Test
+ public void testAddQueueItemSingleItemAlreadyInQueue() throws Exception {
+ Feed feed = new Feed("url", null, "title");
+ feed.setItems(new ArrayList<>());
+ FeedItem item = new FeedItem(0, "title", "id", "link", new Date(), FeedItem.PLAYED, feed);
+ item.setMedia(new FeedMedia(item, "", 0, ""));
+ feed.getItems().add(item);
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+
+ assertTrue(item.getId() != 0);
+ DBWriter.addQueueItem(context, item).get(TIMEOUT, TimeUnit.SECONDS);
+
+ adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ Cursor cursor = adapter.getQueueIDCursor();
+ assertTrue(cursor.moveToFirst());
+ assertEquals(item.getId(), cursor.getLong(0));
+ cursor.close();
+ adapter.close();
+
+ DBWriter.addQueueItem(context, item).get(TIMEOUT, TimeUnit.SECONDS);
+ adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ cursor = adapter.getQueueIDCursor();
+ assertTrue(cursor.moveToFirst());
+ assertEquals(item.getId(), cursor.getLong(0));
+ assertEquals(1, cursor.getCount());
+ cursor.close();
+ adapter.close();
+ }
+
+ @Test
+ public void testAddQueueItemMultipleItems() throws Exception {
+ final int numItems = 10;
+
+ Feed feed;
+ feed = queueTestSetupMultipleItems(numItems);
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ Cursor cursor = adapter.getQueueIDCursor();
+ assertTrue(cursor.moveToFirst());
+ assertEquals(numItems, cursor.getCount());
+ for (int i = 0; i < numItems; i++) {
+ assertTrue(cursor.moveToPosition(i));
+ assertEquals(feed.getItems().get(i).getId(), cursor.getLong(0));
+ }
+ cursor.close();
+ adapter.close();
+ }
+
+ @Test
+ public void testClearQueue() throws Exception {
+ final int numItems = 10;
+
+ queueTestSetupMultipleItems(numItems);
+ DBWriter.clearQueue().get(TIMEOUT, TimeUnit.SECONDS);
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ Cursor cursor = adapter.getQueueIDCursor();
+ assertFalse(cursor.moveToFirst());
+ cursor.close();
+ adapter.close();
+ }
+
+ @Test
+ public void testRemoveQueueItem() throws Exception {
+ final int numItems = 10;
+ Feed feed = createTestFeed(numItems);
+
+ for (int removeIndex = 0; removeIndex < numItems; removeIndex++) {
+ final FeedItem item = feed.getItems().get(removeIndex);
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setQueue(feed.getItems());
+ adapter.close();
+
+ DBWriter.removeQueueItem(context, false, item).get(TIMEOUT, TimeUnit.SECONDS);
+ adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ Cursor queue = adapter.getQueueIDCursor();
+ assertEquals(numItems - 1, queue.getCount());
+ for (int i = 0; i < queue.getCount(); i++) {
+ assertTrue(queue.moveToPosition(i));
+ final long queueID = queue.getLong(0);
+ assertTrue(queueID != item.getId()); // removed item is no longer in queue
+ boolean idFound = false;
+ for (FeedItem other : feed.getItems()) { // items that were not removed are still in the queue
+ idFound = idFound | (other.getId() == queueID);
+ }
+ assertTrue(idFound);
+ }
+ queue.close();
+ adapter.close();
+ }
+ }
+
+ @Test
+ public void testRemoveQueueItemMultipleItems() throws Exception {
+ final int numItems = 5;
+ final int numInQueue = numItems - 1; // the last one not in queue for boundary condition
+ Feed feed = createTestFeed(numItems);
+
+ List<FeedItem> itemsToAdd = feed.getItems().subList(0, numInQueue);
+ withPodDB(adapter -> adapter.setQueue(itemsToAdd));
+
+ // Actual tests
+ //
+
+ // Use array rather than List to make codes more succinct
+ Long[] itemIds = toItemIds(feed.getItems()).toArray(new Long[0]);
+
+ DBWriter.removeQueueItem(context, false,
+ itemIds[1], itemIds[3]).get(TIMEOUT, TimeUnit.SECONDS);
+ assertQueueByItemIds("Average case - 2 items removed successfully",
+ itemIds[0], itemIds[2]);
+
+ DBWriter.removeQueueItem(context, false).get(TIMEOUT, TimeUnit.SECONDS);
+ assertQueueByItemIds("Boundary case - no items supplied. queue should see no change",
+ itemIds[0], itemIds[2]);
+
+ DBWriter.removeQueueItem(context, false,
+ itemIds[0], itemIds[4], -1L).get(TIMEOUT, TimeUnit.SECONDS);
+ assertQueueByItemIds("Boundary case - items not in queue ignored",
+ itemIds[2]);
+
+ DBWriter.removeQueueItem(context, false,
+ itemIds[2], -1L).get(TIMEOUT, TimeUnit.SECONDS);
+ assertQueueByItemIds("Boundary case - invalid itemIds ignored"); // the queue is empty
+
+ }
+
+ @Test
+ public void testMoveQueueItem() throws Exception {
+ final int numItems = 10;
+ Feed feed = new Feed("url", null, "title");
+ feed.setItems(new ArrayList<>());
+ for (int i = 0; i < numItems; i++) {
+ FeedItem item = new FeedItem(0, "title " + i, "id " + i, "link " + i,
+ new Date(), FeedItem.PLAYED, feed);
+ item.setMedia(new FeedMedia(item, "", 0, ""));
+ feed.getItems().add(item);
+ }
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+
+ for (FeedItem item : feed.getItems()) {
+ assertTrue(item.getId() != 0);
+ }
+ for (int from = 0; from < numItems; from++) {
+ for (int to = 0; to < numItems; to++) {
+ if (from == to) {
+ continue;
+ }
+ Log.d(TAG, String.format(Locale.US, "testMoveQueueItem: From=%d, To=%d", from, to));
+ final long fromID = feed.getItems().get(from).getId();
+
+ adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setQueue(feed.getItems());
+ adapter.close();
+
+ DBWriter.moveQueueItem(from, to, false).get(TIMEOUT, TimeUnit.SECONDS);
+ adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ Cursor queue = adapter.getQueueIDCursor();
+ assertEquals(numItems, queue.getCount());
+ assertTrue(queue.moveToPosition(from));
+ assertNotEquals(fromID, queue.getLong(0));
+ assertTrue(queue.moveToPosition(to));
+ assertEquals(fromID, queue.getLong(0));
+
+ queue.close();
+ adapter.close();
+ }
+ }
+ }
+
+ @Test
+ public void testRemoveAllNewFlags() throws Exception {
+ final int numItems = 10;
+ Feed feed = new Feed("url", null, "title");
+ feed.setItems(new ArrayList<>());
+ for (int i = 0; i < numItems; i++) {
+ FeedItem item = new FeedItem(0, "title " + i, "id " + i, "link " + i,
+ new Date(), FeedItem.NEW, feed);
+ item.setMedia(new FeedMedia(item, "", 0, ""));
+ feed.getItems().add(item);
+ }
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+
+ assertTrue(feed.getId() != 0);
+ for (FeedItem item : feed.getItems()) {
+ assertTrue(item.getId() != 0);
+ }
+
+ DBWriter.removeAllNewFlags().get();
+ List<FeedItem> loadedItems = DBReader.getFeedItemList(feed,
+ FeedItemFilter.unfiltered(), SortOrder.DATE_NEW_OLD);
+ for (FeedItem item : loadedItems) {
+ assertFalse(item.isNew());
+ }
+ }
+
+ private static Feed createTestFeed(int numItems) {
+ Feed feed = new Feed("url", null, "title");
+ feed.setItems(new ArrayList<>());
+ for (int i = 0; i < numItems; i++) {
+ FeedItem item = new FeedItem(0, "title " + i, "id " + i, "link " + i,
+ new Date(), FeedItem.PLAYED, feed);
+ item.setMedia(new FeedMedia(item, "", 0, ""));
+ feed.getItems().add(item);
+ }
+
+ withPodDB(adapter -> adapter.setCompleteFeed(feed));
+
+ for (FeedItem item : feed.getItems()) {
+ assertTrue(item.getId() != 0);
+ }
+ return feed;
+ }
+
+ private static void withPodDB(Consumer<PodDBAdapter> action) {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ try {
+ adapter.open();
+ action.accept(adapter);
+ } finally {
+ adapter.close();
+ }
+ }
+
+ private static void assertQueueByItemIds(String message, long... itemIdsExpected) {
+ List<FeedItem> queue = DBReader.getQueue();
+ List<Long> itemIdsActualList = toItemIds(queue);
+ List<Long> itemIdsExpectedList = new ArrayList<>(itemIdsExpected.length);
+ for (long id : itemIdsExpected) {
+ itemIdsExpectedList.add(id);
+ }
+
+ assertEquals(message, itemIdsExpectedList, itemIdsActualList);
+ }
+
+ private static List<Long> toItemIds(List<FeedItem> items) {
+ List<Long> itemIds = new ArrayList<>(items.size());
+ for (FeedItem item : items) {
+ itemIds.add(item.getId());
+ }
+ return itemIds;
+ }
+}
diff --git a/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/ExceptFavoriteCleanupAlgorithmTest.java b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/ExceptFavoriteCleanupAlgorithmTest.java
new file mode 100644
index 000000000..dd77606dc
--- /dev/null
+++ b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/episode/autodownload/ExceptFavoriteCleanupAlgorithmTest.java
@@ -0,0 +1,91 @@
+package de.danoeh.antennapod.net.download.service.episode.autodownload;
+
+import de.danoeh.antennapod.net.download.serviceinterface.AutoDownloadManager;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests that the APFavoriteCleanupAlgorithm is working correctly.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class ExceptFavoriteCleanupAlgorithmTest extends DbCleanupTests {
+ private final int numberOfItems = EPISODE_CACHE_SIZE * 2;
+
+ public ExceptFavoriteCleanupAlgorithmTest() {
+ setCleanupAlgorithm(UserPreferences.EPISODE_CLEANUP_EXCEPT_FAVORITE);
+ AutoDownloadManager.setInstance(new AutoDownloadManagerImpl());
+ }
+
+ @Test
+ public void testPerformAutoCleanupHandleUnplayed() throws IOException {
+ Feed feed = new Feed("url", null, "title");
+ List<FeedItem> items = new ArrayList<>();
+ feed.setItems(items);
+ List<File> files = new ArrayList<>();
+ populateItems(numberOfItems, feed, items, files, FeedItem.UNPLAYED, false, false);
+
+ AutoDownloadManager.getInstance().performAutoCleanup(context);
+ for (int i = 0; i < files.size(); i++) {
+ if (i < EPISODE_CACHE_SIZE) {
+ assertTrue("Only enough items should be deleted", files.get(i).exists());
+ } else {
+ assertFalse("Expected episode to be deleted", files.get(i).exists());
+ }
+ }
+ }
+
+ @Test
+ public void testPerformAutoCleanupDeletesQueued() throws IOException {
+ Feed feed = new Feed("url", null, "title");
+ List<FeedItem> items = new ArrayList<>();
+ feed.setItems(items);
+ List<File> files = new ArrayList<>();
+ populateItems(numberOfItems, feed, items, files, FeedItem.UNPLAYED, true, false);
+
+ AutoDownloadManager.getInstance().performAutoCleanup(context);
+ for (int i = 0; i < files.size(); i++) {
+ if (i < EPISODE_CACHE_SIZE) {
+ assertTrue("Only enough items should be deleted", files.get(i).exists());
+ } else {
+ assertFalse("Queued episodes should be deleted", files.get(i).exists());
+ }
+ }
+ }
+
+ @Test
+ public void testPerformAutoCleanupSavesFavorited() throws IOException {
+ Feed feed = new Feed("url", null, "title");
+ List<FeedItem> items = new ArrayList<>();
+ feed.setItems(items);
+ List<File> files = new ArrayList<>();
+ populateItems(numberOfItems, feed, items, files, FeedItem.UNPLAYED, false, true);
+
+ AutoDownloadManager.getInstance().performAutoCleanup(context);
+ for (int i = 0; i < files.size(); i++) {
+ assertTrue("Favorite episodes should should not be deleted", files.get(i).exists());
+ }
+ }
+
+ @Override
+ public void testPerformAutoCleanupShouldNotDeleteBecauseInQueue() throws IOException {
+ // Yes it should
+ }
+
+ @Override
+ public void testPerformAutoCleanupShouldNotDeleteBecauseInQueue_withFeedsWithNoMedia() throws IOException {
+ // Yes it should
+ }
+}
diff --git a/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/feed/local/LocalFeedUpdaterTest.java b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/feed/local/LocalFeedUpdaterTest.java
new file mode 100644
index 000000000..4fb686ca5
--- /dev/null
+++ b/net/download/service/src/test/java/de/danoeh/antennapod/net/download/service/feed/local/LocalFeedUpdaterTest.java
@@ -0,0 +1,308 @@
+package de.danoeh.antennapod.net.download.service.feed.local;
+
+import android.content.Context;
+import android.media.MediaMetadataRetriever;
+import android.net.Uri;
+import android.webkit.MimeTypeMap;
+
+import androidx.annotation.NonNull;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import de.danoeh.antennapod.model.feed.FeedItemFilter;
+import de.danoeh.antennapod.model.feed.SortOrder;
+import de.danoeh.antennapod.storage.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadServiceInterface;
+import de.danoeh.antennapod.net.download.serviceinterface.DownloadServiceInterfaceStub;
+import de.danoeh.antennapod.model.feed.Feed;
+import de.danoeh.antennapod.model.feed.FeedItem;
+import de.danoeh.antennapod.storage.database.PodDBAdapter;
+import de.danoeh.antennapod.storage.preferences.SynchronizationSettings;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.shadows.ShadowMediaMetadataRetriever;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import de.danoeh.antennapod.storage.preferences.UserPreferences;
+import de.danoeh.antennapod.storage.database.DBReader;
+import de.danoeh.antennapod.storage.database.DBWriter;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.endsWith;
+import static org.hamcrest.CoreMatchers.startsWith;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.empty;
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.robolectric.Shadows.shadowOf;
+
+/**
+ * Test local feeds handling in class LocalFeedUpdater.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class LocalFeedUpdaterTest {
+
+ /**
+ * URL to locate the local feed media files on the external storage (SD card).
+ * The exact URL doesn't matter here as access to external storage is mocked
+ * (seems not to be supported by Robolectric).
+ */
+ private static final String FEED_URL =
+ "content://com.android.externalstorage.documents/tree/primary%3ADownload%2Flocal-feed";
+ private static final String LOCAL_FEED_DIR1 = "src/test/assets/local-feed1";
+ private static final String LOCAL_FEED_DIR2 = "src/test/assets/local-feed2";
+
+ private Context context;
+
+ @Before
+ public void setUp() throws Exception {
+ // Initialize environment
+ context = InstrumentationRegistry.getInstrumentation().getContext();
+ UserPreferences.init(context);
+ PlaybackPreferences.init(context);
+ SynchronizationSettings.init(context);
+ DownloadServiceInterface.setImpl(new DownloadServiceInterfaceStub());
+
+ // Initialize database
+ PodDBAdapter.init(context);
+ PodDBAdapter.deleteDatabase();
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.close();
+
+ mapDummyMetadata(LOCAL_FEED_DIR1);
+ mapDummyMetadata(LOCAL_FEED_DIR2);
+ shadowOf(MimeTypeMap.getSingleton()).addExtensionMimeTypMapping("mp3", "audio/mp3");
+ }
+
+ @After
+ public void tearDown() {
+ DBWriter.tearDownTests();
+ PodDBAdapter.tearDownTests();
+ }
+
+ /**
+ * Test adding a new local feed.
+ */
+ @Test
+ public void testUpdateFeed_AddNewFeed() {
+ // check for empty database
+ List<Feed> feedListBefore = DBReader.getFeedList();
+ assertThat(feedListBefore, is(empty()));
+
+ callUpdateFeed(LOCAL_FEED_DIR2);
+
+ // verify new feed in database
+ verifySingleFeedInDatabaseAndItemCount(2);
+ Feed feedAfter = verifySingleFeedInDatabase();
+ assertEquals(FEED_URL, feedAfter.getDownloadUrl());
+ }
+
+ /**
+ * Test adding further items to an existing local feed.
+ */
+ @Test
+ public void testUpdateFeed_AddMoreItems() {
+ // add local feed with 1 item (localFeedDir1)
+ callUpdateFeed(LOCAL_FEED_DIR1);
+
+ // now add another item (by changing to local feed folder localFeedDir2)
+ callUpdateFeed(LOCAL_FEED_DIR2);
+
+ verifySingleFeedInDatabaseAndItemCount(2);
+ }
+
+ /**
+ * Test removing items from an existing local feed without a corresponding media file.
+ */
+ @Test
+ public void testUpdateFeed_RemoveItems() {
+ // add local feed with 2 items (localFeedDir1)
+ callUpdateFeed(LOCAL_FEED_DIR2);
+
+ // now remove an item (by changing to local feed folder localFeedDir1)
+ callUpdateFeed(LOCAL_FEED_DIR1);
+
+ verifySingleFeedInDatabaseAndItemCount(1);
+ }
+
+ /**
+ * Test feed icon defined in the local feed media folder.
+ */
+ @Test
+ public void testUpdateFeed_FeedIconFromFolder() {
+ callUpdateFeed(LOCAL_FEED_DIR2);
+
+ Feed feedAfter = verifySingleFeedInDatabase();
+ assertThat(feedAfter.getImageUrl(), endsWith("local-feed2/folder.png"));
+ }
+
+ /**
+ * Test default feed icon if there is no matching file in the local feed media folder.
+ */
+ @Test
+ public void testUpdateFeed_FeedIconDefault() {
+ callUpdateFeed(LOCAL_FEED_DIR1);
+
+ Feed feedAfter = verifySingleFeedInDatabase();
+ assertThat(feedAfter.getImageUrl(), startsWith(Feed.PREFIX_GENERATIVE_COVER));
+ }
+
+ /**
+ * Test default feed metadata.
+ *
+ * @see #mapDummyMetadata Title and PubDate are dummy values.
+ */
+ @Test
+ public void testUpdateFeed_FeedMetadata() {
+ callUpdateFeed(LOCAL_FEED_DIR1);
+
+ Feed feed = verifySingleFeedInDatabase();
+ List<FeedItem> feedItems = DBReader.getFeedItemList(feed, FeedItemFilter.unfiltered(), SortOrder.DATE_NEW_OLD);
+ assertEquals("track1.mp3", feedItems.get(0).getTitle());
+ }
+
+ @Test
+ public void testGetImageUrl_EmptyFolder() {
+ String imageUrl = LocalFeedUpdater.getImageUrl(Collections.emptyList(), Uri.EMPTY);
+ assertThat(imageUrl, startsWith(Feed.PREFIX_GENERATIVE_COVER));
+ }
+
+ @Test
+ public void testGetImageUrl_NoImageButAudioFiles() {
+ List<FastDocumentFile> folder = Collections.singletonList(mockDocumentFile("audio.mp3", "audio/mp3"));
+ String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
+ assertThat(imageUrl, startsWith(Feed.PREFIX_GENERATIVE_COVER));
+ }
+
+ @Test
+ public void testGetImageUrl_PreferredImagesFilenames() {
+ for (String filename : LocalFeedUpdater.PREFERRED_FEED_IMAGE_FILENAMES) {
+ List<FastDocumentFile> folder = Arrays.asList(mockDocumentFile("audio.mp3", "audio/mp3"),
+ mockDocumentFile(filename, "image/jpeg")); // image MIME type doesn't matter
+ String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
+ assertThat(imageUrl, endsWith(filename));
+ }
+ }
+
+ @Test
+ public void testGetImageUrl_OtherImageFilenameJpg() {
+ List<FastDocumentFile> folder = Arrays.asList(mockDocumentFile("audio.mp3", "audio/mp3"),
+ mockDocumentFile("my-image.jpg", "image/jpeg"));
+ String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
+ assertThat(imageUrl, endsWith("my-image.jpg"));
+ }
+
+ @Test
+ public void testGetImageUrl_OtherImageFilenameJpeg() {
+ List<FastDocumentFile> folder = Arrays.asList(mockDocumentFile("audio.mp3", "audio/mp3"),
+ mockDocumentFile("my-image.jpeg", "image/jpeg"));
+ String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
+ assertThat(imageUrl, endsWith("my-image.jpeg"));
+ }
+
+ @Test
+ public void testGetImageUrl_OtherImageFilenamePng() {
+ List<FastDocumentFile> folder = Arrays.asList(mockDocumentFile("audio.mp3", "audio/mp3"),
+ mockDocumentFile("my-image.png", "image/png"));
+ String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
+ assertThat(imageUrl, endsWith("my-image.png"));
+ }
+
+ @Test
+ public void testGetImageUrl_OtherImageFilenameUnsupportedMimeType() {
+ List<FastDocumentFile> folder = Arrays.asList(mockDocumentFile("audio.mp3", "audio/mp3"),
+ mockDocumentFile("my-image.svg", "image/svg+xml"));
+ String imageUrl = LocalFeedUpdater.getImageUrl(folder, Uri.EMPTY);
+ assertThat(imageUrl, startsWith(Feed.PREFIX_GENERATIVE_COVER));
+ }
+
+ /**
+ * Fill ShadowMediaMetadataRetriever with dummy duration and title.
+ *
+ * @param localFeedDir assets local feed folder with media files
+ */
+ private void mapDummyMetadata(@NonNull String localFeedDir) {
+ for (String fileName : Objects.requireNonNull(new File(localFeedDir).list())) {
+ String path = localFeedDir + '/' + fileName;
+ ShadowMediaMetadataRetriever.addMetadata(path,
+ MediaMetadataRetriever.METADATA_KEY_DURATION, "10");
+ ShadowMediaMetadataRetriever.addMetadata(path,
+ MediaMetadataRetriever.METADATA_KEY_TITLE, fileName);
+ ShadowMediaMetadataRetriever.addMetadata(path,
+ MediaMetadataRetriever.METADATA_KEY_DATE, "20200601T222324");
+ }
+ }
+
+ /**
+ * Calls the method LocalFeedUpdater#tryUpdateFeed with the given local feed folder.
+ *
+ * @param localFeedDir assets local feed folder with media files
+ */
+ private void callUpdateFeed(@NonNull String localFeedDir) {
+ try (MockedStatic<FastDocumentFile> dfMock = Mockito.mockStatic(FastDocumentFile.class)) {
+ // mock external storage
+ dfMock.when(() -> FastDocumentFile.list(any(), any())).thenReturn(mockLocalFolder(localFeedDir));
+
+ // call method to test
+ Feed feed = new Feed(FEED_URL, null);
+ try {
+ LocalFeedUpdater.tryUpdateFeed(feed, context, null, null);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ /**
+ * Verify that the database contains exactly one feed and return that feed.
+ */
+ @NonNull
+ private static Feed verifySingleFeedInDatabase() {
+ List<Feed> feedListAfter = DBReader.getFeedList();
+ assertEquals(1, feedListAfter.size());
+ return feedListAfter.get(0);
+ }
+
+ /**
+ * Verify that the database contains exactly one feed and the number of
+ * items in the feed.
+ *
+ * @param expectedItemCount expected number of items in the feed
+ */
+ private static void verifySingleFeedInDatabaseAndItemCount(int expectedItemCount) {
+ Feed feed = verifySingleFeedInDatabase();
+ List<FeedItem> feedItems = DBReader.getFeedItemList(feed, FeedItemFilter.unfiltered(), SortOrder.DATE_NEW_OLD);
+ assertEquals(expectedItemCount, feedItems.size());
+ }
+
+ /**
+ * Create a DocumentFile mock object.
+ */
+ @NonNull
+ private static FastDocumentFile mockDocumentFile(@NonNull String fileName, @NonNull String mimeType) {
+ return new FastDocumentFile(fileName, mimeType, Uri.parse("file:///path/" + fileName), 0, 0);
+ }
+
+ private static List<FastDocumentFile> mockLocalFolder(String folderName) {
+ List<FastDocumentFile> files = new ArrayList<>();
+ for (File f : Objects.requireNonNull(new File(folderName).listFiles())) {
+ String extension = MimeTypeMap.getFileExtensionFromUrl(f.getPath());
+ String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
+ files.add(new FastDocumentFile(f.getName(), mimeType,
+ Uri.parse(f.toURI().toString()), f.length(), f.lastModified()));
+ }
+ return files;
+ }
+}