summaryrefslogtreecommitdiff
path: root/core/src
diff options
context:
space:
mode:
Diffstat (limited to 'core/src')
-rw-r--r--core/src/androidTest/java/de/danoeh/antennapod/core/syndication/namespace/atom/AtomTextTest.java49
-rw-r--r--core/src/free/java/de/danoeh/antennapod/core/ClientConfig.java4
-rw-r--r--core/src/main/AndroidManifest.xml5
-rw-r--r--core/src/main/assets/html-export-template.html23
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/DBTasksCallbacks.java20
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/DownloadServiceCallbacks.java10
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/PlaybackServiceCallbacks.java29
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/asynctask/ImageResource.java15
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/backup/OpmlBackupAgent.java10
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/feed/Feed.java90
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java8
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/feed/FeedItemFilter.java103
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java38
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/feed/FeedPreferences.java65
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/feed/LocalFeedUpdater.java46
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/feed/util/ImageResourceUtils.java55
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/glide/ApGlideModule.java1
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/glide/ChapterImageModelLoader.java4
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java24
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/preferences/PlaybackPreferences.java2
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java69
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/receiver/PlayerWidget.java17
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/PlayerWidgetJobService.java243
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadService.java88
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadServiceNotification.java11
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/download/HttpDownloader.java5
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/download/NewEpisodesNotification.java132
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FailedDownloadHandler.java1
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FeedSyncTask.java8
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java2
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/playback/ExoPlayerWrapper.java20
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/playback/LocalPSMP.java25
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java158
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceNotificationBuilder.java18
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceTaskManager.java22
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/ssl/BackportCaCerts.java32
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/ssl/BackportTrustManager.java2
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/APDownloadAlgorithm.java103
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/AutomaticDownloadAlgorithm.java90
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java26
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java122
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/DBUpgrader.java4
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java17
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java31
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/ExceptFavoriteCleanupAlgorithm.java99
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/FeedSearcher.java2
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java79
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedCursorMapper.java70
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedItemFilterQuery.java76
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java8
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/sync/gpoddernet/GpodnetService.java174
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSITunes.java5
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/ChapterUtils.java221
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/DateUtils.java7
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/DownloadError.java8
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/FileNameGenerator.java2
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/LangUtils.java124
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/ShareUtils.java123
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/ThemeUtils.java25
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/URLChecker.java1
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/gui/NotificationUtils.java92
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ChapterReader.java193
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ID3Reader.java299
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/FrameHeader.java20
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/TagHeader.java37
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/playback/AudioPlayer.java9
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/playback/ExternalMedia.java27
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/playback/IPlayer.java2
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java118
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/playback/PlayableException.java13
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/playback/PlayableUtils.java90
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java88
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackServiceStarter.java1
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/playback/RemoteMedia.java2
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/playback/VideoPlayer.java6
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdater.java229
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdaterJobService.java35
-rw-r--r--core/src/main/res/drawable-hdpi/ic_notification_new.pngbin0 -> 1054 bytes
-rw-r--r--core/src/main/res/drawable-mdpi/ic_notification_new.pngbin0 -> 688 bytes
-rw-r--r--core/src/main/res/drawable-xhdpi/ic_notification_new.pngbin0 -> 1402 bytes
-rw-r--r--core/src/main/res/drawable-xxhdpi/ic_notification_new.pngbin0 -> 2207 bytes
-rw-r--r--core/src/main/res/drawable-xxxhdpi/ic_notification_new.pngbin0 -> 3012 bytes
-rw-r--r--core/src/main/res/drawable/ic_av_replay_black_48dp.xml9
-rw-r--r--core/src/main/res/drawable/ic_av_replay_white_48dp.xml9
-rw-r--r--core/src/main/res/drawable/ic_notification_auto_download_complete.xml9
-rw-r--r--core/src/main/res/drawable/ic_share_black.xml7
-rw-r--r--core/src/main/res/drawable/ic_share_white.xml7
-rw-r--r--core/src/main/res/layout/player_widget.xml61
-rw-r--r--core/src/main/res/values-br/strings.xml7
-rw-r--r--core/src/main/res/values-ca/strings.xml4
-rw-r--r--core/src/main/res/values-cs/strings.xml6
-rw-r--r--core/src/main/res/values-da/strings.xml4
-rw-r--r--core/src/main/res/values-de/strings.xml4
-rw-r--r--core/src/main/res/values-es/strings.xml4
-rw-r--r--core/src/main/res/values-et/strings.xml4
-rw-r--r--core/src/main/res/values-eu/strings.xml4
-rw-r--r--core/src/main/res/values-fa/strings.xml4
-rw-r--r--core/src/main/res/values-fi/strings.xml4
-rw-r--r--core/src/main/res/values-fr/strings.xml4
-rw-r--r--core/src/main/res/values-gl/strings.xml4
-rw-r--r--core/src/main/res/values-hu/strings.xml4
-rw-r--r--core/src/main/res/values-it/strings.xml4
-rw-r--r--core/src/main/res/values-iw/strings.xml6
-rw-r--r--core/src/main/res/values-ja/strings.xml3
-rw-r--r--core/src/main/res/values-ko/strings.xml3
-rw-r--r--core/src/main/res/values-lt/strings.xml6
-rw-r--r--core/src/main/res/values-nb/strings.xml4
-rw-r--r--core/src/main/res/values-nl/strings.xml4
-rw-r--r--core/src/main/res/values-pl/strings.xml6
-rw-r--r--core/src/main/res/values-pt-rBR/strings.xml4
-rw-r--r--core/src/main/res/values-pt/strings.xml4
-rw-r--r--core/src/main/res/values-ru/strings.xml6
-rw-r--r--core/src/main/res/values-sv/strings.xml4
-rw-r--r--core/src/main/res/values-tr/strings.xml4
-rw-r--r--core/src/main/res/values-uk/strings.xml6
-rw-r--r--core/src/main/res/values-zh-rCN/strings.xml3
-rw-r--r--core/src/main/res/values-zh-rTW/strings.xml3
-rw-r--r--core/src/main/res/values/arrays.xml20
-rw-r--r--core/src/main/res/values/attrs.xml11
-rw-r--r--core/src/main/res/values/colors.xml3
-rw-r--r--core/src/main/res/values/ids.xml10
-rw-r--r--core/src/main/res/values/keycodes.xml9
-rw-r--r--core/src/main/res/values/strings.xml116
-rw-r--r--core/src/main/res/values/styles.xml10
-rw-r--r--core/src/play/java/de/danoeh/antennapod/core/ClientConfig.java4
-rw-r--r--core/src/play/java/de/danoeh/antennapod/core/cast/CastUtils.java5
-rw-r--r--core/src/play/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java3
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/feed/FeedFilterTest.java126
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/feed/FeedItemTest.java45
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/feed/LocalFeedUpdaterTest.java96
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/service/download/DownloadRequestTest.java (renamed from core/src/androidTest/java/de/danoeh/antennapod/core/service/download/DownloadRequestTest.java)6
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/storage/DbCleanupTests.java237
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/storage/DbNullCleanupAlgorithmTest.java121
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/storage/DbQueueCleanupAlgorithmTest.java52
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/storage/DbReaderTest.java420
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/storage/DbTasksTest.java302
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/storage/DbTestUtils.java77
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/storage/DbWriterTest.java855
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/storage/ExceptFavoriteCleanupAlgorithmTest.java89
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/storage/mapper/FeedCursorMapperTest.java100
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/syndication/handler/AtomParserTest.java70
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/syndication/handler/FeedParserTestHelper.java35
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/syndication/handler/RssParserTest.java86
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/syndication/namespace/atom/AtomTextTest.java35
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/util/DateUtilsTest.java (renamed from core/src/androidTest/java/de/danoeh/antennapod/core/util/DateUtilsTest.java)48
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/util/FilenameGeneratorTest.java98
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/util/URLCheckerTest.java157
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/util/id3reader/ChapterReaderTest.java165
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/util/id3reader/Id3ReaderTest.java151
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/util/playback/ExternalMediaTest.java56
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/util/playback/TimelineTest.java262
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/util/syndication/FeedDiscovererTest.java128
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentChapterReaderTest.java44
-rw-r--r--core/src/test/resources/feed-atom-testAtomBasic.xml1
-rw-r--r--core/src/test/resources/feed-atom-testLogoWithWhitespace.xml2
-rw-r--r--core/src/test/resources/feed-rss-testImageWithWhitespace.xml2
-rw-r--r--core/src/test/resources/feed-rss-testMediaContentMime.xml1
-rw-r--r--core/src/test/resources/feed-rss-testRss2Basic.xml1
-rw-r--r--core/src/test/resources/media-parser/auphonic.m4abin0 -> 114657 bytes
-rw-r--r--core/src/test/resources/media-parser/auphonic.mp3bin0 -> 143695 bytes
-rw-r--r--core/src/test/resources/media-parser/auphonic.oggbin0 -> 6565 bytes
-rw-r--r--core/src/test/resources/media-parser/auphonic.opusbin0 -> 4189 bytes
-rw-r--r--core/src/test/resources/media-parser/ultraschall5.mp3bin0 -> 5903309 bytes
163 files changed, 6252 insertions, 2168 deletions
diff --git a/core/src/androidTest/java/de/danoeh/antennapod/core/syndication/namespace/atom/AtomTextTest.java b/core/src/androidTest/java/de/danoeh/antennapod/core/syndication/namespace/atom/AtomTextTest.java
deleted file mode 100644
index 1ab194133..000000000
--- a/core/src/androidTest/java/de/danoeh/antennapod/core/syndication/namespace/atom/AtomTextTest.java
+++ /dev/null
@@ -1,49 +0,0 @@
-package de.danoeh.antennapod.core.syndication.namespace.atom;
-
-import androidx.test.filters.SmallTest;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.junit.runners.Parameterized;
-
-import java.util.Arrays;
-import java.util.Collection;
-
-import static org.junit.runners.Parameterized.Parameter;
-import static org.junit.runners.Parameterized.Parameters;
-import static org.junit.Assert.assertEquals;
-
-/**
- * Unit test for {@link AtomText}.
- */
-@SmallTest
-@RunWith(Parameterized.class)
-public class AtomTextTest {
-
- @Parameter(value = 0)
- public String input;
-
- @Parameter(value = 1)
- public String expectedOutput;
-
- @Parameters
- public static Collection<Object[]> initParameters() {
- return Arrays.asList(new Object[][] {
- {"&gt;", ">"},
- {">", ">"},
- {"&lt;Fran&ccedil;ais&gt;", "<Français>"},
- {"ßÄÖÜ", "ßÄÖÜ"},
- {"&quot;", "\""},
- {"&szlig;", "ß"},
- {"&#8217;", "’"},
- {"&#x2030;", "‰"},
- {"&euro;", "€"},
- });
- }
-
- @Test
- public void testProcessingHtml() {
- final AtomText atomText = new AtomText("", new NSAtom(), AtomText.TYPE_HTML);
- atomText.setContent(input);
- assertEquals(expectedOutput, atomText.getProcessedContent());
- }
-}
diff --git a/core/src/free/java/de/danoeh/antennapod/core/ClientConfig.java b/core/src/free/java/de/danoeh/antennapod/core/ClientConfig.java
index 04d74f2a2..0193bf8ce 100644
--- a/core/src/free/java/de/danoeh/antennapod/core/ClientConfig.java
+++ b/core/src/free/java/de/danoeh/antennapod/core/ClientConfig.java
@@ -30,10 +30,6 @@ public class ClientConfig {
public static DownloadServiceCallbacks downloadServiceCallbacks;
- public static PlaybackServiceCallbacks playbackServiceCallbacks;
-
- public static DBTasksCallbacks dbTasksCallbacks;
-
public static CastCallbacks castCallbacks;
private static boolean initialized = false;
diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml
index ae5e56e55..59267fa39 100644
--- a/core/src/main/AndroidManifest.xml
+++ b/core/src/main/AndroidManifest.xml
@@ -45,6 +45,11 @@
android:label="@string/feed_update_receiver_name"
android:exported="true"
tools:ignore="ExportedReceiver" /> <!-- allow feeds update to be triggered by external apps -->
+
+ <service
+ android:name=".widget.WidgetUpdaterJobService"
+ android:permission="android.permission.BIND_JOB_SERVICE"
+ android:exported="true"/>
</application>
</manifest>
diff --git a/core/src/main/assets/html-export-template.html b/core/src/main/assets/html-export-template.html
index 19d63f6ca..e4d3ffd31 100644
--- a/core/src/main/assets/html-export-template.html
+++ b/core/src/main/assets/html-export-template.html
@@ -5,14 +5,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* {
- font-family: 'Lato', sans-serif;
+ font-family: "Sarabun", sans-serif;
font-weight: 300;
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
- background: #3498db;
+ background: #0d8eff;
+ background-image: linear-gradient(180deg, #0f9cff, #0682ff);
text-align: center;
padding: 10px;
}
@@ -20,7 +21,7 @@
color: #fff;
font-weight: 300;
display: inline-block;
- margin-top: 40px;
+ margin-top: 30px;
margin-bottom: 20px;
vertical-align: top;
}
@@ -31,7 +32,7 @@
width: 100%;
max-width: 500px;
display: block;
- display: inline-flex;
+ display: inline-flex;
padding: 10px;
}
li > div {
@@ -60,12 +61,14 @@
height: 100px;
margin-right: 10px;
}
- li > div > img {
- float: left;
- }
- li > div > p {
- width: 100%;
- }
+ li > div > img {
+ float: left;
+ text-indent: -10000px;
+ background: #eee;
+ }
+ li > div > p {
+ width: 100%;
+ }
body > a {
color: #ffffff;
display: inline-block;
diff --git a/core/src/main/java/de/danoeh/antennapod/core/DBTasksCallbacks.java b/core/src/main/java/de/danoeh/antennapod/core/DBTasksCallbacks.java
deleted file mode 100644
index 11a6b2c9f..000000000
--- a/core/src/main/java/de/danoeh/antennapod/core/DBTasksCallbacks.java
+++ /dev/null
@@ -1,20 +0,0 @@
-package de.danoeh.antennapod.core;
-
-import de.danoeh.antennapod.core.storage.AutomaticDownloadAlgorithm;
-import de.danoeh.antennapod.core.storage.EpisodeCleanupAlgorithm;
-
-/**
- * Callbacks for the DBTasks class of the storage module.
- */
-public interface DBTasksCallbacks {
-
- /**
- * Returns the client's implementation of the AutomaticDownloadAlgorithm interface.
- */
- AutomaticDownloadAlgorithm getAutomaticDownloadAlgorithm();
-
- /**
- * Returns the client's implementation of the EpisodeCacheCleanupAlgorithm interface.
- */
- EpisodeCleanupAlgorithm getEpisodeCacheCleanupAlgorithm();
-}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/DownloadServiceCallbacks.java b/core/src/main/java/de/danoeh/antennapod/core/DownloadServiceCallbacks.java
index ad3fb8d42..ae9b47629 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/DownloadServiceCallbacks.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/DownloadServiceCallbacks.java
@@ -37,7 +37,7 @@ public interface DownloadServiceCallbacks {
* <p/>
* The PendingIntent takes users to an activity where they can look at all successful and failed downloads.
*
- * @return A non-null PendingIntent for the notification or null if shouldCreateReport()==false
+ * @return A non-null PendingIntent for the notification
*/
PendingIntent getReportNotificationContentIntent(Context context);
@@ -47,14 +47,8 @@ public interface DownloadServiceCallbacks {
* <p/>
* The PendingIntent takes users to an activity where they can look at their episode queue.
*
- * @return A non-null PendingIntent for the notification or null if shouldCreateReport()==false
+ * @return A non-null PendingIntent for the notification
*/
PendingIntent getAutoDownloadReportNotificationContentIntent(Context context);
-
- /**
- * Returns true if the DownloadService should create a report that shows the number of failed
- * downloads when the service shuts down.
- */
- boolean shouldCreateReport();
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/PlaybackServiceCallbacks.java b/core/src/main/java/de/danoeh/antennapod/core/PlaybackServiceCallbacks.java
deleted file mode 100644
index 194ee65ae..000000000
--- a/core/src/main/java/de/danoeh/antennapod/core/PlaybackServiceCallbacks.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package de.danoeh.antennapod.core;
-
-import android.content.Context;
-import android.content.Intent;
-
-import de.danoeh.antennapod.core.feed.MediaType;
-
-/**
- * Callbacks for the PlaybackService of the core module
- */
-public interface PlaybackServiceCallbacks {
-
- /**
- * Returns an intent which starts an audio- or videoplayer, depending on the
- * type of media that is being played.
- *
- * @param mediaType The type of media that is being played.
- * @param remotePlayback true if the media is played on a remote device.
- * @return A non-null activity intent.
- */
- Intent getPlayerActivityIntent(Context context, MediaType mediaType, boolean remotePlayback);
-
- /**
- * Returns true if the PlaybackService should load new episodes from the queue when playback ends
- * and false if the PlaybackService should ignore the queue and load no more episodes when playback
- * finishes.
- */
- boolean useQueue();
-}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/asynctask/ImageResource.java b/core/src/main/java/de/danoeh/antennapod/core/asynctask/ImageResource.java
deleted file mode 100644
index b01e3f3ba..000000000
--- a/core/src/main/java/de/danoeh/antennapod/core/asynctask/ImageResource.java
+++ /dev/null
@@ -1,15 +0,0 @@
-package de.danoeh.antennapod.core.asynctask;
-
-/**
- * Classes that implement this interface provide access to an image resource that can
- * be loaded by the Picasso library.
- */
-public interface ImageResource {
-
- /**
- * Returns the location of the image or null if no image is available.
- * <p/>
- * The location can either be an URL or a local path
- */
- String getImageLocation();
-}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/backup/OpmlBackupAgent.java b/core/src/main/java/de/danoeh/antennapod/core/backup/OpmlBackupAgent.java
index 4c11d0489..c05e2e9f1 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/backup/OpmlBackupAgent.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/backup/OpmlBackupAgent.java
@@ -20,6 +20,7 @@ import java.io.OutputStreamWriter;
import java.io.Reader;
import java.io.Writer;
import java.math.BigInteger;
+import java.nio.charset.Charset;
import java.security.DigestInputStream;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
@@ -34,7 +35,6 @@ import de.danoeh.antennapod.core.feed.Feed;
import de.danoeh.antennapod.core.storage.DBReader;
import de.danoeh.antennapod.core.storage.DownloadRequestException;
import de.danoeh.antennapod.core.storage.DownloadRequester;
-import de.danoeh.antennapod.core.util.LangUtils;
public class OpmlBackupAgent extends BackupAgentHelper {
private static final String OPML_BACKUP_KEY = "opml";
@@ -73,9 +73,9 @@ public class OpmlBackupAgent extends BackupAgentHelper {
try {
digester = MessageDigest.getInstance("MD5");
writer = new OutputStreamWriter(new DigestOutputStream(byteStream, digester),
- LangUtils.UTF_8);
+ Charset.forName("UTF-8"));
} catch (NoSuchAlgorithmException e) {
- writer = new OutputStreamWriter(byteStream, LangUtils.UTF_8);
+ writer = new OutputStreamWriter(byteStream, Charset.forName("UTF-8"));
}
try {
@@ -138,9 +138,9 @@ public class OpmlBackupAgent extends BackupAgentHelper {
try {
digester = MessageDigest.getInstance("MD5");
reader = new InputStreamReader(new DigestInputStream(data, digester),
- LangUtils.UTF_8);
+ Charset.forName("UTF-8"));
} catch (NoSuchAlgorithmException e) {
- reader = new InputStreamReader(data, LangUtils.UTF_8);
+ reader = new InputStreamReader(data, Charset.forName("UTF-8"));
}
try {
diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/Feed.java b/core/src/main/java/de/danoeh/antennapod/core/feed/Feed.java
index a3b66c951..dd8a466eb 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/feed/Feed.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/feed/Feed.java
@@ -1,6 +1,5 @@
package de.danoeh.antennapod.core.feed;
-import android.database.Cursor;
import android.text.TextUtils;
import androidx.annotation.Nullable;
@@ -9,26 +8,28 @@ import java.util.ArrayList;
import java.util.Date;
import java.util.List;
-import de.danoeh.antennapod.core.asynctask.ImageResource;
-import de.danoeh.antennapod.core.storage.DBWriter;
-import de.danoeh.antennapod.core.storage.PodDBAdapter;
import de.danoeh.antennapod.core.util.SortOrder;
/**
- * Data Object for a whole feed
+ * Data Object for a whole feed.
*
* @author daniel
*/
-public class Feed extends FeedFile implements ImageResource {
+public class Feed extends FeedFile {
public static final int FEEDFILETYPE_FEED = 0;
public static final String TYPE_RSS2 = "rss";
public static final String TYPE_ATOM1 = "atom";
public static final String PREFIX_LOCAL_FOLDER = "antennapod_local:";
- /* title as defined by the feed */
+ /**
+ * title as defined by the feed.
+ */
private String feedTitle;
- /* custom title set by the user */
+
+ /**
+ * custom title set by the user.
+ */
private String customTitle;
/**
@@ -42,25 +43,25 @@ public class Feed extends FeedFile implements ImageResource {
private String description;
private String language;
/**
- * Name of the author
+ * Name of the author.
*/
private String author;
private String imageUrl;
private List<FeedItem> items;
/**
- * String that identifies the last update (adopted from Last-Modified or ETag header)
+ * String that identifies the last update (adopted from Last-Modified or ETag header).
*/
private String lastUpdate;
private String paymentLink;
/**
- * Feed type, for example RSS 2 or Atom
+ * Feed type, for example RSS 2 or Atom.
*/
private String type;
/**
- * Feed preferences
+ * Feed preferences.
*/
private FeedPreferences preferences;
@@ -122,7 +123,7 @@ public class Feed extends FeedFile implements ImageResource {
this.paged = paged;
this.nextPageLink = nextPageLink;
this.items = new ArrayList<>();
- if(filter != null) {
+ if (filter != null) {
this.itemfilter = new FeedItemFilter(filter);
} else {
this.itemfilter = new FeedItemFilter(new String[0]);
@@ -132,7 +133,7 @@ public class Feed extends FeedFile implements ImageResource {
}
/**
- * This constructor is used for test purposes
+ * This constructor is used for test purposes.
*/
public Feed(long id, String lastUpdate, String title, String link, String description, String paymentLink,
String author, String language, String type, String feedIdentifier, String imageUrl, String fileUrl,
@@ -175,56 +176,6 @@ public class Feed extends FeedFile implements ImageResource {
preferences = new FeedPreferences(0, true, FeedPreferences.AutoDeleteAction.GLOBAL, VolumeAdaptionSetting.OFF, username, password);
}
- public static Feed fromCursor(Cursor cursor) {
- int indexId = cursor.getColumnIndex(PodDBAdapter.KEY_ID);
- int indexLastUpdate = cursor.getColumnIndex(PodDBAdapter.KEY_LASTUPDATE);
- int indexTitle = cursor.getColumnIndex(PodDBAdapter.KEY_TITLE);
- int indexCustomTitle = cursor.getColumnIndex(PodDBAdapter.KEY_CUSTOM_TITLE);
- int indexLink = cursor.getColumnIndex(PodDBAdapter.KEY_LINK);
- int indexDescription = cursor.getColumnIndex(PodDBAdapter.KEY_DESCRIPTION);
- int indexPaymentLink = cursor.getColumnIndex(PodDBAdapter.KEY_PAYMENT_LINK);
- int indexAuthor = cursor.getColumnIndex(PodDBAdapter.KEY_AUTHOR);
- int indexLanguage = cursor.getColumnIndex(PodDBAdapter.KEY_LANGUAGE);
- int indexType = cursor.getColumnIndex(PodDBAdapter.KEY_TYPE);
- int indexFeedIdentifier = cursor.getColumnIndex(PodDBAdapter.KEY_FEED_IDENTIFIER);
- int indexFileUrl = cursor.getColumnIndex(PodDBAdapter.KEY_FILE_URL);
- int indexDownloadUrl = cursor.getColumnIndex(PodDBAdapter.KEY_DOWNLOAD_URL);
- int indexDownloaded = cursor.getColumnIndex(PodDBAdapter.KEY_DOWNLOADED);
- int indexIsPaged = cursor.getColumnIndex(PodDBAdapter.KEY_IS_PAGED);
- int indexNextPageLink = cursor.getColumnIndex(PodDBAdapter.KEY_NEXT_PAGE_LINK);
- int indexHide = cursor.getColumnIndex(PodDBAdapter.KEY_HIDE);
- int indexSortOrder = cursor.getColumnIndex(PodDBAdapter.KEY_SORT_ORDER);
- int indexLastUpdateFailed = cursor.getColumnIndex(PodDBAdapter.KEY_LAST_UPDATE_FAILED);
- int indexImageUrl = cursor.getColumnIndex(PodDBAdapter.KEY_IMAGE_URL);
-
- Feed feed = new Feed(
- cursor.getLong(indexId),
- cursor.getString(indexLastUpdate),
- cursor.getString(indexTitle),
- cursor.getString(indexCustomTitle),
- cursor.getString(indexLink),
- cursor.getString(indexDescription),
- cursor.getString(indexPaymentLink),
- cursor.getString(indexAuthor),
- cursor.getString(indexLanguage),
- cursor.getString(indexType),
- cursor.getString(indexFeedIdentifier),
- cursor.getString(indexImageUrl),
- cursor.getString(indexFileUrl),
- cursor.getString(indexDownloadUrl),
- cursor.getInt(indexDownloaded) > 0,
- cursor.getInt(indexIsPaged) > 0,
- cursor.getString(indexNextPageLink),
- cursor.getString(indexHide),
- SortOrder.fromCodeString(cursor.getString(indexSortOrder)),
- cursor.getInt(indexLastUpdateFailed) > 0
- );
-
- FeedPreferences preferences = FeedPreferences.fromCursor(cursor);
- feed.setPreferences(preferences);
- return feed;
- }
-
/**
* Returns the item at the specified index.
*
@@ -384,7 +335,7 @@ public class Feed extends FeedFile implements ImageResource {
}
public void setCustomTitle(String customTitle) {
- if(customTitle == null || customTitle.equals(feedTitle)) {
+ if (customTitle == null || customTitle.equals(feedTitle)) {
this.customTitle = null;
} else {
this.customTitle = customTitle;
@@ -479,10 +430,6 @@ public class Feed extends FeedFile implements ImageResource {
return preferences;
}
- public void savePreferences() {
- DBWriter.setFeedPreferences(preferences);
- }
-
@Override
public void setId(long id) {
super.setId(id);
@@ -491,11 +438,6 @@ public class Feed extends FeedFile implements ImageResource {
}
}
- @Override
- public String getImageLocation() {
- return imageUrl;
- }
-
public int getPageNr() {
return pageNr;
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java
index 131cbe563..b2a89d452 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java
@@ -17,17 +17,16 @@ import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeUnit;
-import de.danoeh.antennapod.core.asynctask.ImageResource;
import de.danoeh.antennapod.core.storage.DBReader;
import de.danoeh.antennapod.core.storage.PodDBAdapter;
import de.danoeh.antennapod.core.util.ShownotesProvider;
/**
- * Data Object for a XML message
+ * Item (episode) within a feed.
*
* @author daniel
*/
-public class FeedItem extends FeedComponent implements ShownotesProvider, ImageResource, Serializable {
+public class FeedItem extends FeedComponent implements ShownotesProvider, Serializable {
/** tag that indicates this item is in the queue */
public static final String TAG_QUEUE = "Queue";
@@ -376,14 +375,13 @@ public class FeedItem extends FeedComponent implements ShownotesProvider, ImageR
};
}
- @Override
public String getImageLocation() {
if (imageUrl != null) {
return imageUrl;
} else if (media != null && media.hasEmbeddedPicture()) {
return FeedMedia.FILENAME_PREFIX_EMBEDDED_COVER + media.getLocalMediaUrl();
} else if (feed != null) {
- return feed.getImageLocation();
+ return feed.getImageUrl();
} else {
return null;
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItemFilter.java b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItemFilter.java
index e8e478a86..bd30a3953 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItemFilter.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItemFilter.java
@@ -1,7 +1,6 @@
package de.danoeh.antennapod.core.feed;
import android.text.TextUtils;
-import android.util.Log;
import java.util.ArrayList;
import java.util.Arrays;
@@ -14,77 +13,58 @@ import static de.danoeh.antennapod.core.feed.FeedItem.TAG_FAVORITE;
public class FeedItemFilter {
- private final String[] mProperties;
-
- private boolean showPlayed = false;
- private boolean showUnplayed = false;
- private boolean showPaused = false;
- private boolean showNotPaused = false;
- private boolean showQueued = false;
- private boolean showNotQueued = false;
- private boolean showDownloaded = false;
- private boolean showNotDownloaded = false;
- private boolean showHasMedia = false;
- private boolean showNoMedia = false;
- private boolean showIsFavorite = false;
- private boolean showNotFavorite = false;
+ private final String[] properties;
+
+ public final boolean showPlayed;
+ public final boolean showUnplayed;
+ public final boolean showPaused;
+ public final boolean showNotPaused;
+ public final boolean showQueued;
+ public final boolean showNotQueued;
+ public final boolean showDownloaded;
+ public final boolean showNotDownloaded;
+ public final boolean showHasMedia;
+ public final boolean showNoMedia;
+ public final boolean showIsFavorite;
+ public final boolean showNotFavorite;
+
+ public static FeedItemFilter unfiltered() {
+ return new FeedItemFilter("");
+ }
public FeedItemFilter(String properties) {
this(TextUtils.split(properties, ","));
}
public FeedItemFilter(String[] properties) {
- this.mProperties = properties;
- for (String property : properties) {
- // see R.arrays.feed_filter_values
- switch (property) {
- case "unplayed":
- showUnplayed = true;
- break;
- case "paused":
- showPaused = true;
- break;
- case "not_paused":
- showNotPaused = true;
- break;
- case "played":
- showPlayed = true;
- break;
- case "queued":
- showQueued = true;
- break;
- case "not_queued":
- showNotQueued = true;
- break;
- case "downloaded":
- showDownloaded = true;
- break;
- case "not_downloaded":
- showNotDownloaded = true;
- break;
- case "has_media":
- showHasMedia = true;
- break;
- case "no_media":
- showNoMedia = true;
- break;
- case "is_favorite":
- showIsFavorite = true;
- break;
- case "not_favorite":
- showNotFavorite = true;
- break;
- default:
- break;
- }
- }
+ this.properties = properties;
+
+ // see R.arrays.feed_filter_values
+ showUnplayed = hasProperty("unplayed");
+ showPaused = hasProperty("paused");
+ showNotPaused = hasProperty("not_paused");
+ showPlayed = hasProperty("played");
+ showQueued = hasProperty("queued");
+ showNotQueued = hasProperty("not_queued");
+ showDownloaded = hasProperty("downloaded");
+ showNotDownloaded = hasProperty("not_downloaded");
+ showHasMedia = hasProperty("has_media");
+ showNoMedia = hasProperty("no_media");
+ showIsFavorite = hasProperty("is_favorite");
+ showNotFavorite = hasProperty("not_favorite");
+ }
+
+ private boolean hasProperty(String property) {
+ return Arrays.asList(properties).contains(property);
}
/**
* Run a list of feed items through the filter.
*/
public List<FeedItem> filter(List<FeedItem> items) {
- if(mProperties.length == 0) return items;
+ if (properties.length == 0) {
+ return items;
+ }
List<FeedItem> result = new ArrayList<>();
@@ -126,11 +106,10 @@ public class FeedItemFilter {
}
public String[] getValues() {
- return mProperties.clone();
+ return properties.clone();
}
public boolean isShowDownloaded() {
return showDownloaded;
}
-
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java
index 88945b930..9049a3ba9 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java
@@ -5,6 +5,7 @@ import android.content.SharedPreferences;
import android.content.SharedPreferences.Editor;
import android.database.Cursor;
import android.media.MediaMetadataRetriever;
+import android.net.Uri;
import android.os.Parcel;
import android.os.Parcelable;
import androidx.annotation.Nullable;
@@ -27,6 +28,7 @@ import de.danoeh.antennapod.core.util.ChapterUtils;
import de.danoeh.antennapod.core.util.playback.Playable;
import de.danoeh.antennapod.core.sync.SyncService;
import de.danoeh.antennapod.core.sync.model.EpisodeAction;
+import de.danoeh.antennapod.core.util.playback.PlayableException;
public class FeedMedia extends FeedFile implements Playable {
private static final String TAG = "FeedMedia";
@@ -165,13 +167,20 @@ public class FeedMedia extends FeedFile implements Playable {
*/
public MediaBrowserCompat.MediaItem getMediaItem() {
Playable p = this;
- MediaDescriptionCompat description = new MediaDescriptionCompat.Builder()
+ MediaDescriptionCompat.Builder builder = new MediaDescriptionCompat.Builder()
.setMediaId(String.valueOf(id))
.setTitle(p.getEpisodeTitle())
.setDescription(p.getFeedTitle())
- .setSubtitle(p.getFeedTitle())
- .build();
- return new MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE);
+ .setSubtitle(p.getFeedTitle());
+ if (item != null) {
+ // getImageLocation() also loads embedded images, which we can not send to external devices
+ if (item.getImageUrl() != null) {
+ builder.setIconUri(Uri.parse(item.getImageUrl()));
+ } else if (item.getFeed() != null && item.getFeed().getImageUrl() != null) {
+ builder.setIconUri(Uri.parse(item.getFeed().getImageUrl()));
+ }
+ }
+ return new MediaBrowserCompat.MediaItem(builder.build(), MediaBrowserCompat.MediaItem.FLAG_PLAYABLE);
}
/**
@@ -399,14 +408,7 @@ public class FeedMedia extends FeedFile implements Playable {
if (item.hasChapters()) {
chaptersFromDatabase = DBReader.loadChaptersOfFeedItem(item);
}
-
- List<Chapter> chaptersFromMediaFile;
- if (localFileAvailable()) {
- chaptersFromMediaFile = ChapterUtils.loadChaptersFromFileUrl(this);
- } else {
- chaptersFromMediaFile = ChapterUtils.loadChaptersFromStreamUrl(this, context);
- }
-
+ List<Chapter> chaptersFromMediaFile = ChapterUtils.loadChaptersFromMediaFile(this, context);
return ChapterMerger.merge(chaptersFromDatabase, chaptersFromMediaFile);
}
@@ -470,6 +472,18 @@ public class FeedMedia extends FeedFile implements Playable {
}
@Override
+ public Date getPubDate() {
+ if (item == null) {
+ return null;
+ }
+ if (item.getPubDate() != null) {
+ return item.getPubDate();
+ } else {
+ return null;
+ }
+ }
+
+ @Override
public boolean localFileAvailable() {
return isDownloaded() && file_url != null;
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedPreferences.java
index 5ffee0d62..93efed7e1 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedPreferences.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedPreferences.java
@@ -1,12 +1,10 @@
package de.danoeh.antennapod.core.feed;
-import android.content.Context;
import android.database.Cursor;
import androidx.annotation.NonNull;
import android.text.TextUtils;
import de.danoeh.antennapod.core.preferences.UserPreferences;
-import de.danoeh.antennapod.core.storage.DBWriter;
import de.danoeh.antennapod.core.storage.PodDBAdapter;
/**
@@ -16,37 +14,40 @@ public class FeedPreferences {
public static final float SPEED_USE_GLOBAL = -1;
- @NonNull
- private FeedFilter filter;
- private long feedID;
- private boolean autoDownload;
- private boolean keepUpdated;
-
public enum AutoDeleteAction {
GLOBAL,
YES,
NO
}
- private AutoDeleteAction auto_delete_action;
+ @NonNull
+ private FeedFilter filter;
+ private long feedID;
+ private boolean autoDownload;
+ private boolean keepUpdated;
+ private AutoDeleteAction autoDeleteAction;
private VolumeAdaptionSetting volumeAdaptionSetting;
-
private String username;
private String password;
private float feedPlaybackSpeed;
private int feedSkipIntro;
private int feedSkipEnding;
+ private boolean showEpisodeNotification;
- public FeedPreferences(long feedID, boolean autoDownload, AutoDeleteAction auto_delete_action, VolumeAdaptionSetting volumeAdaptionSetting, String username, String password) {
- this(feedID, autoDownload, true, auto_delete_action, volumeAdaptionSetting,
- username, password, new FeedFilter(), SPEED_USE_GLOBAL, 0, 0);
+ public FeedPreferences(long feedID, boolean autoDownload, AutoDeleteAction autoDeleteAction,
+ VolumeAdaptionSetting volumeAdaptionSetting, String username, String password) {
+ this(feedID, autoDownload, true, autoDeleteAction, volumeAdaptionSetting,
+ username, password, new FeedFilter(), SPEED_USE_GLOBAL, 0, 0, false);
}
- private FeedPreferences(long feedID, boolean autoDownload, boolean keepUpdated, AutoDeleteAction auto_delete_action, VolumeAdaptionSetting volumeAdaptionSetting, String username, String password, @NonNull FeedFilter filter, float feedPlaybackSpeed, int feedSkipIntro, int feedSkipEnding) {
+ private FeedPreferences(long feedID, boolean autoDownload, boolean keepUpdated,
+ AutoDeleteAction autoDeleteAction, VolumeAdaptionSetting volumeAdaptionSetting,
+ String username, String password, @NonNull FeedFilter filter, float feedPlaybackSpeed,
+ int feedSkipIntro, int feedSkipEnding, boolean showEpisodeNotification) {
this.feedID = feedID;
this.autoDownload = autoDownload;
this.keepUpdated = keepUpdated;
- this.auto_delete_action = auto_delete_action;
+ this.autoDeleteAction = autoDeleteAction;
this.volumeAdaptionSetting = volumeAdaptionSetting;
this.username = username;
this.password = password;
@@ -54,6 +55,7 @@ public class FeedPreferences {
this.feedPlaybackSpeed = feedPlaybackSpeed;
this.feedSkipIntro = feedSkipIntro;
this.feedSkipEnding = feedSkipEnding;
+ this.showEpisodeNotification = showEpisodeNotification;
}
public static FeedPreferences fromCursor(Cursor cursor) {
@@ -69,6 +71,7 @@ public class FeedPreferences {
int indexFeedPlaybackSpeed = cursor.getColumnIndex(PodDBAdapter.KEY_FEED_PLAYBACK_SPEED);
int indexAutoSkipIntro = cursor.getColumnIndex(PodDBAdapter.KEY_FEED_SKIP_INTRO);
int indexAutoSkipEnding = cursor.getColumnIndex(PodDBAdapter.KEY_FEED_SKIP_ENDING);
+ int indexEpisodeNotification = cursor.getColumnIndex(PodDBAdapter.KEY_EPISODE_NOTIFICATION);
long feedId = cursor.getLong(indexId);
boolean autoDownload = cursor.getInt(indexAutoDownload) > 0;
@@ -84,6 +87,7 @@ public class FeedPreferences {
float feedPlaybackSpeed = cursor.getFloat(indexFeedPlaybackSpeed);
int feedAutoSkipIntro = cursor.getInt(indexAutoSkipIntro);
int feedAutoSkipEnding = cursor.getInt(indexAutoSkipEnding);
+ boolean showNotification = cursor.getInt(indexEpisodeNotification) > 0;
return new FeedPreferences(feedId,
autoDownload,
autoRefresh,
@@ -94,7 +98,8 @@ public class FeedPreferences {
new FeedFilter(includeFilter, excludeFilter),
feedPlaybackSpeed,
feedAutoSkipIntro,
- feedAutoSkipEnding
+ feedAutoSkipEnding,
+ showNotification
);
}
@@ -168,15 +173,15 @@ public class FeedPreferences {
}
public AutoDeleteAction getAutoDeleteAction() {
- return auto_delete_action;
+ return autoDeleteAction;
}
public VolumeAdaptionSetting getVolumeAdaptionSetting() {
return volumeAdaptionSetting;
}
- public void setAutoDeleteAction(AutoDeleteAction auto_delete_action) {
- this.auto_delete_action = auto_delete_action;
+ public void setAutoDeleteAction(AutoDeleteAction autoDeleteAction) {
+ this.autoDeleteAction = autoDeleteAction;
}
public void setVolumeAdaptionSetting(VolumeAdaptionSetting volumeAdaptionSetting) {
@@ -184,21 +189,15 @@ public class FeedPreferences {
}
public boolean getCurrentAutoDelete() {
- switch (auto_delete_action) {
+ switch (autoDeleteAction) {
case GLOBAL:
return UserPreferences.isAutoDelete();
-
case YES:
return true;
-
case NO:
+ default: // fall-through
return false;
}
- return false; // TODO - add exceptions here
- }
-
- public void save(Context context) {
- DBWriter.setFeedPreferences(this);
}
public String getUsername() {
@@ -240,4 +239,16 @@ public class FeedPreferences {
public int getFeedSkipEnding() {
return feedSkipEnding;
}
+
+ /**
+ * getter for preference if notifications should be display for new episodes.
+ * @return true for displaying notifications
+ */
+ public boolean getShowEpisodeNotification() {
+ return showEpisodeNotification;
+ }
+
+ public void setShowEpisodeNotification(boolean showEpisodeNotification) {
+ this.showEpisodeNotification = showEpisodeNotification;
+ }
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/LocalFeedUpdater.java b/core/src/main/java/de/danoeh/antennapod/core/feed/LocalFeedUpdater.java
index 4e59fd750..d0e15d591 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/feed/LocalFeedUpdater.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/feed/LocalFeedUpdater.java
@@ -6,15 +6,13 @@ import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.text.TextUtils;
+import androidx.annotation.NonNull;
import androidx.documentfile.provider.DocumentFile;
-import org.apache.commons.lang3.StringUtils;
-
import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
-import java.util.Arrays;
import java.util.Collections;
import java.util.Date;
import java.util.HashSet;
@@ -34,6 +32,8 @@ import de.danoeh.antennapod.core.util.DownloadError;
public class LocalFeedUpdater {
+ static final String[] PREFERRED_FEED_IMAGE_FILENAMES = { "folder.jpg", "Folder.jpg", "folder.png", "Folder.png" };
+
public static void updateFeed(Feed feed, Context context) {
try {
tryUpdateFeed(feed, context);
@@ -97,18 +97,7 @@ public class LocalFeedUpdater {
}
}
- List<String> iconLocations = Arrays.asList("folder.jpg", "Folder.jpg", "folder.png", "Folder.png");
- for (String iconLocation : iconLocations) {
- DocumentFile image = documentFolder.findFile(iconLocation);
- if (image != null) {
- feed.setImageUrl(image.getUri().toString());
- break;
- }
- }
- if (StringUtils.isBlank(feed.getImageUrl())) {
- // set default feed image
- feed.setImageUrl(getDefaultIconUrl(context));
- }
+ feed.setImageUrl(getImageUrl(context, documentFolder));
feed.getPreferences().setAutoDownload(false);
feed.getPreferences().setAutoDeleteAction(FeedPreferences.AutoDeleteAction.NO);
@@ -123,6 +112,31 @@ public class LocalFeedUpdater {
}
/**
+ * Returns the image URL for the local feed.
+ */
+ @NonNull
+ static String getImageUrl(@NonNull Context context, @NonNull DocumentFile documentFolder) {
+ // look for special file names
+ for (String iconLocation : PREFERRED_FEED_IMAGE_FILENAMES) {
+ DocumentFile image = documentFolder.findFile(iconLocation);
+ if (image != null) {
+ return image.getUri().toString();
+ }
+ }
+
+ // use the first image in the folder if existing
+ for (DocumentFile file : documentFolder.listFiles()) {
+ String mime = file.getType();
+ if (mime != null && (mime.startsWith("image/jpeg") || mime.startsWith("image/png"))) {
+ return file.getUri().toString();
+ }
+ }
+
+ // use default icon as fallback
+ return getDefaultIconUrl(context);
+ }
+
+ /**
* Returns the URL of the default icon for a local feed. The URL refers to an app resource file.
*/
public static String getDefaultIconUrl(Context context) {
@@ -161,7 +175,7 @@ public class LocalFeedUpdater {
return item;
}
- private static void loadMetadata(FeedItem item, DocumentFile file, Context context) throws Exception {
+ private static void loadMetadata(FeedItem item, DocumentFile file, Context context) {
MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever();
mediaMetadataRetriever.setDataSource(context, file.getUri());
diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/util/ImageResourceUtils.java b/core/src/main/java/de/danoeh/antennapod/core/feed/util/ImageResourceUtils.java
index 674663a6d..b0aee3d77 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/feed/util/ImageResourceUtils.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/feed/util/ImageResourceUtils.java
@@ -1,45 +1,66 @@
package de.danoeh.antennapod.core.feed.util;
-import de.danoeh.antennapod.core.asynctask.ImageResource;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
import de.danoeh.antennapod.core.feed.FeedItem;
import de.danoeh.antennapod.core.feed.FeedMedia;
import de.danoeh.antennapod.core.preferences.UserPreferences;
+import de.danoeh.antennapod.core.util.playback.Playable;
/**
- * Utility class to use the appropriate image resource based on {@link UserPreferences}
+ * Utility class to use the appropriate image resource based on {@link UserPreferences}.
*/
public final class ImageResourceUtils {
private ImageResourceUtils() {
}
- public static String getImageLocation(ImageResource resource) {
+ /**
+ * returns the image location, does prefer the episode cover if available and enabled in settings.
+ */
+ @Nullable
+ public static String getEpisodeListImageLocation(@NonNull Playable playable) {
if (UserPreferences.getUseEpisodeCoverSetting()) {
- return resource.getImageLocation();
+ return playable.getImageLocation();
} else {
- return getShowImageLocation(resource);
+ return getFallbackImageLocation(playable);
}
}
- private static String getShowImageLocation(ImageResource resource) {
+ /**
+ * returns the image location, does prefer the episode cover if available and enabled in settings.
+ */
+ @Nullable
+ public static String getEpisodeListImageLocation(@NonNull FeedItem feedItem) {
+ if (UserPreferences.getUseEpisodeCoverSetting()) {
+ return feedItem.getImageLocation();
+ } else {
+ return getFallbackImageLocation(feedItem);
+ }
+ }
- if (resource instanceof FeedItem) {
- FeedItem item = (FeedItem) resource;
- if (item.getFeed() != null) {
- return item.getFeed().getImageLocation();
- } else {
- return null;
- }
- } else if (resource instanceof FeedMedia) {
- FeedMedia media = (FeedMedia) resource;
+ @Nullable
+ public static String getFallbackImageLocation(@NonNull Playable playable) {
+ if (playable instanceof FeedMedia) {
+ FeedMedia media = (FeedMedia) playable;
FeedItem item = media.getItem();
if (item != null && item.getFeed() != null) {
- return item.getFeed().getImageLocation();
+ return item.getFeed().getImageUrl();
} else {
return null;
}
} else {
- return resource.getImageLocation();
+ return playable.getImageLocation();
+ }
+ }
+
+ @Nullable
+ public static String getFallbackImageLocation(@NonNull FeedItem feedItem) {
+ if (feedItem.getFeed() != null) {
+ return feedItem.getFeed().getImageUrl();
+ } else {
+ return null;
}
}
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/glide/ApGlideModule.java b/core/src/main/java/de/danoeh/antennapod/core/glide/ApGlideModule.java
index ab4247cef..b3adc567e 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/glide/ApGlideModule.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/glide/ApGlideModule.java
@@ -10,7 +10,6 @@ import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory;
import com.bumptech.glide.load.model.StringLoader;
-import com.bumptech.glide.load.model.UriLoader;
import com.bumptech.glide.module.AppGlideModule;
import de.danoeh.antennapod.core.util.EmbeddedChapterImage;
diff --git a/core/src/main/java/de/danoeh/antennapod/core/glide/ChapterImageModelLoader.java b/core/src/main/java/de/danoeh/antennapod/core/glide/ChapterImageModelLoader.java
index 35a9d987b..519d625e2 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/glide/ChapterImageModelLoader.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/glide/ChapterImageModelLoader.java
@@ -10,17 +10,13 @@ import com.bumptech.glide.load.model.ModelLoader;
import com.bumptech.glide.load.model.ModelLoaderFactory;
import com.bumptech.glide.load.model.MultiModelLoaderFactory;
import com.bumptech.glide.signature.ObjectKey;
-import de.danoeh.antennapod.core.ClientConfig;
-import de.danoeh.antennapod.core.feed.Chapter;
import de.danoeh.antennapod.core.service.download.AntennapodHttpClient;
import de.danoeh.antennapod.core.util.EmbeddedChapterImage;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
-import java.net.URL;
import java.nio.ByteBuffer;
-import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.apache.commons.io.IOUtils;
diff --git a/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java
index 0a72b5d5c..209558b19 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/preferences/GpodnetPreferences.java
@@ -26,7 +26,7 @@ public class GpodnetPreferences {
private static String username;
private static String password;
private static String deviceID;
- private static String hostname;
+ private static String hosturl;
private static boolean preferencesLoaded = false;
@@ -40,7 +40,7 @@ public class GpodnetPreferences {
username = prefs.getString(PREF_GPODNET_USERNAME, null);
password = prefs.getString(PREF_GPODNET_PASSWORD, null);
deviceID = prefs.getString(PREF_GPODNET_DEVICEID, null);
- hostname = checkGpodnetHostname(prefs.getString(PREF_GPODNET_HOSTNAME, GpodnetService.DEFAULT_BASE_HOST));
+ hosturl = prefs.getString(PREF_GPODNET_HOSTNAME, GpodnetService.DEFAULT_BASE_HOST);
preferencesLoaded = true;
}
@@ -82,17 +82,16 @@ public class GpodnetPreferences {
writePreference(PREF_GPODNET_DEVICEID, deviceID);
}
- public static String getHostname() {
+ public static String getHosturl() {
ensurePreferencesLoaded();
- return hostname;
+ return hosturl;
}
- public static void setHostname(String value) {
- value = checkGpodnetHostname(value);
- if (!value.equals(hostname)) {
+ public static void setHosturl(String value) {
+ if (!value.equals(hosturl)) {
logout();
writePreference(PREF_GPODNET_HOSTNAME, value);
- hostname = value;
+ hosturl = value;
}
}
@@ -113,13 +112,4 @@ public class GpodnetPreferences {
UserPreferences.setGpodnetNotificationsEnabled();
}
- private static String checkGpodnetHostname(String value) {
- int startIndex = 0;
- if (value.startsWith("http://")) {
- startIndex = "http://".length();
- } else if (value.startsWith("https://")) {
- startIndex = "https://".length();
- }
- return value.substring(startIndex);
- }
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/preferences/PlaybackPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/preferences/PlaybackPreferences.java
index 08ea27434..95b828e28 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/preferences/PlaybackPreferences.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/preferences/PlaybackPreferences.java
@@ -100,7 +100,7 @@ public class PlaybackPreferences implements SharedPreferences.OnSharedPreference
}
public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) {
- if (key.equals(PREF_CURRENT_PLAYER_STATUS)) {
+ if (PREF_CURRENT_PLAYER_STATUS.equals(key)) {
EventBus.getDefault().post(new PlayerStatusEvent());
}
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java b/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java
index 56dd95fe6..cbfe28ded 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/preferences/UserPreferences.java
@@ -6,6 +6,7 @@ import android.content.res.Configuration;
import android.os.Build;
import android.text.TextUtils;
import android.util.Log;
+import android.view.KeyEvent;
import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
@@ -35,6 +36,7 @@ import de.danoeh.antennapod.core.feed.MediaType;
import de.danoeh.antennapod.core.feed.SubscriptionsFilter;
import de.danoeh.antennapod.core.service.download.ProxyConfig;
import de.danoeh.antennapod.core.storage.APCleanupAlgorithm;
+import de.danoeh.antennapod.core.storage.ExceptFavoriteCleanupAlgorithm;
import de.danoeh.antennapod.core.storage.APNullCleanupAlgorithm;
import de.danoeh.antennapod.core.storage.APQueueCleanupAlgorithm;
import de.danoeh.antennapod.core.storage.EpisodeCleanupAlgorithm;
@@ -60,6 +62,7 @@ public class UserPreferences {
private static final String PREF_DRAWER_FEED_COUNTER = "prefDrawerFeedIndicator";
public static final String PREF_EXPANDED_NOTIFICATION = "prefExpandNotify";
public static final String PREF_USE_EPISODE_COVER = "prefEpisodeCover";
+ public static final String PREF_SHOW_TIME_LEFT = "showTimeLeft";
private static final String PREF_PERSISTENT_NOTIFICATION = "prefPersistNotify";
public static final String PREF_COMPACT_NOTIFICATION_BUTTONS = "prefCompactNotificationButtons";
public static final String PREF_LOCKSCREEN_BACKGROUND = "prefLockscreenBackground";
@@ -76,8 +79,8 @@ public class UserPreferences {
public static final String PREF_PAUSE_ON_HEADSET_DISCONNECT = "prefPauseOnHeadsetDisconnect";
public static final String PREF_UNPAUSE_ON_HEADSET_RECONNECT = "prefUnpauseOnHeadsetReconnect";
private static final String PREF_UNPAUSE_ON_BLUETOOTH_RECONNECT = "prefUnpauseOnBluetoothReconnect";
- private static final String PREF_HARDWARE_FOWARD_BUTTON_SKIPS = "prefHardwareForwardButtonSkips";
- private static final String PREF_HARDWARE_PREVIOUS_BUTTON_RESTARTS = "prefHardwarePreviousButtonRestarts";
+ public static final String PREF_HARDWARE_FORWARD_BUTTON = "prefHardwareForwardButton";
+ public static final String PREF_HARDWARE_PREVIOUS_BUTTON = "prefHardwarePreviousButton";
public static final String PREF_FOLLOW_QUEUE = "prefFollowQueue";
public static final String PREF_SKIP_KEEPS_EPISODE = "prefSkipKeepsEpisode";
private static final String PREF_FAVORITE_KEEPS_EPISODE = "prefFavoriteKeepsEpisode";
@@ -136,6 +139,7 @@ public class UserPreferences {
public static final String PREF_CAST_ENABLED = "prefCast"; //Used for enabling Chromecast support
public static final int EPISODE_CLEANUP_QUEUE = -1;
public static final int EPISODE_CLEANUP_NULL = -2;
+ public static final int EPISODE_CLEANUP_EXCEPT_FAVORITE = -3;
public static final int EPISODE_CLEANUP_DEFAULT = 0;
// Constants
@@ -265,6 +269,23 @@ public class UserPreferences {
}
/**
+ * @return {@code true} if we should show remaining time or the duration
+ */
+ public static boolean shouldShowRemainingTime() {
+ return prefs.getBoolean(PREF_SHOW_TIME_LEFT, false);
+ }
+
+ /**
+ * Sets the preference for whether we show the remain time, if not show the duration. This will
+ * send out events so the current playing screen, queue and the episode list would refresh
+ *
+ * @return {@code true} if we should show remaining time or the duration
+ */
+ public static void setShowRemainTimeSetting(Boolean showRemain) {
+ prefs.edit().putBoolean(PREF_SHOW_TIME_LEFT, showRemain).apply();
+ }
+
+ /**
* Returns notification priority.
*
* @return NotificationCompat.PRIORITY_MAX or NotificationCompat.PRIORITY_DEFAULT
@@ -301,10 +322,30 @@ public class UserPreferences {
* @return {@code true} if download reports are shown, {@code false} otherwise
*/
public static boolean showDownloadReport() {
+ if (Build.VERSION.SDK_INT >= 26) {
+ return true; // System handles notification preferences
+ }
+ return prefs.getBoolean(PREF_SHOW_DOWNLOAD_REPORT, true);
+ }
+
+ /**
+ * Used for migration of the preference to system notification channels.
+ */
+ public static boolean getShowDownloadReportRaw() {
return prefs.getBoolean(PREF_SHOW_DOWNLOAD_REPORT, true);
}
public static boolean showAutoDownloadReport() {
+ if (Build.VERSION.SDK_INT >= 26) {
+ return true; // System handles notification preferences
+ }
+ return prefs.getBoolean(PREF_SHOW_AUTO_DOWNLOAD_REPORT, false);
+ }
+
+ /**
+ * Used for migration of the preference to system notification channels.
+ */
+ public static boolean getShowAutoDownloadReportRaw() {
return prefs.getBoolean(PREF_SHOW_AUTO_DOWNLOAD_REPORT, false);
}
@@ -353,12 +394,14 @@ public class UserPreferences {
return prefs.getBoolean(PREF_UNPAUSE_ON_BLUETOOTH_RECONNECT, false);
}
- public static boolean shouldHardwareButtonSkip() {
- return prefs.getBoolean(PREF_HARDWARE_FOWARD_BUTTON_SKIPS, false);
+ public static int getHardwareForwardButton() {
+ return Integer.parseInt(prefs.getString(PREF_HARDWARE_FORWARD_BUTTON,
+ String.valueOf(KeyEvent.KEYCODE_MEDIA_FAST_FORWARD)));
}
- public static boolean shouldHardwarePreviousButtonRestart() {
- return prefs.getBoolean(PREF_HARDWARE_PREVIOUS_BUTTON_RESTARTS, false);
+ public static int getHardwarePreviousButton() {
+ return Integer.parseInt(prefs.getString(PREF_HARDWARE_PREVIOUS_BUTTON,
+ String.valueOf(KeyEvent.KEYCODE_MEDIA_REWIND)));
}
@@ -728,6 +771,16 @@ public class UserPreferences {
}
public static boolean gpodnetNotificationsEnabled() {
+ if (Build.VERSION.SDK_INT >= 26) {
+ return true; // System handles notification preferences
+ }
+ return prefs.getBoolean(PREF_GPODNET_NOTIFICATIONS, true);
+ }
+
+ /**
+ * Used for migration of the preference to system notification channels.
+ */
+ public static boolean getGpodnetNotificationsEnabledRaw() {
return prefs.getBoolean(PREF_GPODNET_NOTIFICATIONS, true);
}
@@ -849,7 +902,9 @@ public class UserPreferences {
return new APNullCleanupAlgorithm();
}
int cleanupValue = getEpisodeCleanupValue();
- if (cleanupValue == EPISODE_CLEANUP_QUEUE) {
+ if (cleanupValue == EPISODE_CLEANUP_EXCEPT_FAVORITE) {
+ return new ExceptFavoriteCleanupAlgorithm();
+ } else if (cleanupValue == EPISODE_CLEANUP_QUEUE) {
return new APQueueCleanupAlgorithm();
} else if (cleanupValue == EPISODE_CLEANUP_NULL) {
return new APNullCleanupAlgorithm();
diff --git a/core/src/main/java/de/danoeh/antennapod/core/receiver/PlayerWidget.java b/core/src/main/java/de/danoeh/antennapod/core/receiver/PlayerWidget.java
index 2e592bdf5..cf0debed2 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/receiver/PlayerWidget.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/receiver/PlayerWidget.java
@@ -9,20 +9,23 @@ import android.util.Log;
import java.util.Arrays;
-import de.danoeh.antennapod.core.service.PlayerWidgetJobService;
+import de.danoeh.antennapod.core.widget.WidgetUpdaterJobService;
public class PlayerWidget extends AppWidgetProvider {
private static final String TAG = "PlayerWidget";
public static final String PREFS_NAME = "PlayerWidgetPrefs";
private static final String KEY_ENABLED = "WidgetEnabled";
public static final String KEY_WIDGET_COLOR = "widget_color";
+ public static final String KEY_WIDGET_SKIP = "widget_skip";
+ public static final String KEY_WIDGET_FAST_FORWARD = "widget_fast_forward";
+ public static final String KEY_WIDGET_REWIND = "widget_rewind";
public static final int DEFAULT_COLOR = 0x00262C31;
@Override
public void onReceive(Context context, Intent intent) {
Log.d(TAG, "onReceive");
super.onReceive(context, intent);
- PlayerWidgetJobService.updateWidget(context);
+ WidgetUpdaterJobService.performBackgroundUpdate(context);
}
@Override
@@ -30,13 +33,14 @@ public class PlayerWidget extends AppWidgetProvider {
super.onEnabled(context);
Log.d(TAG, "Widget enabled");
setEnabled(context, true);
- PlayerWidgetJobService.updateWidget(context);
+ WidgetUpdaterJobService.performBackgroundUpdate(context);
}
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
- Log.d(TAG, "onUpdate() called with: " + "context = [" + context + "], appWidgetManager = [" + appWidgetManager + "], appWidgetIds = [" + Arrays.toString(appWidgetIds) + "]");
- PlayerWidgetJobService.updateWidget(context);
+ Log.d(TAG, "onUpdate() called with: " + "context = [" + context + "], appWidgetManager = ["
+ + appWidgetManager + "], appWidgetIds = [" + Arrays.toString(appWidgetIds) + "]");
+ WidgetUpdaterJobService.performBackgroundUpdate(context);
}
@Override
@@ -52,6 +56,9 @@ public class PlayerWidget extends AppWidgetProvider {
for (int appWidgetId : appWidgetIds) {
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
prefs.edit().remove(KEY_WIDGET_COLOR + appWidgetId).apply();
+ prefs.edit().remove(KEY_WIDGET_REWIND + appWidgetId).apply();
+ prefs.edit().remove(KEY_WIDGET_FAST_FORWARD + appWidgetId).apply();
+ prefs.edit().remove(KEY_WIDGET_SKIP + appWidgetId).apply();
}
super.onDeleted(context, appWidgetIds);
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/PlayerWidgetJobService.java b/core/src/main/java/de/danoeh/antennapod/core/service/PlayerWidgetJobService.java
deleted file mode 100644
index 74735a264..000000000
--- a/core/src/main/java/de/danoeh/antennapod/core/service/PlayerWidgetJobService.java
+++ /dev/null
@@ -1,243 +0,0 @@
-package de.danoeh.antennapod.core.service;
-
-import android.app.PendingIntent;
-import android.appwidget.AppWidgetManager;
-import android.content.ComponentName;
-import android.content.Context;
-import android.content.Intent;
-import android.content.ServiceConnection;
-import android.content.SharedPreferences;
-import android.graphics.Bitmap;
-import android.os.Bundle;
-import android.os.IBinder;
-import androidx.annotation.NonNull;
-import androidx.core.app.SafeJobIntentService;
-import android.util.Log;
-import android.view.KeyEvent;
-import android.view.View;
-import android.widget.RemoteViews;
-
-import com.bumptech.glide.Glide;
-import com.bumptech.glide.request.RequestOptions;
-
-import java.util.concurrent.TimeUnit;
-
-import de.danoeh.antennapod.core.R;
-import de.danoeh.antennapod.core.glide.ApGlideSettings;
-import de.danoeh.antennapod.core.feed.util.PlaybackSpeedUtils;
-import de.danoeh.antennapod.core.receiver.MediaButtonReceiver;
-import de.danoeh.antennapod.core.receiver.PlayerWidget;
-import de.danoeh.antennapod.core.service.playback.PlaybackService;
-import de.danoeh.antennapod.core.service.playback.PlayerStatus;
-import de.danoeh.antennapod.core.util.Converter;
-import de.danoeh.antennapod.core.feed.util.ImageResourceUtils;
-import de.danoeh.antennapod.core.util.TimeSpeedConverter;
-import de.danoeh.antennapod.core.util.playback.Playable;
-
-/**
- * Updates the state of the player widget
- */
-public class PlayerWidgetJobService extends SafeJobIntentService {
-
- private static final String TAG = "PlayerWidgetJobService";
-
- private PlaybackService playbackService;
- private final Object waitForService = new Object();
- private final Object waitUsingService = new Object();
-
- private static final int JOB_ID = -17001;
-
- public static void updateWidget(Context context) {
- enqueueWork(context, PlayerWidgetJobService.class, JOB_ID, new Intent(context, PlayerWidgetJobService.class));
- }
-
- @Override
- protected void onHandleWork(@NonNull Intent intent) {
- if (!PlayerWidget.isEnabled(getApplicationContext())) {
- return;
- }
-
- synchronized (waitForService) {
- if (PlaybackService.isRunning && playbackService == null) {
- bindService(new Intent(this, PlaybackService.class), mConnection, 0);
- while (playbackService == null) {
- try {
- waitForService.wait();
- } catch (InterruptedException e) {
- return;
- }
- }
- }
- }
-
- synchronized (waitUsingService) {
- updateViews();
- }
-
- if (playbackService != null) {
- try {
- unbindService(mConnection);
- } catch (IllegalArgumentException e) {
- Log.w(TAG, "IllegalArgumentException when trying to unbind service");
- }
- }
- }
-
- /**
- * Returns number of cells needed for given size of the widget.
- *
- * @param size Widget size in dp.
- * @return Size in number of cells.
- */
- private static int getCellsForSize(int size) {
- int n = 2;
- while (70 * n - 30 < size) {
- ++n;
- }
- return n - 1;
- }
-
- private void updateViews() {
-
- ComponentName playerWidget = new ComponentName(this, PlayerWidget.class);
- AppWidgetManager manager = AppWidgetManager.getInstance(this);
- int[] widgetIds = manager.getAppWidgetIds(playerWidget);
- RemoteViews views = new RemoteViews(getPackageName(), R.layout.player_widget);
- final PendingIntent startMediaPlayer = PendingIntent.getActivity(this, R.id.pending_intent_player_activity,
- PlaybackService.getPlayerActivityIntent(this), PendingIntent.FLAG_UPDATE_CURRENT);
-
- boolean nothingPlaying = false;
- Playable media;
- PlayerStatus status;
- if (playbackService != null) {
- media = playbackService.getPlayable();
- status = playbackService.getStatus();
- } else {
- media = Playable.PlayableUtils.createInstanceFromPreferences(getApplicationContext());
- status = PlayerStatus.STOPPED;
- }
-
- if (media != null) {
- views.setOnClickPendingIntent(R.id.layout_left, startMediaPlayer);
-
- try {
- Bitmap icon;
- int iconSize = getResources().getDimensionPixelSize(android.R.dimen.app_icon_size);
- icon = Glide.with(PlayerWidgetJobService.this)
- .asBitmap()
- .load(ImageResourceUtils.getImageLocation(media))
- .apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY))
- .submit(iconSize, iconSize)
- .get(500, TimeUnit.MILLISECONDS);
- views.setImageViewBitmap(R.id.imgvCover, icon);
- } catch (Throwable tr) {
- Log.e(TAG, "Error loading the media icon for the widget", tr);
- views.setImageViewResource(R.id.imgvCover, R.mipmap.ic_launcher_round);
- }
-
- views.setTextViewText(R.id.txtvTitle, media.getEpisodeTitle());
- views.setViewVisibility(R.id.txtvTitle, View.VISIBLE);
- views.setViewVisibility(R.id.txtNoPlaying, View.GONE);
-
- String progressString;
- if (playbackService != null) {
- progressString = getProgressString(playbackService.getCurrentPosition(),
- playbackService.getDuration(), playbackService.getCurrentPlaybackSpeed());
- } else {
- progressString = getProgressString(media.getPosition(), media.getDuration(), PlaybackSpeedUtils.getCurrentPlaybackSpeed(media));
- }
-
- if (progressString != null) {
- views.setViewVisibility(R.id.txtvProgress, View.VISIBLE);
- views.setTextViewText(R.id.txtvProgress, progressString);
- }
-
- if (status == PlayerStatus.PLAYING) {
- views.setImageViewResource(R.id.butPlay, R.drawable.ic_av_pause_white_48dp);
- views.setContentDescription(R.id.butPlay, getString(R.string.pause_label));
- } else {
- views.setImageViewResource(R.id.butPlay, R.drawable.ic_av_play_white_48dp);
- views.setContentDescription(R.id.butPlay, getString(R.string.play_label));
- }
- views.setOnClickPendingIntent(R.id.butPlay, createMediaButtonIntent());
- } else {
- nothingPlaying = true;
- }
-
- if (nothingPlaying) {
- // start the app if they click anything
- views.setOnClickPendingIntent(R.id.layout_left, startMediaPlayer);
- views.setOnClickPendingIntent(R.id.butPlay, startMediaPlayer);
- views.setViewVisibility(R.id.txtvProgress, View.GONE);
- views.setViewVisibility(R.id.txtvTitle, View.GONE);
- views.setViewVisibility(R.id.txtNoPlaying, View.VISIBLE);
- views.setImageViewResource(R.id.imgvCover, R.mipmap.ic_launcher_round);
- views.setImageViewResource(R.id.butPlay, R.drawable.ic_av_play_white_48dp);
- }
-
- if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) {
- for (int id : widgetIds) {
- Bundle options = manager.getAppWidgetOptions(id);
- int minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH);
- int columns = getCellsForSize(minWidth);
- if (columns < 3) {
- views.setViewVisibility(R.id.layout_center, View.INVISIBLE);
- } else {
- views.setViewVisibility(R.id.layout_center, View.VISIBLE);
- }
-
- SharedPreferences prefs = getSharedPreferences(PlayerWidget.PREFS_NAME, Context.MODE_PRIVATE);
- int backgroundColor = prefs.getInt(PlayerWidget.KEY_WIDGET_COLOR + id, PlayerWidget.DEFAULT_COLOR);
- views.setInt(R.id.widgetLayout, "setBackgroundColor", backgroundColor);
-
- manager.updateAppWidget(id, views);
- }
- } else {
- manager.updateAppWidget(playerWidget, views);
- }
- }
-
- /**
- * Creates an intent which fakes a mediabutton press
- */
- private PendingIntent createMediaButtonIntent() {
- KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE);
- Intent startingIntent = new Intent(getBaseContext(), MediaButtonReceiver.class);
- startingIntent.setAction(MediaButtonReceiver.NOTIFY_BUTTON_RECEIVER);
- startingIntent.putExtra(Intent.EXTRA_KEY_EVENT, event);
-
- return PendingIntent.getBroadcast(this, 0, startingIntent, 0);
- }
-
- private String getProgressString(int position, int duration, float speed) {
- if (position >= 0 && duration > 0) {
- TimeSpeedConverter converter = new TimeSpeedConverter(speed);
- position = converter.convert(position);
- duration = converter.convert(duration);
- return Converter.getDurationStringLong(position) + " / "
- + Converter.getDurationStringLong(duration);
- } else {
- return null;
- }
- }
-
- private final ServiceConnection mConnection = new ServiceConnection() {
- public void onServiceConnected(ComponentName className, IBinder service) {
- Log.d(TAG, "Connection to service established");
- if (service instanceof PlaybackService.LocalBinder) {
- synchronized (waitForService) {
- playbackService = ((PlaybackService.LocalBinder) service).getService();
- waitForService.notifyAll();
- }
- }
- }
-
- @Override
- public void onServiceDisconnected(ComponentName name) {
- synchronized (waitUsingService) {
- playbackService = null;
- }
- Log.d(TAG, "Disconnected from service");
- }
- };
-}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadService.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadService.java
index 1df873e14..44b673a4d 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadService.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadService.java
@@ -25,7 +25,6 @@ import org.greenrobot.eventbus.EventBus;
import java.io.File;
import java.io.IOException;
-import java.net.HttpURLConnection;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@@ -39,7 +38,6 @@ import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
-import de.danoeh.antennapod.core.ClientConfig;
import de.danoeh.antennapod.core.event.DownloadEvent;
import de.danoeh.antennapod.core.event.FeedItemEvent;
import de.danoeh.antennapod.core.feed.Feed;
@@ -97,6 +95,7 @@ public class DownloadService extends Service {
private final CompletionService<Downloader> downloadExecutor;
private final DownloadRequester requester;
private DownloadServiceNotification notificationManager;
+ private final NewEpisodesNotification newEpisodesNotification;
/**
* Currently running downloads.
@@ -119,7 +118,7 @@ public class DownloadService extends Service {
private ScheduledFuture<?> notificationUpdaterFuture;
private ScheduledFuture<?> downloadPostFuture;
private static final int SCHED_EX_POOL_SIZE = 1;
- private ScheduledThreadPoolExecutor schedExecutor;
+ private final ScheduledThreadPoolExecutor schedExecutor;
private static DownloaderFactory downloaderFactory = new DefaultDownloaderFactory();
private final IBinder mBinder = new LocalBinder();
@@ -135,12 +134,16 @@ public class DownloadService extends Service {
downloads = Collections.synchronizedList(new ArrayList<>());
numberOfDownloads = new AtomicInteger(0);
requester = DownloadRequester.getInstance();
+ newEpisodesNotification = new NewEpisodesNotification();
syncExecutor = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "SyncThread");
t.setPriority(Thread.MIN_PRIORITY);
return t;
});
+ // Must be the first runnable in syncExecutor
+ syncExecutor.execute(newEpisodesNotification::loadCountersBeforeRefresh);
+
Log.d(TAG, "parallel downloads: " + UserPreferences.getParallelDownloads());
downloadExecutor = new ExecutorCompletionService<>(
Executors.newFixedThreadPool(UserPreferences.getParallelDownloads(),
@@ -166,10 +169,10 @@ public class DownloadService extends Service {
Notification notification = notificationManager.updateNotifications(
requester.getNumberOfDownloads(), downloads);
startForeground(R.id.notification_downloading, notification);
+ setupNotificationUpdaterIfNecessary();
syncExecutor.execute(() -> onDownloadQueued(intent));
} else if (numberOfDownloads.get() == 0) {
- stopForeground(true);
- stopSelf();
+ shutdown();
} else {
Log.d(TAG, "onStartCommand: Unknown intent");
}
@@ -189,10 +192,6 @@ public class DownloadService extends Service {
registerReceiver(cancelDownloadReceiver, cancelDownloadReceiverFilter);
downloadCompletionThread.start();
-
- Notification notification = notificationManager.updateNotifications(
- requester.getNumberOfDownloads(), downloads);
- startForeground(R.id.notification_downloading, notification);
}
@Override
@@ -206,8 +205,7 @@ public class DownloadService extends Service {
isRunning = false;
boolean showAutoDownloadReport = UserPreferences.showAutoDownloadReport();
- if (ClientConfig.downloadServiceCallbacks.shouldCreateReport()
- && (UserPreferences.showDownloadReport() || showAutoDownloadReport)) {
+ if (UserPreferences.showDownloadReport() || showAutoDownloadReport) {
notificationManager.updateReport(reportQueue, showAutoDownloadReport);
reportQueue.clear();
}
@@ -228,10 +226,6 @@ public class DownloadService extends Service {
}
unregisterReceiver(cancelDownloadReceiver);
- stopForeground(true);
- NotificationManager nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
- nm.cancel(R.id.notification_downloading);
-
// if this was the initial gpodder sync, i.e. we just synced the feeds successfully,
// it is now time to sync the episode actions
SyncService.sync(this);
@@ -256,13 +250,13 @@ public class DownloadService extends Service {
handleSuccessfulDownload(downloader);
removeDownload(downloader);
numberOfDownloads.decrementAndGet();
- queryDownloadsAsync();
+ stopServiceIfEverythingDoneAsync();
});
} else {
handleFailedDownload(downloader);
removeDownload(downloader);
numberOfDownloads.decrementAndGet();
- queryDownloadsAsync();
+ stopServiceIfEverythingDoneAsync();
}
} catch (InterruptedException e) {
Log.e(TAG, "DownloadCompletionThread was interrupted");
@@ -292,6 +286,10 @@ public class DownloadService extends Service {
if (log.size() > 0 && !log.get(0).isSuccessful()) {
saveDownloadStatus(task.getDownloadStatus());
}
+ if (request.getFeedfileId() != 0 && !request.isInitiatedByUser()) {
+ // Was stored in the database before and not initiated manually
+ newEpisodesNotification.showIfNeeded(DownloadService.this, task.getSavedFeed());
+ }
} else {
DBWriter.setFeedLastUpdateFailed(request.getFeedfileId(), true);
saveDownloadStatus(task.getDownloadStatus());
@@ -327,18 +325,11 @@ public class DownloadService extends Service {
if (item == null) {
return;
}
- boolean httpNotFound = status.getReason() == DownloadError.ERROR_HTTP_DATA_ERROR
- && String.valueOf(HttpURLConnection.HTTP_NOT_FOUND).equals(status.getReasonDetailed());
- boolean forbidden = status.getReason() == DownloadError.ERROR_FORBIDDEN
- && String.valueOf(HttpURLConnection.HTTP_FORBIDDEN).equals(status.getReasonDetailed());
- boolean notEnoughSpace = status.getReason() == DownloadError.ERROR_NOT_ENOUGH_SPACE;
- boolean wrongFileType = status.getReason() == DownloadError.ERROR_FILE_TYPE;
- boolean httpGone = status.getReason() == DownloadError.ERROR_HTTP_DATA_ERROR
- && String.valueOf(HttpURLConnection.HTTP_GONE).equals(status.getReasonDetailed());
- boolean httpBadReq = status.getReason() == DownloadError.ERROR_HTTP_DATA_ERROR
- && String.valueOf(HttpURLConnection.HTTP_BAD_REQUEST).equals(status.getReasonDetailed());
-
- if (httpNotFound || forbidden || notEnoughSpace || wrongFileType || httpGone || httpBadReq ) {
+ boolean unknownHost = status.getReason() == DownloadError.ERROR_UNKNOWN_HOST;
+ boolean unsupportedType = status.getReason() == DownloadError.ERROR_UNSUPPORTED_TYPE;
+ boolean wrongSize = status.getReason() == DownloadError.ERROR_IO_WRONG_SIZE;
+
+ if (! (unknownHost || unsupportedType || wrongSize)) {
try {
DBWriter.saveFeedItemAutoDownloadFailed(item).get();
} catch (ExecutionException | InterruptedException e) {
@@ -414,7 +405,7 @@ public class DownloadService extends Service {
}
postDownloaders();
}
- queryDownloads();
+ stopServiceIfEverythingDone();
}
};
@@ -430,7 +421,7 @@ public class DownloadService extends Service {
+ ", cleanupMedia=" + cleanupMedia);
if (cleanupMedia) {
- ClientConfig.dbTasksCallbacks.getEpisodeCacheCleanupAlgorithm()
+ UserPreferences.getEpisodeCleanupAlgorithm()
.makeRoomForEpisodes(getApplicationContext(), requests.size());
}
@@ -485,7 +476,7 @@ public class DownloadService extends Service {
postDownloaders();
});
}
- handler.post(this::queryDownloads);
+ handler.post(this::stopServiceIfEverythingDone);
}
private static boolean isEnqueued(@NonNull DownloadRequest request,
@@ -542,30 +533,19 @@ public class DownloadService extends Service {
* Calls query downloads on the services main thread. This method should be used instead of queryDownloads if it is
* used from a thread other than the main thread.
*/
- private void queryDownloadsAsync() {
- handler.post(DownloadService.this::queryDownloads);
+ private void stopServiceIfEverythingDoneAsync() {
+ handler.post(DownloadService.this::stopServiceIfEverythingDone);
}
/**
* Check if there's something else to download, otherwise stop.
*/
- private void queryDownloads() {
+ private void stopServiceIfEverythingDone() {
Log.d(TAG, numberOfDownloads.get() + " downloads left");
if (numberOfDownloads.get() <= 0 && DownloadRequester.getInstance().hasNoDownloads()) {
- Log.d(TAG, "Number of downloads is " + numberOfDownloads.get() + ", attempting shutdown");
- stopForeground(true);
- stopSelf();
- if (notificationUpdater != null) {
- notificationUpdater.run();
- } else {
- Log.d(TAG, "Skipping notification update");
- }
- } else {
- setupNotificationUpdater();
- Notification notification = notificationManager.updateNotifications(
- requester.getNumberOfDownloads(), downloads);
- startForeground(R.id.notification_downloading, notification);
+ Log.d(TAG, "Attempting shutdown");
+ shutdown();
}
}
@@ -618,7 +598,7 @@ public class DownloadService extends Service {
/**
* Schedules the notification updater task if it hasn't been scheduled yet.
*/
- private void setupNotificationUpdater() {
+ private void setupNotificationUpdaterIfNecessary() {
if (notificationUpdater == null) {
Log.d(TAG, "Setting up notification updater");
notificationUpdater = new NotificationUpdater();
@@ -655,4 +635,14 @@ public class DownloadService extends Service {
new PostDownloaderTask(downloads), 1, 1, TimeUnit.SECONDS);
}
}
+
+ private void shutdown() {
+ // If the service was run for a very short time, the system may delay closing
+ // the notification. Set the notification text now so that a misleading message
+ // is not left on the notification.
+ notificationUpdater.run();
+ cancelNotificationUpdater();
+ stopForeground(true);
+ stopSelf();
+ }
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadServiceNotification.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadServiceNotification.java
index 0715d50dd..7c8fe9452 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadServiceNotification.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/DownloadServiceNotification.java
@@ -28,7 +28,10 @@ public class DownloadServiceNotification {
private void setupNotificationBuilders() {
notificationCompatBuilder = new NotificationCompat.Builder(context, NotificationUtils.CHANNEL_ID_DOWNLOADING)
- .setOngoing(true)
+ .setOngoing(false)
+ .setWhen(0)
+ .setOnlyAlertOnce(true)
+ .setShowWhen(false)
.setContentIntent(ClientConfig.downloadServiceCallbacks.getNotificationContentIntent(context))
.setSmallIcon(R.drawable.ic_notification_sync);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
@@ -50,7 +53,7 @@ public class DownloadServiceNotification {
String contentTitle = context.getString(R.string.download_notification_title);
String downloadsLeft = (numDownloads > 0)
? context.getResources().getQuantityString(R.plurals.downloads_left, numDownloads, numDownloads)
- : context.getString(R.string.downloads_processing);
+ : context.getString(R.string.service_shutting_down);
String bigText = compileNotificationString(downloads);
notificationCompatBuilder.setContentTitle(contentTitle);
@@ -143,12 +146,12 @@ public class DownloadServiceNotification {
// We are generating an auto-download report
channelId = NotificationUtils.CHANNEL_ID_AUTO_DOWNLOAD;
titleId = R.string.auto_download_report_title;
- iconId = R.drawable.ic_notification_auto_download_complete;
+ iconId = R.drawable.ic_notification_new;
intent = ClientConfig.downloadServiceCallbacks.getAutoDownloadReportNotificationContentIntent(context);
id = R.id.notification_auto_download_report;
content = createAutoDownloadNotificationContent(reportQueue);
} else {
- channelId = NotificationUtils.CHANNEL_ID_ERROR;
+ channelId = NotificationUtils.CHANNEL_ID_DOWNLOAD_ERROR;
titleId = R.string.download_report_title;
iconId = R.drawable.ic_notification_sync_error;
intent = ClientConfig.downloadServiceCallbacks.getReportNotificationContentIntent(context);
diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/HttpDownloader.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/HttpDownloader.java
index 65b7ed7d1..b553a9d1f 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/service/download/HttpDownloader.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/HttpDownloader.java
@@ -4,7 +4,6 @@ import androidx.annotation.NonNull;
import android.text.TextUtils;
import android.util.Log;
-import de.danoeh.antennapod.core.service.BasicAuthorizationInterceptor;
import okhttp3.CacheControl;
import org.apache.commons.io.IOUtils;
@@ -21,14 +20,12 @@ import java.net.UnknownHostException;
import java.util.Collections;
import java.util.Date;
-import de.danoeh.antennapod.core.ClientConfig;
import de.danoeh.antennapod.core.R;
import de.danoeh.antennapod.core.feed.FeedMedia;
import de.danoeh.antennapod.core.util.DateUtils;
import de.danoeh.antennapod.core.util.DownloadError;
import de.danoeh.antennapod.core.util.StorageUtils;
import de.danoeh.antennapod.core.util.URIUtil;
-import okhttp3.Interceptor;
import okhttp3.OkHttpClient;
import okhttp3.Protocol;
import okhttp3.Request;
@@ -226,7 +223,7 @@ public class HttpDownloader extends Downloader {
// written file. This check cannot be made if compression was used
if (!isGzip && request.getSize() != DownloadStatus.SIZE_UNKNOWN &&
request.getSoFar() != request.getSize()) {
- onFail(DownloadError.ERROR_IO_ERROR, "Download completed but size: " +
+ onFail(DownloadError.ERROR_IO_WRONG_SIZE, "Download completed but size: " +
request.getSoFar() + " does not equal expected size " + request.getSize());
return;
} else if (request.getSize() > 0 && request.getSoFar() == 0) {
diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/NewEpisodesNotification.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/NewEpisodesNotification.java
new file mode 100644
index 000000000..799a68037
--- /dev/null
+++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/NewEpisodesNotification.java
@@ -0,0 +1,132 @@
+package de.danoeh.antennapod.core.service.download;
+
+import android.app.Notification;
+import android.app.PendingIntent;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.Resources;
+
+import android.graphics.Bitmap;
+import android.util.Log;
+import androidx.core.app.NotificationCompat;
+import androidx.core.app.NotificationManagerCompat;
+
+import com.bumptech.glide.Glide;
+import com.bumptech.glide.request.RequestOptions;
+import de.danoeh.antennapod.core.R;
+import de.danoeh.antennapod.core.feed.Feed;
+import de.danoeh.antennapod.core.feed.FeedPreferences;
+import de.danoeh.antennapod.core.glide.ApGlideSettings;
+import de.danoeh.antennapod.core.preferences.UserPreferences;
+import de.danoeh.antennapod.core.storage.PodDBAdapter;
+import de.danoeh.antennapod.core.util.LongIntMap;
+import de.danoeh.antennapod.core.util.gui.NotificationUtils;
+
+public class NewEpisodesNotification {
+ private static final String TAG = "NewEpisodesNotification";
+ private static final String GROUP_KEY = "de.danoeh.antennapod.EPISODES";
+
+ private LongIntMap countersBefore;
+
+ public NewEpisodesNotification() {
+ }
+
+ public void loadCountersBeforeRefresh() {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ countersBefore = adapter.getFeedCounters(UserPreferences.FEED_COUNTER_SHOW_NEW);
+ adapter.close();
+ }
+
+ public void showIfNeeded(Context context, Feed feed) {
+ FeedPreferences prefs = feed.getPreferences();
+ if (!prefs.getKeepUpdated() || !prefs.getShowEpisodeNotification()) {
+ return;
+ }
+
+ int newEpisodesBefore = countersBefore.get(feed.getId());
+ int newEpisodesAfter = getNewEpisodeCount(feed.getId());
+
+ Log.d(TAG, "New episodes before: " + newEpisodesBefore + ", after: " + newEpisodesAfter);
+ if (newEpisodesAfter > newEpisodesBefore) {
+ NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
+ showNotification(newEpisodesAfter, feed, context, notificationManager);
+ }
+ }
+
+ private static void showNotification(int newEpisodes, Feed feed, Context context,
+ NotificationManagerCompat notificationManager) {
+ Resources res = context.getResources();
+ String text = res.getQuantityString(
+ R.plurals.new_episode_notification_message, newEpisodes, newEpisodes, feed.getTitle()
+ );
+ String title = res.getQuantityString(R.plurals.new_episode_notification_title, newEpisodes);
+
+ Intent intent = new Intent();
+ intent.setAction("NewEpisodes" + feed.getId());
+ intent.setComponent(new ComponentName(context, "de.danoeh.antennapod.activity.MainActivity"));
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ intent.putExtra("fragment_feed_id", feed.getId());
+ PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0);
+
+ Notification notification = new NotificationCompat.Builder(
+ context, NotificationUtils.CHANNEL_ID_EPISODE_NOTIFICATIONS)
+ .setSmallIcon(R.drawable.ic_notification_new)
+ .setContentTitle(title)
+ .setLargeIcon(loadIcon(context, feed))
+ .setContentText(text)
+ .setContentIntent(pendingIntent)
+ .setGroup(GROUP_KEY)
+ .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
+ .setAutoCancel(true)
+ .build();
+
+ notificationManager.notify(NotificationUtils.CHANNEL_ID_EPISODE_NOTIFICATIONS, feed.hashCode(), notification);
+ showGroupSummaryNotification(context, notificationManager);
+ }
+
+ private static void showGroupSummaryNotification(Context context, NotificationManagerCompat notificationManager) {
+ Intent intent = new Intent();
+ intent.setAction("NewEpisodes");
+ intent.setComponent(new ComponentName(context, "de.danoeh.antennapod.activity.MainActivity"));
+ intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ intent.putExtra("fragment_tag", "EpisodesFragment");
+ PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0);
+
+ Notification notificationGroupSummary = new NotificationCompat.Builder(
+ context, NotificationUtils.CHANNEL_ID_EPISODE_NOTIFICATIONS)
+ .setSmallIcon(R.drawable.ic_notification_new)
+ .setContentTitle(context.getString(R.string.new_episode_notification_group_text))
+ .setContentIntent(pendingIntent)
+ .setGroup(GROUP_KEY)
+ .setGroupSummary(true)
+ .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
+ .setAutoCancel(true)
+ .build();
+ notificationManager.notify(NotificationUtils.CHANNEL_ID_EPISODE_NOTIFICATIONS, 0, notificationGroupSummary);
+ }
+
+ private static Bitmap loadIcon(Context context, Feed feed) {
+ int iconSize = (int) (128 * context.getResources().getDisplayMetrics().density);
+ try {
+ return Glide.with(context)
+ .asBitmap()
+ .load(feed.getImageUrl())
+ .apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY))
+ .apply(new RequestOptions().centerCrop())
+ .submit(iconSize, iconSize)
+ .get();
+ } catch (Throwable tr) {
+ return null;
+ }
+ }
+
+ private static int getNewEpisodeCount(long feedId) {
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ int episodeCount = adapter.getFeedCounters(UserPreferences.FEED_COUNTER_SHOW_NEW, feedId).get(feedId);
+ adapter.close();
+ return episodeCount;
+ }
+}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FailedDownloadHandler.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FailedDownloadHandler.java
index 041d26bd4..386e5e6f7 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FailedDownloadHandler.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FailedDownloadHandler.java
@@ -3,7 +3,6 @@ package de.danoeh.antennapod.core.service.download.handler;
import android.util.Log;
import de.danoeh.antennapod.core.feed.Feed;
import de.danoeh.antennapod.core.service.download.DownloadRequest;
-import de.danoeh.antennapod.core.service.download.DownloadStatus;
import de.danoeh.antennapod.core.storage.DBWriter;
/**
diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FeedSyncTask.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FeedSyncTask.java
index 483a2aa56..e2d9ee614 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FeedSyncTask.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FeedSyncTask.java
@@ -2,6 +2,7 @@ package de.danoeh.antennapod.core.service.download.handler;
import android.content.Context;
import android.util.Log;
+
import de.danoeh.antennapod.core.feed.Feed;
import de.danoeh.antennapod.core.service.download.DownloadRequest;
import de.danoeh.antennapod.core.service.download.DownloadStatus;
@@ -15,6 +16,7 @@ public class FeedSyncTask {
private final DownloadRequest request;
private final Context context;
private DownloadStatus downloadStatus;
+ private Feed savedFeed;
public FeedSyncTask(Context context, DownloadRequest request) {
this.request = request;
@@ -30,7 +32,7 @@ public class FeedSyncTask {
return false;
}
- Feed savedFeed = DBTasks.updateFeed(context, result.feed, false);
+ savedFeed = DBTasks.updateFeed(context, result.feed, false);
// If loadAllPages=true, check if another page is available and queue it for download
final boolean loadAllPages = request.getArguments().getBoolean(DownloadRequester.REQUEST_ARG_LOAD_ALL_PAGES);
final Feed feed = result.feed;
@@ -48,4 +50,8 @@ public class FeedSyncTask {
public DownloadStatus getDownloadStatus() {
return downloadStatus;
}
+
+ public Feed getSavedFeed() {
+ return savedFeed;
+ }
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java
index 501214399..7712ca36b 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/MediaDownloadedHandler.java
@@ -56,7 +56,7 @@ public class MediaDownloadedHandler implements Runnable {
// check if file has chapters
if (media.getItem() != null && !media.getItem().hasChapters()) {
- media.setChapters(ChapterUtils.loadChaptersFromFileUrl(media));
+ media.setChapters(ChapterUtils.loadChaptersFromMediaFile(media, context));
}
// Get duration
diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/ExoPlayerWrapper.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/ExoPlayerWrapper.java
index 71bbf2efd..9a8248984 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/ExoPlayerWrapper.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/ExoPlayerWrapper.java
@@ -2,6 +2,7 @@ package de.danoeh.antennapod.core.service.playback;
import android.content.Context;
import android.net.Uri;
+import android.text.TextUtils;
import android.util.Log;
import android.view.SurfaceHolder;
import com.google.android.exoplayer2.C;
@@ -28,8 +29,10 @@ import com.google.android.exoplayer2.upstream.DataSource;
import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory;
import com.google.android.exoplayer2.upstream.DefaultHttpDataSource;
import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory;
+
import de.danoeh.antennapod.core.ClientConfig;
import de.danoeh.antennapod.core.preferences.UserPreferences;
+import de.danoeh.antennapod.core.service.download.HttpDownloader;
import de.danoeh.antennapod.core.util.playback.IPlayer;
import io.reactivex.Observable;
import io.reactivex.android.schedulers.AndroidSchedulers;
@@ -184,14 +187,22 @@ public class ExoPlayerWrapper implements IPlayer {
exoPlayer.setAudioAttributes(b.build());
}
- @Override
- public void setDataSource(String s) throws IllegalArgumentException, IllegalStateException {
+ public void setDataSource(String s, String user, String password)
+ throws IllegalArgumentException, IllegalStateException {
Log.d(TAG, "setDataSource: " + s);
DefaultHttpDataSourceFactory httpDataSourceFactory = new DefaultHttpDataSourceFactory(
ClientConfig.USER_AGENT, null,
DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS,
DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS,
true);
+
+ if (!TextUtils.isEmpty(user) && !TextUtils.isEmpty(password)) {
+ httpDataSourceFactory.getDefaultRequestProperties().set("Authorization",
+ HttpDownloader.encodeCredentials(
+ user,
+ password,
+ "ISO-8859-1"));
+ }
DataSource.Factory dataSourceFactory = new DefaultDataSourceFactory(context, null, httpDataSourceFactory);
DefaultExtractorsFactory extractorsFactory = new DefaultExtractorsFactory();
extractorsFactory.setConstantBitrateSeekingEnabled(true);
@@ -200,6 +211,11 @@ public class ExoPlayerWrapper implements IPlayer {
}
@Override
+ public void setDataSource(String s) throws IllegalArgumentException, IllegalStateException {
+ setDataSource(s, null, null);
+ }
+
+ @Override
public void setDisplay(SurfaceHolder sh) {
exoPlayer.setVideoSurfaceHolder(sh);
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/LocalPSMP.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/LocalPSMP.java
index ae5d62872..28d8a0e29 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/LocalPSMP.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/LocalPSMP.java
@@ -1,7 +1,8 @@
package de.danoeh.antennapod.core.service.playback;
+import android.app.UiModeManager;
import android.content.Context;
-import android.media.AudioAttributes;
+import android.content.res.Configuration;
import android.media.AudioManager;
import android.os.PowerManager;
import androidx.annotation.NonNull;
@@ -37,6 +38,7 @@ import de.danoeh.antennapod.core.util.RewindAfterPauseUtils;
import de.danoeh.antennapod.core.util.playback.AudioPlayer;
import de.danoeh.antennapod.core.util.playback.IPlayer;
import de.danoeh.antennapod.core.util.playback.Playable;
+import de.danoeh.antennapod.core.util.playback.PlayableException;
import de.danoeh.antennapod.core.util.playback.PlaybackServiceStarter;
import de.danoeh.antennapod.core.util.playback.VideoPlayer;
@@ -261,13 +263,25 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer {
callback.onMediaChanged(false);
setPlaybackParams(PlaybackSpeedUtils.getCurrentPlaybackSpeed(media), UserPreferences.isSkipSilence());
if (stream) {
- mediaPlayer.setDataSource(media.getStreamUrl());
+ if (playable instanceof FeedMedia) {
+ FeedMedia feedMedia = (FeedMedia) playable;
+ FeedPreferences preferences = feedMedia.getItem().getFeed().getPreferences();
+ mediaPlayer.setDataSource(
+ media.getStreamUrl(),
+ preferences.getUsername(),
+ preferences.getPassword());
+ } else {
+ mediaPlayer.setDataSource(media.getStreamUrl());
+ }
} else if (media.getLocalMediaUrl() != null && new File(media.getLocalMediaUrl()).canRead()) {
mediaPlayer.setDataSource(media.getLocalMediaUrl());
} else {
throw new IOException("Unable to read local file " + media.getLocalMediaUrl());
}
- setPlayerStatus(PlayerStatus.INITIALIZED, media);
+ UiModeManager uiModeManager = (UiModeManager) context.getSystemService(Context.UI_MODE_SERVICE);
+ if (uiModeManager.getCurrentModeType() != Configuration.UI_MODE_TYPE_CAR) {
+ setPlayerStatus(PlayerStatus.INITIALIZED, media);
+ }
if (prepareImmediately) {
setPlayerStatus(PlayerStatus.PREPARING, media);
@@ -275,7 +289,7 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer {
onPrepared(startWhenPrepared);
}
- } catch (Playable.PlayableException | IOException | IllegalStateException e) {
+ } catch (PlayableException | IOException | IllegalStateException e) {
e.printStackTrace();
setPlayerStatus(PlayerStatus.ERROR, null);
}
@@ -925,9 +939,6 @@ public class LocalPSMP extends PlaybackServiceMediaPlayer {
boolean isPlaying = playerStatus == PlayerStatus.PLAYING;
- if (playerStatus != PlayerStatus.INDETERMINATE) {
- setPlayerStatus(PlayerStatus.INDETERMINATE, media);
- }
// we're relying on the position stored in the Playable object for post-playback processing
if (media != null) {
int position = getPosition();
diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java
index 69b35bff5..650827e97 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java
@@ -7,6 +7,7 @@ import android.app.UiModeManager;
import android.bluetooth.BluetoothA2dp;
import android.content.BroadcastReceiver;
import android.content.ComponentName;
+import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
@@ -49,9 +50,7 @@ import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
-import de.danoeh.antennapod.core.ClientConfig;
import de.danoeh.antennapod.core.R;
-import de.danoeh.antennapod.core.event.FeedItemEvent;
import de.danoeh.antennapod.core.event.MessageEvent;
import de.danoeh.antennapod.core.event.PlaybackPositionEvent;
import de.danoeh.antennapod.core.event.ServiceEvent;
@@ -69,7 +68,6 @@ import de.danoeh.antennapod.core.preferences.PlaybackPreferences;
import de.danoeh.antennapod.core.preferences.SleepTimerPreferences;
import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.receiver.MediaButtonReceiver;
-import de.danoeh.antennapod.core.service.PlayerWidgetJobService;
import de.danoeh.antennapod.core.storage.DBReader;
import de.danoeh.antennapod.core.storage.DBTasks;
import de.danoeh.antennapod.core.storage.DBWriter;
@@ -80,7 +78,12 @@ import de.danoeh.antennapod.core.util.NetworkUtils;
import de.danoeh.antennapod.core.util.gui.NotificationUtils;
import de.danoeh.antennapod.core.util.playback.ExternalMedia;
import de.danoeh.antennapod.core.util.playback.Playable;
+import de.danoeh.antennapod.core.util.playback.PlayableException;
+import de.danoeh.antennapod.core.util.playback.PlayableUtils;
import de.danoeh.antennapod.core.util.playback.PlaybackServiceStarter;
+import de.danoeh.antennapod.core.widget.WidgetUpdater;
+import de.danoeh.antennapod.ui.appstartintent.MainActivityStarter;
+import de.danoeh.antennapod.ui.appstartintent.VideoPlayerActivityStarter;
import io.reactivex.Observable;
import io.reactivex.Single;
import io.reactivex.android.schedulers.AndroidSchedulers;
@@ -149,7 +152,7 @@ public class PlaybackService extends MediaBrowserServiceCompat {
public static final String ACTION_PAUSE_PLAY_CURRENT_EPISODE = "action.de.danoeh.antennapod.core.service.pausePlayCurrentEpisode";
/**
- * Custom action used by Android Wear
+ * Custom action used by Android Wear, Android Auto
*/
private static final String CUSTOM_ACTION_FAST_FORWARD = "action.de.danoeh.antennapod.core.service.fastForward";
private static final String CUSTOM_ACTION_REWIND = "action.de.danoeh.antennapod.core.service.rewind";
@@ -245,24 +248,31 @@ public class PlaybackService extends MediaBrowserServiceCompat {
* running, the type of the last played media will be looked up.
*/
public static Intent getPlayerActivityIntent(Context context) {
+ boolean showVideoPlayer;
+
if (isRunning) {
- return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, currentMediaType, isCasting);
+ showVideoPlayer = currentMediaType == MediaType.VIDEO && !isCasting;
} else {
- if (PlaybackPreferences.getCurrentEpisodeIsVideo()) {
- return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, MediaType.VIDEO, isCasting);
- } else {
- return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, MediaType.AUDIO, isCasting);
- }
+ showVideoPlayer = PlaybackPreferences.getCurrentEpisodeIsVideo();
+ }
+
+ if (showVideoPlayer) {
+ return new VideoPlayerActivityStarter(context).getIntent();
+ } else {
+ return new MainActivityStarter(context).withOpenPlayer().getIntent();
}
}
/**
- * Same as getPlayerActivityIntent(context), but here the type of activity
+ * Same as {@link #getPlayerActivityIntent(Context)}, but here the type of activity
* depends on the FeedMedia that is provided as an argument.
*/
public static Intent getPlayerActivityIntent(Context context, Playable media) {
- MediaType mt = media.getMediaType();
- return ClientConfig.playbackServiceCallbacks.getPlayerActivityIntent(context, mt, isCasting);
+ if (media.getMediaType() == MediaType.VIDEO && !isCasting) {
+ return new VideoPlayerActivityStarter(context).getIntent();
+ } else {
+ return new MainActivityStarter(context).withOpenPlayer().getIntent();
+ }
}
@Override
@@ -370,9 +380,26 @@ public class PlaybackService extends MediaBrowserServiceCompat {
}
private MediaBrowserCompat.MediaItem createBrowsableMediaItemForRoot() {
+ Uri uri = new Uri.Builder()
+ .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
+ .authority(getResources().getResourcePackageName(R.drawable.ic_playlist_black))
+ .appendPath(getResources().getResourceTypeName(R.drawable.ic_playlist_black))
+ .appendPath(getResources().getResourceEntryName(R.drawable.ic_playlist_black))
+ .build();
+
+ String subtitle = "";
+ try {
+ int count = taskManager.getQueue().size();
+ subtitle = getResources().getQuantityString(R.plurals.num_episodes, count, count);
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+
MediaDescriptionCompat description = new MediaDescriptionCompat.Builder()
+ .setIconUri(uri)
.setMediaId(getResources().getString(R.string.queue_label))
.setTitle(getResources().getString(R.string.queue_label))
+ .setSubtitle(subtitle)
.build();
return new MediaBrowserCompat.MediaItem(description,
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE);
@@ -384,8 +411,8 @@ public class PlaybackService extends MediaBrowserServiceCompat {
.setTitle(feed.getTitle())
.setDescription(feed.getDescription())
.setSubtitle(feed.getCustomTitle());
- if (feed.getImageLocation() != null) {
- builder.setIconUri(Uri.parse(feed.getImageLocation()));
+ if (feed.getImageUrl() != null) {
+ builder.setIconUri(Uri.parse(feed.getImageUrl()));
}
if (feed.getLink() != null) {
builder.setMediaUri(Uri.parse(feed.getLink()));
@@ -651,18 +678,14 @@ public class PlaybackService extends MediaBrowserServiceCompat {
}
return false;
case KeyEvent.KEYCODE_MEDIA_NEXT:
- if (getStatus() != PlayerStatus.PLAYING && getStatus() != PlayerStatus.PAUSED) {
- return false;
- } else if (notificationButton || UserPreferences.shouldHardwareButtonSkip()) {
- // assume the skip command comes from a notification or the lockscreen
- // a >| skip button should actually skip
+ if (!notificationButton) {
+ // Handle remapped button as notification button which is not remapped again.
+ return handleKeycode(UserPreferences.getHardwareForwardButton(), true);
+ } else if (getStatus() == PlayerStatus.PLAYING || getStatus() == PlayerStatus.PAUSED) {
mediaPlayer.skip();
- } else {
- // assume skip command comes from a (bluetooth) media button
- // user actually wants to fast-forward
- seekDelta(UserPreferences.getFastForwardSecs() * 1000);
+ return true;
}
- return true;
+ return false;
case KeyEvent.KEYCODE_MEDIA_FAST_FORWARD:
if (getStatus() == PlayerStatus.PLAYING || getStatus() == PlayerStatus.PAUSED) {
mediaPlayer.seekDelta(UserPreferences.getFastForwardSecs() * 1000);
@@ -670,23 +693,20 @@ public class PlaybackService extends MediaBrowserServiceCompat {
}
return false;
case KeyEvent.KEYCODE_MEDIA_PREVIOUS:
- if (getStatus() != PlayerStatus.PLAYING && getStatus() != PlayerStatus.PAUSED) {
- return false;
- } else if (UserPreferences.shouldHardwarePreviousButtonRestart()) {
- // user wants to restart current episode
+ if (!notificationButton) {
+ // Handle remapped button as notification button which is not remapped again.
+ return handleKeycode(UserPreferences.getHardwarePreviousButton(), true);
+ } else if (getStatus() == PlayerStatus.PLAYING || getStatus() == PlayerStatus.PAUSED) {
mediaPlayer.seekTo(0);
- } else {
- // user wants to rewind current episode
- mediaPlayer.seekDelta(-UserPreferences.getRewindSecs() * 1000);
+ return true;
}
- return true;
+ return false;
case KeyEvent.KEYCODE_MEDIA_REWIND:
if (getStatus() == PlayerStatus.PLAYING || getStatus() == PlayerStatus.PAUSED) {
mediaPlayer.seekDelta(-UserPreferences.getRewindSecs() * 1000);
- } else {
- return false;
+ return true;
}
- return true;
+ return false;
case KeyEvent.KEYCODE_MEDIA_STOP:
if (status == PlayerStatus.PLAYING) {
mediaPlayer.pause(true, true);
@@ -705,7 +725,7 @@ public class PlaybackService extends MediaBrowserServiceCompat {
}
private void startPlayingFromPreferences() {
- Observable.fromCallable(() -> Playable.PlayableUtils.createInstanceFromPreferences(getApplicationContext()))
+ Observable.fromCallable(() -> PlayableUtils.createInstanceFromPreferences(getApplicationContext()))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
@@ -784,8 +804,9 @@ public class PlaybackService extends MediaBrowserServiceCompat {
}
@Override
- public void onWidgetUpdaterTick() {
- PlayerWidgetJobService.updateWidget(getBaseContext());
+ public WidgetUpdater.WidgetState requestWidgetState() {
+ return new WidgetUpdater.WidgetState(getPlayable(), getStatus(),
+ getCurrentPosition(), getDuration(), getCurrentPlaybackSpeed(), isCasting());
}
@Override
@@ -856,9 +877,9 @@ public class PlaybackService extends MediaBrowserServiceCompat {
}
IntentUtils.sendLocalBroadcast(getApplicationContext(), ACTION_PLAYER_STATUS_CHANGED);
- PlayerWidgetJobService.updateWidget(getBaseContext());
bluetoothNotifyChange(newInfo, AVRCP_ACTION_PLAYER_STATUS_CHANGED);
bluetoothNotifyChange(newInfo, AVRCP_ACTION_META_CHANGED);
+ taskManager.requestWidgetUpdate();
}
@Override
@@ -973,15 +994,11 @@ public class PlaybackService extends MediaBrowserServiceCompat {
Log.d(TAG, "getNextInQueue(), but playable not an instance of FeedMedia, so not proceeding");
return null;
}
- if (!ClientConfig.playbackServiceCallbacks.useQueue()) {
- Log.d(TAG, "getNextInQueue(), but queue not in use by this app");
- return null;
- }
Log.d(TAG, "getNextInQueue()");
FeedMedia media = (FeedMedia) currentMedia;
try {
media.loadMetadata();
- } catch (Playable.PlayableException e) {
+ } catch (PlayableException e) {
Log.e(TAG, "Unable to load metadata to get next in queue", e);
return null;
}
@@ -1024,6 +1041,7 @@ public class PlaybackService extends MediaBrowserServiceCompat {
*/
private void onPlaybackEnded(MediaType mediaType, boolean stopPlaying) {
Log.d(TAG, "Playback ended");
+ PlaybackPreferences.clearCurrentlyPlayingTemporaryPlaybackSpeed();
if (stopPlaying) {
taskManager.cancelPositionSaver();
cancelPositionObserver();
@@ -1062,7 +1080,6 @@ public class PlaybackService extends MediaBrowserServiceCompat {
*/
private void onPostPlayback(final Playable playable, boolean ended, boolean skipped,
boolean playingNext) {
- PlaybackPreferences.clearCurrentlyPlayingTemporaryPlaybackSpeed();
if (playable == null) {
Log.e(TAG, "Cannot do post-playback processing: media was null");
return;
@@ -1208,6 +1225,7 @@ public class PlaybackService extends MediaBrowserServiceCompat {
sessionState.setState(state, getCurrentPosition(), getCurrentPlaybackSpeed());
long capabilities = PlaybackStateCompat.ACTION_PLAY_PAUSE
| PlaybackStateCompat.ACTION_REWIND
+ | PlaybackStateCompat.ACTION_PAUSE
| PlaybackStateCompat.ACTION_FAST_FORWARD
| PlaybackStateCompat.ACTION_SKIP_TO_NEXT
| PlaybackStateCompat.ACTION_SEEK_TO;
@@ -1226,7 +1244,8 @@ public class PlaybackService extends MediaBrowserServiceCompat {
capabilities = capabilities | PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS;
}
- UiModeManager uiModeManager = (UiModeManager) getApplicationContext().getSystemService(Context.UI_MODE_SERVICE);
+ UiModeManager uiModeManager = (UiModeManager) getApplicationContext()
+ .getSystemService(Context.UI_MODE_SERVICE);
if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_CAR) {
sessionState.addCustomAction(
new PlaybackStateCompat.CustomAction.Builder(
@@ -1238,17 +1257,24 @@ public class PlaybackService extends MediaBrowserServiceCompat {
CUSTOM_ACTION_FAST_FORWARD,
getString(R.string.fast_forward_label), R.drawable.ic_notification_fast_forward)
.build());
+ } else {
+ // This would give the PIP of videos a play button
+ capabilities = capabilities | PlaybackStateCompat.ACTION_PLAY;
+ if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_WATCH) {
+ flavorHelper.sessionStateAddActionForWear(sessionState,
+ CUSTOM_ACTION_REWIND,
+ getString(R.string.rewind_label),
+ android.R.drawable.ic_media_rew);
+ flavorHelper.sessionStateAddActionForWear(sessionState,
+ CUSTOM_ACTION_FAST_FORWARD,
+ getString(R.string.fast_forward_label),
+ android.R.drawable.ic_media_ff);
+ flavorHelper.mediaSessionSetExtraForWear(mediaSession);
+ }
}
sessionState.setActions(capabilities);
- flavorHelper.sessionStateAddActionForWear(sessionState,
- CUSTOM_ACTION_REWIND, getString(R.string.rewind_label), android.R.drawable.ic_media_rew);
- flavorHelper.sessionStateAddActionForWear(sessionState,
- CUSTOM_ACTION_FAST_FORWARD, getString(R.string.fast_forward_label), android.R.drawable.ic_media_ff);
-
- flavorHelper.mediaSessionSetExtraForWear(mediaSession);
-
mediaSession.setPlaybackState(sessionState.build());
}
@@ -1282,21 +1308,32 @@ public class PlaybackService extends MediaBrowserServiceCompat {
builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, p.getEpisodeTitle());
builder.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, p.getFeedTitle());
- String imageLocation = ImageResourceUtils.getImageLocation(p);
+ String imageLocation = p.getImageLocation();
if (!TextUtils.isEmpty(imageLocation)) {
if (UserPreferences.setLockscreenBackground()) {
+ Bitmap art;
builder.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, imageLocation);
try {
- Bitmap art = Glide.with(this)
+ art = Glide.with(this)
.asBitmap()
.load(imageLocation)
.apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY))
.submit(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL)
.get();
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, art);
- } catch (Throwable tr) {
- Log.e(TAG, Log.getStackTraceString(tr));
+ } catch (Throwable tr1) {
+ try {
+ art = Glide.with(this)
+ .asBitmap()
+ .load(ImageResourceUtils.getFallbackImageLocation(p))
+ .apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY))
+ .submit(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL)
+ .get();
+ builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, art);
+ } catch (Throwable tr2) {
+ Log.e(TAG, Log.getStackTraceString(tr2));
+ }
}
} else if (isCasting) {
// In the absence of metadata art, the controller dialog takes care of creating it.
@@ -1876,7 +1913,10 @@ public class PlaybackService extends MediaBrowserServiceCompat {
@Override
public void onSkipToNext() {
Log.d(TAG, "onSkipToNext()");
- if (UserPreferences.shouldHardwareButtonSkip()) {
+ UiModeManager uiModeManager = (UiModeManager) getApplicationContext()
+ .getSystemService(Context.UI_MODE_SERVICE);
+ if (UserPreferences.getHardwareForwardButton() == KeyEvent.KEYCODE_MEDIA_NEXT
+ || uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_CAR) {
mediaPlayer.skip();
} else {
seekDelta(UserPreferences.getFastForwardSecs() * 1000);
diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceNotificationBuilder.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceNotificationBuilder.java
index 632ac07ea..cbfc36266 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceNotificationBuilder.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceNotificationBuilder.java
@@ -29,6 +29,8 @@ import de.danoeh.antennapod.core.util.TimeSpeedConverter;
import de.danoeh.antennapod.core.util.gui.NotificationUtils;
import de.danoeh.antennapod.core.util.playback.Playable;
import java.util.ArrayList;
+import java.util.concurrent.ExecutionException;
+
import org.apache.commons.lang3.ArrayUtils;
public class PlaybackServiceNotificationBuilder {
@@ -69,15 +71,27 @@ public class PlaybackServiceNotificationBuilder {
}
public void loadIcon() {
- int iconSize = context.getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width);
+ int iconSize = (int) (128 * context.getResources().getDisplayMetrics().density);
try {
icon = Glide.with(context)
.asBitmap()
- .load(ImageResourceUtils.getImageLocation(playable))
+ .load(playable.getImageLocation())
.apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY))
.apply(new RequestOptions().centerCrop())
.submit(iconSize, iconSize)
.get();
+ } catch (ExecutionException e) {
+ try {
+ icon = Glide.with(context)
+ .asBitmap()
+ .load(ImageResourceUtils.getFallbackImageLocation(playable))
+ .apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY))
+ .apply(new RequestOptions().centerCrop())
+ .submit(iconSize, iconSize)
+ .get();
+ } catch (Throwable tr) {
+ Log.e(TAG, "Error loading the media icon for the notification", tr);
+ }
} catch (Throwable tr) {
Log.e(TAG, "Error loading the media icon for the notification", tr);
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceTaskManager.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceTaskManager.java
index 05d64ea3e..b9bc0c712 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceTaskManager.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceTaskManager.java
@@ -8,6 +8,7 @@ import androidx.annotation.NonNull;
import android.util.Log;
import de.danoeh.antennapod.core.preferences.SleepTimerPreferences;
+import de.danoeh.antennapod.core.widget.WidgetUpdater;
import io.reactivex.disposables.Disposable;
import org.greenrobot.eventbus.EventBus;
import org.greenrobot.eventbus.Subscribe;
@@ -199,11 +200,10 @@ public class PlaybackServiceTaskManager {
*/
public synchronized void startWidgetUpdater() {
if (!isWidgetUpdaterActive() && !schedExecutor.isShutdown()) {
- Runnable widgetUpdater = callback::onWidgetUpdaterTick;
+ Runnable widgetUpdater = this::requestWidgetUpdate;
widgetUpdater = useMainThreadIfNecessary(widgetUpdater);
- widgetUpdaterFuture = schedExecutor.scheduleWithFixedDelay(widgetUpdater, WIDGET_UPDATER_NOTIFICATION_INTERVAL,
- WIDGET_UPDATER_NOTIFICATION_INTERVAL, TimeUnit.MILLISECONDS);
-
+ widgetUpdaterFuture = schedExecutor.scheduleWithFixedDelay(widgetUpdater,
+ WIDGET_UPDATER_NOTIFICATION_INTERVAL, WIDGET_UPDATER_NOTIFICATION_INTERVAL, TimeUnit.MILLISECONDS);
Log.d(TAG, "Started WidgetUpdater");
} else {
Log.d(TAG, "Call to startWidgetUpdater was ignored.");
@@ -211,6 +211,18 @@ public class PlaybackServiceTaskManager {
}
/**
+ * Retrieves information about the widget state in the calling thread and then displays it in a background thread.
+ */
+ public synchronized void requestWidgetUpdate() {
+ WidgetUpdater.WidgetState state = callback.requestWidgetState();
+ if (!schedExecutor.isShutdown()) {
+ schedExecutor.execute(() -> WidgetUpdater.updateWidget(context, state));
+ } else {
+ Log.d(TAG, "Call to requestWidgetUpdate was ignored.");
+ }
+ }
+
+ /**
* Starts a new sleep timer with the given waiting time. If another sleep timer is already active, it will be
* cancelled first.
* After waitingTime has elapsed, onSleepTimerExpired() will be called.
@@ -464,7 +476,7 @@ public class PlaybackServiceTaskManager {
void onSleepTimerReset();
- void onWidgetUpdaterTick();
+ WidgetUpdater.WidgetState requestWidgetState();
void onChapterLoaded(Playable media);
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/ssl/BackportCaCerts.java b/core/src/main/java/de/danoeh/antennapod/core/ssl/BackportCaCerts.java
index 720d6a9d9..78c105e38 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/ssl/BackportCaCerts.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/ssl/BackportCaCerts.java
@@ -70,4 +70,36 @@ public class BackportCaCerts {
+ "0MC2Hb46TpSi125sC8KKfPog88Tk5c0NqMuRkrF8hey1FGlmDoLnzc7ILaZRfyHB\n"
+ "NVOFBkpdn627G190\n"
+ "-----END CERTIFICATE-----";
+
+ public static final String LETSENCRYPT_ISRG = "-----BEGIN CERTIFICATE-----\n"
+ + "MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw\n"
+ + "TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh\n"
+ + "cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4\n"
+ + "WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu\n"
+ + "ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY\n"
+ + "MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc\n"
+ + "h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+\n"
+ + "0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U\n"
+ + "A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW\n"
+ + "T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH\n"
+ + "B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC\n"
+ + "B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv\n"
+ + "KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn\n"
+ + "OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn\n"
+ + "jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw\n"
+ + "qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI\n"
+ + "rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV\n"
+ + "HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq\n"
+ + "hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL\n"
+ + "ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ\n"
+ + "3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK\n"
+ + "NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5\n"
+ + "ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur\n"
+ + "TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC\n"
+ + "jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc\n"
+ + "oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq\n"
+ + "4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA\n"
+ + "mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d\n"
+ + "emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=\n"
+ + "-----END CERTIFICATE-----";
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/ssl/BackportTrustManager.java b/core/src/main/java/de/danoeh/antennapod/core/ssl/BackportTrustManager.java
index b8fe950b2..81d2a0709 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/ssl/BackportTrustManager.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/ssl/BackportTrustManager.java
@@ -45,6 +45,8 @@ public class BackportTrustManager {
new ByteArrayInputStream(BackportCaCerts.COMODO.getBytes(Charset.forName("UTF-8")))));
keystore.setCertificateEntry("SECTIGO_USER_TRUST_CA", cf.generateCertificate(
new ByteArrayInputStream(BackportCaCerts.SECTIGO_USER_TRUST.getBytes(Charset.forName("UTF-8")))));
+ keystore.setCertificateEntry("LETSENCRYPT_ISRG_CA", cf.generateCertificate(
+ new ByteArrayInputStream(BackportCaCerts.LETSENCRYPT_ISRG.getBytes(Charset.forName("UTF-8")))));
List<X509TrustManager> managers = new ArrayList<>();
managers.add(getSystemTrustManager(keystore));
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/APDownloadAlgorithm.java b/core/src/main/java/de/danoeh/antennapod/core/storage/APDownloadAlgorithm.java
deleted file mode 100644
index 061d6cf3f..000000000
--- a/core/src/main/java/de/danoeh/antennapod/core/storage/APDownloadAlgorithm.java
+++ /dev/null
@@ -1,103 +0,0 @@
-package de.danoeh.antennapod.core.storage;
-
-import android.content.Context;
-import android.util.Log;
-
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.List;
-
-import de.danoeh.antennapod.core.feed.FeedFilter;
-import de.danoeh.antennapod.core.feed.FeedItem;
-import de.danoeh.antennapod.core.feed.FeedPreferences;
-import de.danoeh.antennapod.core.preferences.UserPreferences;
-import de.danoeh.antennapod.core.util.NetworkUtils;
-import de.danoeh.antennapod.core.util.PowerUtils;
-
-/**
- * Implements the automatic download algorithm used by AntennaPod. This class assumes that
- * the client uses the APEpisodeCleanupAlgorithm.
- */
-public class APDownloadAlgorithm implements AutomaticDownloadAlgorithm {
- private static final String TAG = "APDownloadAlgorithm";
-
- /**
- * Looks for undownloaded episodes in the queue or list of new items and request a download if
- * 1. Network is available
- * 2. The device is charging or the user allows auto download on battery
- * 3. There is free space in the episode cache
- * This method is executed on an internal single thread executor.
- *
- * @param context Used for accessing the DB.
- * @return A Runnable that will be submitted to an ExecutorService.
- */
- @Override
- public Runnable autoDownloadUndownloadedItems(final Context context) {
- return () -> {
-
- // true if we should auto download based on network status
- boolean networkShouldAutoDl = NetworkUtils.autodownloadNetworkAvailable()
- && UserPreferences.isEnableAutodownload();
-
- // true if we should auto download based on power status
- boolean powerShouldAutoDl = PowerUtils.deviceCharging(context)
- || UserPreferences.isEnableAutodownloadOnBattery();
-
- // we should only auto download if both network AND power are happy
- if (networkShouldAutoDl && powerShouldAutoDl) {
-
- Log.d(TAG, "Performing auto-dl of undownloaded episodes");
-
- List<FeedItem> candidates;
- final List<FeedItem> queue = DBReader.getQueue();
- final List<FeedItem> newItems = DBReader.getNewItemsList(0, Integer.MAX_VALUE);
- candidates = new ArrayList<>(queue.size() + newItems.size());
- candidates.addAll(queue);
- for (FeedItem newItem : newItems) {
- FeedPreferences feedPrefs = newItem.getFeed().getPreferences();
- FeedFilter feedFilter = feedPrefs.getFilter();
- if (!candidates.contains(newItem) && feedFilter.shouldAutoDownload(newItem)) {
- candidates.add(newItem);
- }
- }
-
- // filter items that are not auto downloadable
- Iterator<FeedItem> it = candidates.iterator();
- while (it.hasNext()) {
- FeedItem item = it.next();
- if (!item.isAutoDownloadable() || item.getFeed().isLocalFeed()) {
- it.remove();
- }
- }
-
- int autoDownloadableEpisodes = candidates.size();
- int downloadedEpisodes = DBReader.getNumberOfDownloadedEpisodes();
- int deletedEpisodes = UserPreferences.getEpisodeCleanupAlgorithm()
- .makeRoomForEpisodes(context, autoDownloadableEpisodes);
- boolean cacheIsUnlimited =
- UserPreferences.getEpisodeCacheSize() == UserPreferences.getEpisodeCacheSizeUnlimited();
- int episodeCacheSize = UserPreferences.getEpisodeCacheSize();
-
- int episodeSpaceLeft;
- if (cacheIsUnlimited || episodeCacheSize >= downloadedEpisodes + autoDownloadableEpisodes) {
- episodeSpaceLeft = autoDownloadableEpisodes;
- } else {
- episodeSpaceLeft = episodeCacheSize - (downloadedEpisodes - deletedEpisodes);
- }
-
- FeedItem[] itemsToDownload = candidates.subList(0, episodeSpaceLeft)
- .toArray(new FeedItem[episodeSpaceLeft]);
-
- if (itemsToDownload.length > 0) {
- Log.d(TAG, "Enqueueing " + itemsToDownload.length + " items for download");
-
- try {
- DownloadRequester.getInstance().downloadMedia(false, context, false, itemsToDownload);
- } catch (DownloadRequestException e) {
- e.printStackTrace();
- }
- }
- }
- };
- }
-}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/AutomaticDownloadAlgorithm.java b/core/src/main/java/de/danoeh/antennapod/core/storage/AutomaticDownloadAlgorithm.java
index dbb77e19c..f8b643ccf 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/storage/AutomaticDownloadAlgorithm.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/storage/AutomaticDownloadAlgorithm.java
@@ -1,11 +1,28 @@
package de.danoeh.antennapod.core.storage;
import android.content.Context;
+import android.util.Log;
-public interface AutomaticDownloadAlgorithm {
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+
+import de.danoeh.antennapod.core.feed.FeedFilter;
+import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.feed.FeedPreferences;
+import de.danoeh.antennapod.core.preferences.UserPreferences;
+import de.danoeh.antennapod.core.util.NetworkUtils;
+import de.danoeh.antennapod.core.util.PowerUtils;
+
+/**
+ * Implements the automatic download algorithm used by AntennaPod. This class assumes that
+ * the client uses the {@link EpisodeCleanupAlgorithm}.
+ */
+public class AutomaticDownloadAlgorithm {
+ private static final String TAG = "DownloadAlgorithm";
/**
- * Looks for undownloaded episodes and request a download if
+ * Looks for undownloaded episodes in the queue or list of new items and request a download if
* 1. Network is available
* 2. The device is charging or the user allows auto download on battery
* 3. There is free space in the episode cache
@@ -14,5 +31,72 @@ public interface AutomaticDownloadAlgorithm {
* @param context Used for accessing the DB.
* @return A Runnable that will be submitted to an ExecutorService.
*/
- Runnable autoDownloadUndownloadedItems(Context context);
+ public Runnable autoDownloadUndownloadedItems(final Context context) {
+ return () -> {
+
+ // true if we should auto download based on network status
+ boolean networkShouldAutoDl = NetworkUtils.autodownloadNetworkAvailable()
+ && UserPreferences.isEnableAutodownload();
+
+ // true if we should auto download based on power status
+ boolean powerShouldAutoDl = PowerUtils.deviceCharging(context)
+ || UserPreferences.isEnableAutodownloadOnBattery();
+
+ // we should only auto download if both network AND power are happy
+ if (networkShouldAutoDl && powerShouldAutoDl) {
+
+ Log.d(TAG, "Performing auto-dl of undownloaded episodes");
+
+ List<FeedItem> candidates;
+ final List<FeedItem> queue = DBReader.getQueue();
+ final List<FeedItem> newItems = DBReader.getNewItemsList(0, Integer.MAX_VALUE);
+ candidates = new ArrayList<>(queue.size() + newItems.size());
+ candidates.addAll(queue);
+ for (FeedItem newItem : newItems) {
+ FeedPreferences feedPrefs = newItem.getFeed().getPreferences();
+ FeedFilter feedFilter = feedPrefs.getFilter();
+ if (!candidates.contains(newItem) && feedFilter.shouldAutoDownload(newItem)) {
+ candidates.add(newItem);
+ }
+ }
+
+ // filter items that are not auto downloadable
+ Iterator<FeedItem> it = candidates.iterator();
+ while (it.hasNext()) {
+ FeedItem item = it.next();
+ if (!item.isAutoDownloadable() || item.getFeed().isLocalFeed()) {
+ it.remove();
+ }
+ }
+
+ int autoDownloadableEpisodes = candidates.size();
+ int downloadedEpisodes = DBReader.getNumberOfDownloadedEpisodes();
+ int deletedEpisodes = UserPreferences.getEpisodeCleanupAlgorithm()
+ .makeRoomForEpisodes(context, autoDownloadableEpisodes);
+ boolean cacheIsUnlimited =
+ UserPreferences.getEpisodeCacheSize() == UserPreferences.getEpisodeCacheSizeUnlimited();
+ int episodeCacheSize = UserPreferences.getEpisodeCacheSize();
+
+ int episodeSpaceLeft;
+ if (cacheIsUnlimited || episodeCacheSize >= downloadedEpisodes + autoDownloadableEpisodes) {
+ episodeSpaceLeft = autoDownloadableEpisodes;
+ } else {
+ episodeSpaceLeft = episodeCacheSize - (downloadedEpisodes - deletedEpisodes);
+ }
+
+ FeedItem[] itemsToDownload = candidates.subList(0, episodeSpaceLeft)
+ .toArray(new FeedItem[episodeSpaceLeft]);
+
+ if (itemsToDownload.length > 0) {
+ Log.d(TAG, "Enqueueing " + itemsToDownload.length + " items for download");
+
+ try {
+ DownloadRequester.getInstance().downloadMedia(false, context, false, itemsToDownload);
+ } catch (DownloadRequestException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+ };
+ }
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java
index 4c1f23474..fcf61b070 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBReader.java
@@ -11,18 +11,19 @@ import java.io.File;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
-import java.util.Date;
import java.util.List;
import java.util.Map;
import de.danoeh.antennapod.core.feed.Chapter;
import de.danoeh.antennapod.core.feed.Feed;
import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.feed.FeedItemFilter;
import de.danoeh.antennapod.core.feed.FeedMedia;
import de.danoeh.antennapod.core.feed.FeedPreferences;
import de.danoeh.antennapod.core.feed.SubscriptionsFilter;
import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.service.download.DownloadStatus;
+import de.danoeh.antennapod.core.storage.mapper.FeedCursorMapper;
import de.danoeh.antennapod.core.util.LongIntMap;
import de.danoeh.antennapod.core.util.LongList;
import de.danoeh.antennapod.core.util.comparator.DownloadStatusComparator;
@@ -204,7 +205,7 @@ public final class DBReader {
}
private static Feed extractFeedFromCursorRow(Cursor cursor) {
- Feed feed = Feed.fromCursor(cursor);
+ Feed feed = FeedCursorMapper.convert(cursor);
FeedPreferences preferences = FeedPreferences.fromCursor(cursor);
feed.setPreferences(preferences);
return feed;
@@ -367,18 +368,19 @@ public final class DBReader {
}
/**
- * Loads a list of FeedItems sorted by pubDate in descending order.
+ * Loads a filtered list of FeedItems sorted by pubDate in descending order.
*
* @param offset The first episode that should be loaded.
* @param limit The maximum number of episodes that should be loaded.
+ * @param filter The filter describing which episodes to filter out.
*/
@NonNull
- public static List<FeedItem> getRecentlyPublishedEpisodes(int offset, int limit) {
- Log.d(TAG, "getRecentlyPublishedEpisodes() called with: " + "offset = [" + offset + "]" + " limit = [" + limit + "]" );
+ public static List<FeedItem> getRecentlyPublishedEpisodes(int offset, int limit, FeedItemFilter filter) {
+ Log.d(TAG, "getRecentlyPublishedEpisodes() called with: offset=" + offset + ", limit=" + limit);
PodDBAdapter adapter = PodDBAdapter.getInstance();
adapter.open();
- try (Cursor cursor = adapter.getRecentlyPublishedItemsCursor(offset, limit)) {
+ try (Cursor cursor = adapter.getRecentlyPublishedItemsCursor(offset, limit, filter)) {
List<FeedItem> items = extractItemlistFromCursor(adapter, cursor);
loadAdditionalFeedItemListData(items);
return items;
@@ -801,15 +803,9 @@ public final class DBReader {
PodDBAdapter adapter = PodDBAdapter.getInstance();
adapter.open();
- List<Feed> feeds = getFeedList(adapter);
- long[] feedIds = new long[feeds.size()];
- for (int i = 0; i < feeds.size(); i++) {
- feedIds[i] = feeds.get(i).getId();
- }
- final LongIntMap feedCounters = adapter.getFeedCounters(feedIds);
-
+ final LongIntMap feedCounters = adapter.getFeedCounters();
SubscriptionsFilter subscriptionsFilter = UserPreferences.getSubscriptionsFilter();
- feeds = subscriptionsFilter.filter(getFeedList(adapter), feedCounters);
+ List<Feed> feeds = subscriptionsFilter.filter(getFeedList(adapter), feedCounters);
Comparator<Feed> comparator;
int feedOrder = UserPreferences.getFeedOrder();
@@ -839,7 +835,7 @@ public final class DBReader {
}
};
} else if (feedOrder == UserPreferences.FEED_ORDER_MOST_PLAYED) {
- final LongIntMap playedCounters = adapter.getPlayedEpisodesCounters(feedIds);
+ final LongIntMap playedCounters = adapter.getPlayedEpisodesCounters();
comparator = (lhs, rhs) -> {
long counterLhs = playedCounters.get(lhs.getId());
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java
index 3f6a56fc8..596ab624e 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java
@@ -6,8 +6,9 @@ import android.database.Cursor;
import android.os.Looper;
import android.text.TextUtils;
import android.util.Log;
+
import androidx.annotation.VisibleForTesting;
-import de.danoeh.antennapod.core.ClientConfig;
+
import de.danoeh.antennapod.core.R;
import de.danoeh.antennapod.core.event.FeedItemEvent;
import de.danoeh.antennapod.core.event.FeedListUpdateEvent;
@@ -15,10 +16,10 @@ import de.danoeh.antennapod.core.event.MessageEvent;
import de.danoeh.antennapod.core.feed.Feed;
import de.danoeh.antennapod.core.feed.FeedItem;
import de.danoeh.antennapod.core.feed.FeedMedia;
-import de.danoeh.antennapod.core.feed.FeedPreferences;
import de.danoeh.antennapod.core.feed.LocalFeedUpdater;
import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.service.download.DownloadStatus;
+import de.danoeh.antennapod.core.storage.mapper.FeedCursorMapper;
import de.danoeh.antennapod.core.sync.SyncService;
import de.danoeh.antennapod.core.util.DownloadError;
import de.danoeh.antennapod.core.util.LongList;
@@ -30,6 +31,7 @@ import java.util.Collections;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
+import java.util.ListIterator;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
@@ -54,6 +56,8 @@ public final class DBTasks {
*/
private static final ExecutorService autodownloadExec;
+ private static AutomaticDownloadAlgorithm downloadAlgorithm = new AutomaticDownloadAlgorithm();
+
static {
autodownloadExec = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r);
@@ -118,7 +122,18 @@ public final class DBTasks {
throw new IllegalStateException("DBTasks.refreshAllFeeds() must not be called from the main thread.");
}
- refreshFeeds(context, DBReader.getFeedList(), initiatedByUser);
+ List<Feed> feeds = DBReader.getFeedList();
+ ListIterator<Feed> iterator = feeds.listIterator();
+ while (iterator.hasNext()) {
+ if (!iterator.next().getPreferences().getKeepUpdated()) {
+ iterator.remove();
+ }
+ }
+ try {
+ refreshFeeds(context, feeds, false, false, false);
+ } catch (DownloadRequestException e) {
+ e.printStackTrace();
+ }
isRefreshing.set(false);
SharedPreferences prefs = context.getSharedPreferences(PREF_NAME, MODE_PRIVATE);
@@ -132,38 +147,6 @@ public final class DBTasks {
}
/**
- * @param context
- * @param feedList the list of feeds to refresh
- * @param initiatedByUser a boolean indicating if the refresh was triggered by user action.
- */
- private static void refreshFeeds(final Context context,
- final List<Feed> feedList,
- boolean initiatedByUser) {
-
- for (Feed feed : feedList) {
- FeedPreferences prefs = feed.getPreferences();
- // feeds with !getKeepUpdated can only be refreshed
- // directly from the FeedActivity
- if (prefs.getKeepUpdated()) {
- try {
- refreshFeed(context, feed);
- } catch (DownloadRequestException e) {
- e.printStackTrace();
- DBWriter.addDownloadStatus(
- new DownloadStatus(feed,
- feed.getHumanReadableIdentifier(),
- DownloadError.ERROR_REQUEST_ERROR,
- false,
- e.getMessage(),
- initiatedByUser)
- );
- }
- }
- }
-
- }
-
- /**
* Downloads all pages of the given feed even if feed has not been modified since last refresh
*
* @param context Used for requesting the download.
@@ -171,7 +154,7 @@ public final class DBTasks {
*/
public static void forceRefreshCompleteFeed(final Context context, final Feed feed) {
try {
- refreshFeed(context, feed, true, true, false);
+ refreshFeeds(context, Collections.singletonList(feed), true, true, false);
} catch (DownloadRequestException e) {
e.printStackTrace();
DBWriter.addDownloadStatus(
@@ -207,19 +190,6 @@ public final class DBTasks {
}
/**
- * Refresh a specific Feed. The refresh may get canceled if the feed does not seem to be modified
- * and the last update was only few days ago.
- *
- * @param context Used for requesting the download.
- * @param feed The Feed object.
- */
- private static void refreshFeed(Context context, Feed feed)
- throws DownloadRequestException {
- Log.d(TAG, "refreshFeed(feed.id: " + feed.getId() +")");
- refreshFeed(context, feed, false, false, false);
- }
-
- /**
* Refresh a specific feed even if feed has not been modified since last refresh
*
* @param context Used for requesting the download.
@@ -227,26 +197,32 @@ public final class DBTasks {
*/
public static void forceRefreshFeed(Context context, Feed feed, boolean initiatedByUser)
throws DownloadRequestException {
- Log.d(TAG, "refreshFeed(feed.id: " + feed.getId() +")");
- refreshFeed(context, feed, false, true, initiatedByUser);
+ Log.d(TAG, "refreshFeed(feed.id: " + feed.getId() + ")");
+ refreshFeeds(context, Collections.singletonList(feed), false, true, initiatedByUser);
}
- private static void refreshFeed(Context context, Feed feed, boolean loadAllPages, boolean force, boolean initiatedByUser)
- throws DownloadRequestException {
- Feed f;
- String lastUpdate = feed.hasLastUpdateFailed() ? null : feed.getLastUpdate();
- if (feed.getPreferences() == null) {
- f = new Feed(feed.getDownload_url(), lastUpdate, feed.getTitle());
- } else {
- f = new Feed(feed.getDownload_url(), lastUpdate, feed.getTitle(),
- feed.getPreferences().getUsername(), feed.getPreferences().getPassword());
+ private static void refreshFeeds(Context context, List<Feed> feeds, boolean loadAllPages,
+ boolean force, boolean initiatedByUser) throws DownloadRequestException {
+ List<Feed> localFeeds = new ArrayList<>();
+ List<Feed> normalFeeds = new ArrayList<>();
+
+ for (Feed feed : feeds) {
+ if (feed.isLocalFeed()) {
+ localFeeds.add(feed);
+ } else {
+ normalFeeds.add(feed);
+ }
}
- f.setId(feed.getId());
- if (f.isLocalFeed()) {
- new Thread(() -> LocalFeedUpdater.updateFeed(f, context)).start();
- } else {
- DownloadRequester.getInstance().downloadFeed(context, f, loadAllPages, force, initiatedByUser);
+ if (!localFeeds.isEmpty()) {
+ new Thread(() -> {
+ for (Feed feed : localFeeds) {
+ LocalFeedUpdater.updateFeed(feed, context);
+ }
+ }).start();
+ }
+ if (!normalFeeds.isEmpty()) {
+ DownloadRequester.getInstance().downloadFeeds(context, feeds, loadAllPages, force, initiatedByUser);
}
}
@@ -279,7 +255,7 @@ public final class DBTasks {
}
/**
- * Looks for undownloaded episodes in the queue or list of unread items and request a download if
+ * Looks for non-downloaded episodes in the queue or list of unread items and request a download if
* 1. Network is available
* 2. The device is charging or the user allows auto download on battery
* 3. There is free space in the episode cache
@@ -290,9 +266,15 @@ public final class DBTasks {
*/
public static Future<?> autodownloadUndownloadedItems(final Context context) {
Log.d(TAG, "autodownloadUndownloadedItems");
- return autodownloadExec.submit(ClientConfig.dbTasksCallbacks.getAutomaticDownloadAlgorithm()
- .autoDownloadUndownloadedItems(context));
+ return autodownloadExec.submit(downloadAlgorithm.autoDownloadUndownloadedItems(context));
+ }
+ /**
+ * For testing purpose only.
+ */
+ @VisibleForTesting(otherwise = VisibleForTesting.NONE)
+ public static void setDownloadAlgorithm(AutomaticDownloadAlgorithm newDownloadAlgorithm) {
+ downloadAlgorithm = newDownloadAlgorithm;
}
/**
@@ -304,7 +286,7 @@ public final class DBTasks {
* @param context Used for accessing the DB.
*/
public static void performAutoCleanup(final Context context) {
- ClientConfig.dbTasksCallbacks.getEpisodeCacheCleanupAlgorithm().performCleanup(context);
+ UserPreferences.getEpisodeCleanupAlgorithm().performCleanup(context);
}
/**
@@ -535,7 +517,7 @@ public final class DBTasks {
List<Feed> items = new ArrayList<>();
if (cursor.moveToFirst()) {
do {
- items.add(Feed.fromCursor(cursor));
+ items.add(FeedCursorMapper.convert(cursor));
} while (cursor.moveToNext());
}
setResult(items);
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBUpgrader.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBUpgrader.java
index 8574ff33b..93bc74ea8 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBUpgrader.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBUpgrader.java
@@ -310,6 +310,10 @@ class DBUpgrader {
db.execSQL("ALTER TABLE " + PodDBAdapter.TABLE_NAME_FEEDS +
" ADD COLUMN " + PodDBAdapter.KEY_FEED_SKIP_ENDING + " INTEGER DEFAULT 0;");
}
+ if (oldVersion < 2020000) {
+ db.execSQL("ALTER TABLE " + PodDBAdapter.TABLE_NAME_FEEDS
+ + " ADD COLUMN " + PodDBAdapter.KEY_EPISODE_NOTIFICATION + " INTEGER DEFAULT 0;");
+ }
}
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java
index 67d1779fc..a86bdaa65 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java
@@ -21,8 +21,8 @@ import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
-import de.danoeh.antennapod.core.ClientConfig;
import de.danoeh.antennapod.core.R;
import de.danoeh.antennapod.core.event.DownloadLogEvent;
import de.danoeh.antennapod.core.event.FavoritesEvent;
@@ -48,6 +48,7 @@ import de.danoeh.antennapod.core.util.LongList;
import de.danoeh.antennapod.core.util.Permutor;
import de.danoeh.antennapod.core.util.SortOrder;
import de.danoeh.antennapod.core.util.playback.Playable;
+import de.danoeh.antennapod.core.util.playback.PlayableUtils;
/**
* Provides methods for writing data to AntennaPod's database.
@@ -74,6 +75,18 @@ public class DBWriter {
}
/**
+ * Wait until all threads are finished to avoid the "Illegal connection pointer" error of
+ * Robolectric. Call this method only for unit tests.
+ */
+ public static void tearDownTests() {
+ try {
+ dbExec.awaitTermination(1, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ // ignore error
+ }
+ }
+
+ /**
* Deletes a downloaded FeedMedia file from the storage device.
*
* @param context A context that is used for opening a database connection.
@@ -370,7 +383,7 @@ public class DBWriter {
List<FeedItem> updatedItems = new ArrayList<>();
ItemEnqueuePositionCalculator positionCalculator =
new ItemEnqueuePositionCalculator(UserPreferences.getEnqueueLocation());
- Playable currentlyPlaying = Playable.PlayableUtils.createInstanceFromPreferences(context);
+ Playable currentlyPlaying = PlayableUtils.createInstanceFromPreferences(context);
int insertPosition = positionCalculator.calcPosition(queue, currentlyPlaying);
for (long itemId : itemIds) {
if (!itemListContains(queue, itemId)) {
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java
index e3121caa2..638c1bef5 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DownloadRequester.java
@@ -17,6 +17,7 @@ import org.apache.commons.io.FilenameUtils;
import java.io.File;
import java.util.ArrayList;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -184,16 +185,31 @@ public class DownloadRequester implements DownloadStateProvider {
}
/**
- * Downloads a feed
+ * Downloads a feed.
*
* @param context The application's environment.
- * @param feed Feed to download
+ * @param feed Feeds to download
* @param loadAllPages Set to true to download all pages
*/
public synchronized void downloadFeed(Context context, Feed feed, boolean loadAllPages,
- boolean force, boolean initiatedByUser)
- throws DownloadRequestException {
- if (feedFileValid(feed)) {
+ boolean force, boolean initiatedByUser) throws DownloadRequestException {
+ downloadFeeds(context, Collections.singletonList(feed), loadAllPages, force, initiatedByUser);
+ }
+
+ /**
+ * Downloads a list of feeds.
+ *
+ * @param context The application's environment.
+ * @param feeds Feeds to download
+ * @param loadAllPages Set to true to download all pages
+ */
+ public synchronized void downloadFeeds(Context context, List<Feed> feeds, boolean loadAllPages,
+ boolean force, boolean initiatedByUser) throws DownloadRequestException {
+ List<DownloadRequest> requests = new ArrayList<>();
+ for (Feed feed : feeds) {
+ if (!feedFileValid(feed)) {
+ continue;
+ }
String username = (feed.getPreferences() != null) ? feed.getPreferences().getUsername() : null;
String password = (feed.getPreferences() != null) ? feed.getPreferences().getPassword() : null;
String lastModified = feed.isPaged() || force ? null : feed.getLastUpdate();
@@ -206,9 +222,12 @@ public class DownloadRequester implements DownloadStateProvider {
true, username, password, lastModified, true, args, initiatedByUser
);
if (request != null) {
- download(context, request);
+ requests.add(request);
}
}
+ if (!requests.isEmpty()) {
+ download(context, requests.toArray(new DownloadRequest[0]));
+ }
}
public synchronized void downloadFeed(Context context, Feed feed) throws DownloadRequestException {
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/ExceptFavoriteCleanupAlgorithm.java b/core/src/main/java/de/danoeh/antennapod/core/storage/ExceptFavoriteCleanupAlgorithm.java
new file mode 100644
index 000000000..f0788db33
--- /dev/null
+++ b/core/src/main/java/de/danoeh/antennapod/core/storage/ExceptFavoriteCleanupAlgorithm.java
@@ -0,0 +1,99 @@
+package de.danoeh.antennapod.core.storage;
+
+import android.content.Context;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.ExecutionException;
+
+import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.preferences.UserPreferences;
+
+/**
+ * A cleanup algorithm that removes any item that isn't a favorite but only if space is needed.
+ */
+public class ExceptFavoriteCleanupAlgorithm extends EpisodeCleanupAlgorithm {
+
+ private static final String TAG = "ExceptFavCleanupAlgo";
+
+ /**
+ * The maximum number of episodes that could be cleaned up.
+ *
+ * @return the number of episodes that *could* be cleaned up, if needed
+ */
+ public int getReclaimableItems() {
+ return getCandidates().size();
+ }
+
+ @Override
+ public int performCleanup(Context context, int numberOfEpisodesToDelete) {
+ List<FeedItem> candidates = getCandidates();
+ List<FeedItem> delete;
+
+ // in the absence of better data, we'll sort by item publication date
+ Collections.sort(candidates, (lhs, rhs) -> {
+ Date l = lhs.getPubDate();
+ Date r = rhs.getPubDate();
+
+ if (l != null && r != null) {
+ return l.compareTo(r);
+ } else {
+ // No date - compare by id which should be always incremented
+ return Long.compare(lhs.getId(), rhs.getId());
+ }
+ });
+
+ if (candidates.size() > numberOfEpisodesToDelete) {
+ delete = candidates.subList(0, numberOfEpisodesToDelete);
+ } else {
+ delete = candidates;
+ }
+
+ for (FeedItem item : delete) {
+ try {
+ DBWriter.deleteFeedMediaOfItem(context, item.getMedia().getId()).get();
+ } catch (InterruptedException | ExecutionException e) {
+ e.printStackTrace();
+ }
+ }
+
+ int counter = delete.size();
+ Log.i(TAG, String.format(Locale.US,
+ "Auto-delete deleted %d episodes (%d requested)", counter,
+ numberOfEpisodesToDelete));
+
+ return counter;
+ }
+
+ @NonNull
+ private List<FeedItem> getCandidates() {
+ List<FeedItem> candidates = new ArrayList<>();
+ List<FeedItem> downloadedItems = DBReader.getDownloadedItems();
+ for (FeedItem item : downloadedItems) {
+ if (item.hasMedia()
+ && item.getMedia().isDownloaded()
+ && !item.isTagged(FeedItem.TAG_FAVORITE)) {
+ candidates.add(item);
+ }
+ }
+ return candidates;
+ }
+
+ @Override
+ public int getDefaultCleanupParameter() {
+ int cacheSize = UserPreferences.getEpisodeCacheSize();
+ if (cacheSize != UserPreferences.getEpisodeCacheSizeUnlimited()) {
+ int downloadedEpisodes = DBReader.getNumberOfDownloadedEpisodes();
+ if (downloadedEpisodes > cacheSize) {
+ return downloadedEpisodes - cacheSize;
+ }
+ }
+ return 0;
+ }
+}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/FeedSearcher.java b/core/src/main/java/de/danoeh/antennapod/core/storage/FeedSearcher.java
index 2f48cfc07..ea62065fc 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/storage/FeedSearcher.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/storage/FeedSearcher.java
@@ -3,10 +3,8 @@ package de.danoeh.antennapod.core.storage;
import android.content.Context;
import androidx.annotation.NonNull;
import de.danoeh.antennapod.core.feed.Feed;
-import de.danoeh.antennapod.core.feed.FeedComponent;
import de.danoeh.antennapod.core.feed.FeedItem;
-import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.ExecutionException;
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java b/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java
index ad7f40700..adb5e6a74 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java
@@ -1,6 +1,5 @@
package de.danoeh.antennapod.core.storage;
-import android.annotation.SuppressLint;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
@@ -16,7 +15,9 @@ import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.annotation.VisibleForTesting;
+import de.danoeh.antennapod.core.storage.mapper.FeedItemFilterQuery;
import org.apache.commons.io.FileUtils;
import java.io.File;
@@ -31,6 +32,7 @@ import java.util.Set;
import de.danoeh.antennapod.core.feed.Chapter;
import de.danoeh.antennapod.core.feed.Feed;
import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.feed.FeedItemFilter;
import de.danoeh.antennapod.core.feed.FeedMedia;
import de.danoeh.antennapod.core.feed.FeedPreferences;
import de.danoeh.antennapod.core.preferences.UserPreferences;
@@ -50,7 +52,7 @@ public class PodDBAdapter {
private static final String TAG = "PodDBAdapter";
public static final String DATABASE_NAME = "Antennapod.db";
- public static final int VERSION = 1090001;
+ public static final int VERSION = 2020000;
/**
* Maximum number of arguments for IN-operator.
@@ -114,16 +116,17 @@ public class PodDBAdapter {
public static final String KEY_FEED_PLAYBACK_SPEED = "feed_playback_speed";
public static final String KEY_FEED_SKIP_INTRO = "feed_skip_intro";
public static final String KEY_FEED_SKIP_ENDING = "feed_skip_ending";
+ public static final String KEY_EPISODE_NOTIFICATION = "episode_notification";
// Table names
- static final String TABLE_NAME_FEEDS = "Feeds";
- static final String TABLE_NAME_FEED_ITEMS = "FeedItems";
- static final String TABLE_NAME_FEED_IMAGES = "FeedImages";
- static final String TABLE_NAME_FEED_MEDIA = "FeedMedia";
- static final String TABLE_NAME_DOWNLOAD_LOG = "DownloadLog";
- static final String TABLE_NAME_QUEUE = "Queue";
- static final String TABLE_NAME_SIMPLECHAPTERS = "SimpleChapters";
- static final String TABLE_NAME_FAVORITES = "Favorites";
+ public static final String TABLE_NAME_FEEDS = "Feeds";
+ public static final String TABLE_NAME_FEED_ITEMS = "FeedItems";
+ public static final String TABLE_NAME_FEED_IMAGES = "FeedImages";
+ public static final String TABLE_NAME_FEED_MEDIA = "FeedMedia";
+ public static final String TABLE_NAME_DOWNLOAD_LOG = "DownloadLog";
+ public static final String TABLE_NAME_QUEUE = "Queue";
+ public static final String TABLE_NAME_SIMPLECHAPTERS = "SimpleChapters";
+ public static final String TABLE_NAME_FAVORITES = "Favorites";
// SQL Statements for creating new tables
private static final String TABLE_PRIMARY_KEY = KEY_ID
@@ -151,7 +154,8 @@ public class PodDBAdapter {
+ KEY_FEED_PLAYBACK_SPEED + " REAL DEFAULT " + SPEED_USE_GLOBAL + ","
+ KEY_FEED_VOLUME_ADAPTION + " INTEGER DEFAULT 0,"
+ KEY_FEED_SKIP_INTRO + " INTEGER DEFAULT 0,"
- + KEY_FEED_SKIP_ENDING + " INTEGER DEFAULT 0)";
+ + KEY_FEED_SKIP_ENDING + " INTEGER DEFAULT 0,"
+ + KEY_EPISODE_NOTIFICATION + " INTEGER DEFAULT 0)";
private static final String CREATE_TABLE_FEED_ITEMS = "CREATE TABLE "
+ TABLE_NAME_FEED_ITEMS + " (" + TABLE_PRIMARY_KEY + KEY_TITLE
@@ -195,11 +199,11 @@ public class PodDBAdapter {
+ TABLE_NAME_FEED_ITEMS + "_" + KEY_FEED + " ON " + TABLE_NAME_FEED_ITEMS + " ("
+ KEY_FEED + ")";
- static final String CREATE_INDEX_FEEDITEMS_PUBDATE = "CREATE INDEX IF NOT EXISTS "
+ static final String CREATE_INDEX_FEEDITEMS_PUBDATE = "CREATE INDEX "
+ TABLE_NAME_FEED_ITEMS + "_" + KEY_PUBDATE + " ON " + TABLE_NAME_FEED_ITEMS + " ("
+ KEY_PUBDATE + ")";
- static final String CREATE_INDEX_FEEDITEMS_READ = "CREATE INDEX IF NOT EXISTS "
+ static final String CREATE_INDEX_FEEDITEMS_READ = "CREATE INDEX "
+ TABLE_NAME_FEED_ITEMS + "_" + KEY_READ + " ON " + TABLE_NAME_FEED_ITEMS + " ("
+ KEY_READ + ")";
@@ -253,7 +257,8 @@ public class PodDBAdapter {
TABLE_NAME_FEEDS + "." + KEY_EXCLUDE_FILTER,
TABLE_NAME_FEEDS + "." + KEY_FEED_PLAYBACK_SPEED,
TABLE_NAME_FEEDS + "." + KEY_FEED_SKIP_INTRO,
- TABLE_NAME_FEEDS + "." + KEY_FEED_SKIP_ENDING
+ TABLE_NAME_FEEDS + "." + KEY_FEED_SKIP_ENDING,
+ TABLE_NAME_FEEDS + "." + KEY_EPISODE_NOTIFICATION
};
/**
@@ -368,6 +373,7 @@ public class PodDBAdapter {
* For more information see
* <a href="https://github.com/robolectric/robolectric/issues/1890">robolectric/robolectric#1890</a>.</p>
*/
+ @VisibleForTesting(otherwise = VisibleForTesting.NONE)
public static void tearDownTests() {
getInstance().dbHelper.close();
instance = null;
@@ -445,6 +451,7 @@ public class PodDBAdapter {
values.put(KEY_FEED_PLAYBACK_SPEED, prefs.getFeedPlaybackSpeed());
values.put(KEY_FEED_SKIP_INTRO, prefs.getFeedSkipIntro());
values.put(KEY_FEED_SKIP_ENDING, prefs.getFeedSkipEnding());
+ values.put(KEY_EPISODE_NOTIFICATION, prefs.getShowEpisodeNotification());
db.update(TABLE_NAME_FEEDS, values, KEY_ID + "=?", new String[]{String.valueOf(prefs.getFeedID())});
}
@@ -1045,9 +1052,11 @@ public class PodDBAdapter {
return db.rawQuery(query, null);
}
- public final Cursor getRecentlyPublishedItemsCursor(int offset, int limit) {
- final String query = SELECT_FEED_ITEMS_AND_MEDIA
- + "ORDER BY " + KEY_PUBDATE + " DESC LIMIT " + offset + ", " + limit;
+ public final Cursor getRecentlyPublishedItemsCursor(int offset, int limit, FeedItemFilter filter) {
+ String filterQuery = FeedItemFilterQuery.generateFrom(filter);
+ String whereClause = "".equals(filterQuery) ? "" : " WHERE " + filterQuery;
+ final String query = SELECT_FEED_ITEMS_AND_MEDIA + whereClause
+ + " ORDER BY " + KEY_PUBDATE + " DESC LIMIT " + offset + ", " + limit;
return db.rawQuery(query, null);
}
@@ -1161,6 +1170,11 @@ public class PodDBAdapter {
public final LongIntMap getFeedCounters(long... feedIds) {
int setting = UserPreferences.getFeedCounterSetting();
+
+ return getFeedCounters(setting, feedIds);
+ }
+
+ public final LongIntMap getFeedCounters(int setting, long... feedIds) {
String whereRead;
switch (setting) {
case UserPreferences.FEED_COUNTER_SHOW_NEW_UNPLAYED_SUM:
@@ -1185,24 +1199,26 @@ public class PodDBAdapter {
}
private LongIntMap conditionalFeedCounterRead(String whereRead, long... feedIds) {
- // work around TextUtils.join wanting only boxed items
- // and StringUtils.join() causing NoSuchMethodErrors on MIUI
- StringBuilder builder = new StringBuilder();
- for (long id : feedIds) {
- builder.append(id);
- builder.append(',');
- }
+ String limitFeeds = "";
if (feedIds.length > 0) {
+ // work around TextUtils.join wanting only boxed items
+ // and StringUtils.join() causing NoSuchMethodErrors on MIUI
+ StringBuilder builder = new StringBuilder();
+ for (long id : feedIds) {
+ builder.append(id);
+ builder.append(',');
+ }
// there's an extra ',', get rid of it
builder.deleteCharAt(builder.length() - 1);
+ limitFeeds = KEY_FEED + " IN (" + builder.toString() + ") AND ";
}
final String query = "SELECT " + KEY_FEED + ", COUNT(" + TABLE_NAME_FEED_ITEMS + "." + KEY_ID + ") AS count "
+ " FROM " + TABLE_NAME_FEED_ITEMS
+ " LEFT JOIN " + TABLE_NAME_FEED_MEDIA + " ON "
+ TABLE_NAME_FEED_ITEMS + "." + KEY_ID + "=" + TABLE_NAME_FEED_MEDIA + "." + KEY_FEEDITEM
- + " WHERE " + KEY_FEED + " IN (" + builder.toString() + ") "
- + " AND " + whereRead + " GROUP BY " + KEY_FEED;
+ + " WHERE " + limitFeeds + " "
+ + whereRead + " GROUP BY " + KEY_FEED;
Cursor c = db.rawQuery(query, null);
LongIntMap result = new LongIntMap(c.getCount());
@@ -1365,7 +1381,16 @@ public class PodDBAdapter {
}
/**
- * Called when a database corruption happens
+ * Insert raw data to the database. *
+ * Call method only for unit tests.
+ */
+ @VisibleForTesting(otherwise = VisibleForTesting.NONE)
+ public void insertTestData(@NonNull String table, @NonNull ContentValues values) {
+ db.insert(table, null, values);
+ }
+
+ /**
+ * Called when a database corruption happens.
*/
public static class PodDbErrorHandler implements DatabaseErrorHandler {
@Override
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedCursorMapper.java b/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedCursorMapper.java
new file mode 100644
index 000000000..783fba596
--- /dev/null
+++ b/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedCursorMapper.java
@@ -0,0 +1,70 @@
+package de.danoeh.antennapod.core.storage.mapper;
+
+import android.database.Cursor;
+
+import androidx.annotation.NonNull;
+
+import de.danoeh.antennapod.core.feed.Feed;
+import de.danoeh.antennapod.core.feed.FeedPreferences;
+import de.danoeh.antennapod.core.storage.PodDBAdapter;
+import de.danoeh.antennapod.core.util.SortOrder;
+
+/**
+ * Converts a {@link Cursor} to a {@link Feed} object.
+ */
+public abstract class FeedCursorMapper {
+
+ /**
+ * Create a {@link Feed} instance from a database row (cursor).
+ */
+ @NonNull
+ public static Feed convert(@NonNull Cursor cursor) {
+ int indexId = cursor.getColumnIndex(PodDBAdapter.KEY_ID);
+ int indexLastUpdate = cursor.getColumnIndex(PodDBAdapter.KEY_LASTUPDATE);
+ int indexTitle = cursor.getColumnIndex(PodDBAdapter.KEY_TITLE);
+ int indexCustomTitle = cursor.getColumnIndex(PodDBAdapter.KEY_CUSTOM_TITLE);
+ int indexLink = cursor.getColumnIndex(PodDBAdapter.KEY_LINK);
+ int indexDescription = cursor.getColumnIndex(PodDBAdapter.KEY_DESCRIPTION);
+ int indexPaymentLink = cursor.getColumnIndex(PodDBAdapter.KEY_PAYMENT_LINK);
+ int indexAuthor = cursor.getColumnIndex(PodDBAdapter.KEY_AUTHOR);
+ int indexLanguage = cursor.getColumnIndex(PodDBAdapter.KEY_LANGUAGE);
+ int indexType = cursor.getColumnIndex(PodDBAdapter.KEY_TYPE);
+ int indexFeedIdentifier = cursor.getColumnIndex(PodDBAdapter.KEY_FEED_IDENTIFIER);
+ int indexFileUrl = cursor.getColumnIndex(PodDBAdapter.KEY_FILE_URL);
+ int indexDownloadUrl = cursor.getColumnIndex(PodDBAdapter.KEY_DOWNLOAD_URL);
+ int indexDownloaded = cursor.getColumnIndex(PodDBAdapter.KEY_DOWNLOADED);
+ int indexIsPaged = cursor.getColumnIndex(PodDBAdapter.KEY_IS_PAGED);
+ int indexNextPageLink = cursor.getColumnIndex(PodDBAdapter.KEY_NEXT_PAGE_LINK);
+ int indexHide = cursor.getColumnIndex(PodDBAdapter.KEY_HIDE);
+ int indexSortOrder = cursor.getColumnIndex(PodDBAdapter.KEY_SORT_ORDER);
+ int indexLastUpdateFailed = cursor.getColumnIndex(PodDBAdapter.KEY_LAST_UPDATE_FAILED);
+ int indexImageUrl = cursor.getColumnIndex(PodDBAdapter.KEY_IMAGE_URL);
+
+ Feed feed = new Feed(
+ cursor.getLong(indexId),
+ cursor.getString(indexLastUpdate),
+ cursor.getString(indexTitle),
+ cursor.getString(indexCustomTitle),
+ cursor.getString(indexLink),
+ cursor.getString(indexDescription),
+ cursor.getString(indexPaymentLink),
+ cursor.getString(indexAuthor),
+ cursor.getString(indexLanguage),
+ cursor.getString(indexType),
+ cursor.getString(indexFeedIdentifier),
+ cursor.getString(indexImageUrl),
+ cursor.getString(indexFileUrl),
+ cursor.getString(indexDownloadUrl),
+ cursor.getInt(indexDownloaded) > 0,
+ cursor.getInt(indexIsPaged) > 0,
+ cursor.getString(indexNextPageLink),
+ cursor.getString(indexHide),
+ SortOrder.fromCodeString(cursor.getString(indexSortOrder)),
+ cursor.getInt(indexLastUpdateFailed) > 0
+ );
+
+ FeedPreferences preferences = FeedPreferences.fromCursor(cursor);
+ feed.setPreferences(preferences);
+ return feed;
+ }
+}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedItemFilterQuery.java b/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedItemFilterQuery.java
new file mode 100644
index 000000000..f6963b5ac
--- /dev/null
+++ b/core/src/main/java/de/danoeh/antennapod/core/storage/mapper/FeedItemFilterQuery.java
@@ -0,0 +1,76 @@
+package de.danoeh.antennapod.core.storage.mapper;
+
+import de.danoeh.antennapod.core.feed.FeedItemFilter;
+import de.danoeh.antennapod.core.storage.PodDBAdapter;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class FeedItemFilterQuery {
+ private FeedItemFilterQuery() {
+ // Must not be instantiated
+ }
+
+ /**
+ * Express the filter using an SQL boolean statement that can be inserted into an SQL WHERE clause
+ * to yield output filtered according to the rules of this filter.
+ *
+ * @return An SQL boolean statement that matches the desired items,
+ * empty string if there is nothing to filter
+ */
+ public static String generateFrom(FeedItemFilter filter) {
+ // The keys used within this method, but explicitly combined with their table
+ String keyRead = PodDBAdapter.TABLE_NAME_FEED_ITEMS + "." + PodDBAdapter.KEY_READ;
+ String keyPosition = PodDBAdapter.TABLE_NAME_FEED_MEDIA + "." + PodDBAdapter.KEY_POSITION;
+ String keyDownloaded = PodDBAdapter.TABLE_NAME_FEED_MEDIA + "." + PodDBAdapter.KEY_DOWNLOADED;
+ String keyMediaId = PodDBAdapter.TABLE_NAME_FEED_MEDIA + "." + PodDBAdapter.KEY_ID;
+ String keyItemId = PodDBAdapter.TABLE_NAME_FEED_ITEMS + "." + PodDBAdapter.KEY_ID;
+ String keyFeedItem = PodDBAdapter.KEY_FEEDITEM;
+ String tableQueue = PodDBAdapter.TABLE_NAME_QUEUE;
+ String tableFavorites = PodDBAdapter.TABLE_NAME_FAVORITES;
+
+ List<String> statements = new ArrayList<>();
+ if (filter.showPlayed) {
+ statements.add(keyRead + " = 1 ");
+ } else if (filter.showUnplayed) {
+ statements.add(" NOT " + keyRead + " = 1 "); // Match "New" items (read = -1) as well
+ }
+ if (filter.showPaused) {
+ statements.add(" (" + keyPosition + " NOT NULL AND " + keyPosition + " > 0 " + ") ");
+ } else if (filter.showNotPaused) {
+ statements.add(" (" + keyPosition + " IS NULL OR " + keyPosition + " = 0 " + ") ");
+ }
+ if (filter.showQueued) {
+ statements.add(keyItemId + " IN (SELECT " + keyFeedItem + " FROM " + tableQueue + ") ");
+ } else if (filter.showNotQueued) {
+ statements.add(keyItemId + " NOT IN (SELECT " + keyFeedItem + " FROM " + tableQueue + ") ");
+ }
+ if (filter.showDownloaded) {
+ statements.add(keyDownloaded + " = 1 ");
+ } else if (filter.showNotDownloaded) {
+ statements.add(keyDownloaded + " = 0 ");
+ }
+ if (filter.showHasMedia) {
+ statements.add(keyMediaId + " NOT NULL ");
+ } else if (filter.showNoMedia) {
+ statements.add(keyMediaId + " IS NULL ");
+ }
+ if (filter.showIsFavorite) {
+ statements.add(keyItemId + " IN (SELECT " + keyFeedItem + " FROM " + tableFavorites + ") ");
+ } else if (filter.showNotFavorite) {
+ statements.add(keyItemId + " NOT IN (SELECT " + keyFeedItem + " FROM " + tableFavorites + ") ");
+ }
+
+ if (statements.isEmpty()) {
+ return "";
+ }
+
+ StringBuilder query = new StringBuilder(" (" + statements.get(0));
+ for (String r : statements.subList(1, statements.size())) {
+ query.append(" AND ");
+ query.append(r);
+ }
+ query.append(") ");
+ return query.toString();
+ }
+}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java b/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java
index 08c64e062..670a65e44 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/sync/SyncService.java
@@ -80,7 +80,7 @@ public class SyncService extends Worker {
if (!GpodnetPreferences.loggedIn()) {
return Result.success();
}
- syncServiceImpl = new GpodnetService(AntennapodHttpClient.getHttpClient(), GpodnetPreferences.getHostname());
+ syncServiceImpl = new GpodnetService(AntennapodHttpClient.getHttpClient(), GpodnetPreferences.getHosturl());
SharedPreferences.Editor prefs = getApplicationContext().getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
.edit();
prefs.putLong(PREF_LAST_SYNC_ATTEMPT_TIMESTAMP, System.currentTimeMillis()).apply();
@@ -486,7 +486,11 @@ public class SyncService extends Worker {
}
private void updateErrorNotification(SyncServiceException exception) {
- Log.d(TAG, "Posting error notification");
+ if (!UserPreferences.gpodnetNotificationsEnabled()) {
+ Log.d(TAG, "Skipping sync error notification because of user setting");
+ return;
+ }
+ Log.d(TAG, "Posting sync error notification");
final String description = getApplicationContext().getString(R.string.gpodnetsync_error_descr)
+ exception.getMessage();
diff --git a/core/src/main/java/de/danoeh/antennapod/core/sync/gpoddernet/GpodnetService.java b/core/src/main/java/de/danoeh/antennapod/core/sync/gpoddernet/GpodnetService.java
index 62c8ce5f3..cecfc0d2c 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/sync/gpoddernet/GpodnetService.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/sync/gpoddernet/GpodnetService.java
@@ -2,6 +2,7 @@ package de.danoeh.antennapod.core.sync.gpoddernet;
import android.util.Log;
import androidx.annotation.NonNull;
+import de.danoeh.antennapod.core.BuildConfig;
import de.danoeh.antennapod.core.preferences.GpodnetPreferences;
import de.danoeh.antennapod.core.sync.gpoddernet.model.GpodnetDevice;
import de.danoeh.antennapod.core.sync.model.EpisodeAction;
@@ -36,27 +37,63 @@ import java.net.URL;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.Collection;
+import java.util.Collections;
import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
/**
* Communicates with the gpodder.net service.
*/
public class GpodnetService implements ISyncService {
public static final String TAG = "GpodnetService";
public static final String DEFAULT_BASE_HOST = "gpodder.net";
- private static final String BASE_SCHEME = "https";
private static final int UPLOAD_BULK_SIZE = 30;
private static final MediaType TEXT = MediaType.parse("plain/text; charset=utf-8");
private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
- private final String baseHost;
+ private String baseScheme;
+ private String baseHost;
+ private int basePort;
+
private final OkHttpClient httpClient;
private String username = null;
- public GpodnetService(OkHttpClient httpClient, String baseHost) {
+ // split into schema, host and port - missing parts are null
+ private static Pattern urlsplit_regex = Pattern.compile("(?:(https?)://)?([^:]+)(?::(\\d+))?");
+
+ public GpodnetService(OkHttpClient httpClient, String baseHosturl) {
this.httpClient = httpClient;
- this.baseHost = baseHost;
+
+ Matcher m = urlsplit_regex.matcher(baseHosturl);
+ if (m.matches()) {
+ this.baseScheme = m.group(1);
+ this.baseHost = m.group(2);
+ if (m.group(3) == null) {
+ this.basePort = -1;
+ } else {
+ this.basePort = Integer.parseInt(m.group(3)); // regex -> can only be digits
+ }
+ } else {
+ // URL does not match regex: use it anyway -> this will cause an exception on connect
+ this.baseScheme = "https";
+ this.baseHost = baseHosturl;
+ this.basePort = 443;
+ }
+
+ if (this.baseScheme == null) { // assume https
+ this.baseScheme = "https";
+ }
+
+ if (this.baseScheme.equals("https") && this.basePort == -1) {
+ this.basePort = 443;
+ }
+
+ if (this.baseScheme.equals("http") && this.basePort == -1) {
+ this.basePort = 80;
+ }
}
private void requireLoggedIn() {
@@ -71,7 +108,8 @@ public class GpodnetService implements ISyncService {
public List<GpodnetTag> getTopTags(int count) throws GpodnetServiceException {
URL url;
try {
- url = new URI(BASE_SCHEME, baseHost, String.format(Locale.US, "/api/2/tags/%d.json", count), null).toURL();
+ url = new URI(baseScheme, null, baseHost, basePort,
+ String.format(Locale.US, "/api/2/tags/%d.json", count), null, null).toURL();
} catch (MalformedURLException | URISyntaxException e) {
e.printStackTrace();
throw new GpodnetServiceException(e);
@@ -104,8 +142,8 @@ public class GpodnetService implements ISyncService {
public List<GpodnetPodcast> getPodcastsForTag(@NonNull GpodnetTag tag, int count)
throws GpodnetServiceException {
try {
- URL url = new URI(BASE_SCHEME, baseHost, String.format(Locale.US,
- "/api/2/tag/%s/%d.json", tag.getTag(), count), null).toURL();
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format(Locale.US, "/api/2/tag/%s/%d.json", tag.getTag(), count), null, null).toURL();
Request.Builder request = new Request.Builder().url(url);
String response = executeRequest(request);
@@ -130,7 +168,8 @@ public class GpodnetService implements ISyncService {
}
try {
- URL url = new URI(BASE_SCHEME, baseHost, String.format(Locale.US, "/toplist/%d.json", count), null).toURL();
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format(Locale.US, "/toplist/%d.json", count), null, null).toURL();
Request.Builder request = new Request.Builder().url(url);
String response = executeRequest(request);
@@ -161,8 +200,8 @@ public class GpodnetService implements ISyncService {
}
try {
- URL url = new URI(BASE_SCHEME, baseHost,
- String.format(Locale.US, "/suggestions/%d.json", count), null).toURL();
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format(Locale.US, "/suggestions/%d.json", count), null, null).toURL();
Request.Builder request = new Request.Builder().url(url);
String response = executeRequest(request);
@@ -187,7 +226,7 @@ public class GpodnetService implements ISyncService {
.format(Locale.US, "q=%s&scale_logo=%d", query, scaledLogoSize) : String
.format("q=%s", query);
try {
- URL url = new URI(BASE_SCHEME, null, baseHost, -1, "/search.json",
+ URL url = new URI(baseScheme, null, baseHost, basePort, "/search.json",
parameters, null).toURL();
Request.Builder request = new Request.Builder().url(url);
String response = executeRequest(request);
@@ -214,7 +253,8 @@ public class GpodnetService implements ISyncService {
public List<GpodnetDevice> getDevices() throws GpodnetServiceException {
requireLoggedIn();
try {
- URL url = new URI(BASE_SCHEME, baseHost, String.format("/api/2/devices/%s.json", username), null).toURL();
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/api/2/devices/%s.json", username), null, null).toURL();
Request.Builder request = new Request.Builder().url(url);
String response = executeRequest(request);
JSONArray devicesArray = new JSONArray(response);
@@ -226,6 +266,45 @@ public class GpodnetService implements ISyncService {
}
/**
+ * Returns synchronization status of devices.
+ * <p/>
+ * This method requires authentication.
+ *
+ * @throws GpodnetServiceAuthenticationException If there is an authentication error.
+ */
+ public List<List<String>> getSynchronizedDevices() throws GpodnetServiceException {
+ requireLoggedIn();
+ try {
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/api/2/sync-devices/%s.json", username), null, null).toURL();
+ Request.Builder request = new Request.Builder().url(url);
+ String response = executeRequest(request);
+ JSONObject syncStatus = new JSONObject(response);
+ List<List<String>> result = new ArrayList<>();
+
+ JSONArray synchronizedDevices = syncStatus.getJSONArray("synchronized");
+ for (int i = 0; i < synchronizedDevices.length(); i++) {
+ JSONArray groupDevices = synchronizedDevices.getJSONArray(i);
+ List<String> group = new ArrayList<>();
+ for (int j = 0; j < groupDevices.length(); j++) {
+ group.add(groupDevices.getString(j));
+ }
+ result.add(group);
+ }
+
+ JSONArray notSynchronizedDevices = syncStatus.getJSONArray("not-synchronized");
+ for (int i = 0; i < notSynchronizedDevices.length(); i++) {
+ result.add(Collections.singletonList(notSynchronizedDevices.getString(i)));
+ }
+
+ return result;
+ } catch (JSONException | MalformedURLException | URISyntaxException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ /**
* Configures the device of a given user.
* <p/>
* This method requires authentication.
@@ -237,8 +316,8 @@ public class GpodnetService implements ISyncService {
throws GpodnetServiceException {
requireLoggedIn();
try {
- URL url = new URI(BASE_SCHEME, baseHost, String.format(
- "/api/2/devices/%s/%s.json", username, deviceId), null).toURL();
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/api/2/devices/%s/%s.json", username, deviceId), null, null).toURL();
String content;
if (caption != null || type != null) {
JSONObject jsonContent = new JSONObject();
@@ -262,6 +341,39 @@ public class GpodnetService implements ISyncService {
}
/**
+ * Links devices for synchronization.
+ * <p/>
+ * This method requires authentication.
+ *
+ * @throws GpodnetServiceAuthenticationException If there is an authentication error.
+ */
+ public void linkDevices(@NonNull List<String> deviceIds) throws GpodnetServiceException {
+ requireLoggedIn();
+ try {
+ final URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/api/2/sync-devices/%s.json", username), null, null).toURL();
+ JSONObject jsonContent = new JSONObject();
+ JSONArray group = new JSONArray();
+ for (String deviceId : deviceIds) {
+ group.put(deviceId);
+ }
+
+ JSONArray synchronizedGroups = new JSONArray();
+ synchronizedGroups.put(group);
+ jsonContent.put("synchronize", synchronizedGroups);
+ jsonContent.put("stop-synchronize", new JSONArray());
+
+ Log.d("aaaa", jsonContent.toString());
+ RequestBody body = RequestBody.create(JSON, jsonContent.toString());
+ Request.Builder request = new Request.Builder().post(body).url(url);
+ executeRequest(request);
+ } catch (JSONException | MalformedURLException | URISyntaxException e) {
+ e.printStackTrace();
+ throw new GpodnetServiceException(e);
+ }
+ }
+
+ /**
* Returns the subscriptions of a specific device.
* <p/>
* This method requires authentication.
@@ -273,8 +385,8 @@ public class GpodnetService implements ISyncService {
public String getSubscriptionsOfDevice(@NonNull String deviceId) throws GpodnetServiceException {
requireLoggedIn();
try {
- URL url = new URI(BASE_SCHEME, baseHost, String.format(
- "/subscriptions/%s/%s.opml", username, deviceId), null).toURL();
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/subscriptions/%s/%s.opml", username, deviceId), null, null).toURL();
Request.Builder request = new Request.Builder().url(url);
return executeRequest(request);
} catch (MalformedURLException | URISyntaxException e) {
@@ -295,7 +407,8 @@ public class GpodnetService implements ISyncService {
public String getSubscriptionsOfUser() throws GpodnetServiceException {
requireLoggedIn();
try {
- URL url = new URI(BASE_SCHEME, baseHost, String.format("/subscriptions/%s.opml", username), null).toURL();
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/subscriptions/%s.opml", username), null, null).toURL();
Request.Builder request = new Request.Builder().url(url);
return executeRequest(request);
} catch (MalformedURLException | URISyntaxException e) {
@@ -319,8 +432,8 @@ public class GpodnetService implements ISyncService {
throws GpodnetServiceException {
requireLoggedIn();
try {
- URL url = new URI(BASE_SCHEME, baseHost, String.format(
- "/subscriptions/%s/%s.txt", username, deviceId), null).toURL();
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/subscriptions/%s/%s.txt", username, deviceId), null, null).toURL();
StringBuilder builder = new StringBuilder();
for (String s : subscriptions) {
builder.append(s);
@@ -353,8 +466,8 @@ public class GpodnetService implements ISyncService {
@NonNull Collection<String> removed) throws GpodnetServiceException {
requireLoggedIn();
try {
- URL url = new URI(BASE_SCHEME, baseHost, String.format(
- "/api/2/subscriptions/%s/%s.json", username, deviceId), null).toURL();
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/api/2/subscriptions/%s/%s.json", username, deviceId), null, null).toURL();
final JSONObject requestObject = new JSONObject();
requestObject.put("add", new JSONArray(added));
@@ -389,8 +502,7 @@ public class GpodnetService implements ISyncService {
String params = String.format(Locale.US, "since=%d", timestamp);
String path = String.format("/api/2/subscriptions/%s/%s.json", username, deviceId);
try {
- URL url = new URI(BASE_SCHEME, null, baseHost, -1, path, params,
- null).toURL();
+ URL url = new URI(baseScheme, null, baseHost, basePort, path, params, null).toURL();
Request.Builder request = new Request.Builder().url(url);
String response = executeRequest(request);
@@ -432,8 +544,8 @@ public class GpodnetService implements ISyncService {
throws SyncServiceException {
try {
Log.d(TAG, "Uploading partial actions " + from + " to " + to + " of " + episodeActions.size());
- URL url = new URI(BASE_SCHEME, baseHost, String.format(
- "/api/2/episodes/%s.json", username), null).toURL();
+ URL url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/api/2/episodes/%s.json", username), null, null).toURL();
final JSONArray list = new JSONArray();
for (int i = from; i < to; i++) {
@@ -471,7 +583,7 @@ public class GpodnetService implements ISyncService {
String params = String.format(Locale.US, "since=%d", timestamp);
String path = String.format("/api/2/episodes/%s.json", username);
try {
- URL url = new URI(BASE_SCHEME, null, baseHost, -1, path, params, null).toURL();
+ URL url = new URI(baseScheme, null, baseHost, basePort, path, params, null).toURL();
Request.Builder request = new Request.Builder().url(url);
String response = executeRequest(request);
@@ -497,7 +609,8 @@ public class GpodnetService implements ISyncService {
public void authenticate(@NonNull String username, @NonNull String password) throws GpodnetServiceException {
URL url;
try {
- url = new URI(BASE_SCHEME, baseHost, String.format("/api/2/auth/%s/login.json", username), null).toURL();
+ url = new URI(baseScheme, null, baseHost, basePort,
+ String.format("/api/2/auth/%s/login.json", username), null, null).toURL();
} catch (MalformedURLException | URISyntaxException e) {
e.printStackTrace();
throw new GpodnetServiceException(e);
@@ -567,6 +680,13 @@ public class GpodnetService implements ISyncService {
if (responseCode == HttpURLConnection.HTTP_UNAUTHORIZED) {
throw new GpodnetServiceAuthenticationException("Wrong username or password");
} else {
+ if (BuildConfig.DEBUG) {
+ try {
+ Log.d(TAG, response.body().string());
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
throw new GpodnetServiceBadStatusCodeException("Bad response code: " + responseCode, responseCode);
}
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSITunes.java b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSITunes.java
index 1e069a1f0..c57d6a5d1 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSITunes.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/syndication/namespace/NSITunes.java
@@ -3,6 +3,8 @@ package de.danoeh.antennapod.core.syndication.namespace;
import android.text.TextUtils;
import android.util.Log;
+import androidx.core.text.HtmlCompat;
+
import org.xml.sax.Attributes;
import de.danoeh.antennapod.core.feed.FeedItem;
@@ -62,7 +64,8 @@ public class NSITunes extends Namespace {
private void parseAuthor(HandlerState state) {
if (state.getFeed() != null) {
String author = state.getContentBuf().toString();
- state.getFeed().setAuthor(author);
+ state.getFeed().setAuthor(HtmlCompat.fromHtml(author,
+ HtmlCompat.FROM_HTML_MODE_LEGACY).toString());
}
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/ChapterUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/ChapterUtils.java
index d4a2cdca6..4ad35d0c2 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/ChapterUtils.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/ChapterUtils.java
@@ -3,32 +3,27 @@ package de.danoeh.antennapod.core.util;
import android.content.ContentResolver;
import android.content.Context;
import android.net.Uri;
-import androidx.annotation.NonNull;
import android.util.Log;
-
-import java.net.URLConnection;
-import de.danoeh.antennapod.core.ClientConfig;
-import org.apache.commons.io.IOUtils;
-
-import java.io.BufferedInputStream;
-import java.io.File;
-import java.io.FileInputStream;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.URL;
-import java.util.Collections;
-import java.util.List;
-
+import androidx.annotation.NonNull;
import de.danoeh.antennapod.core.feed.Chapter;
+import de.danoeh.antennapod.core.service.download.AntennapodHttpClient;
import de.danoeh.antennapod.core.util.comparator.ChapterStartTimeComparator;
import de.danoeh.antennapod.core.util.id3reader.ChapterReader;
import de.danoeh.antennapod.core.util.id3reader.ID3ReaderException;
import de.danoeh.antennapod.core.util.playback.Playable;
import de.danoeh.antennapod.core.util.vorbiscommentreader.VorbisCommentChapterReader;
import de.danoeh.antennapod.core.util.vorbiscommentreader.VorbisCommentReaderException;
+import okhttp3.Request;
+import okhttp3.Response;
import org.apache.commons.io.input.CountingInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Collections;
+import java.util.List;
+
/**
* Utility class for getting chapter data from media files.
*/
@@ -52,101 +47,57 @@ public class ChapterUtils {
return chapters.size() - 1;
}
- public static List<Chapter> loadChaptersFromStreamUrl(Playable media, Context context) {
- List<Chapter> chapters = ChapterUtils.readID3ChaptersFromPlayableStreamUrl(media, context);
- if (chapters == null) {
- chapters = ChapterUtils.readOggChaptersFromPlayableStreamUrl(media, context);
- }
- return chapters;
- }
-
- public static List<Chapter> loadChaptersFromFileUrl(Playable media) {
- if (!media.localFileAvailable()) {
- Log.e(TAG, "Could not load chapters from file url: local file not available");
- return null;
- }
- List<Chapter> chapters = ChapterUtils.readID3ChaptersFromPlayableFileUrl(media);
- if (chapters == null) {
- chapters = ChapterUtils.readOggChaptersFromPlayableFileUrl(media);
+ public static List<Chapter> loadChaptersFromMediaFile(Playable playable, Context context) {
+ try (CountingInputStream in = openStream(playable, context)) {
+ List<Chapter> chapters = readId3ChaptersFrom(in);
+ if (!chapters.isEmpty()) {
+ Log.i(TAG, "Chapters loaded");
+ return chapters;
+ }
+ } catch (IOException | ID3ReaderException e) {
+ Log.e(TAG, "Unable to load ID3 chapters: " + e.getMessage());
}
- return chapters;
- }
- /**
- * Uses the download URL of a media object of a feeditem to read its ID3
- * chapters.
- */
- private static List<Chapter> readID3ChaptersFromPlayableStreamUrl(Playable p, Context context) {
- if (p == null || p.getStreamUrl() == null) {
- Log.e(TAG, "Unable to read ID3 chapters: media or download URL was null");
- return null;
- }
- Log.d(TAG, "Reading id3 chapters from item " + p.getEpisodeTitle());
- CountingInputStream in = null;
- try {
- if (p.getStreamUrl().startsWith(ContentResolver.SCHEME_CONTENT)) {
- Uri uri = Uri.parse(p.getStreamUrl());
- in = new CountingInputStream(context.getContentResolver().openInputStream(uri));
- } else {
- URL url = new URL(p.getStreamUrl());
- URLConnection urlConnection = url.openConnection();
- urlConnection.setRequestProperty("User-Agent", ClientConfig.USER_AGENT);
- in = new CountingInputStream(urlConnection.getInputStream());
- }
- List<Chapter> chapters = readChaptersFrom(in);
+ try (CountingInputStream in = openStream(playable, context)) {
+ List<Chapter> chapters = readOggChaptersFromInputStream(in);
if (!chapters.isEmpty()) {
+ Log.i(TAG, "Chapters loaded");
return chapters;
}
- Log.i(TAG, "Chapters loaded");
- } catch (IOException | ID3ReaderException | IllegalArgumentException e) {
- Log.e(TAG, Log.getStackTraceString(e));
- } finally {
- IOUtils.closeQuietly(in);
+ } catch (IOException | VorbisCommentReaderException e) {
+ Log.e(TAG, "Unable to load vorbis chapters: " + e.getMessage());
}
return null;
}
- /**
- * Uses the file URL of a media object of a feeditem to read its ID3
- * chapters.
- */
- private static List<Chapter> readID3ChaptersFromPlayableFileUrl(Playable p) {
- if (p == null || !p.localFileAvailable() || p.getLocalMediaUrl() == null) {
- return null;
- }
- Log.d(TAG, "Reading id3 chapters from item " + p.getEpisodeTitle());
- File source = new File(p.getLocalMediaUrl());
- if (!source.exists()) {
- Log.e(TAG, "Unable to read id3 chapters: Source doesn't exist");
- return null;
- }
-
- CountingInputStream in = null;
- try {
- in = new CountingInputStream(new BufferedInputStream(new FileInputStream(source)));
- List<Chapter> chapters = readChaptersFrom(in);
- if (!chapters.isEmpty()) {
- return chapters;
+ private static CountingInputStream openStream(Playable playable, Context context) throws IOException {
+ if (playable.localFileAvailable()) {
+ if (playable.getLocalMediaUrl() == null) {
+ throw new IOException("No local url");
}
- Log.i(TAG, "Chapters loaded");
- } catch (IOException | ID3ReaderException e) {
- Log.e(TAG, Log.getStackTraceString(e));
- } finally {
- IOUtils.closeQuietly(in);
+ File source = new File(playable.getLocalMediaUrl());
+ if (!source.exists()) {
+ throw new IOException("Local file does not exist");
+ }
+ return new CountingInputStream(new FileInputStream(source));
+ } else if (playable.getStreamUrl().startsWith(ContentResolver.SCHEME_CONTENT)) {
+ Uri uri = Uri.parse(playable.getStreamUrl());
+ return new CountingInputStream(context.getContentResolver().openInputStream(uri));
+ } else {
+ Request request = new Request.Builder().url(playable.getStreamUrl()).build();
+ Response response = AntennapodHttpClient.getHttpClient().newCall(request).execute();
+ if (response.body() == null) {
+ throw new IOException("Body is null");
+ }
+ return new CountingInputStream(response.body().byteStream());
}
- return null;
}
@NonNull
- private static List<Chapter> readChaptersFrom(CountingInputStream in) throws IOException, ID3ReaderException {
- ChapterReader reader = new ChapterReader();
- reader.readInputStream(in);
+ private static List<Chapter> readId3ChaptersFrom(CountingInputStream in) throws IOException, ID3ReaderException {
+ ChapterReader reader = new ChapterReader(in);
+ reader.readInputStream();
List<Chapter> chapters = reader.getChapters();
-
- if (chapters == null) {
- Log.i(TAG, "ChapterReader could not find any ID3 chapters");
- return Collections.emptyList();
- }
Collections.sort(chapters, new ChapterStartTimeComparator());
enumerateEmptyChapterTitles(chapters);
if (!chaptersValid(chapters)) {
@@ -156,73 +107,20 @@ public class ChapterUtils {
return chapters;
}
- private static List<Chapter> readOggChaptersFromPlayableStreamUrl(Playable media, Context context) {
- if (media == null || !media.streamAvailable()) {
- return null;
- }
- InputStream input = null;
- try {
- if (media.getStreamUrl().startsWith(ContentResolver.SCHEME_CONTENT)) {
- Uri uri = Uri.parse(media.getStreamUrl());
- input = context.getContentResolver().openInputStream(uri);
- } else {
- URL url = new URL(media.getStreamUrl());
- URLConnection urlConnection = url.openConnection();
- urlConnection.setRequestProperty("User-Agent", ClientConfig.USER_AGENT);
- input = urlConnection.getInputStream();
- }
- if (input != null) {
- return readOggChaptersFromInputStream(media, input);
- }
- } catch (IOException | IllegalArgumentException e) {
- Log.e(TAG, Log.getStackTraceString(e));
- } finally {
- IOUtils.closeQuietly(input);
- }
- return null;
- }
-
- private static List<Chapter> readOggChaptersFromPlayableFileUrl(Playable media) {
- if (media == null || media.getLocalMediaUrl() == null) {
- return null;
- }
- File source = new File(media.getLocalMediaUrl());
- if (source.exists()) {
- InputStream input = null;
- try {
- input = new BufferedInputStream(new FileInputStream(source));
- return readOggChaptersFromInputStream(media, input);
- } catch (FileNotFoundException e) {
- Log.e(TAG, Log.getStackTraceString(e));
- } finally {
- IOUtils.closeQuietly(input);
- }
+ @NonNull
+ private static List<Chapter> readOggChaptersFromInputStream(InputStream input) throws VorbisCommentReaderException {
+ VorbisCommentChapterReader reader = new VorbisCommentChapterReader();
+ reader.readInputStream(input);
+ List<Chapter> chapters = reader.getChapters();
+ if (chapters == null) {
+ return Collections.emptyList();
}
- return null;
- }
-
- private static List<Chapter> readOggChaptersFromInputStream(Playable p, InputStream input) {
- Log.d(TAG, "Trying to read chapters from item with title " + p.getEpisodeTitle());
- try {
- VorbisCommentChapterReader reader = new VorbisCommentChapterReader();
- reader.readInputStream(input);
- List<Chapter> chapters = reader.getChapters();
- if (chapters == null) {
- Log.i(TAG, "ChapterReader could not find any Ogg vorbis chapters");
- return null;
- }
- Collections.sort(chapters, new ChapterStartTimeComparator());
- enumerateEmptyChapterTitles(chapters);
- if (chaptersValid(chapters)) {
- Log.i(TAG, "Chapters loaded");
- return chapters;
- } else {
- Log.e(TAG, "Chapter data was invalid");
- }
- } catch (VorbisCommentReaderException e) {
- e.printStackTrace();
+ Collections.sort(chapters, new ChapterStartTimeComparator());
+ enumerateEmptyChapterTitles(chapters);
+ if (chaptersValid(chapters)) {
+ return chapters;
}
- return null;
+ return Collections.emptyList();
}
/**
@@ -248,5 +146,4 @@ public class ChapterUtils {
}
return true;
}
-
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/DateUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/DateUtils.java
index 833ff33f1..196583bcd 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/DateUtils.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/DateUtils.java
@@ -30,9 +30,12 @@ public class DateUtils {
}
String date = input.trim().replace('/', '-').replaceAll("( ){2,}+", " ");
+ // remove colon from timezone to avoid differences between Android and Java SimpleDateFormat
+ date = date.replaceAll("([+-]\\d\\d):(\\d\\d)$", "$1$2");
+
// CEST is widely used but not in the "ISO 8601 Time zone" list. Let's hack around.
- date = date.replaceAll("CEST$", "+02:00");
- date = date.replaceAll("CET$", "+01:00");
+ date = date.replaceAll("CEST$", "+0200");
+ date = date.replaceAll("CET$", "+0100");
// some generators use "Sept" for September
date = date.replaceAll("\\bSept\\b", "Sep");
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/DownloadError.java b/core/src/main/java/de/danoeh/antennapod/core/util/DownloadError.java
index 0c9989b43..babf3a846 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/DownloadError.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/DownloadError.java
@@ -19,11 +19,11 @@ public enum DownloadError {
ERROR_NOT_ENOUGH_SPACE(10, R.string.download_error_insufficient_space),
ERROR_UNKNOWN_HOST(11, R.string.download_error_unknown_host),
ERROR_REQUEST_ERROR(12, R.string.download_error_request_error),
- ERROR_DB_ACCESS_ERROR(13, R.string.download_error_db_access),
- ERROR_UNAUTHORIZED(14, R.string.download_error_unauthorized),
+ ERROR_DB_ACCESS_ERROR(13, R.string.download_error_db_access),
+ ERROR_UNAUTHORIZED(14, R.string.download_error_unauthorized),
ERROR_FILE_TYPE(15, R.string.download_error_file_type_type),
- ERROR_FORBIDDEN(16, R.string.download_error_forbidden);
-
+ ERROR_FORBIDDEN(16, R.string.download_error_forbidden),
+ ERROR_IO_WRONG_SIZE(17, R.string.download_error_forbidden);
private final int code;
private final int resId;
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/FileNameGenerator.java b/core/src/main/java/de/danoeh/antennapod/core/util/FileNameGenerator.java
index 2a387b7b0..69c23efc2 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/FileNameGenerator.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/FileNameGenerator.java
@@ -13,7 +13,7 @@ import java.security.NoSuchAlgorithmException;
/** Generates valid filenames for a given string. */
public class FileNameGenerator {
@VisibleForTesting
- public static final int MAX_FILENAME_LENGTH = 255; // Limited by ext4
+ public static final int MAX_FILENAME_LENGTH = 242; // limited by CircleCI
private static final int MD5_HEX_LENGTH = 32;
private static final char[] validChars =
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/LangUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/LangUtils.java
deleted file mode 100644
index 20af6415e..000000000
--- a/core/src/main/java/de/danoeh/antennapod/core/util/LangUtils.java
+++ /dev/null
@@ -1,124 +0,0 @@
-package de.danoeh.antennapod.core.util;
-
-import androidx.collection.ArrayMap;
-
-import java.nio.charset.Charset;
-
-public class LangUtils {
-
- private LangUtils(){}
-
- public static final Charset UTF_8 = Charset.forName("UTF-8");
-
- private static final ArrayMap<String, String> languages;
- static {
- languages = new ArrayMap<>();
- languages.put("af", "Afrikaans");
- languages.put("sq", "Albanian");
- languages.put("sq", "Albanian");
- languages.put("eu", "Basque");
- languages.put("be", "Belarusian");
- languages.put("bg", "Bulgarian");
- languages.put("ca", "Catalan");
- languages.put("Chinese (Simplified)", "zh-cn");
- languages.put("Chinese (Traditional)", "zh-tw");
- languages.put("hr", "Croatian");
- languages.put("cs", "Czech");
- languages.put("da", "Danish");
- languages.put("nl", "Dutch");
- languages.put("nl-be", "Dutch (Belgium)");
- languages.put("nl-nl", "Dutch (Netherlands)");
- languages.put("en", "English");
- languages.put("en-au", "English (Australia)");
- languages.put("en-bz", "English (Belize)");
- languages.put("en-ca", "English (Canada)");
- languages.put("en-ie", "English (Ireland)");
- languages.put("en-jm", "English (Jamaica)");
- languages.put("en-nz", "English (New Zealand)");
- languages.put("en-ph", "English (Phillipines)");
- languages.put("en-za", "English (South Africa)");
- languages.put("en-tt", "English (Trinidad)");
- languages.put("en-gb", "English (United Kingdom)");
- languages.put("en-us", "English (United States)");
- languages.put("en-zw", "English (Zimbabwe)");
- languages.put("et", "Estonian");
- languages.put("fo", "Faeroese");
- languages.put("fi", "Finnish");
- languages.put("fr", "French");
- languages.put("fr-be", "French (Belgium)");
- languages.put("fr-ca", "French (Canada)");
- languages.put("fr-fr", "French (France)");
- languages.put("fr-lu", "French (Luxembourg)");
- languages.put("fr-mc", "French (Monaco)");
- languages.put("fr-ch", "French (Switzerland)");
- languages.put("gl", "Galician");
- languages.put("gd", "Gaelic");
- languages.put("de", "German");
- languages.put("de-at", "German (Austria)");
- languages.put("de-de", "German (Germany)");
- languages.put("de-li", "German (Liechtenstein)");
- languages.put("de-lu", "German (Luxembourg)");
- languages.put("de-ch", "German (Switzerland)");
- languages.put("el", "Greek");
- languages.put("haw", "Hawaiian");
- languages.put("hu", "Hungarian");
- languages.put("is", "Icelandic");
- languages.put("in", "Indonesian");
- languages.put("ga", "Irish");
- languages.put("it", "Italian");
- languages.put("it-it", "Italian (Italy)");
- languages.put("it-ch", "Italian (Switzerland)");
- languages.put("ja", "Japanese");
- languages.put("ko", "Korean");
- languages.put("mk", "Macedonian");
- languages.put("no", "Norwegian");
- languages.put("pl", "Polish");
- languages.put("pt", "Portugese");
- languages.put("pt-br", "Portugese (Brazil)");
- languages.put("pt-pt", "Portugese (Portugal");
- languages.put("ro", "Romanian");
- languages.put("ro-mo", "Romanian (Moldova)");
- languages.put("ro-ro", "Romanian (Romania");
- languages.put("ru", "Russian");
- languages.put("ru-mo", "Russian (Moldova)");
- languages.put("ru-ru", "Russian (Russia)");
- languages.put("sr", "Serbian");
- languages.put("sk", "Slovak");
- languages.put("sl", "Slovenian");
- languages.put("es", "Spanish");
- languages.put("es-ar", "Spanish (Argentinia)");
- languages.put("es=bo", "Spanish (Bolivia)");
- languages.put("es-cl", "Spanish (Chile)");
- languages.put("es-co", "Spanish (Colombia)");
- languages.put("es-cr", "Spanish (Costa Rica)");
- languages.put("es-do", "Spanish (Dominican Republic)");
- languages.put("es-ec", "Spanish (Ecuador)");
- languages.put("es-sv", "Spanish (El Salvador)");
- languages.put("es-gt", "Spanish (Guatemala)");
- languages.put("es-hn", "Spanish (Honduras)");
- languages.put("es-mx", "Spanish (Mexico)");
- languages.put("es-ni", "Spanish (Nicaragua)");
- languages.put("es-pa", "Spanish (Panama)");
- languages.put("es-py", "Spanish (Paraguay)");
- languages.put("es-pe", "Spanish (Peru)");
- languages.put("es-pr", "Spanish (Puerto Rico)");
- languages.put("es-es", "Spanish (Spain)");
- languages.put("es-uy", "Spanish (Uruguay)");
- languages.put("es-ve", "Spanish (Venezuela)");
- languages.put("sv", "Swedish");
- languages.put("sv-fi", "Swedish (Finland)");
- languages.put("sv-se", "Swedish (Sweden)");
- languages.put("tr", "Turkish");
- languages.put("uk", "Ukranian");
- }
-
- /** Finds language string for key or returns the language key if it can't be found. */
- public static String getLanguageString(String key) {
- String language = languages.get(key);
- if (language != null) {
- return language;
- } else {
- return key;
- }
- }
-}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/ShareUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/ShareUtils.java
index 3e9e8327e..2622d81aa 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/ShareUtils.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/ShareUtils.java
@@ -19,74 +19,77 @@ import de.danoeh.antennapod.core.feed.FeedMedia;
/** Utility methods for sharing data */
public class ShareUtils {
- private static final String TAG = "ShareUtils";
-
- private ShareUtils() {}
-
- public static void shareLink(Context context, String text) {
- Intent i = new Intent(Intent.ACTION_SEND);
- i.setType("text/plain");
- i.putExtra(Intent.EXTRA_TEXT, text);
- context.startActivity(Intent.createChooser(i, context.getString(R.string.share_url_label)));
- }
+ private static final String TAG = "ShareUtils";
- public static void shareFeedlink(Context context, Feed feed) {
- shareLink(context, feed.getTitle() + ": " + feed.getLink());
- }
-
- public static void shareFeedDownloadLink(Context context, Feed feed) {
- shareLink(context, feed.getTitle() + ": " + feed.getDownload_url());
- }
+ private ShareUtils() {
+ }
- public static void shareFeedItemLink(Context context, FeedItem item) {
- shareFeedItemLink(context, item, false);
- }
+ public static void shareLink(Context context, String text) {
+ Intent i = new Intent(Intent.ACTION_SEND);
+ i.setType("text/plain");
+ i.putExtra(Intent.EXTRA_TEXT, text);
+ context.startActivity(Intent.createChooser(i, context.getString(R.string.share_url_label)));
+ }
- public static void shareFeedItemDownloadLink(Context context, FeedItem item) {
- shareFeedItemDownloadLink(context, item, false);
- }
+ public static void shareFeedlink(Context context, Feed feed) {
+ shareLink(context, feed.getTitle() + ": " + feed.getLink());
+ }
- private static String getItemShareText(FeedItem item) {
- return item.getFeed().getTitle() + ": " + item.getTitle();
- }
+ public static void shareFeedDownloadLink(Context context, Feed feed) {
+ shareLink(context, feed.getTitle() + ": " + feed.getDownload_url());
+ }
+
+ public static void shareFeedItemLink(Context context, FeedItem item) {
+ shareFeedItemLink(context, item, false);
+ }
+
+ public static void shareFeedItemDownloadLink(Context context, FeedItem item) {
+ shareFeedItemDownloadLink(context, item, false);
+ }
+
+ private static String getItemShareText(FeedItem item) {
+ return item.getFeed().getTitle() + ": " + item.getTitle();
+ }
public static boolean hasLinkToShare(FeedItem item) {
- return FeedItemUtil.getLinkWithFallback(item) != null;
+ return FeedItemUtil.getLinkWithFallback(item) != null;
}
- public static void shareFeedItemLink(Context context, FeedItem item, boolean withPosition) {
- String text = getItemShareText(item) + " " + FeedItemUtil.getLinkWithFallback(item);
- if(withPosition) {
- int pos = item.getMedia().getPosition();
- text += " [" + Converter.getDurationStringLong(pos) + "]";
- }
- shareLink(context, text);
- }
+ public static void shareFeedItemLink(Context context, FeedItem item, boolean withPosition) {
+ String text = getItemShareText(item) + " " + FeedItemUtil.getLinkWithFallback(item);
+ if (withPosition) {
+ int pos = item.getMedia().getPosition();
+ text += " [" + Converter.getDurationStringLong(pos) + "]";
+ }
+ shareLink(context, text);
+ }
- public static void shareFeedItemDownloadLink(Context context, FeedItem item, boolean withPosition) {
- String text = getItemShareText(item) + " " + item.getMedia().getDownload_url();
- if(withPosition) {
- int pos = item.getMedia().getPosition();
- text += " [" + Converter.getDurationStringLong(pos) + "]";
- }
- shareLink(context, text);
- }
+ public static void shareFeedItemDownloadLink(Context context, FeedItem item, boolean withPosition) {
+ String text = getItemShareText(item) + " " + item.getMedia().getDownload_url();
+ if (withPosition) {
+ int pos = item.getMedia().getPosition();
+ text += "#t=" + pos / 1000;
+ text += " [" + Converter.getDurationStringLong(pos) + "]";
+ }
+ shareLink(context, text);
+ }
- public static void shareFeedItemFile(Context context, FeedMedia media) {
- Intent i = new Intent(Intent.ACTION_SEND);
- i.setType(media.getMime_type());
- Uri fileUri = FileProvider.getUriForFile(context, context.getString(R.string.provider_authority),
- new File(media.getLocalMediaUrl()));
- i.putExtra(Intent.EXTRA_STREAM, fileUri);
- i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
- if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) {
- List<ResolveInfo> resInfoList = context.getPackageManager().queryIntentActivities(i, PackageManager.MATCH_DEFAULT_ONLY);
- for (ResolveInfo resolveInfo : resInfoList) {
- String packageName = resolveInfo.activityInfo.packageName;
- context.grantUriPermission(packageName, fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
- }
- }
- context.startActivity(Intent.createChooser(i, context.getString(R.string.share_file_label)));
- Log.e(TAG, "shareFeedItemFile called");
- }
+ public static void shareFeedItemFile(Context context, FeedMedia media) {
+ Intent i = new Intent(Intent.ACTION_SEND);
+ i.setType(media.getMime_type());
+ Uri fileUri = FileProvider.getUriForFile(context, context.getString(R.string.provider_authority),
+ new File(media.getLocalMediaUrl()));
+ i.putExtra(Intent.EXTRA_STREAM, fileUri);
+ i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) {
+ List<ResolveInfo> resInfoList = context.getPackageManager()
+ .queryIntentActivities(i, PackageManager.MATCH_DEFAULT_ONLY);
+ for (ResolveInfo resolveInfo : resInfoList) {
+ String packageName = resolveInfo.activityInfo.packageName;
+ context.grantUriPermission(packageName, fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ }
+ }
+ context.startActivity(Intent.createChooser(i, context.getString(R.string.share_file_label)));
+ Log.e(TAG, "shareFeedItemFile called");
+ }
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/ThemeUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/ThemeUtils.java
deleted file mode 100644
index 44b31f0be..000000000
--- a/core/src/main/java/de/danoeh/antennapod/core/util/ThemeUtils.java
+++ /dev/null
@@ -1,25 +0,0 @@
-package de.danoeh.antennapod.core.util;
-
-import android.content.Context;
-import androidx.annotation.AttrRes;
-import androidx.annotation.ColorInt;
-import android.util.TypedValue;
-import androidx.annotation.DrawableRes;
-
-public class ThemeUtils {
- private ThemeUtils() {
-
- }
-
- public static @ColorInt int getColorFromAttr(Context context, @AttrRes int attr) {
- TypedValue typedValue = new TypedValue();
- context.getTheme().resolveAttribute(attr, typedValue, true);
- return typedValue.data;
- }
-
- public static @DrawableRes int getDrawableFromAttr(Context context, @AttrRes int attr) {
- TypedValue typedValue = new TypedValue();
- context.getTheme().resolveAttribute(attr, typedValue, true);
- return typedValue.resourceId;
- }
-}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/URLChecker.java b/core/src/main/java/de/danoeh/antennapod/core/util/URLChecker.java
index ac7f4848c..cb7db1709 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/URLChecker.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/URLChecker.java
@@ -9,7 +9,6 @@ import de.danoeh.antennapod.core.BuildConfig;
import okhttp3.HttpUrl;
import java.util.ArrayList;
-import java.util.Collections;
import java.util.List;
/**
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/gui/NotificationUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/gui/NotificationUtils.java
index ddbe68938..5895c5933 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/gui/NotificationUtils.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/gui/NotificationUtils.java
@@ -2,69 +2,86 @@ package de.danoeh.antennapod.core.util.gui;
import android.app.NotificationChannel;
+import android.app.NotificationChannelGroup;
import android.app.NotificationManager;
import android.content.Context;
import android.os.Build;
import androidx.annotation.RequiresApi;
import de.danoeh.antennapod.core.R;
+import de.danoeh.antennapod.core.preferences.UserPreferences;
public class NotificationUtils {
public static final String CHANNEL_ID_USER_ACTION = "user_action";
public static final String CHANNEL_ID_DOWNLOADING = "downloading";
public static final String CHANNEL_ID_PLAYING = "playing";
- public static final String CHANNEL_ID_ERROR = "error";
+ public static final String CHANNEL_ID_DOWNLOAD_ERROR = "error";
public static final String CHANNEL_ID_SYNC_ERROR = "sync_error";
public static final String CHANNEL_ID_AUTO_DOWNLOAD = "auto_download";
+ public static final String CHANNEL_ID_EPISODE_NOTIFICATIONS = "episode_notifications";
+
+ public static final String GROUP_ID_ERRORS = "group_errors";
+ public static final String GROUP_ID_NEWS = "group_news";
public static void createChannels(Context context) {
- if (android.os.Build.VERSION.SDK_INT < 26) {
+ if (Build.VERSION.SDK_INT < 26) {
return;
}
NotificationManager mNotificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
if (mNotificationManager != null) {
+ mNotificationManager.createNotificationChannelGroup(createGroupErrors(context));
+ mNotificationManager.createNotificationChannelGroup(createGroupNews(context));
+
mNotificationManager.createNotificationChannel(createChannelUserAction(context));
mNotificationManager.createNotificationChannel(createChannelDownloading(context));
mNotificationManager.createNotificationChannel(createChannelPlaying(context));
mNotificationManager.createNotificationChannel(createChannelError(context));
mNotificationManager.createNotificationChannel(createChannelSyncError(context));
mNotificationManager.createNotificationChannel(createChannelAutoDownload(context));
+ mNotificationManager.createNotificationChannel(createChannelEpisodeNotification(context));
}
}
@RequiresApi(api = Build.VERSION_CODES.O)
private static NotificationChannel createChannelUserAction(Context c) {
- NotificationChannel mChannel = new NotificationChannel(CHANNEL_ID_USER_ACTION,
+ NotificationChannel notificationChannel = new NotificationChannel(CHANNEL_ID_USER_ACTION,
c.getString(R.string.notification_channel_user_action), NotificationManager.IMPORTANCE_HIGH);
- mChannel.setDescription(c.getString(R.string.notification_channel_user_action_description));
- return mChannel;
+ notificationChannel.setDescription(c.getString(R.string.notification_channel_user_action_description));
+ notificationChannel.setGroup(GROUP_ID_ERRORS);
+ return notificationChannel;
}
@RequiresApi(api = Build.VERSION_CODES.O)
private static NotificationChannel createChannelDownloading(Context c) {
- NotificationChannel mChannel = new NotificationChannel(CHANNEL_ID_DOWNLOADING,
+ NotificationChannel notificationChannel = new NotificationChannel(CHANNEL_ID_DOWNLOADING,
c.getString(R.string.notification_channel_downloading), NotificationManager.IMPORTANCE_LOW);
- mChannel.setDescription(c.getString(R.string.notification_channel_downloading_description));
- mChannel.setShowBadge(false);
- return mChannel;
+ notificationChannel.setDescription(c.getString(R.string.notification_channel_downloading_description));
+ notificationChannel.setShowBadge(false);
+ return notificationChannel;
}
@RequiresApi(api = Build.VERSION_CODES.O)
private static NotificationChannel createChannelPlaying(Context c) {
- NotificationChannel mChannel = new NotificationChannel(CHANNEL_ID_PLAYING,
+ NotificationChannel notificationChannel = new NotificationChannel(CHANNEL_ID_PLAYING,
c.getString(R.string.notification_channel_playing), NotificationManager.IMPORTANCE_LOW);
- mChannel.setDescription(c.getString(R.string.notification_channel_playing_description));
- mChannel.setShowBadge(false);
- return mChannel;
+ notificationChannel.setDescription(c.getString(R.string.notification_channel_playing_description));
+ notificationChannel.setShowBadge(false);
+ return notificationChannel;
}
@RequiresApi(api = Build.VERSION_CODES.O)
private static NotificationChannel createChannelError(Context c) {
- NotificationChannel mChannel = new NotificationChannel(CHANNEL_ID_ERROR,
- c.getString(R.string.notification_channel_error), NotificationManager.IMPORTANCE_HIGH);
- mChannel.setDescription(c.getString(R.string.notification_channel_error_description));
- return mChannel;
+ NotificationChannel notificationChannel = new NotificationChannel(CHANNEL_ID_DOWNLOAD_ERROR,
+ c.getString(R.string.notification_channel_download_error), NotificationManager.IMPORTANCE_HIGH);
+ notificationChannel.setDescription(c.getString(R.string.notification_channel_download_error_description));
+ notificationChannel.setGroup(GROUP_ID_ERRORS);
+
+ if (!UserPreferences.getShowDownloadReportRaw()) {
+ // Migration from app managed setting: disable notification
+ notificationChannel.setImportance(NotificationManager.IMPORTANCE_NONE);
+ }
+ return notificationChannel;
}
@RequiresApi(api = Build.VERSION_CODES.O)
@@ -72,14 +89,47 @@ public class NotificationUtils {
NotificationChannel notificationChannel = new NotificationChannel(CHANNEL_ID_SYNC_ERROR,
c.getString(R.string.notification_channel_sync_error), NotificationManager.IMPORTANCE_HIGH);
notificationChannel.setDescription(c.getString(R.string.notification_channel_sync_error_description));
+ notificationChannel.setGroup(GROUP_ID_ERRORS);
+
+ if (!UserPreferences.getGpodnetNotificationsEnabledRaw()) {
+ // Migration from app managed setting: disable notification
+ notificationChannel.setImportance(NotificationManager.IMPORTANCE_NONE);
+ }
return notificationChannel;
}
@RequiresApi(api = Build.VERSION_CODES.O)
private static NotificationChannel createChannelAutoDownload(Context c) {
- NotificationChannel mChannel = new NotificationChannel(CHANNEL_ID_AUTO_DOWNLOAD,
- c.getString(R.string.notification_channel_auto_download), NotificationManager.IMPORTANCE_DEFAULT);
- mChannel.setDescription(c.getString(R.string.notification_channel_episode_auto_download));
- return mChannel;
+ NotificationChannel notificationChannel = new NotificationChannel(CHANNEL_ID_AUTO_DOWNLOAD,
+ c.getString(R.string.notification_channel_auto_download), NotificationManager.IMPORTANCE_NONE);
+ notificationChannel.setDescription(c.getString(R.string.notification_channel_episode_auto_download));
+ notificationChannel.setGroup(GROUP_ID_NEWS);
+
+ if (UserPreferences.getShowAutoDownloadReportRaw()) {
+ // Migration from app managed setting: enable notification
+ notificationChannel.setImportance(NotificationManager.IMPORTANCE_DEFAULT);
+ }
+ return notificationChannel;
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ private static NotificationChannel createChannelEpisodeNotification(Context c) {
+ NotificationChannel channel = new NotificationChannel(CHANNEL_ID_EPISODE_NOTIFICATIONS,
+ c.getString(R.string.notification_channel_new_episode), NotificationManager.IMPORTANCE_DEFAULT);
+ channel.setDescription(c.getString(R.string.notification_channel_new_episode_description));
+ channel.setGroup(GROUP_ID_NEWS);
+ return channel;
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ private static NotificationChannelGroup createGroupErrors(Context c) {
+ return new NotificationChannelGroup(GROUP_ID_ERRORS,
+ c.getString(R.string.notification_group_errors));
+ }
+
+ @RequiresApi(api = Build.VERSION_CODES.O)
+ private static NotificationChannelGroup createGroupNews(Context c) {
+ return new NotificationChannelGroup(GROUP_ID_NEWS,
+ c.getString(R.string.notification_group_news));
}
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ChapterReader.java b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ChapterReader.java
index ce3577a9e..69d8316c2 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ChapterReader.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ChapterReader.java
@@ -2,149 +2,114 @@ package de.danoeh.antennapod.core.util.id3reader;
import android.text.TextUtils;
import android.util.Log;
+import androidx.annotation.NonNull;
import de.danoeh.antennapod.core.feed.Chapter;
import de.danoeh.antennapod.core.feed.ID3Chapter;
import de.danoeh.antennapod.core.util.EmbeddedChapterImage;
import de.danoeh.antennapod.core.util.id3reader.model.FrameHeader;
-import de.danoeh.antennapod.core.util.id3reader.model.TagHeader;
+import org.apache.commons.io.input.CountingInputStream;
import java.io.IOException;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.List;
-import org.apache.commons.io.input.CountingInputStream;
+/**
+ * Reads ID3 chapters.
+ * See https://id3.org/id3v2-chapters-1.0
+ */
public class ChapterReader extends ID3Reader {
private static final String TAG = "ID3ChapterReader";
- private static final String FRAME_ID_CHAPTER = "CHAP";
- private static final String FRAME_ID_TITLE = "TIT2";
- private static final String FRAME_ID_LINK = "WXXX";
- private static final String FRAME_ID_PICTURE = "APIC";
- private static final int IMAGE_TYPE_COVER = 3;
+ public static final String FRAME_ID_CHAPTER = "CHAP";
+ public static final String FRAME_ID_TITLE = "TIT2";
+ public static final String FRAME_ID_LINK = "WXXX";
+ public static final String FRAME_ID_PICTURE = "APIC";
+ public static final String MIME_IMAGE_URL = "-->";
+ public static final int IMAGE_TYPE_COVER = 3;
- private List<Chapter> chapters;
- private ID3Chapter currentChapter;
+ private final List<Chapter> chapters = new ArrayList<>();
- @Override
- public int onStartTagHeader(TagHeader header) {
- chapters = new ArrayList<>();
- Log.d(TAG, "header: " + header);
- return ID3Reader.ACTION_DONT_SKIP;
+ public ChapterReader(CountingInputStream input) {
+ super(input);
}
@Override
- public int onStartFrameHeader(FrameHeader header, CountingInputStream input) throws IOException, ID3ReaderException {
- Log.d(TAG, "header: " + header);
- switch (header.getId()) {
- case FRAME_ID_CHAPTER:
- if (currentChapter != null) {
- if (!hasId3Chapter(currentChapter)) {
- chapters.add(currentChapter);
- Log.d(TAG, "Found chapter: " + currentChapter);
- currentChapter = null;
- }
- }
- StringBuilder elementId = new StringBuilder();
- readISOString(elementId, input, Integer.MAX_VALUE);
- char[] startTimeSource = readChars(input, 4);
- long startTime = ((int) startTimeSource[0] << 24)
- | ((int) startTimeSource[1] << 16)
- | ((int) startTimeSource[2] << 8) | startTimeSource[3];
- currentChapter = new ID3Chapter(elementId.toString(), startTime);
- skipBytes(input, 12);
- return ID3Reader.ACTION_DONT_SKIP;
- case FRAME_ID_TITLE:
- if (currentChapter != null && currentChapter.getTitle() == null) {
- StringBuilder title = new StringBuilder();
- readString(title, input, header.getSize());
- currentChapter
- .setTitle(title.toString());
- Log.d(TAG, "Found title: " + currentChapter.getTitle());
+ protected void readFrame(@NonNull FrameHeader frameHeader) throws IOException, ID3ReaderException {
+ if (FRAME_ID_CHAPTER.equals(frameHeader.getId())) {
+ Log.d(TAG, "Handling frame: " + frameHeader.toString());
+ Chapter chapter = readChapter(frameHeader);
+ Log.d(TAG, "Chapter done: " + chapter);
+ chapters.add(chapter);
+ } else {
+ super.readFrame(frameHeader);
+ }
+ }
- return ID3Reader.ACTION_DONT_SKIP;
- }
+ public Chapter readChapter(@NonNull FrameHeader frameHeader) throws IOException, ID3ReaderException {
+ int chapterStartedPosition = getPosition();
+ String elementId = readIsoStringNullTerminated(100);
+ long startTime = readInt();
+ skipBytes(12); // Ignore end time, start offset, end offset
+ ID3Chapter chapter = new ID3Chapter(elementId, startTime);
+
+ // Read sub-frames
+ while (getPosition() < chapterStartedPosition + frameHeader.getSize()) {
+ FrameHeader subFrameHeader = readFrameHeader();
+ readChapterSubFrame(subFrameHeader, chapter);
+ }
+ return chapter;
+ }
+
+ public void readChapterSubFrame(@NonNull FrameHeader frameHeader, @NonNull Chapter chapter)
+ throws IOException, ID3ReaderException {
+ Log.d(TAG, "Handling subframe: " + frameHeader.toString());
+ int frameStartPosition = getPosition();
+ switch (frameHeader.getId()) {
+ case FRAME_ID_TITLE:
+ chapter.setTitle(readEncodingAndString(frameHeader.getSize()));
+ Log.d(TAG, "Found title: " + chapter.getTitle());
break;
case FRAME_ID_LINK:
- if (currentChapter != null) {
- // skip description
- int descriptionLength = readString(null, input, header.getSize());
- StringBuilder link = new StringBuilder();
- readISOString(link, input, header.getSize() - descriptionLength);
- try {
- String decodedLink = URLDecoder.decode(link.toString(), "UTF-8");
- currentChapter.setLink(decodedLink);
- Log.d(TAG, "Found link: " + currentChapter.getLink());
- } catch (IllegalArgumentException iae) {
- Log.w(TAG, "Bad URL found in ID3 data");
- }
-
- return ID3Reader.ACTION_DONT_SKIP;
+ readEncodingAndString(frameHeader.getSize()); // skip description
+ String url = readIsoStringNullTerminated(frameStartPosition + frameHeader.getSize() - getPosition());
+ try {
+ String decodedLink = URLDecoder.decode(url, "ISO-8859-1");
+ chapter.setLink(decodedLink);
+ Log.d(TAG, "Found link: " + chapter.getLink());
+ } catch (IllegalArgumentException iae) {
+ Log.w(TAG, "Bad URL found in ID3 data");
}
break;
case FRAME_ID_PICTURE:
- if (currentChapter != null) {
- Log.d(TAG, header.toString());
- StringBuilder mime = new StringBuilder();
- int read = readString(mime, input, header.getSize());
- byte type = (byte) readChars(input, 1)[0];
- read++;
- StringBuilder description = new StringBuilder();
- read += readISOString(description, input, header.getSize()); // Should use same encoding as mime
-
- Log.d(TAG, "Found apic: " + mime + "," + description);
- if (mime.toString().equals("-->")) {
- // Data contains a link to a picture
- StringBuilder link = new StringBuilder();
- readISOString(link, input, header.getSize());
- Log.d(TAG, "link: " + link.toString());
- if (TextUtils.isEmpty(currentChapter.getImageUrl()) || type == IMAGE_TYPE_COVER) {
- currentChapter.setImageUrl(link.toString());
- }
- } else {
- // Data contains the picture
- int length = header.getSize() - read;
- if (TextUtils.isEmpty(currentChapter.getImageUrl()) || type == IMAGE_TYPE_COVER) {
- currentChapter.setImageUrl(EmbeddedChapterImage.makeUrl(input.getCount(), length));
- }
- skipBytes(input, length);
+ byte encoding = readByte();
+ String mime = readEncodedString(encoding, frameHeader.getSize());
+ byte type = readByte();
+ String description = readEncodedString(encoding, frameHeader.getSize());
+ Log.d(TAG, "Found apic: " + mime + "," + description);
+ if (MIME_IMAGE_URL.equals(mime)) {
+ String link = readIsoStringNullTerminated(frameHeader.getSize());
+ Log.d(TAG, "Link: " + link);
+ if (TextUtils.isEmpty(chapter.getImageUrl()) || type == IMAGE_TYPE_COVER) {
+ chapter.setImageUrl(link);
+ }
+ } else {
+ int alreadyConsumed = getPosition() - frameStartPosition;
+ int rawImageDataLength = frameHeader.getSize() - alreadyConsumed;
+ if (TextUtils.isEmpty(chapter.getImageUrl()) || type == IMAGE_TYPE_COVER) {
+ chapter.setImageUrl(EmbeddedChapterImage.makeUrl(getPosition(), rawImageDataLength));
}
- return ID3Reader.ACTION_DONT_SKIP;
}
break;
+ default:
+ Log.d(TAG, "Unknown chapter sub-frame.");
+ break;
}
- return super.onStartFrameHeader(header, input);
- }
-
- private boolean hasId3Chapter(ID3Chapter chapter) {
- for (Chapter c : chapters) {
- if (((ID3Chapter) c).getId3ID().equals(chapter.getId3ID())) {
- return true;
- }
- }
- return false;
- }
-
- @Override
- public void onEndTag() {
- if (currentChapter != null) {
- if (!hasId3Chapter(currentChapter)) {
- chapters.add(currentChapter);
- }
- }
- Log.d(TAG, "Reached end of tag");
- if (chapters != null) {
- for (Chapter c : chapters) {
- Log.d(TAG, "chapter: " + c);
- }
- }
- }
-
- @Override
- public void onNoTagHeaderFound() {
- Log.d(TAG, "No tag header found");
- super.onNoTagHeaderFound();
+ // Skip garbage to fill frame completely
+ // This also asserts that we are not reading too many bytes from this frame.
+ int alreadyConsumed = getPosition() - frameStartPosition;
+ skipBytes(frameHeader.getSize() - alreadyConsumed);
}
public List<Chapter> getChapters() {
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ID3Reader.java b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ID3Reader.java
index 124388254..17313ca14 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ID3Reader.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/ID3Reader.java
@@ -1,151 +1,112 @@
package de.danoeh.antennapod.core.util.id3reader;
+import android.util.Log;
+import androidx.annotation.NonNull;
+import de.danoeh.antennapod.core.util.id3reader.model.FrameHeader;
+import de.danoeh.antennapod.core.util.id3reader.model.TagHeader;
import org.apache.commons.io.IOUtils;
+import org.apache.commons.io.input.CountingInputStream;
+import java.io.ByteArrayOutputStream;
import java.io.IOException;
-import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
-import de.danoeh.antennapod.core.util.id3reader.model.FrameHeader;
-import de.danoeh.antennapod.core.util.id3reader.model.TagHeader;
-import org.apache.commons.io.input.CountingInputStream;
-
/**
- * Reads the ID3 Tag of a given file. In order to use this class, you should
- * create a subclass of it and overwrite the onStart* - or onEnd* - methods.
+ * Reads the ID3 Tag of a given file.
+ * See https://id3.org/id3v2.3.0
*/
public class ID3Reader {
- private static final int HEADER_LENGTH = 10;
- private static final int ID3_LENGTH = 3;
+ private static final String TAG = "ID3Reader";
private static final int FRAME_ID_LENGTH = 4;
-
- private static final int ACTION_SKIP = 1;
- static final int ACTION_DONT_SKIP = 2;
-
- private int readerPosition;
-
- private static final byte ENCODING_UTF16_WITH_BOM = 1;
- private static final byte ENCODING_UTF16_WITHOUT_BOM = 2;
- private static final byte ENCODING_UTF8 = 3;
+ public static final byte ENCODING_ISO = 0;
+ public static final byte ENCODING_UTF16_WITH_BOM = 1;
+ public static final byte ENCODING_UTF16_WITHOUT_BOM = 2;
+ public static final byte ENCODING_UTF8 = 3;
private TagHeader tagHeader;
+ private final CountingInputStream inputStream;
- ID3Reader() {
+ public ID3Reader(CountingInputStream input) {
+ inputStream = input;
}
- public final void readInputStream(CountingInputStream input) throws IOException, ID3ReaderException {
- int rc;
- readerPosition = 0;
- char[] tagHeaderSource = readChars(input, HEADER_LENGTH);
- tagHeader = createTagHeader(tagHeaderSource);
- if (tagHeader == null) {
- onNoTagHeaderFound();
- } else {
- rc = onStartTagHeader(tagHeader);
- if (rc != ACTION_SKIP) {
- while (readerPosition < tagHeader.getSize()) {
- FrameHeader frameHeader = createFrameHeader(readChars(input, HEADER_LENGTH));
- if (checkForNullString(frameHeader.getId())) {
- break;
- }
- rc = onStartFrameHeader(frameHeader, input);
- if (rc == ACTION_SKIP) {
- if (frameHeader.getSize() + readerPosition > tagHeader.getSize()) {
- break;
- }
- skipBytes(input, frameHeader.getSize());
- }
- }
+ public void readInputStream() throws IOException, ID3ReaderException {
+ tagHeader = readTagHeader();
+ int tagContentStartPosition = getPosition();
+ while (getPosition() < tagContentStartPosition + tagHeader.getSize()) {
+ FrameHeader frameHeader = readFrameHeader();
+ if (frameHeader.getId().charAt(0) < '0' || frameHeader.getId().charAt(0) > 'z') {
+ Log.d(TAG, "Stopping because of invalid frame: " + frameHeader.toString());
+ return;
}
- onEndTag();
+ readFrame(frameHeader);
}
}
- /** Returns true if string only contains null-bytes. */
- private boolean checkForNullString(String s) {
- if (!s.isEmpty()) {
- int i = 0;
- if (s.charAt(i) == 0) {
- for (i = 1; i < s.length(); i++) {
- if (s.charAt(i) != 0) {
- return false;
- }
- }
- return true;
- }
- return false;
- } else {
- return true;
- }
+ protected void readFrame(@NonNull FrameHeader frameHeader) throws IOException, ID3ReaderException {
+ Log.d(TAG, "Skipping frame: " + frameHeader.toString());
+ skipBytes(frameHeader.getSize());
+ }
+ int getPosition() {
+ return inputStream.getCount();
}
/**
- * Read a certain number of chars from the given input stream. This method
- * changes the readerPosition-attribute.
+ * Skip a certain number of bytes on the given input stream.
*/
- char[] readChars(InputStream input, int number) throws IOException, ID3ReaderException {
- char[] header = new char[number];
- for (int i = 0; i < number; i++) {
- int b = input.read();
- readerPosition++;
- if (b != -1) {
- header[i] = (char) b;
- } else {
- throw new ID3ReaderException("Unexpected end of stream");
- }
+ void skipBytes(int number) throws IOException, ID3ReaderException {
+ if (number < 0) {
+ throw new ID3ReaderException("Trying to read a negative number of bytes");
}
- return header;
+ IOUtils.skipFully(inputStream, number);
}
- /**
- * Skip a certain number of bytes on the given input stream. This method
- * changes the readerPosition-attribute.
- */
- void skipBytes(InputStream input, int number) throws IOException {
- if (number <= 0) {
- number = 1;
- }
- IOUtils.skipFully(input, number);
+ byte readByte() throws IOException {
+ return (byte) inputStream.read();
+ }
- readerPosition += number;
+ short readShort() throws IOException {
+ char firstByte = (char) inputStream.read();
+ char secondByte = (char) inputStream.read();
+ return (short) ((firstByte << 8) | secondByte);
}
- private TagHeader createTagHeader(char[] source) throws ID3ReaderException {
- boolean hasTag = (source[0] == 0x49) && (source[1] == 0x44)
- && (source[2] == 0x33);
- if (source.length != HEADER_LENGTH) {
- throw new ID3ReaderException("Length of header must be "
- + HEADER_LENGTH);
- }
- if (hasTag) {
- String id = new String(source, 0, ID3_LENGTH);
- char version = (char) ((source[3] << 8) | source[4]);
- byte flags = (byte) source[5];
- int size = (source[6] << 24) | (source[7] << 16) | (source[8] << 8)
- | source[9];
- size = unsynchsafe(size);
- return new TagHeader(id, size, version, flags);
- } else {
- return null;
- }
+ int readInt() throws IOException {
+ char firstByte = (char) inputStream.read();
+ char secondByte = (char) inputStream.read();
+ char thirdByte = (char) inputStream.read();
+ char fourthByte = (char) inputStream.read();
+ return (firstByte << 24) | (secondByte << 16) | (thirdByte << 8) | fourthByte;
}
- private FrameHeader createFrameHeader(char[] source)
- throws ID3ReaderException {
- if (source.length != HEADER_LENGTH) {
- throw new ID3ReaderException("Length of header must be "
- + HEADER_LENGTH);
+ void expectChar(char expected) throws ID3ReaderException, IOException {
+ char read = (char) inputStream.read();
+ if (read != expected) {
+ throw new ID3ReaderException("Expected " + expected + " and got " + read);
}
- String id = new String(source, 0, FRAME_ID_LENGTH);
+ }
- int size = (((int) source[4]) << 24) | (((int) source[5]) << 16)
- | (((int) source[6]) << 8) | source[7];
+ @NonNull
+ TagHeader readTagHeader() throws ID3ReaderException, IOException {
+ expectChar('I');
+ expectChar('D');
+ expectChar('3');
+ short version = readShort();
+ byte flags = readByte();
+ int size = unsynchsafe(readInt());
+ return new TagHeader("ID3", size, version, flags);
+ }
+
+ @NonNull
+ FrameHeader readFrameHeader() throws IOException {
+ String id = readIsoStringFixed(FRAME_ID_LENGTH);
+ int size = readInt();
if (tagHeader != null && tagHeader.getVersion() >= 0x0400) {
size = unsynchsafe(size);
}
- char flags = (char) ((source[8] << 8) | source[9]);
+ short flags = readShort();
return new FrameHeader(id, size, flags);
}
@@ -162,81 +123,73 @@ public class ID3Reader {
return out;
}
- protected int readString(StringBuilder buffer, InputStream input, int max) throws IOException,
- ID3ReaderException {
- if (max > 0) {
- char[] encoding = readChars(input, 1);
- max--;
-
- if (encoding[0] == ENCODING_UTF16_WITH_BOM || encoding[0] == ENCODING_UTF16_WITHOUT_BOM) {
- return readUnicodeString(buffer, input, max, Charset.forName("UTF-16")) + 1; // take encoding byte into account
- } else if (encoding[0] == ENCODING_UTF8) {
- return readUnicodeString(buffer, input, max, Charset.forName("UTF-8")) + 1; // take encoding byte into account
- } else {
- return readISOString(buffer, input, max) + 1; // take encoding byte into account
- }
- } else {
- if (buffer != null) {
- buffer.append("");
- }
- return 0;
- }
+ /**
+ * Reads a null-terminated string with encoding.
+ */
+ protected String readEncodingAndString(int max) throws IOException {
+ byte encoding = readByte();
+ return readEncodedString(encoding, max - 1);
}
- protected int readISOString(StringBuilder buffer, InputStream input, int max)
- throws IOException, ID3ReaderException {
+ @SuppressWarnings("CharsetObjectCanBeUsed")
+ protected String readIsoStringFixed(int length) throws IOException {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
int bytesRead = 0;
- char c;
- while (++bytesRead <= max && (c = (char) input.read()) > 0) {
- if (buffer != null) {
- buffer.append(c);
- }
+ while (bytesRead < length) {
+ bytes.write(readByte());
+ bytesRead++;
}
- return bytesRead;
- }
-
- private int readUnicodeString(StringBuilder strBuffer, InputStream input, int max, Charset charset)
- throws IOException, ID3ReaderException {
- byte[] buffer = new byte[max];
- int c;
- int cZero = -1;
- int i = 0;
- for (; i < max; i++) {
- c = input.read();
- if (c == -1) {
- break;
- } else if (c == 0) {
- if (cZero == 0) {
- // termination character found
- break;
- } else {
- cZero = 0;
- }
- } else {
- buffer[i] = (byte) c;
- cZero = -1;
- }
- }
- if (strBuffer != null) {
- strBuffer.append(charset.newDecoder().decode(ByteBuffer.wrap(buffer)).toString());
- }
- return i;
+ return Charset.forName("ISO-8859-1").newDecoder().decode(ByteBuffer.wrap(bytes.toByteArray())).toString();
}
- int onStartTagHeader(TagHeader header) {
- return ACTION_SKIP;
+ protected String readIsoStringNullTerminated(int max) throws IOException {
+ return readEncodedString(ENCODING_ISO, max);
}
- int onStartFrameHeader(FrameHeader header, CountingInputStream input) throws IOException, ID3ReaderException {
- return ACTION_SKIP;
+ @SuppressWarnings("CharsetObjectCanBeUsed")
+ String readEncodedString(int encoding, int max) throws IOException {
+ if (encoding == ENCODING_UTF16_WITH_BOM || encoding == ENCODING_UTF16_WITHOUT_BOM) {
+ return readEncodedString2(Charset.forName("UTF-16"), max);
+ } else if (encoding == ENCODING_UTF8) {
+ return readEncodedString2(Charset.forName("UTF-8"), max);
+ } else {
+ return readEncodedString1(Charset.forName("ISO-8859-1"), max);
+ }
}
- void onEndTag() {
-
+ /**
+ * Reads chars where the encoding uses 1 char per symbol.
+ */
+ private String readEncodedString1(Charset charset, int max) throws IOException {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ int bytesRead = 0;
+ while (bytesRead < max) {
+ byte c = readByte();
+ bytesRead++;
+ if (c == 0) {
+ break;
+ }
+ bytes.write(c);
+ }
+ return charset.newDecoder().decode(ByteBuffer.wrap(bytes.toByteArray())).toString();
}
- void onNoTagHeaderFound() {
-
+ /**
+ * Reads chars where the encoding uses 2 chars per symbol.
+ */
+ private String readEncodedString2(Charset charset, int max) throws IOException {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ int bytesRead = 0;
+ while (bytesRead + 1 < max) {
+ byte c1 = readByte();
+ byte c2 = readByte();
+ if (c1 == 0 && c2 == 0) {
+ break;
+ }
+ bytesRead += 2;
+ bytes.write(c1);
+ bytes.write(c2);
+ }
+ return charset.newDecoder().decode(ByteBuffer.wrap(bytes.toByteArray())).toString();
}
-
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/FrameHeader.java b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/FrameHeader.java
index 2f3f378ab..e4af89a86 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/FrameHeader.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/FrameHeader.java
@@ -1,17 +1,19 @@
package de.danoeh.antennapod.core.util.id3reader.model;
-public class FrameHeader extends Header {
+import androidx.annotation.NonNull;
- private final char flags;
+public class FrameHeader extends Header {
+ private final short flags;
- public FrameHeader(String id, int size, char flags) {
- super(id, size);
- this.flags = flags;
- }
+ public FrameHeader(String id, int size, short flags) {
+ super(id, size);
+ this.flags = flags;
+ }
- @Override
- public String toString() {
- return String.format("FrameHeader [flags=%s, id=%s, size=%s]", Integer.toBinaryString(flags), id, Integer.toBinaryString(size));
+ @Override
+ @NonNull
+ public String toString() {
+ return String.format("FrameHeader [flags=%s, id=%s, size=%s]", Integer.toBinaryString(flags), id, size);
}
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/TagHeader.java b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/TagHeader.java
index b652a139c..2590db029 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/TagHeader.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/id3reader/model/TagHeader.java
@@ -1,26 +1,25 @@
package de.danoeh.antennapod.core.util.id3reader.model;
-public class TagHeader extends Header {
-
- private final char version;
- private final byte flags;
+import androidx.annotation.NonNull;
- public TagHeader(String id, int size, char version, byte flags) {
- super(id, size);
- this.version = version;
- this.flags = flags;
- }
-
- @Override
- public String toString() {
- return "TagHeader [version=" + version + ", flags=" + flags + ", id="
- + id + ", size=" + size + "]";
- }
+public class TagHeader extends Header {
+ private final short version;
+ private final byte flags;
- public char getVersion() {
- return version;
- }
+ public TagHeader(String id, int size, short version, byte flags) {
+ super(id, size);
+ this.version = version;
+ this.flags = flags;
+ }
-
+ @Override
+ @NonNull
+ public String toString() {
+ return "TagHeader [version=" + version + ", flags=" + flags + ", id="
+ + id + ", size=" + size + "]";
+ }
+ public short getVersion() {
+ return version;
+ }
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/AudioPlayer.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/AudioPlayer.java
index fecb14d25..c948d98a3 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/AudioPlayer.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/AudioPlayer.java
@@ -1,7 +1,6 @@
package de.danoeh.antennapod.core.util.playback;
import android.content.Context;
-import android.content.SharedPreferences;
import androidx.preference.PreferenceManager;
import android.util.Log;
import android.view.SurfaceHolder;
@@ -11,6 +10,7 @@ import org.antennapod.audio.MediaPlayer;
import de.danoeh.antennapod.core.preferences.UserPreferences;
+import java.io.IOException;
import java.util.Collections;
import java.util.List;
@@ -21,7 +21,7 @@ public class AudioPlayer extends MediaPlayer implements IPlayer {
super(context, true, ClientConfig.USER_AGENT);
PreferenceManager.getDefaultSharedPreferences(context)
.registerOnSharedPreferenceChangeListener((sharedPreferences, key) -> {
- if (key.equals(UserPreferences.PREF_MEDIA_PLAYER)) {
+ if (UserPreferences.PREF_MEDIA_PLAYER.equals(key)) {
checkMpi();
}
});
@@ -65,4 +65,9 @@ public class AudioPlayer extends MediaPlayer implements IPlayer {
public int getSelectedAudioTrack() {
return -1;
}
+
+ @Override
+ public void setDataSource(String streamUrl, String username, String password) throws IOException {
+ setDataSource(streamUrl);
+ }
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/ExternalMedia.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/ExternalMedia.java
index 6c107996f..007658626 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/ExternalMedia.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/ExternalMedia.java
@@ -6,10 +6,16 @@ import android.content.SharedPreferences.Editor;
import android.media.MediaMetadataRetriever;
import android.os.Parcel;
import android.os.Parcelable;
+import android.text.TextUtils;
import de.danoeh.antennapod.core.feed.Chapter;
import de.danoeh.antennapod.core.feed.MediaType;
import de.danoeh.antennapod.core.util.ChapterUtils;
+import de.danoeh.antennapod.core.util.DateUtils;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.Date;
import java.util.List;
+import java.util.Locale;
import java.util.concurrent.Callable;
import org.apache.commons.io.FilenameUtils;
@@ -25,6 +31,7 @@ public class ExternalMedia implements Playable {
private String episodeTitle;
private String feedTitle;
private MediaType mediaType;
+ private Date pubDate;
private List<Chapter> chapters;
private int duration;
private int position;
@@ -99,12 +106,23 @@ public class ExternalMedia implements Playable {
e.printStackTrace();
throw new PlayableException("NumberFormatException when reading duration of media file");
}
- setChapters(ChapterUtils.loadChaptersFromFileUrl(this));
+
+ String dateStr = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DATE);
+ if (!TextUtils.isEmpty(dateStr)) {
+ try {
+ SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss", Locale.getDefault());
+ pubDate = simpleDateFormat.parse(dateStr);
+ } catch (ParseException parseException) {
+ pubDate = DateUtils.parse(dateStr);
+ }
+ } else {
+ pubDate = null;
+ }
}
@Override
public void loadChapterMarks(Context context) {
-
+ setChapters(ChapterUtils.loadChaptersFromMediaFile(this, context));
}
@Override
@@ -148,6 +166,11 @@ public class ExternalMedia implements Playable {
}
@Override
+ public Date getPubDate() {
+ return pubDate;
+ }
+
+ @Override
public int getPosition() {
return position;
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/IPlayer.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/IPlayer.java
index 363004709..a511916fa 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/IPlayer.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/IPlayer.java
@@ -35,6 +35,8 @@ public interface IPlayer {
void setDataSource(String path) throws IllegalStateException, IOException,
IllegalArgumentException, SecurityException;
+ void setDataSource(String streamUrl, String username, String password) throws IOException;
+
void setDisplay(SurfaceHolder sh);
void setPlaybackParams(float speed, boolean skipSilence);
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java
index 5b15913c8..8a4c561f4 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java
@@ -3,24 +3,19 @@ package de.danoeh.antennapod.core.util.playback;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Parcelable;
-import androidx.preference.PreferenceManager;
-import android.util.Log;
+
import androidx.annotation.Nullable;
-import de.danoeh.antennapod.core.asynctask.ImageResource;
import de.danoeh.antennapod.core.feed.Chapter;
-import de.danoeh.antennapod.core.feed.FeedMedia;
import de.danoeh.antennapod.core.feed.MediaType;
-import de.danoeh.antennapod.core.preferences.PlaybackPreferences;
-import de.danoeh.antennapod.core.storage.DBReader;
import de.danoeh.antennapod.core.util.ShownotesProvider;
-
+import java.util.Date;
import java.util.List;
/**
* Interface for objects that can be played by the PlaybackService.
*/
-public interface Playable extends Parcelable,
- ShownotesProvider, ImageResource {
+public interface Playable extends Parcelable, ShownotesProvider {
+ public static final int INVALID_TIME = -1;
/**
* Save information about the playable in a preference so that it can be
@@ -68,6 +63,11 @@ public interface Playable extends Parcelable,
String getFeedTitle();
/**
+ * Returns the published date
+ */
+ Date getPubDate();
+
+ /**
* Returns a unique identifier, for example a file url or an ID from a
* database.
*/
@@ -172,99 +172,11 @@ public interface Playable extends Parcelable,
void setChapters(List<Chapter> chapters);
/**
- * Provides utility methods for Playable objects.
+ * Returns the location of the image or null if no image is available.
+ * This can be the feed item image URL, the local embedded media image path, the feed image URL,
+ * or the remote media image URL, depending on what's available.
*/
- class PlayableUtils {
- private PlayableUtils(){}
-
- private static final String TAG = "PlayableUtils";
-
- /**
- * Restores a playable object from a sharedPreferences file. This method might load data from the database,
- * depending on the type of playable that was restored.
- *
- * @return The restored Playable object
- */
- @Nullable
- public static Playable createInstanceFromPreferences(Context context) {
- long currentlyPlayingMedia = PlaybackPreferences.getCurrentlyPlayingMediaType();
- if (currentlyPlayingMedia != PlaybackPreferences.NO_MEDIA_PLAYING) {
- SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext());
- return PlayableUtils.createInstanceFromPreferences(context,
- (int) currentlyPlayingMedia, prefs);
- }
- return null;
- }
-
- /**
- * Restores a playable object from a sharedPreferences file. This method might load data from the database,
- * depending on the type of playable that was restored.
- *
- * @param type An integer that represents the type of the Playable object
- * that is restored.
- * @param pref The SharedPreferences file from which the Playable object
- * is restored
- * @return The restored Playable object
- */
- public static Playable createInstanceFromPreferences(Context context, int type,
- SharedPreferences pref) {
- Playable result = null;
- // ADD new Playable types here:
- switch (type) {
- case FeedMedia.PLAYABLE_TYPE_FEEDMEDIA:
- result = createFeedMediaInstance(pref);
- break;
- case ExternalMedia.PLAYABLE_TYPE_EXTERNAL_MEDIA:
- result = createExternalMediaInstance(pref);
- break;
- }
- if (result == null) {
- Log.e(TAG, "Could not restore Playable object from preferences");
- }
- return result;
- }
-
- private static Playable createFeedMediaInstance(SharedPreferences pref) {
- Playable result = null;
- long mediaId = pref.getLong(FeedMedia.PREF_MEDIA_ID, -1);
- if (mediaId != -1) {
- result = DBReader.getFeedMedia(mediaId);
- }
- return result;
- }
-
- private static Playable createExternalMediaInstance(SharedPreferences pref) {
- Playable result = null;
- String source = pref.getString(ExternalMedia.PREF_SOURCE_URL, null);
- String mediaType = pref.getString(ExternalMedia.PREF_MEDIA_TYPE, null);
- if (source != null && mediaType != null) {
- int position = pref.getInt(ExternalMedia.PREF_POSITION, 0);
- long lastPlayedTime = pref.getLong(ExternalMedia.PREF_LAST_PLAYED_TIME, 0);
- result = new ExternalMedia(source, MediaType.valueOf(mediaType),
- position, lastPlayedTime);
- }
- return result;
- }
- }
-
- class PlayableException extends Exception {
- private static final long serialVersionUID = 1L;
-
- public PlayableException() {
- super();
- }
-
- public PlayableException(String detailMessage, Throwable throwable) {
- super(detailMessage, throwable);
- }
-
- public PlayableException(String detailMessage) {
- super(detailMessage);
- }
-
- public PlayableException(Throwable throwable) {
- super(throwable);
- }
-
- }
+ @Nullable
+ String getImageLocation();
+
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlayableException.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlayableException.java
new file mode 100644
index 000000000..c0c21d647
--- /dev/null
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlayableException.java
@@ -0,0 +1,13 @@
+package de.danoeh.antennapod.core.util.playback;
+
+/**
+ * Exception thrown by {@link Playable} implementations.
+ */
+public class PlayableException extends Exception {
+
+ private static final long serialVersionUID = 1L;
+
+ public PlayableException(String detailMessage) {
+ super(detailMessage);
+ }
+}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlayableUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlayableUtils.java
new file mode 100644
index 000000000..413058758
--- /dev/null
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlayableUtils.java
@@ -0,0 +1,90 @@
+package de.danoeh.antennapod.core.util.playback;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.preference.PreferenceManager;
+
+import de.danoeh.antennapod.core.feed.FeedMedia;
+import de.danoeh.antennapod.core.feed.MediaType;
+import de.danoeh.antennapod.core.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.core.storage.DBReader;
+
+/**
+ * Provides utility methods for Playable objects.
+ */
+public abstract class PlayableUtils {
+
+ private static final String TAG = "PlayableUtils";
+
+ /**
+ * Restores a playable object from a sharedPreferences file. This method might load data from the database,
+ * depending on the type of playable that was restored.
+ *
+ * @return The restored Playable object
+ */
+ @Nullable
+ public static Playable createInstanceFromPreferences(@NonNull Context context) {
+ long currentlyPlayingMedia = PlaybackPreferences.getCurrentlyPlayingMediaType();
+ if (currentlyPlayingMedia != PlaybackPreferences.NO_MEDIA_PLAYING) {
+ SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context.getApplicationContext());
+ return PlayableUtils.createInstanceFromPreferences((int) currentlyPlayingMedia, prefs);
+ }
+ return null;
+ }
+
+ /**
+ * Restores a playable object from a sharedPreferences file. This method might load data from the database,
+ * depending on the type of playable that was restored.
+ *
+ * @param type An integer that represents the type of the Playable object
+ * that is restored.
+ * @param pref The SharedPreferences file from which the Playable object
+ * is restored
+ * @return The restored Playable object
+ */
+ private static Playable createInstanceFromPreferences(int type, SharedPreferences pref) {
+ Playable result;
+ // ADD new Playable types here:
+ switch (type) {
+ case FeedMedia.PLAYABLE_TYPE_FEEDMEDIA:
+ result = createFeedMediaInstance(pref);
+ break;
+ case ExternalMedia.PLAYABLE_TYPE_EXTERNAL_MEDIA:
+ result = createExternalMediaInstance(pref);
+ break;
+ default:
+ result = null;
+ break;
+ }
+ if (result == null) {
+ Log.e(TAG, "Could not restore Playable object from preferences");
+ }
+ return result;
+ }
+
+ private static Playable createFeedMediaInstance(SharedPreferences pref) {
+ Playable result = null;
+ long mediaId = pref.getLong(FeedMedia.PREF_MEDIA_ID, -1);
+ if (mediaId != -1) {
+ result = DBReader.getFeedMedia(mediaId);
+ }
+ return result;
+ }
+
+ private static Playable createExternalMediaInstance(SharedPreferences pref) {
+ Playable result = null;
+ String source = pref.getString(ExternalMedia.PREF_SOURCE_URL, null);
+ String mediaType = pref.getString(ExternalMedia.PREF_MEDIA_TYPE, null);
+ if (source != null && mediaType != null) {
+ int position = pref.getInt(ExternalMedia.PREF_POSITION, 0);
+ long lastPlayedTime = pref.getLong(ExternalMedia.PREF_LAST_PLAYED_TIME, 0);
+ result = new ExternalMedia(source, MediaType.valueOf(mediaType),
+ position, lastPlayedTime);
+ }
+ return result;
+ }
+}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java
index 425a07f4a..117e32cd4 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackController.java
@@ -17,12 +17,10 @@ import android.util.Pair;
import android.view.SurfaceHolder;
import android.widget.ImageButton;
import androidx.annotation.NonNull;
-import androidx.core.content.ContextCompat;
import de.danoeh.antennapod.core.R;
import de.danoeh.antennapod.core.event.MessageEvent;
import de.danoeh.antennapod.core.event.ServiceEvent;
import de.danoeh.antennapod.core.feed.Chapter;
-import de.danoeh.antennapod.core.feed.FeedMedia;
import de.danoeh.antennapod.core.feed.MediaType;
import de.danoeh.antennapod.core.feed.util.PlaybackSpeedUtils;
import de.danoeh.antennapod.core.preferences.PlaybackPreferences;
@@ -30,13 +28,9 @@ import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.service.playback.PlaybackService;
import de.danoeh.antennapod.core.service.playback.PlaybackServiceMediaPlayer;
import de.danoeh.antennapod.core.service.playback.PlayerStatus;
-import de.danoeh.antennapod.core.storage.DBTasks;
-import de.danoeh.antennapod.core.util.Optional;
-import de.danoeh.antennapod.core.util.ThemeUtils;
-import de.danoeh.antennapod.core.util.playback.Playable.PlayableUtils;
+import de.danoeh.antennapod.ui.common.ThemeUtils;
import io.reactivex.Maybe;
import io.reactivex.MaybeOnSubscribe;
-import io.reactivex.Observable;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.Disposable;
import io.reactivex.schedulers.Schedulers;
@@ -51,7 +45,7 @@ import java.util.List;
* Communicates with the playback service. GUI classes should use this class to
* control playback instead of communicating with the PlaybackService directly.
*/
-public class PlaybackController {
+public abstract class PlaybackController {
private static final String TAG = "PlaybackController";
private static final int INVALID_TIME = -1;
@@ -66,7 +60,6 @@ public class PlaybackController {
private boolean initialized = false;
private boolean eventsRegistered = false;
- private Disposable serviceBinder;
private Disposable mediaLoader;
public PlaybackController(@NonNull Activity activity) {
@@ -153,9 +146,6 @@ public class PlaybackController {
}
private void unbind() {
- if (serviceBinder != null) {
- serviceBinder.dispose();
- }
try {
activity.unbindService(mConnection);
} catch (IllegalArgumentException e) {
@@ -178,56 +168,11 @@ public class PlaybackController {
*/
private void bindToService() {
Log.d(TAG, "Trying to connect to service");
- if (serviceBinder != null) {
- serviceBinder.dispose();
- }
- serviceBinder = Observable.fromCallable(this::getPlayLastPlayedMediaIntent)
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(optionalIntent -> {
- boolean bound = false;
- if (!PlaybackService.isRunning) {
- if (optionalIntent.isPresent()) {
- Log.d(TAG, "Calling start service");
- ContextCompat.startForegroundService(activity, optionalIntent.get());
- bound = activity.bindService(optionalIntent.get(), mConnection, 0);
- } else {
- status = PlayerStatus.STOPPED;
- setupGUI();
- handleStatus();
- }
- } else {
- Log.d(TAG, "PlaybackService is running, trying to connect without start command.");
- bound = activity.bindService(new Intent(activity, PlaybackService.class),
- mConnection, 0);
- }
- Log.d(TAG, "Result for service binding: " + bound);
- }, error -> Log.e(TAG, Log.getStackTraceString(error)));
- }
-
- /**
- * Returns an intent that starts the PlaybackService and plays the last
- * played media or null if no last played media could be found.
- */
- @NonNull
- private Optional<Intent> getPlayLastPlayedMediaIntent() {
- Log.d(TAG, "Trying to restore last played media");
- Playable media = PlayableUtils.createInstanceFromPreferences(activity);
- if (media == null) {
- Log.d(TAG, "No last played media found");
- return Optional.empty();
- }
-
- boolean fileExists = media.localFileAvailable();
- boolean lastIsStream = PlaybackPreferences.getCurrentEpisodeIsStream();
- if (!fileExists && !lastIsStream && media instanceof FeedMedia) {
- DBTasks.notifyMissingFeedMediaFile(activity, (FeedMedia) media);
+ if (!PlaybackService.isRunning) {
+ throw new IllegalStateException("Trying to bind but service is not running");
}
-
- return Optional.of(new PlaybackServiceStarter(activity, media)
- .startWhenPrepared(false)
- .shouldStream(lastIsStream || !fileExists)
- .getIntent());
+ boolean bound = activity.bindService(new Intent(activity, PlaybackService.class), mConnection, 0);
+ Log.d(TAG, "Result for service binding: " + bound);
}
private final ServiceConnection mConnection = new ServiceConnection() {
@@ -331,8 +276,6 @@ public class PlaybackController {
}
};
- public void setupGUI() {}
-
public void onPositionObserverUpdate() {}
@@ -431,7 +374,10 @@ public class PlaybackController {
}
private void checkMediaInfoLoaded() {
- mediaInfoLoaded = (mediaInfoLoaded || loadMediaInfo());
+ if (!mediaInfoLoaded) {
+ loadMediaInfo();
+ }
+ mediaInfoLoaded = true;
}
private void updatePlayButtonAppearance(int resource, CharSequence contentDescription) {
@@ -446,9 +392,7 @@ public class PlaybackController {
return null;
}
- public boolean loadMediaInfo() {
- return false;
- }
+ public abstract void loadMediaInfo();
public void onAwaitingVideoSurface() {}
@@ -463,10 +407,9 @@ public class PlaybackController {
status = info.playerStatus;
media = info.playable;
- setupGUI();
- handleStatus();
// make sure that new media is loaded if it's available
mediaInfoLoaded = false;
+ handleStatus();
} else {
Log.e(TAG,
@@ -564,6 +507,13 @@ public class PlaybackController {
}
}
+ public void extendSleepTimer(long extendTime) {
+ long timeLeft = getSleepTimerTimeLeft();
+ if (playbackService != null && timeLeft != INVALID_TIME) {
+ setSleepTimer(timeLeft + extendTime);
+ }
+ }
+
public void setSleepTimer(long time) {
if (playbackService != null) {
playbackService.setSleepTimer(time);
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackServiceStarter.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackServiceStarter.java
index b12967264..107399e60 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackServiceStarter.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/PlaybackServiceStarter.java
@@ -2,7 +2,6 @@ package de.danoeh.antennapod.core.util.playback;
import android.content.Context;
import android.content.Intent;
-import androidx.annotation.NonNull;
import androidx.core.content.ContextCompat;
import de.danoeh.antennapod.core.preferences.PlaybackPreferences;
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/RemoteMedia.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/RemoteMedia.java
index 29eb20aca..7de1a7812 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/RemoteMedia.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/RemoteMedia.java
@@ -130,7 +130,7 @@ public class RemoteMedia implements Playable {
@Override
public void loadChapterMarks(Context context) {
- setChapters(ChapterUtils.loadChaptersFromStreamUrl(this, context));
+ setChapters(ChapterUtils.loadChaptersFromMediaFile(this, context));
}
@Override
diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/VideoPlayer.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/VideoPlayer.java
index d18801870..6728c027d 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/VideoPlayer.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/VideoPlayer.java
@@ -3,6 +3,7 @@ package de.danoeh.antennapod.core.util.playback;
import android.media.MediaPlayer;
import android.util.Log;
+import java.io.IOException;
import java.util.Collections;
import java.util.List;
@@ -52,4 +53,9 @@ public class VideoPlayer extends MediaPlayer implements IPlayer {
public int getSelectedAudioTrack() {
return -1;
}
+
+ @Override
+ public void setDataSource(String streamUrl, String username, String password) throws IOException {
+ setDataSource(streamUrl);
+ }
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdater.java b/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdater.java
new file mode 100644
index 000000000..afbe6526b
--- /dev/null
+++ b/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdater.java
@@ -0,0 +1,229 @@
+package de.danoeh.antennapod.core.widget;
+
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.graphics.Bitmap;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.View;
+import android.widget.RemoteViews;
+
+import com.bumptech.glide.Glide;
+import com.bumptech.glide.request.RequestOptions;
+
+import java.util.concurrent.TimeUnit;
+
+import de.danoeh.antennapod.core.R;
+import de.danoeh.antennapod.core.feed.MediaType;
+import de.danoeh.antennapod.core.glide.ApGlideSettings;
+import de.danoeh.antennapod.core.receiver.MediaButtonReceiver;
+import de.danoeh.antennapod.core.receiver.PlayerWidget;
+import de.danoeh.antennapod.core.service.playback.PlayerStatus;
+import de.danoeh.antennapod.core.util.Converter;
+import de.danoeh.antennapod.core.feed.util.ImageResourceUtils;
+import de.danoeh.antennapod.core.util.TimeSpeedConverter;
+import de.danoeh.antennapod.core.util.playback.Playable;
+import de.danoeh.antennapod.ui.appstartintent.MainActivityStarter;
+import de.danoeh.antennapod.ui.appstartintent.VideoPlayerActivityStarter;
+
+/**
+ * Updates the state of the player widget.
+ */
+public abstract class WidgetUpdater {
+ private static final String TAG = "WidgetUpdater";
+
+ public static class WidgetState {
+ final Playable media;
+ final PlayerStatus status;
+ final int position;
+ final int duration;
+ final float playbackSpeed;
+ final boolean isCasting;
+
+ public WidgetState(Playable media, PlayerStatus status, int position, int duration,
+ float playbackSpeed, boolean isCasting) {
+ this.media = media;
+ this.status = status;
+ this.position = position;
+ this.duration = duration;
+ this.playbackSpeed = playbackSpeed;
+ this.isCasting = isCasting;
+ }
+
+ public WidgetState(PlayerStatus status) {
+ this(null, status, Playable.INVALID_TIME, Playable.INVALID_TIME, 1.0f, false);
+ }
+ }
+
+ /**
+ * Update the widgets with the given parameters. Must be called in a background thread.
+ */
+ public static void updateWidget(Context context, WidgetState widgetState) {
+ if (!PlayerWidget.isEnabled(context) || widgetState == null) {
+ return;
+ }
+ ComponentName playerWidget = new ComponentName(context, PlayerWidget.class);
+ AppWidgetManager manager = AppWidgetManager.getInstance(context);
+ int[] widgetIds = manager.getAppWidgetIds(playerWidget);
+
+ PendingIntent startMediaPlayer;
+ if (widgetState.media != null && widgetState.media.getMediaType() == MediaType.VIDEO
+ && !widgetState.isCasting) {
+ startMediaPlayer = new VideoPlayerActivityStarter(context).getPendingIntent();
+ } else {
+ startMediaPlayer = new MainActivityStarter(context).withOpenPlayer().getPendingIntent();
+ }
+ RemoteViews views;
+ views = new RemoteViews(context.getPackageName(), R.layout.player_widget);
+
+ if (widgetState.media != null) {
+ Bitmap icon;
+ int iconSize = context.getResources().getDimensionPixelSize(android.R.dimen.app_icon_size);
+ views.setOnClickPendingIntent(R.id.layout_left, startMediaPlayer);
+ views.setOnClickPendingIntent(R.id.imgvCover, startMediaPlayer);
+
+ try {
+ icon = Glide.with(context)
+ .asBitmap()
+ .load(widgetState.media.getImageLocation())
+ .apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY))
+ .submit(iconSize, iconSize)
+ .get(500, TimeUnit.MILLISECONDS);
+ views.setImageViewBitmap(R.id.imgvCover, icon);
+ } catch (Throwable tr1) {
+ try {
+ icon = Glide.with(context)
+ .asBitmap()
+ .load(ImageResourceUtils.getFallbackImageLocation(widgetState.media))
+ .apply(RequestOptions.diskCacheStrategyOf(ApGlideSettings.AP_DISK_CACHE_STRATEGY))
+ .submit(iconSize, iconSize)
+ .get(500, TimeUnit.MILLISECONDS);
+ views.setImageViewBitmap(R.id.imgvCover, icon);
+ } catch (Throwable tr2) {
+ Log.e(TAG, "Error loading the media icon for the widget", tr2);
+ views.setImageViewResource(R.id.imgvCover, R.mipmap.ic_launcher_round);
+ }
+ }
+
+ views.setTextViewText(R.id.txtvTitle, widgetState.media.getEpisodeTitle());
+ views.setViewVisibility(R.id.txtvTitle, View.VISIBLE);
+ views.setViewVisibility(R.id.txtNoPlaying, View.GONE);
+
+ String progressString = getProgressString(widgetState.position,
+ widgetState.duration, widgetState.playbackSpeed);
+ if (progressString != null) {
+ views.setViewVisibility(R.id.txtvProgress, View.VISIBLE);
+ views.setTextViewText(R.id.txtvProgress, progressString);
+ }
+
+ if (widgetState.status == PlayerStatus.PLAYING) {
+ views.setImageViewResource(R.id.butPlay, R.drawable.ic_av_pause_white_48dp);
+ views.setContentDescription(R.id.butPlay, context.getString(R.string.pause_label));
+ views.setImageViewResource(R.id.butPlayExtended, R.drawable.ic_av_pause_white_48dp);
+ views.setContentDescription(R.id.butPlayExtended, context.getString(R.string.pause_label));
+ } else {
+ views.setImageViewResource(R.id.butPlay, R.drawable.ic_av_play_white_48dp);
+ views.setContentDescription(R.id.butPlay, context.getString(R.string.play_label));
+ views.setImageViewResource(R.id.butPlayExtended, R.drawable.ic_av_play_white_48dp);
+ views.setContentDescription(R.id.butPlayExtended, context.getString(R.string.play_label));
+ }
+ views.setOnClickPendingIntent(R.id.butPlay,
+ createMediaButtonIntent(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE));
+ views.setOnClickPendingIntent(R.id.butPlayExtended,
+ createMediaButtonIntent(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE));
+ views.setOnClickPendingIntent(R.id.butRew,
+ createMediaButtonIntent(context, KeyEvent.KEYCODE_MEDIA_REWIND));
+ views.setOnClickPendingIntent(R.id.butFastForward,
+ createMediaButtonIntent(context, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD));
+ views.setOnClickPendingIntent(R.id.butSkip,
+ createMediaButtonIntent(context, KeyEvent.KEYCODE_MEDIA_NEXT));
+ } else {
+ // start the app if they click anything
+ views.setOnClickPendingIntent(R.id.layout_left, startMediaPlayer);
+ views.setOnClickPendingIntent(R.id.butPlay, startMediaPlayer);
+ views.setOnClickPendingIntent(R.id.butPlayExtended,
+ createMediaButtonIntent(context, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE));
+ views.setViewVisibility(R.id.txtvProgress, View.GONE);
+ views.setViewVisibility(R.id.txtvTitle, View.GONE);
+ views.setViewVisibility(R.id.txtNoPlaying, View.VISIBLE);
+ views.setImageViewResource(R.id.imgvCover, R.mipmap.ic_launcher_round);
+ views.setImageViewResource(R.id.butPlay, R.drawable.ic_av_play_white_48dp);
+ views.setImageViewResource(R.id.butPlayExtended, R.drawable.ic_av_play_white_48dp);
+ }
+
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) {
+ for (int id : widgetIds) {
+ Bundle options = manager.getAppWidgetOptions(id);
+ SharedPreferences prefs = context.getSharedPreferences(PlayerWidget.PREFS_NAME, Context.MODE_PRIVATE);
+ int minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH);
+ int columns = getCellsForSize(minWidth);
+ if (columns < 3) {
+ views.setViewVisibility(R.id.layout_center, View.INVISIBLE);
+ } else {
+ views.setViewVisibility(R.id.layout_center, View.VISIBLE);
+ }
+ boolean showRewind = prefs.getBoolean(PlayerWidget.KEY_WIDGET_REWIND + id, false);
+ boolean showFastForward = prefs.getBoolean(PlayerWidget.KEY_WIDGET_FAST_FORWARD + id, false);
+ boolean showSkip = prefs.getBoolean(PlayerWidget.KEY_WIDGET_SKIP + id, false);
+
+ if (showRewind || showSkip || showFastForward) {
+ views.setInt(R.id.extendedButtonsContainer, "setVisibility", View.VISIBLE);
+ views.setInt(R.id.butPlay, "setVisibility", View.GONE);
+ views.setInt(R.id.butRew, "setVisibility", showRewind ? View.VISIBLE : View.GONE);
+ views.setInt(R.id.butFastForward, "setVisibility", showFastForward ? View.VISIBLE : View.GONE);
+ views.setInt(R.id.butSkip, "setVisibility", showSkip ? View.VISIBLE : View.GONE);
+ }
+
+ int backgroundColor = prefs.getInt(PlayerWidget.KEY_WIDGET_COLOR + id, PlayerWidget.DEFAULT_COLOR);
+ views.setInt(R.id.widgetLayout, "setBackgroundColor", backgroundColor);
+
+ manager.updateAppWidget(id, views);
+ }
+ } else {
+ manager.updateAppWidget(playerWidget, views);
+ }
+ }
+
+ /**
+ * Returns number of cells needed for given size of the widget.
+ *
+ * @param size Widget size in dp.
+ * @return Size in number of cells.
+ */
+ private static int getCellsForSize(int size) {
+ int n = 2;
+ while (70 * n - 30 < size) {
+ ++n;
+ }
+ return n - 1;
+ }
+
+ /**
+ * Creates an intent which fakes a mediabutton press.
+ */
+ private static PendingIntent createMediaButtonIntent(Context context, int eventCode) {
+ KeyEvent event = new KeyEvent(KeyEvent.ACTION_DOWN, eventCode);
+ Intent startingIntent = new Intent(context, MediaButtonReceiver.class);
+ startingIntent.setAction(MediaButtonReceiver.NOTIFY_BUTTON_RECEIVER);
+ startingIntent.putExtra(Intent.EXTRA_KEY_EVENT, event);
+
+ return PendingIntent.getBroadcast(context, eventCode, startingIntent, 0);
+ }
+
+ private static String getProgressString(int position, int duration, float speed) {
+ if (position >= 0 && duration > 0) {
+ TimeSpeedConverter converter = new TimeSpeedConverter(speed);
+ position = converter.convert(position);
+ duration = converter.convert(duration);
+ return Converter.getDurationStringLong(position) + " / "
+ + Converter.getDurationStringLong(duration);
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdaterJobService.java b/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdaterJobService.java
new file mode 100644
index 000000000..004588945
--- /dev/null
+++ b/core/src/main/java/de/danoeh/antennapod/core/widget/WidgetUpdaterJobService.java
@@ -0,0 +1,35 @@
+package de.danoeh.antennapod.core.widget;
+
+import android.content.Context;
+import android.content.Intent;
+import androidx.annotation.NonNull;
+import androidx.core.app.SafeJobIntentService;
+import de.danoeh.antennapod.core.feed.util.PlaybackSpeedUtils;
+import de.danoeh.antennapod.core.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.core.service.playback.PlayerStatus;
+import de.danoeh.antennapod.core.util.playback.Playable;
+import de.danoeh.antennapod.core.util.playback.PlayableUtils;
+
+public class WidgetUpdaterJobService extends SafeJobIntentService {
+ private static final int JOB_ID = -17001;
+
+ /**
+ * Loads the current media from the database and updates the widget in a background job.
+ */
+ public static void performBackgroundUpdate(Context context) {
+ enqueueWork(context, WidgetUpdaterJobService.class,
+ WidgetUpdaterJobService.JOB_ID, new Intent(context, WidgetUpdaterJobService.class));
+ }
+
+ @Override
+ protected void onHandleWork(@NonNull Intent intent) {
+ Playable media = PlayableUtils.createInstanceFromPreferences(getApplicationContext());
+ if (media != null) {
+ WidgetUpdater.updateWidget(this, new WidgetUpdater.WidgetState(media, PlayerStatus.STOPPED,
+ media.getPosition(), media.getDuration(), PlaybackSpeedUtils.getCurrentPlaybackSpeed(media),
+ PlaybackPreferences.getCurrentEpisodeIsStream()));
+ } else {
+ WidgetUpdater.updateWidget(this, new WidgetUpdater.WidgetState(PlayerStatus.STOPPED));
+ }
+ }
+} \ No newline at end of file
diff --git a/core/src/main/res/drawable-hdpi/ic_notification_new.png b/core/src/main/res/drawable-hdpi/ic_notification_new.png
new file mode 100644
index 000000000..28a8446e4
--- /dev/null
+++ b/core/src/main/res/drawable-hdpi/ic_notification_new.png
Binary files differ
diff --git a/core/src/main/res/drawable-mdpi/ic_notification_new.png b/core/src/main/res/drawable-mdpi/ic_notification_new.png
new file mode 100644
index 000000000..02530f5e4
--- /dev/null
+++ b/core/src/main/res/drawable-mdpi/ic_notification_new.png
Binary files differ
diff --git a/core/src/main/res/drawable-xhdpi/ic_notification_new.png b/core/src/main/res/drawable-xhdpi/ic_notification_new.png
new file mode 100644
index 000000000..49c696798
--- /dev/null
+++ b/core/src/main/res/drawable-xhdpi/ic_notification_new.png
Binary files differ
diff --git a/core/src/main/res/drawable-xxhdpi/ic_notification_new.png b/core/src/main/res/drawable-xxhdpi/ic_notification_new.png
new file mode 100644
index 000000000..ec6ef4f1e
--- /dev/null
+++ b/core/src/main/res/drawable-xxhdpi/ic_notification_new.png
Binary files differ
diff --git a/core/src/main/res/drawable-xxxhdpi/ic_notification_new.png b/core/src/main/res/drawable-xxxhdpi/ic_notification_new.png
new file mode 100644
index 000000000..66f968872
--- /dev/null
+++ b/core/src/main/res/drawable-xxxhdpi/ic_notification_new.png
Binary files differ
diff --git a/core/src/main/res/drawable/ic_av_replay_black_48dp.xml b/core/src/main/res/drawable/ic_av_replay_black_48dp.xml
new file mode 100644
index 000000000..1446ae48b
--- /dev/null
+++ b/core/src/main/res/drawable/ic_av_replay_black_48dp.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt"
+ android:viewportWidth="24"
+ android:viewportHeight="24"
+ android:width="48dp"
+ android:height="48dp">
+ <path
+ android:pathData="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6 -6 6 -6 -2.69 -6 -6H4c0 4.42 3.58 8 8 8s8 -3.58 8 -8 -3.58 -8 -8 -8z"
+ android:fillColor="#000000" />
+</vector>
diff --git a/core/src/main/res/drawable/ic_av_replay_white_48dp.xml b/core/src/main/res/drawable/ic_av_replay_white_48dp.xml
new file mode 100644
index 000000000..b6343effc
--- /dev/null
+++ b/core/src/main/res/drawable/ic_av_replay_white_48dp.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android" xmlns:aapt="http://schemas.android.com/aapt"
+ android:viewportWidth="24"
+ android:viewportHeight="24"
+ android:width="48dp"
+ android:height="48dp">
+ <path
+ android:pathData="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6 -6 6s-6 -2.69 -6 -6H4c0 4.42 3.58 8 8 8s8 -3.58 8 -8S16.42 5 12 5z"
+ android:fillColor="#FFFFFF" />
+</vector>
diff --git a/core/src/main/res/drawable/ic_notification_auto_download_complete.xml b/core/src/main/res/drawable/ic_notification_auto_download_complete.xml
deleted file mode 100644
index 0caf27836..000000000
--- a/core/src/main/res/drawable/ic_notification_auto_download_complete.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
- android:width="24dp"
- android:height="24dp"
- android:viewportWidth="24"
- android:viewportHeight="24">
- <path
- android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM16.59,7.58L10,14.17l-2.59,-2.58L6,13l4,4 8,-8z"
- android:fillColor="#ffffff"/>
-</vector>
diff --git a/core/src/main/res/drawable/ic_share_black.xml b/core/src/main/res/drawable/ic_share_black.xml
new file mode 100644
index 000000000..f396c50de
--- /dev/null
+++ b/core/src/main/res/drawable/ic_share_black.xml
@@ -0,0 +1,7 @@
+<vector android:height="24dp"
+ android:viewportHeight="24.0" android:viewportWidth="24.0"
+ android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path
+ android:fillColor="#FF000000"
+ android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z"/>
+</vector>
diff --git a/core/src/main/res/drawable/ic_share_white.xml b/core/src/main/res/drawable/ic_share_white.xml
new file mode 100644
index 000000000..ae1b3d12b
--- /dev/null
+++ b/core/src/main/res/drawable/ic_share_white.xml
@@ -0,0 +1,7 @@
+<vector android:height="24dp"
+ android:viewportHeight="24.0" android:viewportWidth="24.0"
+ android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+ <path
+ android:fillColor="#FFFFFFFF"
+ android:pathData="M18,16.08c-0.76,0 -1.44,0.3 -1.96,0.77L8.91,12.7c0.05,-0.23 0.09,-0.46 0.09,-0.7s-0.04,-0.47 -0.09,-0.7l7.05,-4.11c0.54,0.5 1.25,0.81 2.04,0.81 1.66,0 3,-1.34 3,-3s-1.34,-3 -3,-3 -3,1.34 -3,3c0,0.24 0.04,0.47 0.09,0.7L8.04,9.81C7.5,9.31 6.79,9 6,9c-1.66,0 -3,1.34 -3,3s1.34,3 3,3c0.79,0 1.5,-0.31 2.04,-0.81l7.12,4.16c-0.05,0.21 -0.08,0.43 -0.08,0.65 0,1.61 1.31,2.92 2.92,2.92 1.61,0 2.92,-1.31 2.92,-2.92s-1.31,-2.92 -2.92,-2.92z"/>
+</vector>
diff --git a/core/src/main/res/layout/player_widget.xml b/core/src/main/res/layout/player_widget.xml
index 8e38d7f6e..ab42e4cb4 100644
--- a/core/src/main/res/layout/player_widget.xml
+++ b/core/src/main/res/layout/player_widget.xml
@@ -27,7 +27,7 @@
<LinearLayout
android:id="@+id/layout_left"
- android:layout_width="0dp"
+ android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_alignParentLeft="true"
android:layout_alignParentStart="true"
@@ -59,7 +59,7 @@
android:maxLines="3"
android:text="@string/no_media_playing_label"
android:textColor="@color/white"
- android:textSize="@dimen/text_size_medium"
+ android:textSize="16sp"
android:textStyle="bold" />
<TextView
@@ -67,8 +67,9 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
+ android:ellipsize="end"
android:textColor="@color/white"
- android:textSize="@dimen/text_size_medium"
+ android:textSize="16sp"
android:textStyle="bold"
android:visibility="gone" />
@@ -78,9 +79,61 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/white"
+ android:textSize="14sp"
android:visibility="gone" />
+
+ <LinearLayout
+ android:id="@+id/extendedButtonsContainer"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:orientation="horizontal"
+ android:visibility="gone">
+
+ <ImageButton
+ android:id="@+id/butRew"
+ android:layout_width="36dp"
+ android:layout_height="36dp"
+ android:background="?android:attr/selectableItemBackground"
+ android:contentDescription="@string/rewind_label"
+ android:layout_marginRight="2dp"
+ android:layout_marginEnd="2dp"
+ android:scaleType="fitXY"
+ android:src="@drawable/ic_av_fast_rewind_white_48dp"/>
+
+ <ImageButton
+ android:id="@+id/butPlayExtended"
+ android:layout_width="36dp"
+ android:layout_height="36dp"
+ android:background="?android:attr/selectableItemBackground"
+ android:contentDescription="@string/play_label"
+ android:layout_marginRight="2dp"
+ android:layout_marginEnd="2dp"
+ android:scaleType="fitXY"
+ android:src="@drawable/ic_av_play_white_48dp"/>
+
+ <ImageButton
+ android:id="@+id/butFastForward"
+ android:layout_width="36dp"
+ android:layout_height="36dp"
+ android:background="?android:attr/selectableItemBackground"
+ android:contentDescription="@string/fast_forward_label"
+ android:layout_marginRight="2dp"
+ android:layout_marginEnd="2dp"
+ android:scaleType="fitXY"
+ android:src="@drawable/ic_av_fast_forward_white_48dp"/>
+
+ <ImageButton
+ android:id="@+id/butSkip"
+ android:layout_width="36dp"
+ android:layout_height="36dp"
+ android:background="?android:attr/selectableItemBackground"
+ android:contentDescription="@string/skip_episode_label"
+ android:layout_marginRight="2dp"
+ android:layout_marginEnd="2dp"
+ android:scaleType="fitXY"
+ android:src="@drawable/ic_av_skip_white_24dp"/>
+ </LinearLayout>
</LinearLayout>
</LinearLayout>
</RelativeLayout>
-
</FrameLayout> \ No newline at end of file
diff --git a/core/src/main/res/values-br/strings.xml b/core/src/main/res/values-br/strings.xml
index d5d6bde01..3ff5df24f 100644
--- a/core/src/main/res/values-br/strings.xml
+++ b/core/src/main/res/values-br/strings.xml
@@ -161,13 +161,6 @@
<string name="delete_label">Dilemel</string>
<string name="delete_failed">N\'haller ket dilemel ar restr. Gallout a rit klask adloc\'hañ.</string>
<string name="delete_episode_label">Dilemel ar rann</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d rann dilamet </item>
- <item quantity="two">%d rann dilamet </item>
- <item quantity="few">%d rann dilamet </item>
- <item quantity="many">%d rann dilamet </item>
- <item quantity="other">%d rann dilamet </item>
- </plurals>
<string name="remove_new_flag_label">Tennañ ar merk \"nevez\"</string>
<string name="removed_new_flag_label">Tennet eo bet ar merk \"nevez\"</string>
<string name="mark_read_label">Merkañ evel lennet</string>
diff --git a/core/src/main/res/values-ca/strings.xml b/core/src/main/res/values-ca/strings.xml
index f3a62edad..616efedba 100644
--- a/core/src/main/res/values-ca/strings.xml
+++ b/core/src/main/res/values-ca/strings.xml
@@ -149,10 +149,6 @@
<string name="delete_label">Esborrar</string>
<string name="delete_failed">No s\'ha pogut esborrar el fitxer. Reiniciar el dispositiu pot ajudar.</string>
<string name="delete_episode_label">Esborrar episodi.</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d episodi esborrat.</item>
- <item quantity="other">%d episodis esborrats.</item>
- </plurals>
<string name="remove_new_flag_label">Retirar bandera de \"nou\"</string>
<string name="removed_new_flag_label">Retirada bandera de \"nou\"</string>
<string name="mark_read_label">Marca com a llegit</string>
diff --git a/core/src/main/res/values-cs/strings.xml b/core/src/main/res/values-cs/strings.xml
index 2547c52b8..d59def684 100644
--- a/core/src/main/res/values-cs/strings.xml
+++ b/core/src/main/res/values-cs/strings.xml
@@ -169,12 +169,6 @@
<string name="delete_label">Smazat</string>
<string name="delete_failed">Nelze smazat soubor. Restart přístroje může pomoci.</string>
<string name="delete_episode_label">Smazat epizodu</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d epizoda stažena.</item>
- <item quantity="few">%d epizody staženy.</item>
- <item quantity="many">%d epizod staženo.</item>
- <item quantity="other">%d epizod staženo.</item>
- </plurals>
<string name="remove_new_flag_label">Odstranit příznak „nová“</string>
<string name="removed_new_flag_label">Příznak „nová“ odstraněn</string>
<string name="mark_read_label">Označit jako poslechnuté</string>
diff --git a/core/src/main/res/values-da/strings.xml b/core/src/main/res/values-da/strings.xml
index 9d762f2a4..5d777e3a8 100644
--- a/core/src/main/res/values-da/strings.xml
+++ b/core/src/main/res/values-da/strings.xml
@@ -160,10 +160,6 @@
<string name="delete_label">Slet</string>
<string name="delete_failed">Kan ikke slette fil. En genstart af enheden vil sandsynligvis hjælpe.</string>
<string name="delete_episode_label">Slet udsendelse</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d udsendelse slettet.</item>
- <item quantity="other">%d udsendelser slettet.</item>
- </plurals>
<string name="remove_new_flag_label">Fjern \"ny\"-markering</string>
<string name="removed_new_flag_label">Fjernet \"ny\"-markering</string>
<string name="mark_read_label">Marker som afspillet</string>
diff --git a/core/src/main/res/values-de/strings.xml b/core/src/main/res/values-de/strings.xml
index 31699b437..1beeeb0af 100644
--- a/core/src/main/res/values-de/strings.xml
+++ b/core/src/main/res/values-de/strings.xml
@@ -160,10 +160,6 @@
<string name="delete_label">Löschen</string>
<string name="delete_failed">Die Datei kann nicht gelöscht werden. Eventuell hilft es, das Gerät neu zu starten.</string>
<string name="delete_episode_label">Episode löschen</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d Episode gelöscht.</item>
- <item quantity="other">%d Episoden gelöscht.</item>
- </plurals>
<string name="remove_new_flag_label">\"Neu\"-Markierung entfernen</string>
<string name="removed_new_flag_label">\"Neu\"-Markierung entfernt</string>
<string name="mark_read_label">Als gespielt markieren</string>
diff --git a/core/src/main/res/values-es/strings.xml b/core/src/main/res/values-es/strings.xml
index 11ddda302..48cd6f8dd 100644
--- a/core/src/main/res/values-es/strings.xml
+++ b/core/src/main/res/values-es/strings.xml
@@ -160,10 +160,6 @@
<string name="delete_label">Borrar</string>
<string name="delete_failed">No se puede borrar el fichero. Reiniciar el dispositivo podría ayudar.</string>
<string name="delete_episode_label">Borrar Episodio</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%depisodio borrado.</item>
- <item quantity="other">%depisodios borrados.</item>
- </plurals>
<string name="remove_new_flag_label">Eliminar marca \"nuevo\"</string>
<string name="removed_new_flag_label">Eliminada marca \"nuevo\"</string>
<string name="mark_read_label">Marcar como reproducido</string>
diff --git a/core/src/main/res/values-et/strings.xml b/core/src/main/res/values-et/strings.xml
index fb18b452b..65e20126b 100644
--- a/core/src/main/res/values-et/strings.xml
+++ b/core/src/main/res/values-et/strings.xml
@@ -160,10 +160,6 @@
<string name="delete_label">Kustuta</string>
<string name="delete_failed">Faili ei saa kustutada. Aidata võib seadme taaskäivitamine.</string>
<string name="delete_episode_label">Kustuta saade</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">Kustutati %d saade.</item>
- <item quantity="other">Kustutati %d saadet.</item>
- </plurals>
<string name="remove_new_flag_label">Eemalda silt \"uus\"</string>
<string name="removed_new_flag_label">Eemaldati silt \"uus\"</string>
<string name="mark_read_label">Märgi kuulatuks</string>
diff --git a/core/src/main/res/values-eu/strings.xml b/core/src/main/res/values-eu/strings.xml
index 3c63e07fe..6bc7b3c9e 100644
--- a/core/src/main/res/values-eu/strings.xml
+++ b/core/src/main/res/values-eu/strings.xml
@@ -160,10 +160,6 @@
<string name="delete_label">Ezabatu</string>
<string name="delete_failed">Ezin da fitxategia ezabatu. Gailua berrabiarazteak lagun dezake.</string>
<string name="delete_episode_label">Saioa ezabatu</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%dsaio ezabatua.</item>
- <item quantity="other">%d saio ezabatuak.</item>
- </plurals>
<string name="remove_new_flag_label">Kendu \"berria\" ikurra</string>
<string name="removed_new_flag_label">\"Berria\" ikurra kendu da</string>
<string name="mark_read_label">Markatu ikusita bezala</string>
diff --git a/core/src/main/res/values-fa/strings.xml b/core/src/main/res/values-fa/strings.xml
index afdb51c39..d4bb6a29c 100644
--- a/core/src/main/res/values-fa/strings.xml
+++ b/core/src/main/res/values-fa/strings.xml
@@ -160,10 +160,6 @@
<string name="delete_label">حذف</string>
<string name="delete_failed">پرونده حذف نشد. راه‌اندازی مجدد دستگاه می‌تواند کمک کند.</string>
<string name="delete_episode_label">حذف قسمت</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d قسمت حذف شد.</item>
- <item quantity="other">%d قسمت حذف شد.</item>
- </plurals>
<string name="remove_new_flag_label">حذف نشان «جدید»</string>
<string name="removed_new_flag_label">نشان «جدید» حذف شد</string>
<string name="mark_read_label">علامت‌گذاری به‌عنوان پخش‌شده</string>
diff --git a/core/src/main/res/values-fi/strings.xml b/core/src/main/res/values-fi/strings.xml
index 2dc804ee3..ff6c9c773 100644
--- a/core/src/main/res/values-fi/strings.xml
+++ b/core/src/main/res/values-fi/strings.xml
@@ -149,10 +149,6 @@
<string name="delete_label">Poista</string>
<string name="delete_failed">Ei voida poistaa tiedostoa. Laitteen uudelleenkäynnistys saattaa auttaa.</string>
<string name="delete_episode_label">Poista jakso</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d jakso poistettu.</item>
- <item quantity="other">%d jaksoa poistettu.</item>
- </plurals>
<string name="remove_new_flag_label">Poista \"uusi\"-lippu</string>
<string name="removed_new_flag_label">Poistettiin \"uusi\"-lippu</string>
<string name="mark_read_label">Merkitse soitetuksi</string>
diff --git a/core/src/main/res/values-fr/strings.xml b/core/src/main/res/values-fr/strings.xml
index 345d6532b..a5366f34b 100644
--- a/core/src/main/res/values-fr/strings.xml
+++ b/core/src/main/res/values-fr/strings.xml
@@ -160,10 +160,6 @@
<string name="delete_label">Supprimer</string>
<string name="delete_failed">Suppression du fichier impossible. Redémarrer pourrait aider.</string>
<string name="delete_episode_label">Suppression de l\'épisode</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d épisode supprimé.</item>
- <item quantity="other">%d épisodes supprimés.</item>
- </plurals>
<string name="remove_new_flag_label">Ne plus considérer nouveau</string>
<string name="removed_new_flag_label">Le statut \"nouveau\" a été supprimé</string>
<string name="mark_read_label">Marquer comme lu</string>
diff --git a/core/src/main/res/values-gl/strings.xml b/core/src/main/res/values-gl/strings.xml
index 56ac341ad..cd9d7654a 100644
--- a/core/src/main/res/values-gl/strings.xml
+++ b/core/src/main/res/values-gl/strings.xml
@@ -160,10 +160,6 @@
<string name="delete_label">Borrar</string>
<string name="delete_failed">Non se puido eliminar o ficheiro. Reiniciar o dispositivo podería axudar.</string>
<string name="delete_episode_label">Eliminar episodio</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d episodio eliminado.</item>
- <item quantity="other">%d episodios eliminados.</item>
- </plurals>
<string name="remove_new_flag_label">Quitar marca \"novo\"</string>
<string name="removed_new_flag_label">Eliminouse marca \"novo\"</string>
<string name="mark_read_label">Marcar como reproducido</string>
diff --git a/core/src/main/res/values-hu/strings.xml b/core/src/main/res/values-hu/strings.xml
index 8a610af4e..5f90546e3 100644
--- a/core/src/main/res/values-hu/strings.xml
+++ b/core/src/main/res/values-hu/strings.xml
@@ -157,10 +157,6 @@
<string name="delete_label">Törlés</string>
<string name="delete_failed">A fájl nem törölhető. Az eszköz újraindítása segíthet a probléma megoldásában.</string>
<string name="delete_episode_label">Epizód törlése</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d epizód törölve. </item>
- <item quantity="other">%d epizód törölve.</item>
- </plurals>
<string name="remove_new_flag_label">Az „új” jelző eltávolítása</string>
<string name="removed_new_flag_label">Az „új” jelző eltávolítva</string>
<string name="mark_read_label">Megjelölés lejátszottként</string>
diff --git a/core/src/main/res/values-it/strings.xml b/core/src/main/res/values-it/strings.xml
index a960b81fb..65ad90f28 100644
--- a/core/src/main/res/values-it/strings.xml
+++ b/core/src/main/res/values-it/strings.xml
@@ -160,10 +160,6 @@
<string name="delete_label">Elimina</string>
<string name="delete_failed">Impossibile eliminare il file. Prova a riavviare il dispositivo.</string>
<string name="delete_episode_label">Elimina episodio</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d episodio eliminato.</item>
- <item quantity="other">%d episodi eliminati.</item>
- </plurals>
<string name="remove_new_flag_label">Rimuovi flag \"nuovo\"</string>
<string name="removed_new_flag_label">Flag \"nuovo\" rimosso</string>
<string name="mark_read_label">Segna come riprodotto</string>
diff --git a/core/src/main/res/values-iw/strings.xml b/core/src/main/res/values-iw/strings.xml
index 0ed0fc076..f9016ec3f 100644
--- a/core/src/main/res/values-iw/strings.xml
+++ b/core/src/main/res/values-iw/strings.xml
@@ -168,12 +168,6 @@
<string name="delete_label">מחיקה</string>
<string name="delete_failed">לא ניתן למחוק קובץ. הפעלת המכשיר מחדש עשויה לסייע.</string>
<string name="delete_episode_label">מחיקת פרק</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">פרק אחד נמחק.</item>
- <item quantity="two">%d פרקים נמחקו.</item>
- <item quantity="many">%d פרקים נמחקו.</item>
- <item quantity="other">%d פרקים נמחקו.</item>
- </plurals>
<string name="remove_new_flag_label">הסרת הסימון „חדש”</string>
<string name="removed_new_flag_label">הוסר הסימון „חדש”</string>
<string name="mark_read_label">סימון כנצפה</string>
diff --git a/core/src/main/res/values-ja/strings.xml b/core/src/main/res/values-ja/strings.xml
index 62ceb3418..7a38857a2 100644
--- a/core/src/main/res/values-ja/strings.xml
+++ b/core/src/main/res/values-ja/strings.xml
@@ -141,9 +141,6 @@
<string name="delete_label">削除</string>
<string name="delete_failed">ファイルを削除できません。デバイスを再起動してみてください。</string>
<string name="delete_episode_label">エピソードを削除</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="other">%d エピソードを削除しました。</item>
- </plurals>
<string name="remove_new_flag_label">\"新規\" フラグを削除</string>
<string name="removed_new_flag_label">\"新規\" フラグを削除しました</string>
<string name="mark_read_label">再生済としてマーク</string>
diff --git a/core/src/main/res/values-ko/strings.xml b/core/src/main/res/values-ko/strings.xml
index 95dd859e9..538aa2629 100644
--- a/core/src/main/res/values-ko/strings.xml
+++ b/core/src/main/res/values-ko/strings.xml
@@ -145,9 +145,6 @@
<string name="delete_label">삭제</string>
<string name="delete_failed">파일을 삭제할 수 없습니다. 장치를 재부팅하면 동작할 수도 있습니다.</string>
<string name="delete_episode_label">에피소드 삭제</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="other">%d개 에피소드 삭제함.</item>
- </plurals>
<string name="remove_new_flag_label">\"신규\" 플래그 제거</string>
<string name="removed_new_flag_label">\"신규\" 플래그 제거함</string>
<string name="mark_read_label">재생했다고 표시</string>
diff --git a/core/src/main/res/values-lt/strings.xml b/core/src/main/res/values-lt/strings.xml
index 442916985..fac693b25 100644
--- a/core/src/main/res/values-lt/strings.xml
+++ b/core/src/main/res/values-lt/strings.xml
@@ -157,12 +157,6 @@
<string name="delete_label">Ištrinti</string>
<string name="delete_failed">Nepavyksta ištrinti failo. Įrenginio paleidimas iš naujo gali padėti.</string>
<string name="delete_episode_label">Ištrinti epizodą</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d epizodas ištrintas.</item>
- <item quantity="few">%d epizodai ištrinti.</item>
- <item quantity="many">%d epizodai ištrinti.</item>
- <item quantity="other">%d epizodai ištrinti.</item>
- </plurals>
<string name="remove_new_flag_label">Pašalinti „naujas“ gairelę</string>
<string name="removed_new_flag_label">„naujas“ gairelė pašalinta</string>
<string name="mark_read_label">Pažymėti kaip perklausytą</string>
diff --git a/core/src/main/res/values-nb/strings.xml b/core/src/main/res/values-nb/strings.xml
index 862787354..eb653afd2 100644
--- a/core/src/main/res/values-nb/strings.xml
+++ b/core/src/main/res/values-nb/strings.xml
@@ -155,10 +155,6 @@
<string name="delete_label">Slett</string>
<string name="delete_failed">Kan ikke slette filen. Omstart av enheten kan hjelpe.</string>
<string name="delete_episode_label">Slett episode</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d episode slettet.</item>
- <item quantity="other">%d episode slettet.</item>
- </plurals>
<string name="remove_new_flag_label">Fjern \"Ny\"-markering</string>
<string name="removed_new_flag_label">Fjernet \"Ny\"-markering</string>
<string name="mark_read_label">Marker som avspilt</string>
diff --git a/core/src/main/res/values-nl/strings.xml b/core/src/main/res/values-nl/strings.xml
index b17dd6c72..6cdd59e77 100644
--- a/core/src/main/res/values-nl/strings.xml
+++ b/core/src/main/res/values-nl/strings.xml
@@ -160,10 +160,6 @@
<string name="delete_label">Verwijderen</string>
<string name="delete_failed">Kan bestand niet verwijderen; start je apparaat opnieuw op.</string>
<string name="delete_episode_label">Aflevering verwijderen</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d aflevering verwijderd.</item>
- <item quantity="other">%d afleveringen verwijderd.</item>
- </plurals>
<string name="remove_new_flag_label">\'Nieuw\'-label verwijderen</string>
<string name="removed_new_flag_label">\'Nieuw\'-label is verwijderd</string>
<string name="mark_read_label">Als afgespeeld markeren</string>
diff --git a/core/src/main/res/values-pl/strings.xml b/core/src/main/res/values-pl/strings.xml
index c9578ae9d..c2b977382 100644
--- a/core/src/main/res/values-pl/strings.xml
+++ b/core/src/main/res/values-pl/strings.xml
@@ -168,12 +168,6 @@
<string name="delete_label">Usuń</string>
<string name="delete_failed">Nie można usunąć pliku. Restart urządzenia może w tym pomóc.</string>
<string name="delete_episode_label">Usuń odcinek</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">Usunięto %d odcinek. </item>
- <item quantity="few">Usunięto %d odcinki(ów). </item>
- <item quantity="many">Usunięto %d odcinki(ów). </item>
- <item quantity="other">Usunięto %d odcinki(ów). </item>
- </plurals>
<string name="remove_new_flag_label">Usuń flagę \"nowy\"</string>
<string name="removed_new_flag_label">Usunięto flagę \"nowy\"</string>
<string name="mark_read_label">Oznacz jako odtworzone</string>
diff --git a/core/src/main/res/values-pt-rBR/strings.xml b/core/src/main/res/values-pt-rBR/strings.xml
index 83731741e..a4b499bf0 100644
--- a/core/src/main/res/values-pt-rBR/strings.xml
+++ b/core/src/main/res/values-pt-rBR/strings.xml
@@ -160,10 +160,6 @@
<string name="delete_label">Apagar</string>
<string name="delete_failed">Não foi possível apagar o arquivo. Experimente reiniciar o dispositivo.</string>
<string name="delete_episode_label">Apagar Episódio</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d episódio apagado.</item>
- <item quantity="other">%d episódios apagados.</item>
- </plurals>
<string name="remove_new_flag_label">Remover etiqueta de \"novo\"</string>
<string name="removed_new_flag_label">Etiqueta de \"novo\" removida</string>
<string name="mark_read_label">Marcar como reproduzido</string>
diff --git a/core/src/main/res/values-pt/strings.xml b/core/src/main/res/values-pt/strings.xml
index 4fd2f1875..7e2350504 100644
--- a/core/src/main/res/values-pt/strings.xml
+++ b/core/src/main/res/values-pt/strings.xml
@@ -160,10 +160,6 @@
<string name="delete_label">Eliminar</string>
<string name="delete_failed">Episódio não eliminado. Tente reiniciar o dispositivo.</string>
<string name="delete_episode_label">Eliminar episódio</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d episódio eliminado.</item>
- <item quantity="other">%d episódios eliminados.</item>
- </plurals>
<string name="remove_new_flag_label">Remover a marca \"novo\"</string>
<string name="removed_new_flag_label">A marca \"novo\" foi removida</string>
<string name="mark_read_label">Marcar como reproduzido</string>
diff --git a/core/src/main/res/values-ru/strings.xml b/core/src/main/res/values-ru/strings.xml
index df7fb7cd5..90284b2cc 100644
--- a/core/src/main/res/values-ru/strings.xml
+++ b/core/src/main/res/values-ru/strings.xml
@@ -168,12 +168,6 @@
<string name="delete_label">Удалить</string>
<string name="delete_failed">Невозможно удалить файл. Попробуйте перезагрузить устройство.</string>
<string name="delete_episode_label">Удалить выпуск</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d выпуск удален.</item>
- <item quantity="few">%d выпуска удалены.</item>
- <item quantity="many">%d выпусков удалены.</item>
- <item quantity="other">%d выпусков удалено.</item>
- </plurals>
<string name="remove_new_flag_label">Убрать пометку «Новый»</string>
<string name="removed_new_flag_label">Пометка «Новый» убрана</string>
<string name="mark_read_label">Отметить как прослушанное</string>
diff --git a/core/src/main/res/values-sv/strings.xml b/core/src/main/res/values-sv/strings.xml
index 5568529dc..fdd6de667 100644
--- a/core/src/main/res/values-sv/strings.xml
+++ b/core/src/main/res/values-sv/strings.xml
@@ -160,10 +160,6 @@
<string name="delete_label">Ta bort</string>
<string name="delete_failed">Kunde inte ta bort filen. Testa att starta om enheten.</string>
<string name="delete_episode_label">Radera episod</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">1%d episod raderad.</item>
- <item quantity="other">1%d episder raderade.</item>
- </plurals>
<string name="remove_new_flag_label">Ta bort \"ny\"-flagga</string>
<string name="removed_new_flag_label">Tog bort \"ny\"-flagga</string>
<string name="mark_read_label">Markera som spelad</string>
diff --git a/core/src/main/res/values-tr/strings.xml b/core/src/main/res/values-tr/strings.xml
index 0d9517b1e..d2fa1e92a 100644
--- a/core/src/main/res/values-tr/strings.xml
+++ b/core/src/main/res/values-tr/strings.xml
@@ -160,10 +160,6 @@
<string name="delete_label">Sil</string>
<string name="delete_failed">Dosya silinemiyor. Cihazı yeniden başlatmak yardımcı olabilir.</string>
<string name="delete_episode_label">Delete Episode</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d episode deleted.</item>
- <item quantity="other">%d episodes deleted.</item>
- </plurals>
<string name="remove_new_flag_label">Remove \"new\" flag</string>
<string name="removed_new_flag_label">Removed \"new\" flag</string>
<string name="mark_read_label">Oynatıldı olarak işaretle</string>
diff --git a/core/src/main/res/values-uk/strings.xml b/core/src/main/res/values-uk/strings.xml
index dd2d0ac62..f9c2abe20 100644
--- a/core/src/main/res/values-uk/strings.xml
+++ b/core/src/main/res/values-uk/strings.xml
@@ -137,12 +137,6 @@
<string name="delete_label">Видалити</string>
<string name="delete_failed">Файл не видалено. Можливо, перезавантаження пристрою допоможе.</string>
<string name="delete_episode_label">Видалити епізод</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d епізод видалено.</item>
- <item quantity="few">%dепізода видалено. </item>
- <item quantity="many">%dепізодів видалено. </item>
- <item quantity="other">%d епізодів видалено.</item>
- </plurals>
<string name="mark_read_label">Позначити як відтворений</string>
<string name="marked_as_read_label">Позначено як відтворений</string>
<plurals name="marked_read_batch_label">
diff --git a/core/src/main/res/values-zh-rCN/strings.xml b/core/src/main/res/values-zh-rCN/strings.xml
index 41fe955eb..fb74f256c 100644
--- a/core/src/main/res/values-zh-rCN/strings.xml
+++ b/core/src/main/res/values-zh-rCN/strings.xml
@@ -156,9 +156,6 @@
<string name="delete_label">删除</string>
<string name="delete_failed">无法删除文件。重启可能解决该问题。</string>
<string name="delete_episode_label">删除节目</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="other">已删除%d个节目</item>
- </plurals>
<string name="remove_new_flag_label">移除“新的”标签</string>
<string name="removed_new_flag_label">已移除“新的”标签</string>
<string name="mark_read_label">标记已播放</string>
diff --git a/core/src/main/res/values-zh-rTW/strings.xml b/core/src/main/res/values-zh-rTW/strings.xml
index 9b63a1114..2e3dacecc 100644
--- a/core/src/main/res/values-zh-rTW/strings.xml
+++ b/core/src/main/res/values-zh-rTW/strings.xml
@@ -156,9 +156,6 @@
<string name="delete_label">刪除</string>
<string name="delete_failed">刪除文件失敗。重啟設備試試看。</string>
<string name="delete_episode_label">刪除這一集</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="other">已刪除 %d 集。</item>
- </plurals>
<string name="remove_new_flag_label">移除「新」的標記</string>
<string name="removed_new_flag_label">已移除「新」的標記</string>
<string name="mark_read_label">標記為已播放</string>
diff --git a/core/src/main/res/values/arrays.xml b/core/src/main/res/values/arrays.xml
index 1ab44d847..6b3a10f46 100644
--- a/core/src/main/res/values/arrays.xml
+++ b/core/src/main/res/values/arrays.xml
@@ -96,6 +96,7 @@
</string-array>
<string-array name="episode_cleanup_entries">
+ <item>@string/episode_cleanup_except_favorite_removal</item>
<item>@string/episode_cleanup_queue_removal</item>
<item>0</item>
<item>1</item>
@@ -105,6 +106,20 @@
<item>@string/episode_cleanup_never</item>
</string-array>
+ <string-array name="button_action_options">
+ <item>@string/button_action_fast_forward</item>
+ <item>@string/button_action_rewind</item>
+ <item>@string/button_action_skip_episode</item>
+ <item>@string/button_action_restart_episode</item>
+ </string-array>
+
+ <string-array name="button_action_values">
+ <item>@string/keycode_media_fast_forward</item>
+ <item>@string/keycode_media_rewind</item>
+ <item>@string/keycode_media_next</item>
+ <item>@string/keycode_media_previous</item>
+ </string-array>
+
<string-array name="enqueue_location_options">
<item>@string/enqueue_location_back</item>
<item>@string/enqueue_location_front</item>
@@ -119,6 +134,7 @@
</string-array>
<string-array name="episode_cleanup_values">
+ <item>-3</item>
<item>-1</item>
<item>0</item>
<item>12</item>
@@ -234,15 +250,15 @@
</string-array>
<string-array name="media_player_options">
+ <item>@string/media_player_exoplayer_recommended</item>
<item>@string/media_player_builtin</item>
<item>@string/media_player_sonic</item>
- <item>@string/media_player_exoplayer_recommended</item>
</string-array>
<string-array name="media_player_values">
+ <item>exoplayer</item>
<item>builtin</item>
<item>sonic</item>
- <item>exoplayer</item>
</string-array>
<!-- sort for podcast screen, not for queue -->
diff --git a/core/src/main/res/values/attrs.xml b/core/src/main/res/values/attrs.xml
index 3a5ec310b..91ecae93d 100644
--- a/core/src/main/res/values/attrs.xml
+++ b/core/src/main/res/values/attrs.xml
@@ -7,6 +7,7 @@
<attr name="av_fast_forward" format="reference"/>
<attr name="av_pause" format="reference"/>
<attr name="av_play" format="reference"/>
+ <attr name="av_replay" format="reference"/>
<attr name="av_skip" format="reference"/>
<attr name="av_rewind" format="reference"/>
<attr name="ic_delete" format="reference"/>
@@ -61,12 +62,6 @@
<attr name="filter_dialog_clear" format="color"/>
<attr name="filter_dialog_button_background" format="reference"/>
<attr name="ic_notifications" format="reference"/>
-
- <declare-styleable name="SquareImageView">
- <attr name="direction" format="enum">
- <enum name="width" value="0"/>
- <enum name="height" value="1"/>
- <enum name="minimum" value="2"/>
- </attr>
- </declare-styleable>
+ <attr name="seek_background" format="color" />
+ <attr name="ic_share" format="reference"/>
</resources>
diff --git a/core/src/main/res/values/colors.xml b/core/src/main/res/values/colors.xml
index d09f53d64..fc2409e11 100644
--- a/core/src/main/res/values/colors.xml
+++ b/core/src/main/res/values/colors.xml
@@ -5,6 +5,7 @@
<color name="grey100">#f5f5f5</color>
<color name="grey600">#757575</color>
<color name="light_gray">#bfbfbf</color>
+ <color name="medium_gray">#afafaf</color>
<color name="black">#000000</color>
<color name="download_success_green">#248800</color>
<color name="download_failed_red">#B00020</color>
@@ -21,6 +22,8 @@
<color name="highlight_dark">#43707070</color>
<color name="highlight_trueblack">#43707070</color>
<color name="non_square_icon_background">#22777777</color>
+ <color name="seek_background_light">#90000000</color>
+ <color name="seek_background_dark">#905B5B5B</color>
<color name="accent_light">#0078C2</color>
<color name="accent_dark">#3D8BFF</color>
diff --git a/core/src/main/res/values/ids.xml b/core/src/main/res/values/ids.xml
index 3c173b72d..87046cc0f 100644
--- a/core/src/main/res/values/ids.xml
+++ b/core/src/main/res/values/ids.xml
@@ -23,14 +23,4 @@
<item name="notification_auto_download_report" type="id"/>
<item name="notification_playing" type="id"/>
<item name="notification_streaming_confirmation" type="id"/>
-
- <!-- PendingIntent objects that use the same action but different extras need to use a unique request code -->
- <item name="pending_intent_download_service_notification" type="id"/>
- <item name="pending_intent_download_service_auth" type="id"/>
- <item name="pending_intent_download_service_report" type="id"/>
- <item name="pending_intent_download_service_autodownload_report" type="id"/>
- <item name="pending_intent_allow_stream_always" type="id"/>
- <item name="pending_intent_allow_stream_this_time" type="id"/>
- <item name="pending_intent_player_activity" type="id"/>
- <item name="pending_intent_sync_error" type="id"/>
</resources> \ No newline at end of file
diff --git a/core/src/main/res/values/keycodes.xml b/core/src/main/res/values/keycodes.xml
new file mode 100644
index 000000000..e0d44ce04
--- /dev/null
+++ b/core/src/main/res/values/keycodes.xml
@@ -0,0 +1,9 @@
+<resources
+ xmlns:tools="http://schemas.android.com/tools"
+ tools:ignore="MissingTranslation">
+
+ <string name="keycode_media_next">87</string>
+ <string name="keycode_media_previous">88</string>
+ <string name="keycode_media_rewind">89</string>
+ <string name="keycode_media_fast_forward">90</string>
+</resources>
diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml
index c56d8ec62..8e15d60d7 100644
--- a/core/src/main/res/values/strings.xml
+++ b/core/src/main/res/values/strings.xml
@@ -4,13 +4,14 @@
tools:ignore="MissingTranslation">
<!-- Activity and fragment titles -->
- <string name="app_name" translate="false">AntennaPod</string>
- <string name="provider_authority" translate="false">de.danoeh.antennapod.provider</string>
+ <string name="app_name" translatable="false">AntennaPod</string>
+ <string name="provider_authority" translatable="false">de.danoeh.antennapod.provider</string>
<string name="feed_update_receiver_name">Update Subscriptions</string>
<string name="feeds_label">Podcasts</string>
<string name="statistics_label">Statistics</string>
<string name="add_feed_label">Add Podcast</string>
<string name="episodes_label">Episodes</string>
+ <string name="queue_label">Queue</string>
<string name="all_episodes_short_label">All</string>
<string name="new_episodes_label">New</string>
<string name="favorite_episodes_label">Favorites</string>
@@ -22,7 +23,7 @@
<string name="downloads_log_label">Log</string>
<string name="subscriptions_label">Subscriptions</string>
<string name="subscriptions_list_label">Subscriptions List</string>
- <string name="cancel_download_label">Cancel\nDownload</string>
+ <string name="cancel_download_label">Cancel Download</string>
<string name="playback_history_label">Playback History</string>
<string name="gpodnet_main_label">gpodder.net</string>
<string name="gpodnet_auth_label">gpodder.net Login</string>
@@ -32,6 +33,9 @@
<string name="download_statistics_label">Downloads</string>
<string name="notification_pref_fragment">Notifications</string>
+ <!-- Google Assistant -->
+ <string name="app_action_not_found">\"%1$s\" not found</string>
+
<!-- Statistics fragment -->
<string name="total_time_listened_to_podcasts">Total time of episodes played:</string>
<string name="statistics_details_dialog">%1$d out of %2$d episodes started.\n\nPlayed %3$s out of %4$s.</string>
@@ -62,6 +66,8 @@
<!-- Bug report activity -->
<string name="log_file_share_exception">No compatible apps found</string>
+ <string name="export_logs_menu_title">Export detailed logs</string>
+ <string name="confirm_export_log_dialog_message">Detailed logs may contain sensitive information, such as your subscriptions list</string>
<!-- Webview actions -->
<string name="open_in_browser_label">Open in Browser</string>
@@ -93,7 +99,6 @@
<string name="description_label">Description</string>
<string name="episodes_suffix">\u0020episodes</string>
<string name="processing_label">Processing</string>
- <string name="save_username_password_label">Save username and password</string>
<string name="close_label">Close</string>
<string name="retry_label">Retry</string>
<string name="auto_download_label">Include in auto downloads</string>
@@ -105,12 +110,13 @@
<string name="feed_volume_reduction_off">Off</string>
<string name="feed_volume_reduction_light">Light</string>
<string name="feed_volume_reduction_heavy">Heavy</string>
- <string name="parallel_downloads_suffix">\u0020parallel downloads</string>
+ <string name="parallel_downloads">%1$d parallel downloads</string>
<string name="feed_auto_download_global">Global default</string>
<string name="feed_auto_download_always">Always</string>
<string name="feed_auto_download_never">Never</string>
<string name="send_label">Send&#8230;</string>
<string name="episode_cleanup_never">Never</string>
+ <string name="episode_cleanup_except_favorite_removal">When not favorited</string>
<string name="episode_cleanup_queue_removal">When not in queue</string>
<string name="episode_cleanup_after_listening">After finishing</string>
<plurals name="episode_cleanup_hours_after_listening">
@@ -125,7 +131,22 @@
<item quantity="one">%d selected</item>
<item quantity="other">%d selected</item>
</plurals>
+ <plurals name="num_episodes">
+ <item quantity="one">%d episode</item>
+ <item quantity="other">%d episodes</item>
+ </plurals>
<string name="loading_more">Loading more…</string>
+ <string name="episode_notification">Episode Notifications</string>
+ <string name="episode_notification_summary">Show a notification when a new episode is released.</string>
+ <plurals name="new_episode_notification_message">
+ <item quantity="one">%2$s has a new episode</item>
+ <item quantity="other">%2$s has %1$d new episodes</item>
+ </plurals>
+ <plurals name="new_episode_notification_title">
+ <item quantity="one">New Episode</item>
+ <item quantity="other">New Episodes</item>
+ </plurals>
+ <string name="new_episode_notification_group_text">Your subscriptions have new episodes.</string>
<!-- Actions on feeds -->
<string name="mark_all_read_label">Mark all as played</string>
@@ -174,9 +195,9 @@
<string name="delete_label">Delete</string>
<string name="delete_failed">Unable to delete file. Rebooting the device could help.</string>
<string name="delete_episode_label">Delete Episode</string>
- <plurals name="deleted_episode_batch_label">
- <item quantity="one">%d episode deleted.</item>
- <item quantity="other">%d episodes deleted.</item>
+ <plurals name="deleted_multi_episode_batch_label">
+ <item quantity="one">%d episode selected, %d download deleted.</item>
+ <item quantity="other">%d episodes selected, %d download(s) deleted.</item>
</plurals>
<string name="remove_new_flag_label">Remove \"new\" flag</string>
<string name="removed_new_flag_label">Removed \"new\" flag</string>
@@ -236,6 +257,7 @@
<string name="download_error_file_type_type">File Type Error</string>
<string name="download_error_forbidden">Forbidden</string>
<string name="download_canceled_msg">Download canceled</string>
+ <string name="download_wrong_size">The server connection was lost before completing the download</string>
<string name="download_canceled_autodownload_enabled_msg">Download canceled\nDisabled <i>Auto Download</i> for this item</string>
<string name="download_report_title">Downloads completed with error(s)</string>
<string name="auto_download_report_title">Auto-downloads completed</string>
@@ -248,7 +270,7 @@
<item quantity="one">%d download left</item>
<item quantity="other">%d downloads left</item>
</plurals>
- <string name="downloads_processing">Processing downloads</string>
+ <string name="service_shutting_down">Service shutting down</string>
<string name="download_notification_title">Downloading podcast data</string>
<plurals name="download_report_content">
<item quantity="one">%d download succeeded, %d failed</item>
@@ -283,11 +305,12 @@
<string name="playback_error_source">Unable to access media file</string>
<string name="playback_error_unknown">Unknown Error</string>
<string name="no_media_playing_label">No media playing</string>
- <string name="position_default_label" translate="false">00:00:00</string>
+ <string name="position_default_label" translatable="false">00:00:00</string>
<string name="player_buffering_msg">Buffering</string>
<string name="player_go_to_picture_in_picture">Picture-in-picture mode</string>
<string name="unknown_media_key">AntennaPod - Unknown media key: %1$d</string>
<string name="error_file_not_found">File not found</string>
+ <string name="no_media_label">Item does not contain a media file</string>
<!-- Queue operations -->
<string name="lock_queue">Lock Queue</string>
@@ -348,7 +371,6 @@
<string name="storage_pref">Storage</string>
<string name="storage_sum">Episode auto delete, Import, Export</string>
<string name="project_pref">Project</string>
- <string name="queue_label">Queue</string>
<string name="synchronization_pref">Synchronization</string>
<string name="synchronization_sum">Synchronize with other devices using gpodder.net</string>
<string name="automation">Automation</string>
@@ -359,19 +381,24 @@
<string name="external_elements">External elements</string>
<string name="interruptions">Interruptions</string>
<string name="playback_control">Playback control</string>
+ <string name="reassign_hardware_buttons">Reassign hardware buttons</string>
<string name="preference_search_hint">Search…</string>
<string name="preference_search_no_results">No results</string>
<string name="preference_search_clear_history">Clear history</string>
<string name="media_player">Media player</string>
<string name="pref_episode_cleanup_title">Episode Cleanup</string>
- <string name="pref_episode_cleanup_summary">Episodes that aren\'t in the queue and aren\'t favorites should be eligible for removal if Auto Download needs space for new episodes</string>
+ <string name="pref_episode_cleanup_summary">Episodes that should be eligible for removal if Auto Download needs space for new episodes</string>
<string name="pref_pauseOnDisconnect_sum">Pause playback when headphones or bluetooth are disconnected</string>
<string name="pref_unpauseOnHeadsetReconnect_sum">Resume playback when the headphones are reconnected</string>
<string name="pref_unpauseOnBluetoothReconnect_sum">Resume playback when bluetooth reconnects</string>
- <string name="pref_hardwareForwardButtonSkips_title">Forward Button Skips</string>
- <string name="pref_hardwareForwardButtonSkips_sum">When pressing a forward button on a bluetooth-connected device skip to the next episode instead of fast-forwarding</string>
- <string name="pref_hardwarePreviousButtonRestarts_title">Previous button restarts</string>
- <string name="pref_hardwarePreviousButtonRestarts_sum">When pressing a hardware previous button restart playing the current episode instead of rewinding</string>
+ <string name="pref_hardware_forward_button_title">Forward Button</string>
+ <string name="pref_hardware_forward_button_summary">Customize the forward button behavior</string>
+ <string name="pref_hardware_previous_button_title">Previous Button</string>
+ <string name="pref_hardware_previous_button_summary">Customize the previous button behavior</string>
+ <string name="button_action_fast_forward">Fast Forward</string>
+ <string name="button_action_rewind">Rewind</string>
+ <string name="button_action_skip_episode">Skip Episode</string>
+ <string name="button_action_restart_episode">Restart Episode</string>
<string name="pref_followQueue_sum">Jump to next queue item when playback completes</string>
<string name="pref_auto_delete_sum">Delete episode when playback completes</string>
<string name="pref_auto_delete_title">Auto Delete</string>
@@ -391,8 +418,11 @@
<string name="pref_autoUpdateIntervallOrTime_Disable">Disable</string>
<string name="pref_autoUpdateIntervallOrTime_Interval">Set Interval</string>
<string name="pref_autoUpdateIntervallOrTime_TimeOfDay">Set Time of Day</string>
- <string name="pref_autoUpdateIntervallOrTime_every">every %1$s</string>
<string name="pref_autoUpdateIntervallOrTime_at">at %1$s</string>
+ <plurals name="pref_autoUpdateIntervallOrTime_every_hours">
+ <item quantity="one">Every hour</item>
+ <item quantity="other">Every %d hours</item>
+ </plurals>
<string name="pref_followQueue_title">Continuous Playback</string>
<string name="pref_pauseOnHeadsetDisconnect_title">Headphones or Bluetooth disconnect</string>
<string name="pref_unpauseOnHeadsetReconnect_title">Headphones Reconnect</string>
@@ -426,7 +456,9 @@
<string name="pref_episode_cache_title">Episode Cache</string>
<string name="pref_episode_cache_summary">Total number of downloaded episodes cached on the device. Automatic download will be suspended if this number is reached.</string>
<string name="pref_episode_cover_title">Use Episode Cover</string>
- <string name="pref_episode_cover_summary">Use the episode specific cover whenever available. If unchecked, the app will always use the podcast cover image.</string>
+ <string name="pref_episode_cover_summary">Use the episode specific cover in lists whenever available. If unchecked, the app will always use the podcast cover image.</string>
+ <string name="pref_show_remain_time_title">Show Remaining Time</string>
+ <string name="pref_show_remain_time_summary">Display remaining time of episodes when checked. If unchecked, display total duration of episodes.</string>
<string name="pref_theme_title_use_system">Use system theme</string>
<string name="pref_theme_title_light">Light</string>
<string name="pref_theme_title_dark">Dark</string>
@@ -446,8 +478,6 @@
<string name="pref_gpodnet_full_sync_title">Force full synchronization</string>
<string name="pref_gpodnet_full_sync_sum">Sync all subscriptions and episode states with gpodder.net.</string>
<string name="pref_gpodnet_login_status"><![CDATA[Logged in as <i>%1$s</i> with device <i>%2$s</i>]]></string>
- <string name="pref_gpodnet_notifications_title">Synchronization failed</string>
- <string name="pref_gpodnet_notifications_sum">This setting does not apply to authentication errors.</string>
<string name="pref_playback_speed_sum">Customize the speeds available for variable speed playback</string>
<string name="pref_feed_playback_speed_sum">The speed to use when starting audio playback for episodes in this podcast</string>
<string name="pref_feed_skip">Auto Skip</string>
@@ -462,8 +492,6 @@
<string name="pref_fast_forward_sum">Customize the number of seconds to jump forward when the fast forward button is clicked</string>
<string name="pref_rewind">Rewind Skip Time</string>
<string name="pref_rewind_sum">Customize the number of seconds to jump backwards when the rewind button is clicked</string>
- <string name="pref_gpodnet_sethostname_title">Set hostname</string>
- <string name="pref_gpodnet_sethostname_use_default_host">Use default host</string>
<string name="pref_expandNotify_title">High Notification priority</string>
<string name="pref_expandNotify_sum">This usually expands the notification to show playback buttons.</string>
<string name="pref_persistNotify_title">Persistent Playback Controls</string>
@@ -474,10 +502,6 @@
<string name="pref_compact_notification_buttons_dialog_error">You can only select a maximum of %1$d items.</string>
<string name="pref_lockscreen_background_title">Set Lockscreen Background</string>
<string name="pref_lockscreen_background_sum">Set the lockscreen background to the current episode\'s image. As a side effect, this will also show the image in third party apps.</string>
- <string name="pref_showDownloadReport_title">Download failed</string>
- <string name="pref_showDownloadReport_sum">If downloads fail, generate a report that shows the details of the failure.</string>
- <string name="pref_showAutoDownloadReport_title">Automatic download completed</string>
- <string name="pref_showAutoDownloadReport_sum">Show a notification for automatically downloaded episodes.</string>
<string name="pref_expand_notify_unsupport_toast">Android versions before 4.1 do not support expanded notifications.</string>
<string name="pref_enqueue_location_title">Enqueue Location</string>
<string name="pref_enqueue_location_sum">Add episodes to: %1$s</string>
@@ -487,6 +511,7 @@
<string name="pref_smart_mark_as_played_disabled">Disabled</string>
<string name="pref_image_cache_size_title">Image Cache Size</string>
<string name="pref_image_cache_size_sum">Size of the disk cache for images.</string>
+ <string name="documentation_support">Documentation &amp; Support</string>
<string name="visit_user_forum">User forum</string>
<string name="bug_report_title">Report bug</string>
<string name="open_bug_tracker">Open bug tracker</string>
@@ -498,15 +523,14 @@
<string name="pref_current_value">Current value: %1$s</string>
<string name="pref_proxy_title">Proxy</string>
<string name="pref_proxy_sum">Set a network proxy</string>
- <string name="pref_faq">Frequently Asked Questions</string>
<string name="pref_no_browser_found">No web browser found.</string>
<string name="pref_cast_title">Chromecast support</string>
<string name="pref_cast_message_play_flavor">Enable support for remote media playback on Cast devices (such as Chromecast, Audio Speakers or Android TV)</string>
<string name="pref_cast_message_free_flavor">Chromecast requires third party proprietary libraries that are disabled in this version of AntennaPod</string>
<string name="pref_enqueue_downloaded_title">Enqueue Downloaded</string>
<string name="pref_enqueue_downloaded_summary">Add downloaded episodes to the queue</string>
- <string name="media_player_builtin">Built-in Android player</string>
- <string name="media_player_sonic" translatable="false">Sonic Media Player</string>
+ <string name="media_player_builtin">Built-in Android player (deprecated) </string>
+ <string name="media_player_sonic">Sonic Media Player (deprecated) </string>
<string name="media_player_exoplayer_recommended">ExoPlayer (recommended)</string>
<string name="media_player_switch_to_exoplayer">Switch to ExoPlayer</string>
<string name="media_player_switched_to_exoplayer">Switched to ExoPlayer.</string>
@@ -600,6 +624,7 @@
<!-- Sleep timer -->
<string name="set_sleeptimer_label">Set sleep timer</string>
<string name="disable_sleeptimer_label">Disable sleep timer</string>
+ <string name="extend_sleep_timer_label">+%d min</string>
<string name="sleep_timer_label">Sleep timer</string>
<string name="time_dialog_invalid_input">Invalid input, time has to be an integer</string>
<string name="shake_to_reset_label">Shake to reset</string>
@@ -628,23 +653,24 @@
<string name="gpodnet_suggestions_header">SUGGESTIONS</string>
<string name="gpodnet_search_hint">Search gpodder.net</string>
<string name="gpodnetauth_login_title">Login</string>
- <string name="gpodnetauth_login_descr">Welcome to the gpodder.net login process. First, type in your login information:</string>
<string name="gpodnetauth_login_butLabel">Login</string>
- <string name="gpodnetauth_login_register">If you do not have an account yet, you can create one here:\nhttps://gpodder.net/register/</string>
+ <string name="gpodnetauth_encryption_warning">Password and data are not encrypted!</string>
+ <string name="create_account">Create account</string>
<string name="username_label">Username</string>
<string name="password_label">Password</string>
- <string name="gpodnetauth_device_title">Device Selection</string>
+ <string name="gpodnet_description">Gpodder.net is an open-source podcast synchronization service that is independent of the AntennaPod project.</string>
+ <string name="gpodnetauth_server_official">Official gpodder.net server</string>
+ <string name="gpodnetauth_server_custom">Custom server</string>
+ <string name="gpodnetauth_host">Hostname</string>
+ <string name="gpodnetauth_select_server">Select server</string>
<string name="gpodnetauth_device_descr">Create a new device to use for your gpodder.net account or choose an existing one:</string>
- <string name="gpodnetauth_device_deviceID">Device ID:\u0020</string>
- <string name="gpodnetauth_device_caption">Caption</string>
- <string name="gpodnetauth_device_butCreateNewDevice">Create new device</string>
- <string name="gpodnetauth_device_chooseExistingDevice">Choose existing device:</string>
- <string name="gpodnetauth_device_errorEmpty">Device ID must not be empty</string>
- <string name="gpodnetauth_device_errorAlreadyUsed">Device ID already in use</string>
+ <string name="gpodnetauth_device_name">Device name</string>
+ <string name="gpodnetauth_device_name_default">AntennaPod on %1$s</string>
<string name="gpodnetauth_device_caption_errorEmpty">Caption must not be empty</string>
+ <string name="gpodnetauth_existing_devices">Existing devices</string>
+ <string name="gpodnetauth_create_device">Create device</string>
<string name="gpodnetauth_device_butChoose">Choose</string>
- <string name="gpodnetauth_finish_title">Login successful!</string>
<string name="gpodnetauth_finish_descr">Congratulations! Your gpodder.net account is now linked with your device. AntennaPod will from now on automatically sync subscriptions on your device with your gpodder.net account.</string>
<string name="gpodnetauth_finish_butsyncnow">Start sync now</string>
<string name="gpodnetauth_finish_butgomainscreen">Go to main screen</string>
@@ -855,18 +881,22 @@
<string name="cast_failed_media_error_skipping">Error playing media. Skipping&#8230;</string>
<!-- Notification channels -->
+ <string name="notification_group_errors">Errors</string>
+ <string name="notification_group_news">News</string>
<string name="notification_channel_user_action">Action required</string>
<string name="notification_channel_user_action_description">Shown if your action is required, for example if you need to enter a password.</string>
<string name="notification_channel_downloading">Downloading</string>
<string name="notification_channel_downloading_description">Shown while currently downloading.</string>
<string name="notification_channel_playing">Currently playing</string>
<string name="notification_channel_playing_description">Allows to control playback. This is the main notification you see while playing a podcast.</string>
- <string name="notification_channel_error">Errors</string>
- <string name="notification_channel_error_description">Shown if something went wrong, for example if download or feed update fails.</string>
- <string name="notification_channel_sync_error">Synchronization Errors</string>
+ <string name="notification_channel_download_error">Download failed</string>
+ <string name="notification_channel_download_error_description">Shown when download or feed update fails.</string>
+ <string name="notification_channel_sync_error">Synchronization failed</string>
<string name="notification_channel_sync_error_description">Shown when gpodder synchronization fails.</string>
- <string name="notification_channel_auto_download">Auto Downloads</string>
+ <string name="notification_channel_auto_download">Automatic download completed</string>
<string name="notification_channel_episode_auto_download">Shown when episodes have been automatically downloaded.</string>
+ <string name="notification_channel_new_episode">New Episode</string>
+ <string name="notification_channel_new_episode_description">Shown when a new episode of a podcast was found, where notifications are enabled</string>
<!-- Widget settings -->
<string name="widget_settings">Widget settings</string>
diff --git a/core/src/main/res/values/styles.xml b/core/src/main/res/values/styles.xml
index b5c10ae0e..533fa8420 100644
--- a/core/src/main/res/values/styles.xml
+++ b/core/src/main/res/values/styles.xml
@@ -20,6 +20,8 @@
<item name="action_icon_color">@color/black</item>
<item name="drawer_activated_color">@color/highlight_light</item>
<item name="android:textAllCaps">false</item>
+ <item name="android:textColorHint">@color/grey600</item>
+ <item name="seek_background">@color/seek_background_light</item>
<item name="storage">@drawable/ic_storage_black</item>
<item name="ic_network">@drawable/ic_network_black</item>
@@ -31,6 +33,7 @@
<item name="av_download">@drawable/ic_download_black</item>
<item name="av_pause">@drawable/ic_av_pause_black_48dp</item>
<item name="av_play">@drawable/ic_av_play_black_48dp</item>
+ <item name="av_replay">@drawable/ic_av_replay_black_48dp</item>
<item name="av_rewind">@drawable/ic_av_fast_rewind_black_48dp</item>
<item name="av_fast_forward">@drawable/ic_av_fast_forward_black_48dp</item>
<item name="av_skip">@drawable/ic_av_skip_black_48dp</item>
@@ -76,6 +79,7 @@
<item name="filter_dialog_clear">@color/filter_dialog_clear_light</item>
<item name="filter_dialog_button_background">@drawable/filter_dialog_background_light</item>
<item name="ic_notifications">@drawable/ic_notifications_black</item>
+ <item name="ic_share">@drawable/ic_share_black</item>
</style>
<style name="Theme.AntennaPod.Dark" parent="Theme.Base.AntennaPod.Dark">
@@ -98,6 +102,8 @@
<item name="currently_playing_background">@color/highlight_dark</item>
<item name="action_icon_color">@color/white</item>
<item name="android:textAllCaps">false</item>
+ <item name="android:textColorHint">@color/medium_gray</item>
+ <item name="seek_background">@color/seek_background_dark</item>
<item name="storage">@drawable/ic_storage_white</item>
<item name="ic_network">@drawable/ic_network_white</item>
@@ -111,6 +117,7 @@
<item name="av_fast_forward">@drawable/ic_av_fast_forward_white_48dp</item>
<item name="av_pause">@drawable/ic_av_pause_white_48dp</item>
<item name="av_play">@drawable/ic_av_play_white_48dp</item>
+ <item name="av_replay">@drawable/ic_av_replay_white_48dp</item>
<item name="av_skip">@drawable/ic_av_skip_white_48dp</item>
<item name="ic_settings_speed">@drawable/ic_playback_speed_white</item>
<item name="ic_settings_skip">@drawable/ic_av_skip_white_24dp</item>
@@ -154,6 +161,7 @@
<item name="filter_dialog_clear">@color/filter_dialog_clear_dark</item>
<item name="filter_dialog_button_background">@drawable/filter_dialog_background_dark</item>
<item name="ic_notifications">@drawable/ic_notifications_white</item>
+ <item name="ic_share">@drawable/ic_share_white</item>
</style>
<style name="Theme.AntennaPod.TrueBlack" parent="Theme.Base.AntennaPod.TrueBlack">
@@ -164,12 +172,14 @@
<item name="colorPrimaryDark">@color/black</item>
<item name="actionBarStyle">@style/Widget.AntennaPod.ActionBar.Black</item>
<item name="drawer_activated_color">@color/highlight_trueblack</item>
+ <item name="currently_playing_background">@color/highlight_trueblack</item>
<item name="android:textColorPrimary">@color/white</item>
<item name="android:color">@color/white</item>
<item name="android:colorBackground">@color/black</item>
<item name="android:windowBackground">@color/black</item>
<item name="android:actionBarStyle">@color/black</item>
<item name="background_elevated">@color/black</item>
+ <item name="android:textColorHint">@color/medium_gray</item>
</style>
<style name="Theme.AntennaPod.Light.NoTitle" parent="Theme.AntennaPod.Light">
diff --git a/core/src/play/java/de/danoeh/antennapod/core/ClientConfig.java b/core/src/play/java/de/danoeh/antennapod/core/ClientConfig.java
index 41e95d99e..0225c508a 100644
--- a/core/src/play/java/de/danoeh/antennapod/core/ClientConfig.java
+++ b/core/src/play/java/de/danoeh/antennapod/core/ClientConfig.java
@@ -36,10 +36,6 @@ public class ClientConfig {
public static DownloadServiceCallbacks downloadServiceCallbacks;
- public static PlaybackServiceCallbacks playbackServiceCallbacks;
-
- public static DBTasksCallbacks dbTasksCallbacks;
-
public static CastCallbacks castCallbacks;
private static boolean initialized = false;
diff --git a/core/src/play/java/de/danoeh/antennapod/core/cast/CastUtils.java b/core/src/play/java/de/danoeh/antennapod/core/cast/CastUtils.java
index 6fa874eca..ab638b568 100644
--- a/core/src/play/java/de/danoeh/antennapod/core/cast/CastUtils.java
+++ b/core/src/play/java/de/danoeh/antennapod/core/cast/CastUtils.java
@@ -10,6 +10,7 @@ import com.google.android.gms.cast.MediaInfo;
import com.google.android.gms.cast.MediaMetadata;
import com.google.android.gms.common.images.WebImage;
+import de.danoeh.antennapod.core.util.playback.PlayableException;
import de.danoeh.antennapod.core.util.playback.RemoteMedia;
import java.util.Calendar;
import java.util.List;
@@ -93,7 +94,7 @@ public class CastUtils {
MediaMetadata metadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_GENERIC);
try{
media.loadMetadata();
- } catch (Playable.PlayableException e) {
+ } catch (PlayableException e) {
Log.e(TAG, "Unable to load FeedMedia metadata", e);
}
FeedItem feedItem = media.getItem();
@@ -202,7 +203,7 @@ public class CastUtils {
} else {
Log.d(TAG, "FeedMedia object obtained does NOT match the MediaInfo provided. id=" + mediaId);
}
- } catch (Playable.PlayableException e) {
+ } catch (PlayableException e) {
Log.e(TAG, "Unable to load FeedMedia metadata to compare with MediaInfo", e);
}
} else {
diff --git a/core/src/play/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java b/core/src/play/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java
index f629793e2..e61896965 100644
--- a/core/src/play/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java
+++ b/core/src/play/java/de/danoeh/antennapod/core/service/playback/RemotePSMP.java
@@ -28,6 +28,7 @@ import de.danoeh.antennapod.core.cast.CastConsumer;
import de.danoeh.antennapod.core.cast.CastManager;
import de.danoeh.antennapod.core.cast.CastUtils;
import de.danoeh.antennapod.core.cast.DefaultCastConsumer;
+import de.danoeh.antennapod.core.util.playback.PlayableException;
import de.danoeh.antennapod.core.util.playback.RemoteMedia;
import de.danoeh.antennapod.core.feed.FeedMedia;
import de.danoeh.antennapod.core.feed.MediaType;
@@ -360,7 +361,7 @@ public class RemotePSMP extends PlaybackServiceMediaPlayer {
if (prepareImmediately) {
prepare();
}
- } catch (Playable.PlayableException e) {
+ } catch (PlayableException e) {
Log.e(TAG, "Error while loading media metadata", e);
setPlayerStatus(PlayerStatus.STOPPED, null);
}
diff --git a/core/src/test/java/de/danoeh/antennapod/core/feed/FeedFilterTest.java b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedFilterTest.java
new file mode 100644
index 000000000..8b4a13473
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedFilterTest.java
@@ -0,0 +1,126 @@
+package de.danoeh.antennapod.core.feed;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+public class FeedFilterTest {
+
+ @Test
+ public void testNullFilter() {
+ FeedFilter filter = new FeedFilter();
+ FeedItem item = new FeedItem();
+ item.setTitle("Hello world");
+
+ assertFalse(filter.excludeOnly());
+ assertFalse(filter.includeOnly());
+ assertEquals("", filter.getExcludeFilter());
+ assertEquals("", filter.getIncludeFilter());
+ assertTrue(filter.shouldAutoDownload(item));
+ }
+
+ @Test
+ public void testBasicIncludeFilter() {
+ String includeFilter = "Hello";
+ FeedFilter filter = new FeedFilter(includeFilter, "");
+ FeedItem item = new FeedItem();
+ item.setTitle("Hello world");
+
+ FeedItem item2 = new FeedItem();
+ item2.setTitle("Don't include me");
+
+ assertFalse(filter.excludeOnly());
+ assertTrue(filter.includeOnly());
+ assertEquals("", filter.getExcludeFilter());
+ assertEquals(includeFilter, filter.getIncludeFilter());
+ assertTrue(filter.shouldAutoDownload(item));
+ assertFalse(filter.shouldAutoDownload(item2));
+ }
+
+ @Test
+ public void testBasicExcludeFilter() {
+ String excludeFilter = "Hello";
+ FeedFilter filter = new FeedFilter("", excludeFilter);
+ FeedItem item = new FeedItem();
+ item.setTitle("Hello world");
+
+ FeedItem item2 = new FeedItem();
+ item2.setTitle("Item2");
+
+ assertTrue(filter.excludeOnly());
+ assertFalse(filter.includeOnly());
+ assertEquals(excludeFilter, filter.getExcludeFilter());
+ assertEquals("", filter.getIncludeFilter());
+ assertFalse(filter.shouldAutoDownload(item));
+ assertTrue(filter.shouldAutoDownload(item2));
+ }
+
+ @Test
+ public void testComplexIncludeFilter() {
+ String includeFilter = "Hello \n\"Two words\"";
+ FeedFilter filter = new FeedFilter(includeFilter, "");
+ FeedItem item = new FeedItem();
+ item.setTitle("hello world");
+
+ FeedItem item2 = new FeedItem();
+ item2.setTitle("Two three words");
+
+ FeedItem item3 = new FeedItem();
+ item3.setTitle("One two words");
+
+ assertFalse(filter.excludeOnly());
+ assertTrue(filter.includeOnly());
+ assertEquals("", filter.getExcludeFilter());
+ assertEquals(includeFilter, filter.getIncludeFilter());
+ assertTrue(filter.shouldAutoDownload(item));
+ assertFalse(filter.shouldAutoDownload(item2));
+ assertTrue(filter.shouldAutoDownload(item3));
+ }
+
+ @Test
+ public void testComplexExcludeFilter() {
+ String excludeFilter = "Hello \"Two words\"";
+ FeedFilter filter = new FeedFilter("", excludeFilter);
+ FeedItem item = new FeedItem();
+ item.setTitle("hello world");
+
+ FeedItem item2 = new FeedItem();
+ item2.setTitle("One three words");
+
+ FeedItem item3 = new FeedItem();
+ item3.setTitle("One two words");
+
+ assertTrue(filter.excludeOnly());
+ assertFalse(filter.includeOnly());
+ assertEquals(excludeFilter, filter.getExcludeFilter());
+ assertEquals("", filter.getIncludeFilter());
+ assertFalse(filter.shouldAutoDownload(item));
+ assertTrue(filter.shouldAutoDownload(item2));
+ assertFalse(filter.shouldAutoDownload(item3));
+ }
+
+ @Test
+ public void testComboFilter() {
+ String includeFilter = "Hello world";
+ String excludeFilter = "dislike";
+ FeedFilter filter = new FeedFilter(includeFilter, excludeFilter);
+
+ FeedItem download = new FeedItem();
+ download.setTitle("Hello everyone!");
+ // because, while it has words from the include filter it also has exclude words
+ FeedItem doNotDownload = new FeedItem();
+ doNotDownload.setTitle("I dislike the world");
+ // because it has no words from the include filter
+ FeedItem doNotDownload2 = new FeedItem();
+ doNotDownload2.setTitle("no words to include");
+
+ assertTrue(filter.hasExcludeFilter());
+ assertTrue(filter.hasIncludeFilter());
+ assertTrue(filter.shouldAutoDownload(download));
+ assertFalse(filter.shouldAutoDownload(doNotDownload));
+ assertFalse(filter.shouldAutoDownload(doNotDownload2));
+ }
+
+}
diff --git a/core/src/test/java/de/danoeh/antennapod/core/feed/FeedItemTest.java b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedItemTest.java
index 6bd753561..5bcbed97a 100644
--- a/core/src/test/java/de/danoeh/antennapod/core/feed/FeedItemTest.java
+++ b/core/src/test/java/de/danoeh/antennapod/core/feed/FeedItemTest.java
@@ -2,16 +2,23 @@ package de.danoeh.antennapod.core.feed;
import org.junit.Before;
import org.junit.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
import java.text.SimpleDateFormat;
import java.util.Date;
+import de.danoeh.antennapod.core.storage.DBReader;
+
import static de.danoeh.antennapod.core.feed.FeedItemMother.anyFeedItemWithImage;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
public class FeedItemTest {
+ private static final String TEXT_LONG = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.";
+ private static final String TEXT_SHORT = "Lorem ipsum";
+
private FeedItem original;
private FeedItem changedFeedItem;
@@ -22,21 +29,21 @@ public class FeedItemTest {
}
@Test
- public void testUpdateFromOther_feedItemImageDownloadUrlChanged() throws Exception {
+ public void testUpdateFromOther_feedItemImageDownloadUrlChanged() {
setNewFeedItemImageDownloadUrl();
original.updateFromOther(changedFeedItem);
assertFeedItemImageWasUpdated();
}
@Test
- public void testUpdateFromOther_feedItemImageRemoved() throws Exception {
+ public void testUpdateFromOther_feedItemImageRemoved() {
feedItemImageRemoved();
original.updateFromOther(changedFeedItem);
assertFeedItemImageWasNotUpdated();
}
@Test
- public void testUpdateFromOther_feedItemImageAdded() throws Exception {
+ public void testUpdateFromOther_feedItemImageAdded() {
original.setImageUrl(null);
setNewFeedItemImageDownloadUrl();
original.updateFromOther(changedFeedItem);
@@ -102,4 +109,36 @@ public class FeedItemTest {
assertEquals(anyFeedItemWithImage().getImageUrl(), original.getImageUrl());
}
+ /**
+ * If one of `description` or `content:encoded` is null, use the other one.
+ */
+ @Test
+ public void testShownotesNullValues() throws Exception {
+ testShownotes(null, TEXT_LONG);
+ testShownotes(TEXT_LONG, null);
+ }
+
+ /**
+ * If `description` is reasonably longer than `content:encoded`, use `description`.
+ */
+ @Test
+ public void testShownotesLength() throws Exception {
+ testShownotes(TEXT_SHORT, TEXT_LONG);
+ testShownotes(TEXT_LONG, TEXT_SHORT);
+ }
+
+ /**
+ * Checks if the shownotes equal TEXT_LONG, using the given `description` and `content:encoded`.
+ *
+ * @param description Description of the feed item
+ * @param contentEncoded `content:encoded` of the feed item
+ */
+ private void testShownotes(String description, String contentEncoded) throws Exception {
+ try (MockedStatic<DBReader> ignore = Mockito.mockStatic(DBReader.class)) {
+ FeedItem item = new FeedItem();
+ item.setDescription(description);
+ item.setContentEncoded(contentEncoded);
+ assertEquals(TEXT_LONG, item.loadShownotes().call());
+ }
+ }
} \ No newline at end of file
diff --git a/core/src/test/java/de/danoeh/antennapod/core/feed/LocalFeedUpdaterTest.java b/core/src/test/java/de/danoeh/antennapod/core/feed/LocalFeedUpdaterTest.java
index b4d3b201e..b38f8586d 100644
--- a/core/src/test/java/de/danoeh/antennapod/core/feed/LocalFeedUpdaterTest.java
+++ b/core/src/test/java/de/danoeh/antennapod/core/feed/LocalFeedUpdaterTest.java
@@ -3,6 +3,7 @@ package de.danoeh.antennapod.core.feed;
import android.app.Application;
import android.content.Context;
import android.media.MediaMetadataRetriever;
+import android.net.Uri;
import android.webkit.MimeTypeMap;
import androidx.annotation.NonNull;
@@ -30,11 +31,14 @@ import de.danoeh.antennapod.core.ClientConfig;
import de.danoeh.antennapod.core.R;
import de.danoeh.antennapod.core.preferences.UserPreferences;
import de.danoeh.antennapod.core.storage.DBReader;
+import de.danoeh.antennapod.core.storage.DBWriter;
import de.danoeh.antennapod.core.storage.PodDBAdapter;
+import static org.hamcrest.CoreMatchers.endsWith;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.empty;
import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -82,6 +86,7 @@ public class LocalFeedUpdaterTest {
@After
public void tearDown() {
+ DBWriter.tearDownTests();
PodDBAdapter.tearDownTests();
}
@@ -92,7 +97,7 @@ public class LocalFeedUpdaterTest {
public void testUpdateFeed_AddNewFeed() {
// check for empty database
List<Feed> feedListBefore = DBReader.getFeedList();
- assertTrue(feedListBefore.isEmpty());
+ assertThat(feedListBefore, is(empty()));
callUpdateFeed(LOCAL_FEED_DIR2);
@@ -138,7 +143,7 @@ public class LocalFeedUpdaterTest {
callUpdateFeed(LOCAL_FEED_DIR2);
Feed feedAfter = verifySingleFeedInDatabase();
- assertTrue(feedAfter.getImageUrl().contains("local-feed2/folder.png"));
+ assertThat(feedAfter.getImageUrl(), endsWith("local-feed2/folder.png"));
}
/**
@@ -150,7 +155,7 @@ public class LocalFeedUpdaterTest {
Feed feedAfter = verifySingleFeedInDatabase();
String resourceEntryName = context.getResources().getResourceEntryName(R.raw.local_feed_default_icon);
- assertTrue(feedAfter.getImageUrl().contains(resourceEntryName));
+ assertThat(feedAfter.getImageUrl(), endsWith(resourceEntryName));
}
/**
@@ -179,6 +184,65 @@ public class LocalFeedUpdaterTest {
assertEquals(24, calendar.get(Calendar.SECOND));
}
+ @Test
+ public void testGetImageUrl_EmptyFolder() {
+ DocumentFile documentFolder = mockDocumentFolder();
+ String imageUrl = LocalFeedUpdater.getImageUrl(context, documentFolder);
+ String defaultImageName = context.getResources().getResourceEntryName(R.raw.local_feed_default_icon);
+ assertThat(imageUrl, endsWith(defaultImageName));
+ }
+
+ @Test
+ public void testGetImageUrl_NoImageButAudioFiles() {
+ DocumentFile documentFolder = mockDocumentFolder(mockDocumentFile("audio.mp3", "audio/mp3"));
+ String imageUrl = LocalFeedUpdater.getImageUrl(context, documentFolder);
+ String defaultImageName = context.getResources().getResourceEntryName(R.raw.local_feed_default_icon);
+ assertThat(imageUrl, endsWith(defaultImageName));
+ }
+
+ @Test
+ public void testGetImageUrl_PreferredImagesFilenames() {
+ for (String filename : LocalFeedUpdater.PREFERRED_FEED_IMAGE_FILENAMES) {
+ DocumentFile documentFolder = mockDocumentFolder(mockDocumentFile("audio.mp3", "audio/mp3"),
+ mockDocumentFile(filename, "image/jpeg")); // image MIME type doesn't matter
+ String imageUrl = LocalFeedUpdater.getImageUrl(context, documentFolder);
+ assertThat(imageUrl, endsWith(filename));
+ }
+ }
+
+ @Test
+ public void testGetImageUrl_OtherImageFilenameJpg() {
+ DocumentFile documentFolder = mockDocumentFolder(mockDocumentFile("audio.mp3", "audio/mp3"),
+ mockDocumentFile("my-image.jpg", "image/jpeg"));
+ String imageUrl = LocalFeedUpdater.getImageUrl(context, documentFolder);
+ assertThat(imageUrl, endsWith("my-image.jpg"));
+ }
+
+ @Test
+ public void testGetImageUrl_OtherImageFilenameJpeg() {
+ DocumentFile documentFolder = mockDocumentFolder(mockDocumentFile("audio.mp3", "audio/mp3"),
+ mockDocumentFile("my-image.jpeg", "image/jpeg"));
+ String imageUrl = LocalFeedUpdater.getImageUrl(context, documentFolder);
+ assertThat(imageUrl, endsWith("my-image.jpeg"));
+ }
+
+ @Test
+ public void testGetImageUrl_OtherImageFilenamePng() {
+ DocumentFile documentFolder = mockDocumentFolder(mockDocumentFile("audio.mp3", "audio/mp3"),
+ mockDocumentFile("my-image.png", "image/png"));
+ String imageUrl = LocalFeedUpdater.getImageUrl(context, documentFolder);
+ assertThat(imageUrl, endsWith("my-image.png"));
+ }
+
+ @Test
+ public void testGetImageUrl_OtherImageFilenameUnsupportedMimeType() {
+ DocumentFile documentFolder = mockDocumentFolder(mockDocumentFile("audio.mp3", "audio/mp3"),
+ mockDocumentFile("my-image.svg", "image/svg+xml"));
+ String imageUrl = LocalFeedUpdater.getImageUrl(context, documentFolder);
+ String defaultImageName = context.getResources().getResourceEntryName(R.raw.local_feed_default_icon);
+ assertThat(imageUrl, endsWith(defaultImageName));
+ }
+
/**
* Fill ShadowMediaMetadataRetriever with dummy duration and title.
*
@@ -237,4 +301,26 @@ public class LocalFeedUpdaterTest {
List<FeedItem> feedItems = DBReader.getFeedItemList(feed);
assertEquals(expectedItemCount, feedItems.size());
}
+
+ /**
+ * Create a DocumentFile mock object.
+ */
+ @NonNull
+ private static DocumentFile mockDocumentFile(@NonNull String fileName, @NonNull String mimeType) {
+ DocumentFile file = mock(DocumentFile.class);
+ when(file.getName()).thenReturn(fileName);
+ when(file.getUri()).thenReturn(Uri.parse("file:///path/" + fileName));
+ when(file.getType()).thenReturn(mimeType);
+ return file;
+ }
+
+ /**
+ * Create a DocumentFile folder mock object with a list of files.
+ */
+ @NonNull
+ private static DocumentFile mockDocumentFolder(DocumentFile... files) {
+ DocumentFile documentFolder = mock(DocumentFile.class);
+ when(documentFolder.listFiles()).thenReturn(files);
+ return documentFolder;
+ }
}
diff --git a/core/src/androidTest/java/de/danoeh/antennapod/core/service/download/DownloadRequestTest.java b/core/src/test/java/de/danoeh/antennapod/core/service/download/DownloadRequestTest.java
index fc7e26820..8c7ecbc52 100644
--- a/core/src/androidTest/java/de/danoeh/antennapod/core/service/download/DownloadRequestTest.java
+++ b/core/src/test/java/de/danoeh/antennapod/core/service/download/DownloadRequestTest.java
@@ -3,9 +3,9 @@ package de.danoeh.antennapod.core.service.download;
import android.os.Bundle;
import android.os.Parcel;
-import androidx.test.filters.SmallTest;
-
import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
import java.util.ArrayList;
@@ -13,7 +13,7 @@ import de.danoeh.antennapod.core.feed.FeedFile;
import static org.junit.Assert.assertEquals;
-@SmallTest
+@RunWith(RobolectricTestRunner.class)
public class DownloadRequestTest {
@Test
diff --git a/core/src/test/java/de/danoeh/antennapod/core/storage/DbCleanupTests.java b/core/src/test/java/de/danoeh/antennapod/core/storage/DbCleanupTests.java
new file mode 100644
index 000000000..13d24adc1
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/storage/DbCleanupTests.java
@@ -0,0 +1,237 @@
+package de.danoeh.antennapod.core.storage;
+
+import android.app.Application;
+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.core.ApplicationCallbacks;
+import de.danoeh.antennapod.core.ClientConfig;
+import de.danoeh.antennapod.core.feed.Feed;
+import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.feed.FeedMedia;
+import de.danoeh.antennapod.core.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.core.preferences.UserPreferences;
+
+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.core.storage.DbTestUtils.saveFeedlist;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 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);
+
+ Application app = (Application) context;
+ ClientConfig.applicationCallbacks = mock(ApplicationCallbacks.class);
+ when(ClientConfig.applicationCallbacks.getApplicationInstance()).thenReturn(app);
+ }
+
+ @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);
+
+ DBTasks.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", "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);
+
+ DBTasks.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);
+
+ DBTasks.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.setFile_url("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);
+
+ DBTasks.performAutoCleanup(context);
+ for (File file : files) {
+ assertTrue(file.exists());
+ }
+ }
+}
diff --git a/core/src/test/java/de/danoeh/antennapod/core/storage/DbNullCleanupAlgorithmTest.java b/core/src/test/java/de/danoeh/antennapod/core/storage/DbNullCleanupAlgorithmTest.java
new file mode 100644
index 000000000..b4dbacb82
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/storage/DbNullCleanupAlgorithmTest.java
@@ -0,0 +1,121 @@
+package de.danoeh.antennapod.core.storage;
+
+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.core.feed.Feed;
+import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.feed.FeedMedia;
+import de.danoeh.antennapod.core.preferences.UserPreferences;
+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);
+ }
+
+ @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", "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);
+ }
+ DBTasks.performAutoCleanup(context);
+ for (int i = 0; i < files.size(); i++) {
+ assertTrue(files.get(i).exists());
+ }
+ }
+}
diff --git a/core/src/test/java/de/danoeh/antennapod/core/storage/DbQueueCleanupAlgorithmTest.java b/core/src/test/java/de/danoeh/antennapod/core/storage/DbQueueCleanupAlgorithmTest.java
new file mode 100644
index 000000000..6e53bd20e
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/storage/DbQueueCleanupAlgorithmTest.java
@@ -0,0 +1,52 @@
+package de.danoeh.antennapod.core.storage;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+import de.danoeh.antennapod.core.feed.Feed;
+import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.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);
+ }
+
+ /**
+ * 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);
+
+ DBTasks.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/core/src/test/java/de/danoeh/antennapod/core/storage/DbReaderTest.java b/core/src/test/java/de/danoeh/antennapod/core/storage/DbReaderTest.java
new file mode 100644
index 000000000..212b94743
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/storage/DbReaderTest.java
@@ -0,0 +1,420 @@
+package de.danoeh.antennapod.core.storage;
+
+import android.content.Context;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Random;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+import de.danoeh.antennapod.core.feed.Feed;
+import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.feed.FeedMedia;
+import de.danoeh.antennapod.core.preferences.UserPreferences;
+import de.danoeh.antennapod.core.util.LongList;
+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.core.storage.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(RobolectricTestRunner.class)
+public class DbReaderTest {
+
+ @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();
+ }
+
+ @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();
+
+ Feed feed1 = new Feed(0, null, "A", "link", "d", null, null, null, "rss", "A", null, "", "", true);
+ Feed feed2 = new Feed(0, null, "b", "link", "d", null, null, null, "rss", "b", null, "", "", true);
+ Feed feed3 = new Feed(0, null, "C", "link", "d", null, null, null, "rss", "C", null, "", "", true);
+ Feed feed4 = new Feed(0, null, "d", "link", "d", null, null, null, "rss", "d", null, "", "", true);
+ 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).getDownload_url());
+ }
+ }
+
+ @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);
+ 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().setFile_url("file" + i);
+ downloaded.add(item);
+ }
+ }
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setFeedItemlist(downloaded);
+ adapter.close();
+ return downloaded;
+ }
+
+ @Test
+ public void testGetDownloadedItems() {
+ final int numItems = 10;
+ List<FeedItem> downloaded = saveDownloadedItems(numItems);
+ List<FeedItem> downloadedSaved = DBReader.getDownloadedItems();
+ assertNotNull(downloadedSaved);
+ assertEquals(downloaded.size(), downloadedSaved.size());
+ for (FeedItem item : downloadedSaved) {
+ assertNotNull(item.getMedia());
+ assertTrue(item.getMedia().isDownloaded());
+ assertNotNull(item.getMedia().getDownload_url());
+ }
+ }
+
+ @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.setFeedItemlist(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.getNewItemsList(0, Integer.MAX_VALUE);
+ 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 testGetPlaybackHistory() {
+ final int numItems = (DBReader.PLAYBACK_HISTORY_SIZE + 1) * 2;
+ final int playedItems = DBReader.PLAYBACK_HISTORY_SIZE + 1;
+ final int numReturnedItems = Math.min(playedItems, DBReader.PLAYBACK_HISTORY_SIZE);
+ 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.getPlaybackHistory();
+ assertNotNull(saved);
+ assertEquals("Wrong size: ", numReturnedItems, saved.size());
+ for (int i = 0; i < numReturnedItems; i++) {
+ FeedItem item = saved.get(i);
+ assertNotNull(item.getMedia().getPlaybackCompletionDate());
+ assertEquals("Wrong sort order: ", item.getId(), ids[i]);
+ }
+ }
+
+ @Test
+ public void testGetNavDrawerDataQueueEmptyNoUnreadItems() {
+ final int numFeeds = 10;
+ final int numItems = 10;
+ DbTestUtils.saveFeedlist(numFeeds, numItems, true);
+ DBReader.NavDrawerData navDrawerData = DBReader.getNavDrawerData();
+ assertEquals(numFeeds, navDrawerData.feeds.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();
+
+ DBReader.NavDrawerData navDrawerData = DBReader.getNavDrawerData();
+ assertEquals(numFeeds, navDrawerData.feeds.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(), item2.getChapters());
+ }
+}
diff --git a/core/src/test/java/de/danoeh/antennapod/core/storage/DbTasksTest.java b/core/src/test/java/de/danoeh/antennapod/core/storage/DbTasksTest.java
new file mode 100644
index 000000000..be9f53cdb
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/storage/DbTasksTest.java
@@ -0,0 +1,302 @@
+package de.danoeh.antennapod.core.storage;
+
+import android.app.Application;
+import android.content.Context;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+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.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import de.danoeh.antennapod.core.ApplicationCallbacks;
+import de.danoeh.antennapod.core.ClientConfig;
+import de.danoeh.antennapod.core.feed.Feed;
+import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.feed.FeedMedia;
+import de.danoeh.antennapod.core.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.core.preferences.UserPreferences;
+
+import static de.danoeh.antennapod.core.util.FeedItemUtil.getIdList;
+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;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Test class for {@link DBTasks}.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class DbTasksTest {
+ private Context context;
+
+ @Before
+ public void setUp() {
+ context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ UserPreferences.init(context);
+ PlaybackPreferences.init(context);
+
+ Application app = (Application) context;
+ ClientConfig.applicationCallbacks = mock(ApplicationCallbacks.class);
+ when(ClientConfig.applicationCallbacks.getApplicationInstance()).thenReturn(app);
+
+ // 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 = DBTasks.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 = DBTasks.updateFeed(context, feed1, false);
+ Feed savedFeed2 = DBTasks.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 = DBTasks.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 = DBTasks.updateFeed(context, feed, false);
+ assertNotSame(newFeed, feed);
+
+ final Feed feedFromDB = DBReader.getFeed(newFeed.getId());
+ final FeedItem feedItemFromDB = feedFromDB.getItems().get(0);
+ assertTrue("state: " + feedItemFromDB.getState(), 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 = DBTasks.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
+ }
+
+ @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();
+ }
+ }
+
+ @Test
+ public void testAddQueueItemsInDownload_EnqueueEnabled() throws Exception {
+ // Setup test data / environment
+ UserPreferences.setEnqueueDownloadedEpisodes(true);
+ UserPreferences.setEnqueueLocation(UserPreferences.EnqueueLocation.BACK);
+
+ List<FeedItem> fis1 = createSavedFeed("Feed 1", 2).getItems();
+ List<FeedItem> fis2 = createSavedFeed("Feed 2", 3).getItems();
+
+ DBWriter.addQueueItem(context, fis1.get(0), fis2.get(0)).get();
+ // the first item fis1.get(0) is already in the queue
+ FeedItem[] itemsToDownload = new FeedItem[]{ fis1.get(0), fis1.get(1), fis2.get(2), fis2.get(1) };
+
+ // Expectations:
+ List<FeedItem> expectedEnqueued = Arrays.asList(fis1.get(1), fis2.get(2), fis2.get(1));
+ List<FeedItem> expectedQueue = new ArrayList<>();
+ expectedQueue.addAll(DBReader.getQueue());
+ expectedQueue.addAll(expectedEnqueued);
+
+ // Run actual test and assert results
+ List<? extends FeedItem> actualEnqueued =
+ DBTasks.enqueueFeedItemsToDownload(context, Arrays.asList(itemsToDownload));
+
+ assertEqualsByIds("Only items not in the queue are enqueued", expectedEnqueued, actualEnqueued);
+ assertEqualsByIds("Queue has new items appended", expectedQueue, DBReader.getQueue());
+ }
+
+ @Test
+ public void testAddQueueItemsInDownload_EnqueueDisabled() throws Exception {
+ // Setup test data / environment
+ UserPreferences.setEnqueueDownloadedEpisodes(false);
+
+ List<FeedItem> fis1 = createSavedFeed("Feed 1", 2).getItems();
+ List<FeedItem> fis2 = createSavedFeed("Feed 2", 3).getItems();
+
+ DBWriter.addQueueItem(context, fis1.get(0), fis2.get(0)).get();
+ FeedItem[] itemsToDownload = new FeedItem[]{ fis1.get(0), fis1.get(1), fis2.get(2), fis2.get(1) };
+
+ // Expectations:
+ List<FeedItem> expectedEnqueued = Collections.emptyList();
+ List<FeedItem> expectedQueue = DBReader.getQueue();
+
+ // Run actual test and assert results
+ List<? extends FeedItem> actualEnqueued =
+ DBTasks.enqueueFeedItemsToDownload(context, Arrays.asList(itemsToDownload));
+
+ assertEqualsByIds("No item is enqueued", expectedEnqueued, actualEnqueued);
+ assertEqualsByIds("Queue is unchanged", expectedQueue, DBReader.getQueue());
+ }
+
+ private void assertEqualsByIds(String msg, List<? extends FeedItem> expected, List<? extends FeedItem> actual) {
+ // assert only the IDs, so that any differences are easily to spot.
+ List<Long> expectedIds = getIdList(expected);
+ List<Long> actualIds = getIdList(actual);
+ assertEquals(msg, expectedIds, actualIds);
+ }
+
+ private Feed createSavedFeed(String title, int numFeedItems) {
+ final Feed feed = new Feed("url", null, title);
+
+ if (numFeedItems > 0) {
+ List<FeedItem> items = new ArrayList<>(numFeedItems);
+ for (int i = 1; i <= numFeedItems; i++) {
+ FeedItem item = new FeedItem(0, "item " + i + " of " + title, "id", "link",
+ new Date(), FeedItem.UNPLAYED, feed);
+ items.add(item);
+ }
+ feed.setItems(items);
+ }
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.setCompleteFeed(feed);
+ adapter.close();
+ return feed;
+ }
+
+}
diff --git a/core/src/test/java/de/danoeh/antennapod/core/storage/DbTestUtils.java b/core/src/test/java/de/danoeh/antennapod/core/storage/DbTestUtils.java
new file mode 100644
index 000000000..400ddda36
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/storage/DbTestUtils.java
@@ -0,0 +1,77 @@
+package de.danoeh.antennapod.core.storage;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import de.danoeh.antennapod.core.feed.Chapter;
+import de.danoeh.antennapod.core.feed.Feed;
+import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.feed.FeedMedia;
+import de.danoeh.antennapod.core.feed.SimpleChapter;
+import de.danoeh.antennapod.core.util.comparator.FeedItemPubdateComparator;
+
+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, false);
+ 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 SimpleChapter(k, "item " + j + " chapter " + k,
+ "http://example.com", "http://example.com/image.png"));
+ }
+ }
+ f.getItems().add(item);
+ }
+ Collections.sort(f.getItems(), new FeedItemPubdateComparator());
+ 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/core/src/test/java/de/danoeh/antennapod/core/storage/DbWriterTest.java b/core/src/test/java/de/danoeh/antennapod/core/storage/DbWriterTest.java
new file mode 100644
index 000000000..3efb2705f
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/storage/DbWriterTest.java
@@ -0,0 +1,855 @@
+package de.danoeh.antennapod.core.storage;
+
+import android.app.Application;
+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 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.core.ApplicationCallbacks;
+import de.danoeh.antennapod.core.ClientConfig;
+import de.danoeh.antennapod.core.feed.Feed;
+import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.feed.FeedMedia;
+import de.danoeh.antennapod.core.preferences.PlaybackPreferences;
+import de.danoeh.antennapod.core.preferences.UserPreferences;
+import de.danoeh.antennapod.core.util.FeedItemUtil;
+
+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;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 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);
+
+ Application app = (Application) context;
+ ClientConfig.applicationCallbacks = mock(ApplicationCallbacks.class);
+ when(ClientConfig.applicationCallbacks.getApplicationInstance()).thenReturn(app);
+
+ // 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.getId())
+ .get(TIMEOUT, TimeUnit.SECONDS);
+ media = DBReader.getFeedMedia(media.getId());
+ assertNotNull(media);
+ assertFalse(dest.exists());
+ assertFalse(media.isDownloaded());
+ assertNull(media.getFile_url());
+ }
+
+ @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();
+ assertTrue(queue.size() != 0);
+
+ DBWriter.deleteFeedMediaOfItem(context, media.getId());
+ Awaitility.await().until(() -> !dest.exists());
+ media = DBReader.getFeedMedia(media.getId());
+ assertNotNull(media);
+ assertFalse(dest.exists());
+ assertFalse(media.isDownloaded());
+ assertNull(media.getFile_url());
+ queue = DBReader.getQueue();
+ assertEquals(0, queue.size());
+ }
+
+ @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.getSingleFeedMediaCursor(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.getSingleFeedMediaCursor(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.getSingleFeedMediaCursor(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);
+ 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);
+ 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);
+ 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);
+ 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());
+ List<Long> expectedIds;
+ expectedIds = FeedItemUtil.getIdList(feed.getItems());
+ List<Long> actualIds = new ArrayList<>();
+ for (int i = 0; i < numItems; i++) {
+ assertTrue(cursor.moveToPosition(i));
+ actualIds.add(cursor.getLong(0));
+ }
+ cursor.close();
+ adapter.close();
+ assertEquals("Bulk add to queue: result order should be the same as the order given",
+ expectedIds, actualIds);
+ }
+
+ @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);
+ 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 testMarkFeedRead() 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.UNPLAYED, 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.markFeedRead(feed.getId()).get(TIMEOUT, TimeUnit.SECONDS);
+ List<FeedItem> loadedItems = DBReader.getFeedItemList(feed);
+ for (FeedItem item : loadedItems) {
+ assertTrue(item.isPlayed());
+ }
+ }
+
+ @Test
+ public void testMarkAllItemsReadSameFeed() 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.UNPLAYED, 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.markAllItemsRead().get(TIMEOUT, TimeUnit.SECONDS);
+ List<FeedItem> loadedItems = DBReader.getFeedItemList(feed);
+ for (FeedItem item : loadedItems) {
+ assertTrue(item.isPlayed());
+ }
+ }
+
+ 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);
+ 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/core/src/test/java/de/danoeh/antennapod/core/storage/ExceptFavoriteCleanupAlgorithmTest.java b/core/src/test/java/de/danoeh/antennapod/core/storage/ExceptFavoriteCleanupAlgorithmTest.java
new file mode 100644
index 000000000..8c02391ca
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/storage/ExceptFavoriteCleanupAlgorithmTest.java
@@ -0,0 +1,89 @@
+package de.danoeh.antennapod.core.storage;
+
+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.core.feed.Feed;
+import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.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);
+ }
+
+ @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);
+
+ DBTasks.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);
+
+ DBTasks.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);
+
+ DBTasks.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/core/src/test/java/de/danoeh/antennapod/core/storage/mapper/FeedCursorMapperTest.java b/core/src/test/java/de/danoeh/antennapod/core/storage/mapper/FeedCursorMapperTest.java
new file mode 100644
index 000000000..c779b6d55
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/storage/mapper/FeedCursorMapperTest.java
@@ -0,0 +1,100 @@
+package de.danoeh.antennapod.core.storage.mapper;
+
+import android.content.ContentValues;
+import android.content.Context;
+import android.database.Cursor;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import de.danoeh.antennapod.core.feed.Feed;
+import de.danoeh.antennapod.core.storage.PodDBAdapter;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+@RunWith(RobolectricTestRunner.class)
+public class FeedCursorMapperTest {
+ private PodDBAdapter adapter;
+
+ @Before
+ public void setUp() {
+ Context context = InstrumentationRegistry.getInstrumentation().getContext();
+
+ PodDBAdapter.init(context);
+ adapter = PodDBAdapter.getInstance();
+
+ writeFeedToDatabase();
+ }
+
+ @After
+ public void tearDown() {
+ PodDBAdapter.tearDownTests();
+ }
+
+ @SuppressWarnings("ConstantConditions")
+ @Test
+ public void testFromCursor() {
+ try (Cursor cursor = adapter.getAllFeedsCursor()) {
+ cursor.moveToNext();
+ Feed feed = FeedCursorMapper.convert(cursor);
+ assertTrue(feed.getId() >= 0);
+ assertEquals("feed custom title", feed.getTitle());
+ assertEquals("feed custom title", feed.getCustomTitle());
+ assertEquals("feed link", feed.getLink());
+ assertEquals("feed description", feed.getDescription());
+ assertEquals("feed payment link", feed.getPaymentLink());
+ assertEquals("feed author", feed.getAuthor());
+ assertEquals("feed language", feed.getLanguage());
+ assertEquals("feed image url", feed.getImageUrl());
+ assertEquals("feed file url", feed.getFile_url());
+ assertEquals("feed download url", feed.getDownload_url());
+ assertTrue(feed.isDownloaded());
+ assertEquals("feed last update", feed.getLastUpdate());
+ assertEquals("feed type", feed.getType());
+ assertEquals("feed identifier", feed.getFeedIdentifier());
+ assertTrue(feed.isPaged());
+ assertEquals("feed next page link", feed.getNextPageLink());
+ assertTrue(feed.getItemFilter().showUnplayed);
+ assertEquals(1, feed.getSortOrder().code);
+ assertTrue(feed.hasLastUpdateFailed());
+ }
+ }
+
+ /**
+ * Insert test data to the database.
+ * Uses raw database insert instead of adapter.setCompleteFeed() to avoid testing the Feed class
+ * against itself.
+ */
+ private void writeFeedToDatabase() {
+ ContentValues values = new ContentValues();
+ values.put(PodDBAdapter.KEY_TITLE, "feed title");
+ values.put(PodDBAdapter.KEY_CUSTOM_TITLE, "feed custom title");
+ values.put(PodDBAdapter.KEY_LINK, "feed link");
+ values.put(PodDBAdapter.KEY_DESCRIPTION, "feed description");
+ values.put(PodDBAdapter.KEY_PAYMENT_LINK, "feed payment link");
+ values.put(PodDBAdapter.KEY_AUTHOR, "feed author");
+ values.put(PodDBAdapter.KEY_LANGUAGE, "feed language");
+ values.put(PodDBAdapter.KEY_IMAGE_URL, "feed image url");
+
+ values.put(PodDBAdapter.KEY_FILE_URL, "feed file url");
+ values.put(PodDBAdapter.KEY_DOWNLOAD_URL, "feed download url");
+ values.put(PodDBAdapter.KEY_DOWNLOADED, true);
+ values.put(PodDBAdapter.KEY_LASTUPDATE, "feed last update");
+ values.put(PodDBAdapter.KEY_TYPE, "feed type");
+ values.put(PodDBAdapter.KEY_FEED_IDENTIFIER, "feed identifier");
+
+ values.put(PodDBAdapter.KEY_IS_PAGED, true);
+ values.put(PodDBAdapter.KEY_NEXT_PAGE_LINK, "feed next page link");
+ values.put(PodDBAdapter.KEY_HIDE, "unplayed");
+ values.put(PodDBAdapter.KEY_SORT_ORDER, "1");
+ values.put(PodDBAdapter.KEY_LAST_UPDATE_FAILED, true);
+
+ adapter.insertTestData(PodDBAdapter.TABLE_NAME_FEEDS, values);
+ }
+}
diff --git a/core/src/test/java/de/danoeh/antennapod/core/syndication/handler/AtomParserTest.java b/core/src/test/java/de/danoeh/antennapod/core/syndication/handler/AtomParserTest.java
new file mode 100644
index 000000000..82f7fcfca
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/syndication/handler/AtomParserTest.java
@@ -0,0 +1,70 @@
+package de.danoeh.antennapod.core.syndication.handler;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.io.File;
+import java.util.Date;
+
+import de.danoeh.antennapod.core.feed.Feed;
+import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.feed.FeedMedia;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests for Atom feeds in FeedHandler.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class AtomParserTest {
+
+ @Test
+ public void testAtomBasic() throws Exception {
+ File feedFile = FeedParserTestHelper.getFeedFile("feed-atom-testAtomBasic.xml");
+ Feed feed = FeedParserTestHelper.runFeedParser(feedFile);
+ assertEquals(Feed.TYPE_ATOM1, feed.getType());
+ assertEquals("title", feed.getTitle());
+ assertEquals("http://example.com/feed", feed.getFeedIdentifier());
+ assertEquals("http://example.com", feed.getLink());
+ assertEquals("This is the description", feed.getDescription());
+ assertEquals("http://example.com/payment", feed.getPaymentLink());
+ assertEquals("http://example.com/picture", feed.getImageUrl());
+ assertEquals(10, feed.getItems().size());
+ for (int i = 0; i < feed.getItems().size(); i++) {
+ FeedItem item = feed.getItems().get(i);
+ assertEquals("http://example.com/item-" + i, item.getItemIdentifier());
+ assertEquals("item-" + i, item.getTitle());
+ assertNull(item.getDescription());
+ assertNull(item.getContentEncoded());
+ assertEquals("http://example.com/items/" + i, item.getLink());
+ assertEquals(new Date(i * 60000), item.getPubDate());
+ assertNull(item.getPaymentLink());
+ assertEquals("http://example.com/picture", item.getImageLocation());
+ // media
+ assertTrue(item.hasMedia());
+ FeedMedia media = item.getMedia();
+ //noinspection ConstantConditions
+ assertEquals("http://example.com/media-" + i, media.getDownload_url());
+ assertEquals(1024 * 1024, media.getSize());
+ assertEquals("audio/mp3", media.getMime_type());
+ // chapters
+ assertNull(item.getChapters());
+ }
+ }
+
+ @Test
+ public void testLogoWithWhitespace() throws Exception {
+ File feedFile = FeedParserTestHelper.getFeedFile("feed-atom-testLogoWithWhitespace.xml");
+ Feed feed = FeedParserTestHelper.runFeedParser(feedFile);
+ assertEquals("title", feed.getTitle());
+ assertEquals("http://example.com/feed", feed.getFeedIdentifier());
+ assertEquals("http://example.com", feed.getLink());
+ assertEquals("This is the description", feed.getDescription());
+ assertEquals("http://example.com/payment", feed.getPaymentLink());
+ assertEquals("https://example.com/image.png", feed.getImageUrl());
+ assertEquals(0, feed.getItems().size());
+ }
+}
diff --git a/core/src/test/java/de/danoeh/antennapod/core/syndication/handler/FeedParserTestHelper.java b/core/src/test/java/de/danoeh/antennapod/core/syndication/handler/FeedParserTestHelper.java
new file mode 100644
index 000000000..c02a7d209
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/syndication/handler/FeedParserTestHelper.java
@@ -0,0 +1,35 @@
+package de.danoeh.antennapod.core.syndication.handler;
+
+import androidx.annotation.NonNull;
+
+import java.io.File;
+
+import de.danoeh.antennapod.core.feed.Feed;
+
+/**
+ * Tests for FeedHandler.
+ */
+public abstract class FeedParserTestHelper {
+
+ /**
+ * Returns the File object for a file in the resources folder.
+ */
+ @NonNull
+ static File getFeedFile(@NonNull String fileName) {
+ //noinspection ConstantConditions
+ return new File(FeedParserTestHelper.class.getClassLoader().getResource(fileName).getFile());
+ }
+
+ /**
+ * Runs the feed parser on the given file.
+ */
+ @NonNull
+ static Feed runFeedParser(@NonNull File feedFile) throws Exception {
+ FeedHandler handler = new FeedHandler();
+ Feed parsedFeed = new Feed("http://example.com/feed", null);
+ parsedFeed.setFile_url(feedFile.getAbsolutePath());
+ parsedFeed.setDownloaded(true);
+ handler.parseFeed(parsedFeed);
+ return parsedFeed;
+ }
+}
diff --git a/core/src/test/java/de/danoeh/antennapod/core/syndication/handler/RssParserTest.java b/core/src/test/java/de/danoeh/antennapod/core/syndication/handler/RssParserTest.java
new file mode 100644
index 000000000..1195520a6
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/syndication/handler/RssParserTest.java
@@ -0,0 +1,86 @@
+package de.danoeh.antennapod.core.syndication.handler;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.io.File;
+import java.util.Date;
+
+import de.danoeh.antennapod.core.feed.Feed;
+import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.feed.FeedMedia;
+import de.danoeh.antennapod.core.feed.MediaType;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests for RSS feeds in FeedHandler.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class RssParserTest {
+
+ @Test
+ public void testRss2Basic() throws Exception {
+ File feedFile = FeedParserTestHelper.getFeedFile("feed-rss-testRss2Basic.xml");
+ Feed feed = FeedParserTestHelper.runFeedParser(feedFile);
+ assertEquals(Feed.TYPE_RSS2, feed.getType());
+ assertEquals("title", feed.getTitle());
+ assertEquals("en", feed.getLanguage());
+ assertEquals("http://example.com", feed.getLink());
+ assertEquals("This is the description", feed.getDescription());
+ assertEquals("http://example.com/payment", feed.getPaymentLink());
+ assertEquals("http://example.com/picture", feed.getImageUrl());
+ assertEquals(10, feed.getItems().size());
+ for (int i = 0; i < feed.getItems().size(); i++) {
+ FeedItem item = feed.getItems().get(i);
+ assertEquals("http://example.com/item-" + i, item.getItemIdentifier());
+ assertEquals("item-" + i, item.getTitle());
+ assertNull(item.getDescription());
+ assertNull(item.getContentEncoded());
+ assertEquals("http://example.com/items/" + i, item.getLink());
+ assertEquals(new Date(i * 60000), item.getPubDate());
+ assertNull(item.getPaymentLink());
+ assertEquals("http://example.com/picture", item.getImageLocation());
+ // media
+ assertTrue(item.hasMedia());
+ FeedMedia media = item.getMedia();
+ //noinspection ConstantConditions
+ assertEquals("http://example.com/media-" + i, media.getDownload_url());
+ assertEquals(1024 * 1024, media.getSize());
+ assertEquals("audio/mp3", media.getMime_type());
+ // chapters
+ assertNull(item.getChapters());
+ }
+ }
+
+ @Test
+ public void testImageWithWhitespace() throws Exception {
+ File feedFile = FeedParserTestHelper.getFeedFile("feed-rss-testImageWithWhitespace.xml");
+ Feed feed = FeedParserTestHelper.runFeedParser(feedFile);
+ assertEquals("title", feed.getTitle());
+ assertEquals("http://example.com", feed.getLink());
+ assertEquals("This is the description", feed.getDescription());
+ assertEquals("http://example.com/payment", feed.getPaymentLink());
+ assertEquals("https://example.com/image.png", feed.getImageUrl());
+ assertEquals(0, feed.getItems().size());
+ }
+
+ @Test
+ public void testMediaContentMime() throws Exception {
+ File feedFile = FeedParserTestHelper.getFeedFile("feed-rss-testMediaContentMime.xml");
+ Feed feed = FeedParserTestHelper.runFeedParser(feedFile);
+ assertEquals("title", feed.getTitle());
+ assertEquals("http://example.com", feed.getLink());
+ assertEquals("This is the description", feed.getDescription());
+ assertEquals("http://example.com/payment", feed.getPaymentLink());
+ assertNull(feed.getImageUrl());
+ assertEquals(1, feed.getItems().size());
+ FeedItem feedItem = feed.getItems().get(0);
+ //noinspection ConstantConditions
+ assertEquals(MediaType.VIDEO, feedItem.getMedia().getMediaType());
+ assertEquals("https://www.example.com/file.mp4", feedItem.getMedia().getDownload_url());
+ }
+}
diff --git a/core/src/test/java/de/danoeh/antennapod/core/syndication/namespace/atom/AtomTextTest.java b/core/src/test/java/de/danoeh/antennapod/core/syndication/namespace/atom/AtomTextTest.java
new file mode 100644
index 000000000..6bc614364
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/syndication/namespace/atom/AtomTextTest.java
@@ -0,0 +1,35 @@
+package de.danoeh.antennapod.core.syndication.namespace.atom;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * Unit test for {@link AtomText}.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class AtomTextTest {
+
+ private static final String[][] TEST_DATA = {
+ {"&gt;", ">"},
+ {">", ">"},
+ {"&lt;Fran&ccedil;ais&gt;", "<Français>"},
+ {"ßÄÖÜ", "ßÄÖÜ"},
+ {"&quot;", "\""},
+ {"&szlig;", "ß"},
+ {"&#8217;", "’"},
+ {"&#x2030;", "‰"},
+ {"&euro;", "€"}
+ };
+
+ @Test
+ public void testProcessingHtml() {
+ for (String[] pair : TEST_DATA) {
+ final AtomText atomText = new AtomText("", new NSAtom(), AtomText.TYPE_HTML);
+ atomText.setContent(pair[0]);
+ assertEquals(pair[1], atomText.getProcessedContent());
+ }
+ }
+}
diff --git a/core/src/androidTest/java/de/danoeh/antennapod/core/util/DateUtilsTest.java b/core/src/test/java/de/danoeh/antennapod/core/util/DateUtilsTest.java
index 5d98f133c..92888ae8b 100644
--- a/core/src/androidTest/java/de/danoeh/antennapod/core/util/DateUtilsTest.java
+++ b/core/src/test/java/de/danoeh/antennapod/core/util/DateUtilsTest.java
@@ -1,6 +1,5 @@
package de.danoeh.antennapod.core.util;
-import androidx.test.filters.SmallTest;
import org.junit.Test;
import java.util.Calendar;
@@ -12,16 +11,11 @@ import static org.junit.Assert.assertEquals;
/**
* Unit test for {@link DateUtils}.
- *
- * Note: It NEEDS to be run in android devices, i.e., it cannot be run in standard JDK, because
- * the test invokes some android platform-specific behavior in the underlying
- * {@link java.text.SimpleDateFormat} used by {@link DateUtils}.
- *
*/
-@SmallTest
public class DateUtilsTest {
+
@Test
- public void testParseDateWithMicroseconds() throws Exception {
+ public void testParseDateWithMicroseconds() {
GregorianCalendar exp = new GregorianCalendar(2015, 2, 28, 13, 31, 4);
exp.setTimeZone(TimeZone.getTimeZone("UTC"));
Date expected = new Date(exp.getTimeInMillis() + 963);
@@ -30,7 +24,7 @@ public class DateUtilsTest {
}
@Test
- public void testParseDateWithCentiseconds() throws Exception {
+ public void testParseDateWithCentiseconds() {
GregorianCalendar exp = new GregorianCalendar(2015, 2, 28, 13, 31, 4);
exp.setTimeZone(TimeZone.getTimeZone("UTC"));
Date expected = new Date(exp.getTimeInMillis() + 960);
@@ -39,17 +33,17 @@ public class DateUtilsTest {
}
@Test
- public void testParseDateWithDeciseconds() throws Exception {
+ public void testParseDateWithDeciseconds() {
GregorianCalendar exp = new GregorianCalendar(2015, 2, 28, 13, 31, 4);
exp.setTimeZone(TimeZone.getTimeZone("UTC"));
Date expected = new Date(exp.getTimeInMillis() + 900);
Date actual = DateUtils.parse("2015-03-28T13:31:04.9");
- assertEquals(expected.getTime()/1000, actual.getTime()/1000);
- assertEquals(900, actual.getTime()%1000);
+ assertEquals(expected.getTime() / 1000, actual.getTime() / 1000);
+ assertEquals(900, actual.getTime() % 1000);
}
@Test
- public void testParseDateWithMicrosecondsAndTimezone() throws Exception {
+ public void testParseDateWithMicrosecondsAndTimezone() {
GregorianCalendar exp = new GregorianCalendar(2015, 2, 28, 6, 31, 4);
exp.setTimeZone(TimeZone.getTimeZone("UTC"));
Date expected = new Date(exp.getTimeInMillis() + 963);
@@ -58,7 +52,7 @@ public class DateUtilsTest {
}
@Test
- public void testParseDateWithCentisecondsAndTimezone() throws Exception {
+ public void testParseDateWithCentisecondsAndTimezone() {
GregorianCalendar exp = new GregorianCalendar(2015, 2, 28, 6, 31, 4);
exp.setTimeZone(TimeZone.getTimeZone("UTC"));
Date expected = new Date(exp.getTimeInMillis() + 960);
@@ -67,17 +61,17 @@ public class DateUtilsTest {
}
@Test
- public void testParseDateWithDecisecondsAndTimezone() throws Exception {
+ public void testParseDateWithDecisecondsAndTimezone() {
GregorianCalendar exp = new GregorianCalendar(2015, 2, 28, 6, 31, 4);
exp.setTimeZone(TimeZone.getTimeZone("UTC"));
Date expected = new Date(exp.getTimeInMillis() + 900);
Date actual = DateUtils.parse("2015-03-28T13:31:04.9 +0700");
- assertEquals(expected.getTime()/1000, actual.getTime()/1000);
- assertEquals(900, actual.getTime()%1000);
+ assertEquals(expected.getTime() / 1000, actual.getTime() / 1000);
+ assertEquals(900, actual.getTime() % 1000);
}
@Test
- public void testParseDateWithTimezoneName() throws Exception {
+ public void testParseDateWithTimezoneName() {
GregorianCalendar exp = new GregorianCalendar(2015, 2, 28, 6, 31, 4);
exp.setTimeZone(TimeZone.getTimeZone("UTC"));
Date expected = new Date(exp.getTimeInMillis());
@@ -86,7 +80,7 @@ public class DateUtilsTest {
}
@Test
- public void testParseDateWithTimezoneName2() throws Exception {
+ public void testParseDateWithTimezoneName2() {
GregorianCalendar exp = new GregorianCalendar(2015, 2, 28, 6, 31, 0);
exp.setTimeZone(TimeZone.getTimeZone("UTC"));
Date expected = new Date(exp.getTimeInMillis());
@@ -95,7 +89,7 @@ public class DateUtilsTest {
}
@Test
- public void testParseDateWithTimeZoneOffset() throws Exception {
+ public void testParseDateWithTimeZoneOffset() {
GregorianCalendar exp = new GregorianCalendar(2015, 2, 28, 12, 16, 12);
exp.setTimeZone(TimeZone.getTimeZone("UTC"));
Date expected = new Date(exp.getTimeInMillis());
@@ -104,7 +98,7 @@ public class DateUtilsTest {
}
@Test
- public void testAsctime() throws Exception {
+ public void testAsctime() {
GregorianCalendar exp = new GregorianCalendar(2011, 4, 25, 12, 33, 0);
exp.setTimeZone(TimeZone.getTimeZone("UTC"));
Date expected = new Date(exp.getTimeInMillis());
@@ -113,7 +107,7 @@ public class DateUtilsTest {
}
@Test
- public void testMultipleConsecutiveSpaces() throws Exception {
+ public void testMultipleConsecutiveSpaces() {
GregorianCalendar exp = new GregorianCalendar(2010, 2, 23, 6, 6, 26);
exp.setTimeZone(TimeZone.getTimeZone("UTC"));
Date expected = new Date(exp.getTimeInMillis());
@@ -121,14 +115,8 @@ public class DateUtilsTest {
assertEquals(expected, actual);
}
- /**
- * Requires Android platform.
- *
- * Reason: Standard JDK cannot parse timezone <code>-08:00</code> (ISO 8601 format). It only accepts
- * <code>-0800</code> (RFC 822 format)
- */
@Test
- public void testParseDateWithNoTimezonePadding() throws Exception {
+ public void testParseDateWithNoTimezonePadding() {
GregorianCalendar exp = new GregorianCalendar(2017, 1, 22, 22, 28, 0);
exp.setTimeZone(TimeZone.getTimeZone("UTC"));
Date expected = new Date(exp.getTimeInMillis() + 2);
@@ -143,7 +131,7 @@ public class DateUtilsTest {
* @see #testParseDateWithNoTimezonePadding()
*/
@Test
- public void testParseDateWithForCest() throws Exception {
+ public void testParseDateWithForCest() {
GregorianCalendar exp1 = new GregorianCalendar(2017, 0, 28, 22, 0, 0);
exp1.setTimeZone(TimeZone.getTimeZone("UTC"));
Date expected1 = new Date(exp1.getTimeInMillis());
diff --git a/core/src/test/java/de/danoeh/antennapod/core/util/FilenameGeneratorTest.java b/core/src/test/java/de/danoeh/antennapod/core/util/FilenameGeneratorTest.java
new file mode 100644
index 000000000..af22a4b9d
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/util/FilenameGeneratorTest.java
@@ -0,0 +1,98 @@
+package de.danoeh.antennapod.core.util;
+
+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/core/src/test/java/de/danoeh/antennapod/core/util/URLCheckerTest.java b/core/src/test/java/de/danoeh/antennapod/core/util/URLCheckerTest.java
new file mode 100644
index 000000000..a4b3dee06
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/util/URLCheckerTest.java
@@ -0,0 +1,157 @@
+package de.danoeh.antennapod.core.util;
+
+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.assertTrue;
+
+/**
+ * Test class for {@link URLChecker}
+ */
+@RunWith(RobolectricTestRunner.class)
+public class URLCheckerTest {
+
+ @Test
+ public void testCorrectURLHttp() {
+ final String in = "http://example.com";
+ final String out = URLChecker.prepareURL(in);
+ assertEquals(in, out);
+ }
+
+ @Test
+ public void testCorrectURLHttps() {
+ final String in = "https://example.com";
+ final String out = URLChecker.prepareURL(in);
+ assertEquals(in, out);
+ }
+
+ @Test
+ public void testMissingProtocol() {
+ final String in = "example.com";
+ final String out = URLChecker.prepareURL(in);
+ assertEquals("http://example.com", out);
+ }
+
+ @Test
+ public void testFeedProtocol() {
+ final String in = "feed://example.com";
+ final String out = URLChecker.prepareURL(in);
+ assertEquals("http://example.com", out);
+ }
+
+ @Test
+ public void testPcastProtocolNoScheme() {
+ final String in = "pcast://example.com";
+ final String out = URLChecker.prepareURL(in);
+ assertEquals("http://example.com", out);
+ }
+
+ @Test
+ public void testItpcProtocol() {
+ final String in = "itpc://example.com";
+ final String out = URLChecker.prepareURL(in);
+ assertEquals("http://example.com", out);
+ }
+
+ @Test
+ public void testItpcProtocolWithScheme() {
+ final String in = "itpc://https://example.com";
+ final String out = URLChecker.prepareURL(in);
+ assertEquals("https://example.com", out);
+ }
+
+ @Test
+ public void testWhiteSpaceUrlShouldNotAppend() {
+ final String in = "\n http://example.com \t";
+ final String out = URLChecker.prepareURL(in);
+ assertEquals("http://example.com", out);
+ }
+
+ @Test
+ public void testWhiteSpaceShouldAppend() {
+ final String in = "\n example.com \t";
+ final String out = URLChecker.prepareURL(in);
+ assertEquals("http://example.com", out);
+ }
+
+ @Test
+ public void testAntennaPodSubscribeProtocolNoScheme() {
+ final String in = "antennapod-subscribe://example.com";
+ final String out = URLChecker.prepareURL(in);
+ assertEquals("http://example.com", out);
+ }
+
+ @Test
+ public void testPcastProtocolWithScheme() {
+ final String in = "pcast://https://example.com";
+ final String out = URLChecker.prepareURL(in);
+ assertEquals("https://example.com", out);
+ }
+
+ @Test
+ public void testAntennaPodSubscribeProtocolWithScheme() {
+ final String in = "antennapod-subscribe://https://example.com";
+ final String out = URLChecker.prepareURL(in);
+ assertEquals("https://example.com", out);
+ }
+
+ @Test
+ public void testProtocolRelativeUrlIsAbsolute() {
+ final String in = "https://example.com";
+ final String inBase = "http://examplebase.com";
+ final String out = URLChecker.prepareURL(in, inBase);
+ assertEquals(in, out);
+ }
+
+ @Test
+ public void testProtocolRelativeUrlIsRelativeHttps() {
+ final String in = "//example.com";
+ final String inBase = "https://examplebase.com";
+ final String out = URLChecker.prepareURL(in, inBase);
+ assertEquals("https://example.com", out);
+ }
+
+ @Test
+ public void testProtocolRelativeUrlIsHttpsWithApSubscribeProtocol() {
+ final String in = "//example.com";
+ final String inBase = "antennapod-subscribe://https://examplebase.com";
+ final String out = URLChecker.prepareURL(in, inBase);
+ assertEquals("https://example.com", out);
+ }
+
+ @Test
+ public void testProtocolRelativeUrlBaseUrlNull() {
+ final String in = "example.com";
+ final String out = URLChecker.prepareURL(in, null);
+ assertEquals("http://example.com", out);
+ }
+
+ @Test
+ public void testUrlEqualsSame() {
+ assertTrue(URLChecker.urlEquals("https://www.example.com/test", "https://www.example.com/test"));
+ assertTrue(URLChecker.urlEquals("https://www.example.com/test", "https://www.example.com/test/"));
+ assertTrue(URLChecker.urlEquals("https://www.example.com/test", "https://www.example.com//test"));
+ assertTrue(URLChecker.urlEquals("https://www.example.com", "https://www.example.com/"));
+ assertTrue(URLChecker.urlEquals("https://www.example.com", "http://www.example.com"));
+ assertTrue(URLChecker.urlEquals("http://www.example.com/", "https://www.example.com/"));
+ assertTrue(URLChecker.urlEquals("https://www.example.com/?id=42", "https://www.example.com/?id=42"));
+ assertTrue(URLChecker.urlEquals("https://example.com/podcast%20test", "https://example.com/podcast test"));
+ assertTrue(URLChecker.urlEquals("https://example.com/?a=podcast%20test", "https://example.com/?a=podcast test"));
+ assertTrue(URLChecker.urlEquals("https://example.com/?", "https://example.com/"));
+ assertTrue(URLChecker.urlEquals("https://example.com/?", "https://example.com"));
+ assertTrue(URLChecker.urlEquals("https://Example.com", "https://example.com"));
+ assertTrue(URLChecker.urlEquals("https://example.com/test", "https://example.com/Test"));
+ }
+
+ @Test
+ public void testUrlEqualsDifferent() {
+ assertFalse(URLChecker.urlEquals("https://www.example.com/test", "https://www.example2.com/test"));
+ assertFalse(URLChecker.urlEquals("https://www.example.com/test", "https://www.example.de/test"));
+ assertFalse(URLChecker.urlEquals("https://example.com/", "https://otherpodcast.example.com/"));
+ assertFalse(URLChecker.urlEquals("https://www.example.com/?id=42&a=b", "https://www.example.com/?id=43&a=b"));
+ assertFalse(URLChecker.urlEquals("https://example.com/podcast%25test", "https://example.com/podcast test"));
+ }
+}
diff --git a/core/src/test/java/de/danoeh/antennapod/core/util/id3reader/ChapterReaderTest.java b/core/src/test/java/de/danoeh/antennapod/core/util/id3reader/ChapterReaderTest.java
new file mode 100644
index 000000000..ee4d43131
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/util/id3reader/ChapterReaderTest.java
@@ -0,0 +1,165 @@
+package de.danoeh.antennapod.core.util.id3reader;
+
+import de.danoeh.antennapod.core.feed.Chapter;
+import de.danoeh.antennapod.core.feed.ID3Chapter;
+import de.danoeh.antennapod.core.util.EmbeddedChapterImage;
+import de.danoeh.antennapod.core.util.id3reader.model.FrameHeader;
+import org.apache.commons.io.input.CountingInputStream;
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.util.List;
+
+import static de.danoeh.antennapod.core.util.id3reader.Id3ReaderTest.concat;
+import static de.danoeh.antennapod.core.util.id3reader.Id3ReaderTest.generateFrameHeader;
+import static de.danoeh.antennapod.core.util.id3reader.Id3ReaderTest.generateId3Header;
+import static org.junit.Assert.assertEquals;
+
+public class ChapterReaderTest {
+ private static final byte CHAPTER_WITHOUT_SUBFRAME_START_TIME = 23;
+ private static final byte[] CHAPTER_WITHOUT_SUBFRAME = {
+ 'C', 'H', '1', 0, // String ID for mapping to CTOC
+ 0, 0, 0, CHAPTER_WITHOUT_SUBFRAME_START_TIME, // Start time
+ 0, 0, 0, 0, // End time
+ 0, 0, 0, 0, // Start offset
+ 0, 0, 0, 0 // End offset
+ };
+
+ @Test
+ public void testReadFullTagWithChapter() throws IOException, ID3ReaderException {
+ byte[] chapter = concat(
+ generateFrameHeader(ChapterReader.FRAME_ID_CHAPTER, CHAPTER_WITHOUT_SUBFRAME.length),
+ CHAPTER_WITHOUT_SUBFRAME);
+ byte[] data = concat(
+ generateId3Header(chapter.length),
+ chapter);
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ ChapterReader reader = new ChapterReader(inputStream);
+ reader.readInputStream();
+ assertEquals(1, reader.getChapters().size());
+ assertEquals(CHAPTER_WITHOUT_SUBFRAME_START_TIME, reader.getChapters().get(0).getStart());
+ }
+
+ @Test
+ public void testReadFullTagWithMultipleChapters() throws IOException, ID3ReaderException {
+ byte[] chapter = concat(
+ generateFrameHeader(ChapterReader.FRAME_ID_CHAPTER, CHAPTER_WITHOUT_SUBFRAME.length),
+ CHAPTER_WITHOUT_SUBFRAME);
+ byte[] data = concat(
+ generateId3Header(2 * chapter.length),
+ chapter,
+ chapter);
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ ChapterReader reader = new ChapterReader(inputStream);
+ reader.readInputStream();
+ assertEquals(2, reader.getChapters().size());
+ assertEquals(CHAPTER_WITHOUT_SUBFRAME_START_TIME, reader.getChapters().get(0).getStart());
+ assertEquals(CHAPTER_WITHOUT_SUBFRAME_START_TIME, reader.getChapters().get(1).getStart());
+ }
+
+ @Test
+ public void testReadChapterWithoutSubframes() throws IOException, ID3ReaderException {
+ FrameHeader header = new FrameHeader(ChapterReader.FRAME_ID_CHAPTER,
+ CHAPTER_WITHOUT_SUBFRAME.length, (short) 0);
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(CHAPTER_WITHOUT_SUBFRAME));
+ Chapter chapter = new ChapterReader(inputStream).readChapter(header);
+ assertEquals(CHAPTER_WITHOUT_SUBFRAME_START_TIME, chapter.getStart());
+ }
+
+ @Test
+ public void testReadChapterWithTitle() throws IOException, ID3ReaderException {
+ byte[] title = {
+ ID3Reader.ENCODING_ISO,
+ 'H', 'e', 'l', 'l', 'o', // Title
+ 0 // Null-terminated
+ };
+ byte[] chapterData = concat(
+ CHAPTER_WITHOUT_SUBFRAME,
+ generateFrameHeader(ChapterReader.FRAME_ID_TITLE, title.length),
+ title);
+ FrameHeader header = new FrameHeader(ChapterReader.FRAME_ID_CHAPTER, chapterData.length, (short) 0);
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(chapterData));
+ ChapterReader reader = new ChapterReader(inputStream);
+ Chapter chapter = reader.readChapter(header);
+ assertEquals(CHAPTER_WITHOUT_SUBFRAME_START_TIME, chapter.getStart());
+ assertEquals("Hello", chapter.getTitle());
+ }
+
+ @Test
+ public void testReadTitleWithGarbage() throws IOException, ID3ReaderException {
+ byte[] titleSubframeContent = {
+ ID3Reader.ENCODING_ISO,
+ 'A', // Title
+ 0, // Null-terminated
+ 42, 42, 42, 42 // Garbage, should be ignored
+ };
+ FrameHeader header = new FrameHeader(ChapterReader.FRAME_ID_TITLE, titleSubframeContent.length, (short) 0);
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(titleSubframeContent));
+ ChapterReader reader = new ChapterReader(inputStream);
+ Chapter chapter = new ID3Chapter("", 0);
+ reader.readChapterSubFrame(header, chapter);
+ assertEquals("A", chapter.getTitle());
+
+ // Should skip the garbage and point to the next frame
+ assertEquals(titleSubframeContent.length, reader.getPosition());
+ }
+
+ @Test
+ public void testRealFileUltraschall() throws IOException, ID3ReaderException {
+ CountingInputStream inputStream = new CountingInputStream(getClass().getClassLoader()
+ .getResource("media-parser/ultraschall5.mp3").openStream());
+ ChapterReader reader = new ChapterReader(inputStream);
+ reader.readInputStream();
+ List<Chapter> chapters = reader.getChapters();
+
+ assertEquals(3, chapters.size());
+
+ assertEquals(0, chapters.get(0).getStart());
+ assertEquals(4004, chapters.get(1).getStart());
+ assertEquals(7999, chapters.get(2).getStart());
+
+ assertEquals("Marke 1", chapters.get(0).getTitle());
+ assertEquals("Marke 2", chapters.get(1).getTitle());
+ assertEquals("Marke 3", chapters.get(2).getTitle());
+
+ assertEquals("https://example.com", chapters.get(0).getLink());
+ assertEquals("https://example.com", chapters.get(1).getLink());
+ assertEquals("https://example.com", chapters.get(2).getLink());
+
+ assertEquals(EmbeddedChapterImage.makeUrl(16073, 2750569), chapters.get(0).getImageUrl());
+ assertEquals(EmbeddedChapterImage.makeUrl(2766765, 15740), chapters.get(1).getImageUrl());
+ assertEquals(EmbeddedChapterImage.makeUrl(2782628, 2750569), chapters.get(2).getImageUrl());
+ }
+
+ @Test
+ public void testRealFileAuphonic() throws IOException, ID3ReaderException {
+ CountingInputStream inputStream = new CountingInputStream(getClass().getClassLoader()
+ .getResource("media-parser/auphonic.mp3").openStream());
+ ChapterReader reader = new ChapterReader(inputStream);
+ reader.readInputStream();
+ List<Chapter> chapters = reader.getChapters();
+
+ assertEquals(4, chapters.size());
+
+ assertEquals(0, chapters.get(0).getStart());
+ assertEquals(3000, chapters.get(1).getStart());
+ assertEquals(6000, chapters.get(2).getStart());
+ assertEquals(9000, chapters.get(3).getStart());
+
+ assertEquals("Chapter 1 - ❤️😊", chapters.get(0).getTitle());
+ assertEquals("Chapter 2 - ßöÄ", chapters.get(1).getTitle());
+ assertEquals("Chapter 3 - 爱", chapters.get(2).getTitle());
+ assertEquals("Chapter 4", chapters.get(3).getTitle());
+
+ assertEquals("https://example.com", chapters.get(0).getLink());
+ assertEquals("https://example.com", chapters.get(1).getLink());
+ assertEquals("https://example.com", chapters.get(2).getLink());
+ assertEquals("https://example.com", chapters.get(3).getLink());
+
+ assertEquals(EmbeddedChapterImage.makeUrl(765, 308), chapters.get(0).getImageUrl());
+ assertEquals(EmbeddedChapterImage.makeUrl(1271, 308), chapters.get(1).getImageUrl());
+ assertEquals(EmbeddedChapterImage.makeUrl(1771, 308), chapters.get(2).getImageUrl());
+ assertEquals(EmbeddedChapterImage.makeUrl(2259, 308), chapters.get(3).getImageUrl());
+ }
+}
diff --git a/core/src/test/java/de/danoeh/antennapod/core/util/id3reader/Id3ReaderTest.java b/core/src/test/java/de/danoeh/antennapod/core/util/id3reader/Id3ReaderTest.java
new file mode 100644
index 000000000..584141b83
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/util/id3reader/Id3ReaderTest.java
@@ -0,0 +1,151 @@
+package de.danoeh.antennapod.core.util.id3reader;
+
+import de.danoeh.antennapod.core.util.id3reader.model.FrameHeader;
+import de.danoeh.antennapod.core.util.id3reader.model.TagHeader;
+import org.apache.commons.io.input.CountingInputStream;
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+public class Id3ReaderTest {
+ @Test
+ public void testReadString() throws IOException {
+ byte[] data = {
+ ID3Reader.ENCODING_ISO,
+ 'T', 'e', 's', 't',
+ 0 // Null-terminated
+ };
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ String string = new ID3Reader(inputStream).readEncodingAndString(1000);
+ assertEquals("Test", string);
+ }
+
+ @Test
+ public void testReadMultipleStrings() throws IOException {
+ byte[] data = {
+ ID3Reader.ENCODING_ISO,
+ 'F', 'o', 'o',
+ 0, // Null-terminated
+ ID3Reader.ENCODING_ISO,
+ 'B', 'a', 'r',
+ 0 // Null-terminated
+ };
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ ID3Reader reader = new ID3Reader(inputStream);
+ assertEquals("Foo", reader.readEncodingAndString(1000));
+ assertEquals("Bar", reader.readEncodingAndString(1000));
+ }
+
+ @Test
+ public void testReadingLimit() throws IOException {
+ byte[] data = {
+ ID3Reader.ENCODING_ISO,
+ 'A', 'B', 'C', 'D'
+ };
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ ID3Reader reader = new ID3Reader(inputStream);
+ assertEquals("ABC", reader.readEncodingAndString(4)); // Includes encoding
+ assertEquals('D', reader.readByte());
+ }
+
+ @Test
+ public void testReadUtf16RespectsBom() throws IOException {
+ byte[] data = {
+ ID3Reader.ENCODING_UTF16_WITH_BOM,
+ (byte) 0xff, (byte) 0xfe, // BOM: Little-endian
+ 'A', 0, 'B', 0, 'C', 0,
+ 0, 0, // Null-terminated
+ ID3Reader.ENCODING_UTF16_WITH_BOM,
+ (byte) 0xfe, (byte) 0xff, // BOM: Big-endian
+ 0, 'D', 0, 'E', 0, 'F',
+ 0, 0, // Null-terminated
+ };
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ ID3Reader reader = new ID3Reader(inputStream);
+ assertEquals("ABC", reader.readEncodingAndString(1000));
+ assertEquals("DEF", reader.readEncodingAndString(1000));
+ }
+
+ @Test
+ public void testReadUtf16NullPrefix() throws IOException {
+ byte[] data = {
+ ID3Reader.ENCODING_UTF16_WITH_BOM,
+ (byte) 0xff, (byte) 0xfe, // BOM
+ 0x00, 0x01, // Latin Capital Letter A with macron (Ā)
+ 0, 0, // Null-terminated
+ };
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ String string = new ID3Reader(inputStream).readEncodingAndString(1000);
+ assertEquals("Ā", string);
+ }
+
+ @Test
+ public void testReadingLimitUtf16() throws IOException {
+ byte[] data = {
+ ID3Reader.ENCODING_UTF16_WITHOUT_BOM,
+ 'A', 0, 'B', 0, 'C', 0, 'D', 0
+ };
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ ID3Reader reader = new ID3Reader(inputStream);
+ reader.readEncodingAndString(6); // Includes encoding, produces broken string
+ assertTrue("Should respect limit even if it breaks a symbol", reader.getPosition() <= 6);
+ }
+
+ @Test
+ public void testReadTagHeader() throws IOException, ID3ReaderException {
+ byte[] data = generateId3Header(23);
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ TagHeader header = new ID3Reader(inputStream).readTagHeader();
+ assertEquals("ID3", header.getId());
+ assertEquals(42, header.getVersion());
+ assertEquals(23, header.getSize());
+ }
+
+ @Test
+ public void testReadFrameHeader() throws IOException {
+ byte[] data = generateFrameHeader("CHAP", 42);
+ CountingInputStream inputStream = new CountingInputStream(new ByteArrayInputStream(data));
+ FrameHeader header = new ID3Reader(inputStream).readFrameHeader();
+ assertEquals("CHAP", header.getId());
+ assertEquals(42, header.getSize());
+ }
+
+ public static byte[] generateFrameHeader(String id, int size) {
+ return concat(
+ id.getBytes(StandardCharsets.ISO_8859_1), // Frame ID
+ new byte[] {
+ (byte) (size >> 24), (byte) (size >> 16),
+ (byte) (size >> 8), (byte) (size), // Size
+ 0, 0 // Flags
+ });
+ }
+
+ static byte[] generateId3Header(int size) {
+ return new byte[] {
+ 'I', 'D', '3', // Identifier
+ 0, 42, // Version
+ 0, // Flags
+ (byte) (size >> 24), (byte) (size >> 16),
+ (byte) (size >> 8), (byte) (size), // Size
+ };
+ }
+
+ static byte[] concat(byte[]... arrays) {
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+ try {
+ for (byte[] array : arrays) {
+ outputStream.write(array);
+ }
+ } catch (IOException e) {
+ fail(e.getMessage());
+ }
+ return outputStream.toByteArray();
+ }
+}
diff --git a/core/src/test/java/de/danoeh/antennapod/core/util/playback/ExternalMediaTest.java b/core/src/test/java/de/danoeh/antennapod/core/util/playback/ExternalMediaTest.java
new file mode 100644
index 000000000..d5e63eeba
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/util/playback/ExternalMediaTest.java
@@ -0,0 +1,56 @@
+package de.danoeh.antennapod.core.util.playback;
+
+import android.annotation.SuppressLint;
+import android.content.Context;
+import android.content.SharedPreferences;
+import androidx.preference.PreferenceManager;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+import de.danoeh.antennapod.core.feed.MediaType;
+import org.junit.After;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * Tests for {@link ExternalMedia} entity.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class ExternalMediaTest {
+
+ private static final int NOT_SET = -1;
+ private static final int POSITION = 50;
+ private static final int LAST_PLAYED_TIME = 1650;
+
+ @After
+ public void tearDown() {
+ clearSharedPrefs();
+ }
+
+ @SuppressLint("CommitPrefEdits")
+ private void clearSharedPrefs() {
+ SharedPreferences prefs = getDefaultSharedPrefs();
+ SharedPreferences.Editor editor = prefs.edit();
+ editor.clear();
+ editor.commit();
+ }
+
+ private SharedPreferences getDefaultSharedPrefs() {
+ Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ return PreferenceManager.getDefaultSharedPreferences(context);
+ }
+
+ @Test
+ public void testSaveCurrentPositionUpdatesPreferences() {
+ assertEquals(NOT_SET, getDefaultSharedPrefs().getInt(ExternalMedia.PREF_POSITION, NOT_SET));
+ assertEquals(NOT_SET, getDefaultSharedPrefs().getLong(ExternalMedia.PREF_LAST_PLAYED_TIME, NOT_SET));
+
+ ExternalMedia media = new ExternalMedia("source", MediaType.AUDIO);
+ media.saveCurrentPosition(getDefaultSharedPrefs(), POSITION, LAST_PLAYED_TIME);
+
+ assertEquals(POSITION, getDefaultSharedPrefs().getInt(ExternalMedia.PREF_POSITION, NOT_SET));
+ assertEquals(LAST_PLAYED_TIME, getDefaultSharedPrefs().getLong(ExternalMedia.PREF_LAST_PLAYED_TIME, NOT_SET));
+ }
+}
diff --git a/core/src/test/java/de/danoeh/antennapod/core/util/playback/TimelineTest.java b/core/src/test/java/de/danoeh/antennapod/core/util/playback/TimelineTest.java
new file mode 100644
index 000000000..8927a41de
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/util/playback/TimelineTest.java
@@ -0,0 +1,262 @@
+package de.danoeh.antennapod.core.util.playback;
+
+import android.content.Context;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
+import org.jsoup.select.Elements;
+
+import java.util.Date;
+import java.util.List;
+
+import de.danoeh.antennapod.core.feed.Chapter;
+import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.feed.FeedMedia;
+import de.danoeh.antennapod.core.storage.DBReader;
+
+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 static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Test class for {@link Timeline}.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class TimelineTest {
+
+ private Context context;
+ MockedStatic<DBReader> dbReaderMock;
+
+ @Before
+ public void setUp() {
+ context = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ // mock DBReader, because Timeline.processShownotes() calls FeedItem.loadShownotes()
+ // which calls DBReader.loadDescriptionOfFeedItem(), but we don't need the database here
+ dbReaderMock = Mockito.mockStatic(DBReader.class);
+ }
+
+ @After
+ public void tearDown() {
+ dbReaderMock.close();
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ private Playable newTestPlayable(List<Chapter> chapters, String shownotes, int duration) {
+ FeedItem item = new FeedItem(0, "Item", "item-id", "http://example.com/item", new Date(), FeedItem.PLAYED, null);
+ item.setChapters(chapters);
+ item.setContentEncoded(shownotes);
+ FeedMedia media = new FeedMedia(item, "http://example.com/episode", 100, "audio/mp3");
+ media.setDuration(duration);
+ item.setMedia(media);
+ return media;
+ }
+
+ @Test
+ public void testProcessShownotesAddTimecodeHhmmssNoChapters() {
+ final String timeStr = "10:11:12";
+ final long time = 3600 * 1000 * 10 + 60 * 1000 * 11 + 12 * 1000;
+
+ Playable p = newTestPlayable(null, "<p> Some test text with a timecode "
+ + timeStr + " here.</p>", Integer.MAX_VALUE);
+ Timeline t = new Timeline(context, p);
+ String res = t.processShownotes();
+ checkLinkCorrect(res, new long[]{time}, new String[]{timeStr});
+ }
+
+ @Test
+ public void testProcessShownotesAddTimecodeHhmmssMoreThen24HoursNoChapters() {
+ final String timeStr = "25:00:00";
+ final long time = 25 * 60 * 60 * 1000;
+
+ Playable p = newTestPlayable(null, "<p> Some test text with a timecode "
+ + timeStr + " here.</p>", Integer.MAX_VALUE);
+ Timeline t = new Timeline(context, p);
+ String res = t.processShownotes();
+ checkLinkCorrect(res, new long[]{time}, new String[]{timeStr});
+ }
+
+ @Test
+ public void testProcessShownotesAddTimecodeHhmmNoChapters() {
+ final String timeStr = "10:11";
+ final long time = 3600 * 1000 * 10 + 60 * 1000 * 11;
+
+ Playable p = newTestPlayable(null, "<p> Some test text with a timecode "
+ + timeStr + " here.</p>", Integer.MAX_VALUE);
+ Timeline t = new Timeline(context, p);
+ String res = t.processShownotes();
+ checkLinkCorrect(res, new long[]{time}, new String[]{timeStr});
+ }
+
+ @Test
+ public void testProcessShownotesAddTimecodeMmssNoChapters() {
+ final String timeStr = "10:11";
+ final long time = 10 * 60 * 1000 + 11 * 1000;
+
+ Playable p = newTestPlayable(null, "<p> Some test text with a timecode "
+ + timeStr + " here.</p>", 11 * 60 * 1000);
+ Timeline t = new Timeline(context, p);
+ String res = t.processShownotes();
+ checkLinkCorrect(res, new long[]{time}, new String[]{timeStr});
+ }
+
+ @Test
+ public void testProcessShownotesAddTimecodeHmmssNoChapters() {
+ final String timeStr = "2:11:12";
+ final long time = 2 * 60 * 60 * 1000 + 11 * 60 * 1000 + 12 * 1000;
+ Playable p = newTestPlayable(null, "<p> Some test text with a timecode "
+ + timeStr + " here.</p>", Integer.MAX_VALUE);
+ Timeline t = new Timeline(context, p);
+ String res = t.processShownotes();
+ checkLinkCorrect(res, new long[]{time}, new String[]{timeStr});
+ }
+
+ @Test
+ public void testProcessShownotesAddTimecodeMssNoChapters() {
+ final String timeStr = "1:12";
+ final long time = 60 * 1000 + 12 * 1000;
+
+ Playable p = newTestPlayable(null, "<p> Some test text with a timecode "
+ + timeStr + " here.</p>", 2 * 60 * 1000);
+ Timeline t = new Timeline(context, p);
+ String res = t.processShownotes();
+ checkLinkCorrect(res, new long[]{time}, new String[]{timeStr});
+ }
+
+ @Test
+ public void testProcessShownotesAddNoTimecodeDuration() {
+ final String timeStr = "2:11:12";
+ final int time = 2 * 60 * 60 * 1000 + 11 * 60 * 1000 + 12 * 1000;
+ String originalText = "<p> Some test text with a timecode " + timeStr + " here.</p>";
+ Playable p = newTestPlayable(null, originalText, time);
+ Timeline t = new Timeline(context, p);
+ String res = t.processShownotes();
+ Document d = Jsoup.parse(res);
+ assertEquals("Should not parse time codes that equal duration", 0, d.body().getElementsByTag("a").size());
+ }
+
+ @Test
+ public void testProcessShownotesAddTimecodeMultipleFormatsNoChapters() {
+ final String[] timeStrings = new String[]{ "10:12", "1:10:12" };
+
+ Playable p = newTestPlayable(null, "<p> Some test text with a timecode "
+ + timeStrings[0] + " here. Hey look another one " + timeStrings[1] + " here!</p>", 2 * 60 * 60 * 1000);
+ Timeline t = new Timeline(context, p);
+ String res = t.processShownotes();
+ checkLinkCorrect(res, new long[]{10 * 60 * 1000 + 12 * 1000,
+ 60 * 60 * 1000 + 10 * 60 * 1000 + 12 * 1000}, timeStrings);
+ }
+
+ @Test
+ public void testProcessShownotesAddTimecodeMultipleShortFormatNoChapters() {
+
+ // One of these timecodes fits as HH:MM and one does not so both should be parsed as MM:SS.
+ final String[] timeStrings = new String[]{ "10:12", "2:12" };
+
+ Playable p = newTestPlayable(null, "<p> Some test text with a timecode "
+ + timeStrings[0] + " here. Hey look another one " + timeStrings[1] + " here!</p>", 3 * 60 * 60 * 1000);
+ Timeline t = new Timeline(context, p);
+ String res = t.processShownotes();
+ checkLinkCorrect(res, new long[]{10 * 60 * 1000 + 12 * 1000, 2 * 60 * 1000 + 12 * 1000}, timeStrings);
+ }
+
+ @Test
+ public void testProcessShownotesAddTimecodeParentheses() {
+ final String timeStr = "10:11";
+ final long time = 3600 * 1000 * 10 + 60 * 1000 * 11;
+
+ Playable p = newTestPlayable(null, "<p> Some test text with a timecode ("
+ + timeStr + ") here.</p>", Integer.MAX_VALUE);
+ Timeline t = new Timeline(context, p);
+ String res = t.processShownotes();
+ checkLinkCorrect(res, new long[]{time}, new String[]{timeStr});
+ }
+
+ @Test
+ public void testProcessShownotesAddTimecodeBrackets() {
+ final String timeStr = "10:11";
+ final long time = 3600 * 1000 * 10 + 60 * 1000 * 11;
+
+ Playable p = newTestPlayable(null, "<p> Some test text with a timecode ["
+ + timeStr + "] here.</p>", Integer.MAX_VALUE);
+ Timeline t = new Timeline(context, p);
+ String res = t.processShownotes();
+ checkLinkCorrect(res, new long[]{time}, new String[]{timeStr});
+ }
+
+ @Test
+ public void testProcessShownotesAddTimecodeAngleBrackets() {
+ final String timeStr = "10:11";
+ final long time = 3600 * 1000 * 10 + 60 * 1000 * 11;
+
+ Playable p = newTestPlayable(null, "<p> Some test text with a timecode <"
+ + timeStr + "> here.</p>", Integer.MAX_VALUE);
+ Timeline t = new Timeline(context, p);
+ String res = t.processShownotes();
+ checkLinkCorrect(res, new long[]{time}, new String[]{timeStr});
+ }
+
+ @Test
+ public void testProcessShownotesAndInvalidTimecode() {
+ final String[] timeStrs = new String[] {"2:1", "0:0", "000", "00", "00:000"};
+
+ StringBuilder shownotes = new StringBuilder("<p> Some test text with timecodes ");
+ for (String timeStr : timeStrs) {
+ shownotes.append(timeStr).append(" ");
+ }
+ shownotes.append("here.</p>");
+
+ Playable p = newTestPlayable(null, shownotes.toString(), Integer.MAX_VALUE);
+ Timeline t = new Timeline(context, p);
+ String res = t.processShownotes();
+ checkLinkCorrect(res, new long[0], new String[0]);
+ }
+
+ private void checkLinkCorrect(String res, long[] timecodes, String[] timecodeStr) {
+ assertNotNull(res);
+ Document d = Jsoup.parse(res);
+ Elements links = d.body().getElementsByTag("a");
+ int countedLinks = 0;
+ for (Element link : links) {
+ String href = link.attributes().get("href");
+ String text = link.text();
+ if (href.startsWith("antennapod://")) {
+ assertTrue(href.endsWith(String.valueOf(timecodes[countedLinks])));
+ assertEquals(timecodeStr[countedLinks], text);
+ countedLinks++;
+ assertTrue("Contains too many links: " + countedLinks + " > "
+ + timecodes.length, countedLinks <= timecodes.length);
+ }
+ }
+ assertEquals(timecodes.length, countedLinks);
+ }
+
+ @Test
+ public void testIsTimecodeLink() {
+ assertFalse(Timeline.isTimecodeLink(null));
+ assertFalse(Timeline.isTimecodeLink("http://antennapod/timecode/123123"));
+ assertFalse(Timeline.isTimecodeLink("antennapod://timecode/"));
+ assertFalse(Timeline.isTimecodeLink("antennapod://123123"));
+ assertFalse(Timeline.isTimecodeLink("antennapod://timecode/123123a"));
+ assertTrue(Timeline.isTimecodeLink("antennapod://timecode/123"));
+ assertTrue(Timeline.isTimecodeLink("antennapod://timecode/1"));
+ }
+
+ @Test
+ public void testGetTimecodeLinkTime() {
+ assertEquals(-1, Timeline.getTimecodeLinkTime(null));
+ assertEquals(-1, Timeline.getTimecodeLinkTime("http://timecode/123"));
+ assertEquals(123, Timeline.getTimecodeLinkTime("antennapod://timecode/123"));
+
+ }
+}
diff --git a/core/src/test/java/de/danoeh/antennapod/core/util/syndication/FeedDiscovererTest.java b/core/src/test/java/de/danoeh/antennapod/core/util/syndication/FeedDiscovererTest.java
new file mode 100644
index 000000000..3df5230cc
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/util/syndication/FeedDiscovererTest.java
@@ -0,0 +1,128 @@
+package de.danoeh.antennapod.core.util.syndication;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.apache.commons.io.FileUtils;
+import org.apache.commons.io.IOUtils;
+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.io.FileOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Test class for {@link FeedDiscoverer}
+ */
+@RunWith(RobolectricTestRunner.class)
+public class FeedDiscovererTest {
+
+ private FeedDiscoverer fd;
+
+ private File testDir;
+
+ @Before
+ public void setUp() {
+ fd = new FeedDiscoverer();
+ testDir = new File(InstrumentationRegistry
+ .getInstrumentation().getTargetContext().getFilesDir(), "FeedDiscovererTest");
+ //noinspection ResultOfMethodCallIgnored
+ testDir.mkdir();
+ assertTrue(testDir.exists());
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ FileUtils.deleteDirectory(testDir);
+ }
+
+ @SuppressWarnings("SameParameterValue")
+ private String createTestHtmlString(String rel, String type, String href, String title) {
+ return String.format("<html><head><title>Test</title><link rel=\"%s\" type=\"%s\" href=\"%s\" title=\"%s\"></head><body></body></html>",
+ rel, type, href, title);
+ }
+
+ private String createTestHtmlString(String rel, String type, String href) {
+ return String.format("<html><head><title>Test</title><link rel=\"%s\" type=\"%s\" href=\"%s\"></head><body></body></html>",
+ rel, type, href);
+ }
+
+ private void checkFindUrls(boolean isAlternate, boolean isRss, boolean withTitle, boolean isAbsolute, boolean fromString) throws Exception {
+ final String title = "Test title";
+ final String hrefAbs = "http://example.com/feed";
+ final String hrefRel = "/feed";
+ final String base = "http://example.com";
+
+ final String rel = (isAlternate) ? "alternate" : "feed";
+ final String type = (isRss) ? "application/rss+xml" : "application/atom+xml";
+ final String href = (isAbsolute) ? hrefAbs : hrefRel;
+
+ Map<String, String> res;
+ String html = (withTitle) ? createTestHtmlString(rel, type, href, title)
+ : createTestHtmlString(rel, type, href);
+ if (fromString) {
+ res = fd.findLinks(html, base);
+ } else {
+ File testFile = new File(testDir, "feed");
+ FileOutputStream out = new FileOutputStream(testFile);
+ IOUtils.write(html, out, StandardCharsets.UTF_8);
+ out.close();
+ res = fd.findLinks(testFile, base);
+ }
+
+ assertNotNull(res);
+ assertEquals(1, res.size());
+ for (String key : res.keySet()) {
+ assertEquals(hrefAbs, key);
+ }
+ assertTrue(res.containsKey(hrefAbs));
+ if (withTitle) {
+ assertEquals(title, res.get(hrefAbs));
+ } else {
+ assertEquals(href, res.get(hrefAbs));
+ }
+ }
+
+ @Test
+ public void testAlternateRSSWithTitleAbsolute() throws Exception {
+ checkFindUrls(true, true, true, true, true);
+ }
+
+ @Test
+ public void testAlternateRSSWithTitleRelative() throws Exception {
+ checkFindUrls(true, true, true, false, true);
+ }
+
+ @Test
+ public void testAlternateRSSNoTitleAbsolute() throws Exception {
+ checkFindUrls(true, true, false, true, true);
+ }
+
+ @Test
+ public void testAlternateRSSNoTitleRelative() throws Exception {
+ checkFindUrls(true, true, false, false, true);
+ }
+
+ @Test
+ public void testAlternateAtomWithTitleAbsolute() throws Exception {
+ checkFindUrls(true, false, true, true, true);
+ }
+
+ @Test
+ public void testFeedAtomWithTitleAbsolute() throws Exception {
+ checkFindUrls(false, false, true, true, true);
+ }
+
+ @Test
+ public void testAlternateRSSWithTitleAbsoluteFromFile() throws Exception {
+ checkFindUrls(true, true, true, true, false);
+ }
+}
diff --git a/core/src/test/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentChapterReaderTest.java b/core/src/test/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentChapterReaderTest.java
new file mode 100644
index 000000000..cf9228292
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/util/vorbiscommentreader/VorbisCommentChapterReaderTest.java
@@ -0,0 +1,44 @@
+package de.danoeh.antennapod.core.util.vorbiscommentreader;
+
+import de.danoeh.antennapod.core.feed.Chapter;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+
+public class VorbisCommentChapterReaderTest {
+
+ @Test
+ public void testRealFilesAuphonic() throws IOException, VorbisCommentReaderException {
+ testRealFileAuphonic("media-parser/auphonic.ogg");
+ testRealFileAuphonic("media-parser/auphonic.opus");
+ }
+
+ public void testRealFileAuphonic(String filename) throws IOException, VorbisCommentReaderException {
+ InputStream inputStream = getClass().getClassLoader()
+ .getResource(filename).openStream();
+ VorbisCommentChapterReader reader = new VorbisCommentChapterReader();
+ reader.readInputStream(inputStream);
+ List<Chapter> chapters = reader.getChapters();
+
+ assertEquals(4, chapters.size());
+
+ assertEquals(0, chapters.get(0).getStart());
+ assertEquals(3000, chapters.get(1).getStart());
+ assertEquals(6000, chapters.get(2).getStart());
+ assertEquals(9000, chapters.get(3).getStart());
+
+ assertEquals("Chapter 1 - ❤️😊", chapters.get(0).getTitle());
+ assertEquals("Chapter 2 - ßöÄ", chapters.get(1).getTitle());
+ assertEquals("Chapter 3 - 爱", chapters.get(2).getTitle());
+ assertEquals("Chapter 4", chapters.get(3).getTitle());
+
+ assertEquals("https://example.com", chapters.get(0).getLink());
+ assertEquals("https://example.com", chapters.get(1).getLink());
+ assertEquals("https://example.com", chapters.get(2).getLink());
+ assertEquals("https://example.com", chapters.get(3).getLink());
+ }
+}
diff --git a/core/src/test/resources/feed-atom-testAtomBasic.xml b/core/src/test/resources/feed-atom-testAtomBasic.xml
new file mode 100644
index 000000000..cefc4f979
--- /dev/null
+++ b/core/src/test/resources/feed-atom-testAtomBasic.xml
@@ -0,0 +1 @@
+<?xml version='1.0' encoding='UTF-8' ?><feed xmlns="http://www.w3.org/2005/Atom"><id>http://example.com/feed</id><title>title</title><link rel="alternate" href="http://example.com" /><subtitle>This is the description</subtitle><logo>http://example.com/picture</logo><link rel="payment" href="http://example.com/payment" type="text/html" /><entry><id>http://example.com/item-0</id><title>item-0</title><link rel="alternate" href="http://example.com/items/0" /><published>1970-01-01T00:00:00Z</published><link rel="enclosure" href="http://example.com/media-0" type="audio/mp3" length="1048576" /></entry><entry><id>http://example.com/item-1</id><title>item-1</title><link rel="alternate" href="http://example.com/items/1" /><published>1970-01-01T00:01:00Z</published><link rel="enclosure" href="http://example.com/media-1" type="audio/mp3" length="1048576" /></entry><entry><id>http://example.com/item-2</id><title>item-2</title><link rel="alternate" href="http://example.com/items/2" /><published>1970-01-01T00:02:00Z</published><link rel="enclosure" href="http://example.com/media-2" type="audio/mp3" length="1048576" /></entry><entry><id>http://example.com/item-3</id><title>item-3</title><link rel="alternate" href="http://example.com/items/3" /><published>1970-01-01T00:03:00Z</published><link rel="enclosure" href="http://example.com/media-3" type="audio/mp3" length="1048576" /></entry><entry><id>http://example.com/item-4</id><title>item-4</title><link rel="alternate" href="http://example.com/items/4" /><published>1970-01-01T00:04:00Z</published><link rel="enclosure" href="http://example.com/media-4" type="audio/mp3" length="1048576" /></entry><entry><id>http://example.com/item-5</id><title>item-5</title><link rel="alternate" href="http://example.com/items/5" /><published>1970-01-01T00:05:00Z</published><link rel="enclosure" href="http://example.com/media-5" type="audio/mp3" length="1048576" /></entry><entry><id>http://example.com/item-6</id><title>item-6</title><link rel="alternate" href="http://example.com/items/6" /><published>1970-01-01T00:06:00Z</published><link rel="enclosure" href="http://example.com/media-6" type="audio/mp3" length="1048576" /></entry><entry><id>http://example.com/item-7</id><title>item-7</title><link rel="alternate" href="http://example.com/items/7" /><published>1970-01-01T00:07:00Z</published><link rel="enclosure" href="http://example.com/media-7" type="audio/mp3" length="1048576" /></entry><entry><id>http://example.com/item-8</id><title>item-8</title><link rel="alternate" href="http://example.com/items/8" /><published>1970-01-01T00:08:00Z</published><link rel="enclosure" href="http://example.com/media-8" type="audio/mp3" length="1048576" /></entry><entry><id>http://example.com/item-9</id><title>item-9</title><link rel="alternate" href="http://example.com/items/9" /><published>1970-01-01T00:09:00Z</published><link rel="enclosure" href="http://example.com/media-9" type="audio/mp3" length="1048576" /></entry></feed> \ No newline at end of file
diff --git a/core/src/test/resources/feed-atom-testLogoWithWhitespace.xml b/core/src/test/resources/feed-atom-testLogoWithWhitespace.xml
new file mode 100644
index 000000000..f4886d56a
--- /dev/null
+++ b/core/src/test/resources/feed-atom-testLogoWithWhitespace.xml
@@ -0,0 +1,2 @@
+<?xml version='1.0' encoding='UTF-8' ?><feed xmlns="http://www.w3.org/2005/Atom"><id>http://example.com/feed</id><title>title</title><link rel="alternate" href="http://example.com" /><subtitle>This is the description</subtitle><link rel="payment" href="http://example.com/payment" type="text/html" /><logo> https://example.com/image.png
+</logo></feed> \ No newline at end of file
diff --git a/core/src/test/resources/feed-rss-testImageWithWhitespace.xml b/core/src/test/resources/feed-rss-testImageWithWhitespace.xml
new file mode 100644
index 000000000..2be9401d2
--- /dev/null
+++ b/core/src/test/resources/feed-rss-testImageWithWhitespace.xml
@@ -0,0 +1,2 @@
+<?xml version='1.0' encoding='UTF-8' ?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>title</title><description>This is the description</description><link>http://example.com</link><language>en</language><atom:link rel="payment" href="http://example.com/payment" type="text/html" /><image><url> https://example.com/image.png
+</url></image></channel></rss> \ No newline at end of file
diff --git a/core/src/test/resources/feed-rss-testMediaContentMime.xml b/core/src/test/resources/feed-rss-testMediaContentMime.xml
new file mode 100644
index 000000000..a715abb37
--- /dev/null
+++ b/core/src/test/resources/feed-rss-testMediaContentMime.xml
@@ -0,0 +1 @@
+<?xml version='1.0' encoding='UTF-8' ?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>title</title><description>This is the description</description><link>http://example.com</link><language>en</language><atom:link rel="payment" href="http://example.com/payment" type="text/html" /><item xmlns:media="http://search.yahoo.com/mrss/"><media:content url="https://www.example.com/file.mp4" medium="video" /></item></channel></rss> \ No newline at end of file
diff --git a/core/src/test/resources/feed-rss-testRss2Basic.xml b/core/src/test/resources/feed-rss-testRss2Basic.xml
new file mode 100644
index 000000000..dd771b61a
--- /dev/null
+++ b/core/src/test/resources/feed-rss-testRss2Basic.xml
@@ -0,0 +1 @@
+<?xml version='1.0' encoding='UTF-8' ?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>title</title><description>This is the description</description><link>http://example.com</link><language>en</language><image><url>http://example.com/picture</url></image><atom:link rel="payment" href="http://example.com/payment" type="text/html" /><item><title>item-0</title><link>http://example.com/items/0</link><pubDate>01 Jan 70 01:00:00 +0100</pubDate><guid>http://example.com/item-0</guid><enclosure url="http://example.com/media-0" length="1048576" type="audio/mp3" /></item><item><title>item-1</title><link>http://example.com/items/1</link><pubDate>01 Jan 70 01:01:00 +0100</pubDate><guid>http://example.com/item-1</guid><enclosure url="http://example.com/media-1" length="1048576" type="audio/mp3" /></item><item><title>item-2</title><link>http://example.com/items/2</link><pubDate>01 Jan 70 01:02:00 +0100</pubDate><guid>http://example.com/item-2</guid><enclosure url="http://example.com/media-2" length="1048576" type="audio/mp3" /></item><item><title>item-3</title><link>http://example.com/items/3</link><pubDate>01 Jan 70 01:03:00 +0100</pubDate><guid>http://example.com/item-3</guid><enclosure url="http://example.com/media-3" length="1048576" type="audio/mp3" /></item><item><title>item-4</title><link>http://example.com/items/4</link><pubDate>01 Jan 70 01:04:00 +0100</pubDate><guid>http://example.com/item-4</guid><enclosure url="http://example.com/media-4" length="1048576" type="audio/mp3" /></item><item><title>item-5</title><link>http://example.com/items/5</link><pubDate>01 Jan 70 01:05:00 +0100</pubDate><guid>http://example.com/item-5</guid><enclosure url="http://example.com/media-5" length="1048576" type="audio/mp3" /></item><item><title>item-6</title><link>http://example.com/items/6</link><pubDate>01 Jan 70 01:06:00 +0100</pubDate><guid>http://example.com/item-6</guid><enclosure url="http://example.com/media-6" length="1048576" type="audio/mp3" /></item><item><title>item-7</title><link>http://example.com/items/7</link><pubDate>01 Jan 70 01:07:00 +0100</pubDate><guid>http://example.com/item-7</guid><enclosure url="http://example.com/media-7" length="1048576" type="audio/mp3" /></item><item><title>item-8</title><link>http://example.com/items/8</link><pubDate>01 Jan 70 01:08:00 +0100</pubDate><guid>http://example.com/item-8</guid><enclosure url="http://example.com/media-8" length="1048576" type="audio/mp3" /></item><item><title>item-9</title><link>http://example.com/items/9</link><pubDate>01 Jan 70 01:09:00 +0100</pubDate><guid>http://example.com/item-9</guid><enclosure url="http://example.com/media-9" length="1048576" type="audio/mp3" /></item></channel></rss> \ No newline at end of file
diff --git a/core/src/test/resources/media-parser/auphonic.m4a b/core/src/test/resources/media-parser/auphonic.m4a
new file mode 100644
index 000000000..ca59a80f6
--- /dev/null
+++ b/core/src/test/resources/media-parser/auphonic.m4a
Binary files differ
diff --git a/core/src/test/resources/media-parser/auphonic.mp3 b/core/src/test/resources/media-parser/auphonic.mp3
new file mode 100644
index 000000000..ca2a7ed4f
--- /dev/null
+++ b/core/src/test/resources/media-parser/auphonic.mp3
Binary files differ
diff --git a/core/src/test/resources/media-parser/auphonic.ogg b/core/src/test/resources/media-parser/auphonic.ogg
new file mode 100644
index 000000000..de326517a
--- /dev/null
+++ b/core/src/test/resources/media-parser/auphonic.ogg
Binary files differ
diff --git a/core/src/test/resources/media-parser/auphonic.opus b/core/src/test/resources/media-parser/auphonic.opus
new file mode 100644
index 000000000..08538ecb7
--- /dev/null
+++ b/core/src/test/resources/media-parser/auphonic.opus
Binary files differ
diff --git a/core/src/test/resources/media-parser/ultraschall5.mp3 b/core/src/test/resources/media-parser/ultraschall5.mp3
new file mode 100644
index 000000000..a73029a54
--- /dev/null
+++ b/core/src/test/resources/media-parser/ultraschall5.mp3
Binary files differ