diff options
Diffstat (limited to 'net/download')
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 Binary files differnew file mode 100644 index 000000000..b1f993c3f --- /dev/null +++ b/net/download/service/src/test/assets/local-feed1/track1.mp3 diff --git a/net/download/service/src/test/assets/local-feed2/folder.png b/net/download/service/src/test/assets/local-feed2/folder.png Binary files differnew file mode 100644 index 000000000..9e522a986 --- /dev/null +++ b/net/download/service/src/test/assets/local-feed2/folder.png diff --git a/net/download/service/src/test/assets/local-feed2/track1.mp3 b/net/download/service/src/test/assets/local-feed2/track1.mp3 Binary files differnew file mode 100644 index 000000000..b1f993c3f --- /dev/null +++ b/net/download/service/src/test/assets/local-feed2/track1.mp3 diff --git a/net/download/service/src/test/assets/local-feed2/track2.mp3 b/net/download/service/src/test/assets/local-feed2/track2.mp3 Binary files differnew file mode 100644 index 000000000..310cddd6b --- /dev/null +++ b/net/download/service/src/test/assets/local-feed2/track2.mp3 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; + } +} |