summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorByteHamster <ByteHamster@users.noreply.github.com>2020-10-25 20:21:05 +0100
committerGitHub <noreply@github.com>2020-10-25 20:21:05 +0100
commit1a1b66340402971d5dc8c666126a61afa80b71fb (patch)
treea6de6275fa337e69651e370135e750bbadf901a1
parent71b6c57773d1af6e6df967bba968e08cb0268335 (diff)
parent679e4829997d886a07fe3361fd9d917db16061d2 (diff)
downloadAntennaPod-1a1b66340402971d5dc8c666126a61afa80b71fb.zip
Merge pull request #4287 from AntennaPod/add-local-feeds
Add local feeds
-rw-r--r--app/src/androidTest/java/de/test/antennapod/storage/DBTasksTest.java34
-rw-r--r--app/src/androidTest/java/de/test/antennapod/storage/DBWriterTest.java36
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/CoverLoader.java29
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/SubscriptionsAdapter.java5
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/actionbutton/DownloadActionButton.java3
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/actionbutton/ItemActionButton.java2
-rw-r--r--app/src/main/java/de/danoeh/antennapod/adapter/actionbutton/PlayLocalActionButton.java51
-rw-r--r--app/src/main/java/de/danoeh/antennapod/dialog/EpisodesApplyActionFragment.java14
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/AddFeedFragment.java56
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/ChaptersFragment.java2
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/FeedInfoFragment.java67
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/FeedItemlistFragment.java12
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/FeedSettingsFragment.java34
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/ItemFragment.java3
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/NavDrawerFragment.java4
-rw-r--r--app/src/main/java/de/danoeh/antennapod/fragment/SubscriptionFragment.java4
-rw-r--r--app/src/main/java/de/danoeh/antennapod/menuhandler/FeedItemMenuHandler.java5
-rw-r--r--app/src/main/java/de/danoeh/antennapod/menuhandler/FeedMenuHandler.java4
-rw-r--r--app/src/main/res/layout/addfeed.xml14
-rw-r--r--app/src/main/res/menu/feedinfo.xml14
-rw-r--r--app/src/main/res/xml/feed_settings.xml4
-rw-r--r--core/build.gradle11
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/feed/Feed.java4
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java2
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java11
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/feed/LocalFeedUpdater.java162
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/glide/ApGlideModule.java7
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/glide/ApOkHttpUrlLoader.java28
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/glide/AudioCoverFetcher.java14
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/glide/MetadataRetrieverLoader.java57
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FeedSyncTask.java3
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java10
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceTaskManager.java2
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java199
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java98
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java33
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/ChapterUtils.java46
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/playback/ExternalMedia.java2
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java9
-rw-r--r--core/src/main/java/de/danoeh/antennapod/core/util/playback/RemoteMedia.java4
-rw-r--r--core/src/main/res/raw/local_feed_default_icon.pngbin0 -> 1240 bytes
-rw-r--r--core/src/main/res/values/colors.xml1
-rw-r--r--core/src/main/res/values/strings.xml6
-rw-r--r--core/src/play/java/de/danoeh/antennapod/core/cast/CastUtils.java10
-rw-r--r--core/src/test/assets/local-feed1/track1.mp3bin0 -> 43341 bytes
-rw-r--r--core/src/test/assets/local-feed2/folder.pngbin0 -> 1589 bytes
-rw-r--r--core/src/test/assets/local-feed2/track1.mp3bin0 -> 43341 bytes
-rw-r--r--core/src/test/assets/local-feed2/track2.mp3bin0 -> 43497 bytes
-rw-r--r--core/src/test/java/androidx/documentfile/provider/AssetsDocumentFile.java138
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/feed/LocalFeedUpdaterTest.java208
-rw-r--r--core/src/test/java/de/danoeh/antennapod/core/storage/ItemEnqueuePositionCalculatorTest.java6
51 files changed, 1234 insertions, 234 deletions
diff --git a/app/src/androidTest/java/de/test/antennapod/storage/DBTasksTest.java b/app/src/androidTest/java/de/test/antennapod/storage/DBTasksTest.java
index 595a9794f..c28ce5003 100644
--- a/app/src/androidTest/java/de/test/antennapod/storage/DBTasksTest.java
+++ b/app/src/androidTest/java/de/test/antennapod/storage/DBTasksTest.java
@@ -68,9 +68,9 @@ public class DBTasksTest {
for (int i = 0; i < NUM_ITEMS; i++) {
feed.getItems().add(new FeedItem(0, "item " + i, "id " + i, "link " + i, new Date(), FeedItem.UNPLAYED, feed));
}
- Feed newFeed = DBTasks.updateFeed(context, feed)[0];
+ Feed newFeed = DBTasks.updateFeed(context, feed, false);
- assertSame(feed, newFeed);
+ assertEquals(feed.getId(), newFeed.getId());
assertTrue(feed.getId() != 0);
for (FeedItem item : feed.getItems()) {
assertFalse(item.isPlayed());
@@ -88,8 +88,8 @@ public class DBTasksTest {
feed1.setItems(new ArrayList<>());
feed2.setItems(new ArrayList<>());
- Feed savedFeed1 = DBTasks.updateFeed(context, feed1)[0];
- Feed savedFeed2 = DBTasks.updateFeed(context, feed2)[0];
+ Feed savedFeed1 = DBTasks.updateFeed(context, feed1, false);
+ Feed savedFeed2 = DBTasks.updateFeed(context, feed2, false);
assertTrue(savedFeed1.getId() != savedFeed2.getId());
}
@@ -124,7 +124,7 @@ public class DBTasksTest {
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)[0];
+ final Feed newFeed = DBTasks.updateFeed(context, feed, false);
assertNotSame(newFeed, feed);
updatedFeedTest(newFeed, feedID, itemIDs, NUM_ITEMS_OLD, NUM_ITEMS_NEW);
@@ -156,7 +156,7 @@ public class DBTasksTest {
list.add(item);
feed.setItems(list);
- final Feed newFeed = DBTasks.updateFeed(context, feed)[0];
+ final Feed newFeed = DBTasks.updateFeed(context, feed, false);
assertNotSame(newFeed, feed);
final Feed feedFromDB = DBReader.getFeed(newFeed.getId());
@@ -164,6 +164,28 @@ public class DBTasksTest {
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
+ }
+
private void updatedFeedTest(final Feed newFeed, long feedID, List<Long> itemIDs, final int NUM_ITEMS_OLD, final int NUM_ITEMS_NEW) {
assertEquals(feedID, newFeed.getId());
assertEquals(NUM_ITEMS_NEW + NUM_ITEMS_OLD, newFeed.getItems().size());
diff --git a/app/src/androidTest/java/de/test/antennapod/storage/DBWriterTest.java b/app/src/androidTest/java/de/test/antennapod/storage/DBWriterTest.java
index d300e23e7..652389d00 100644
--- a/app/src/androidTest/java/de/test/antennapod/storage/DBWriterTest.java
+++ b/app/src/androidTest/java/de/test/antennapod/storage/DBWriterTest.java
@@ -428,6 +428,42 @@ public class DBWriterTest {
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(InstrumentationRegistry.getInstrumentation()
+ .getTargetContext(), 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<>());
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/CoverLoader.java b/app/src/main/java/de/danoeh/antennapod/adapter/CoverLoader.java
index 5acc25bee..d782d4ed5 100644
--- a/app/src/main/java/de/danoeh/antennapod/adapter/CoverLoader.java
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/CoverLoader.java
@@ -15,6 +15,8 @@ import com.bumptech.glide.request.target.CustomViewTarget;
import java.lang.ref.WeakReference;
import com.bumptech.glide.request.transition.Transition;
+
+import de.danoeh.antennapod.R;
import de.danoeh.antennapod.activity.MainActivity;
import de.danoeh.antennapod.core.glide.ApGlideSettings;
@@ -23,6 +25,7 @@ public class CoverLoader {
private String fallbackUri;
private TextView txtvPlaceholder;
private ImageView imgvCover;
+ private boolean textAndImageCombined;
private MainActivity activity;
public CoverLoader(MainActivity activity) {
@@ -49,6 +52,19 @@ public class CoverLoader {
return this;
}
+ /**
+ * Set cover text and if it should be shown even if there is a cover image.
+ *
+ * @param placeholderView Cover text.
+ * @param textAndImageCombined Show cover text even if there is a cover image?
+ */
+ @NonNull
+ public CoverLoader withPlaceholderView(@NonNull TextView placeholderView, boolean textAndImageCombined) {
+ this.txtvPlaceholder = placeholderView;
+ this.textAndImageCombined = textAndImageCombined;
+ return this;
+ }
+
public void load() {
RequestOptions options = new RequestOptions()
.diskCacheStrategy(ApGlideSettings.AP_DISK_CACHE_STRATEGY)
@@ -65,20 +81,22 @@ public class CoverLoader {
.apply(options));
}
- builder.into(new CoverTarget(txtvPlaceholder, imgvCover));
+ builder.into(new CoverTarget(txtvPlaceholder, imgvCover, textAndImageCombined));
}
static class CoverTarget extends CustomViewTarget<ImageView, Drawable> {
private final WeakReference<TextView> placeholder;
private final WeakReference<ImageView> cover;
+ private boolean textAndImageCombined;
- public CoverTarget(TextView txtvPlaceholder, ImageView imgvCover) {
+ public CoverTarget(TextView txtvPlaceholder, ImageView imgvCover, boolean textAndImageCombined) {
super(imgvCover);
if (txtvPlaceholder != null) {
txtvPlaceholder.setVisibility(View.VISIBLE);
}
placeholder = new WeakReference<>(txtvPlaceholder);
cover = new WeakReference<>(imgvCover);
+ this.textAndImageCombined = textAndImageCombined;
}
@Override
@@ -90,7 +108,12 @@ public class CoverLoader {
public void onResourceReady(@NonNull Drawable resource, @Nullable Transition<? super Drawable> transition) {
TextView txtvPlaceholder = placeholder.get();
if (txtvPlaceholder != null) {
- txtvPlaceholder.setVisibility(View.INVISIBLE);
+ if (textAndImageCombined) {
+ int bgColor = txtvPlaceholder.getContext().getResources().getColor(R.color.feed_text_bg);
+ txtvPlaceholder.setBackgroundColor(bgColor);
+ } else {
+ txtvPlaceholder.setVisibility(View.INVISIBLE);
+ }
}
ImageView ivCover = cover.get();
ivCover.setImageDrawable(resource);
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/SubscriptionsAdapter.java b/app/src/main/java/de/danoeh/antennapod/adapter/SubscriptionsAdapter.java
index fdda526ff..8c294a9c9 100644
--- a/app/src/main/java/de/danoeh/antennapod/adapter/SubscriptionsAdapter.java
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/SubscriptionsAdapter.java
@@ -22,6 +22,7 @@ import java.util.Locale;
import de.danoeh.antennapod.R;
import de.danoeh.antennapod.activity.MainActivity;
import de.danoeh.antennapod.core.feed.Feed;
+import de.danoeh.antennapod.core.feed.LocalFeedUpdater;
import de.danoeh.antennapod.fragment.AddFeedFragment;
import de.danoeh.antennapod.fragment.FeedItemlistFragment;
import jp.shts.android.library.TriangleLabelView;
@@ -107,9 +108,11 @@ public class SubscriptionsAdapter extends BaseAdapter implements AdapterView.OnI
holder.count.setVisibility(View.GONE);
}
+ boolean textAndImageCombined = feed.isLocalFeed()
+ && LocalFeedUpdater.getDefaultIconUrl(convertView.getContext()).equals(feed.getImageUrl());
new CoverLoader(mainActivityRef.get())
.withUri(feed.getImageLocation())
- .withPlaceholderView(holder.feedTitle)
+ .withPlaceholderView(holder.feedTitle, textAndImageCombined)
.withCoverView(holder.imageView)
.load();
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/actionbutton/DownloadActionButton.java b/app/src/main/java/de/danoeh/antennapod/adapter/actionbutton/DownloadActionButton.java
index 3e210c822..0f7c2bdd0 100644
--- a/app/src/main/java/de/danoeh/antennapod/adapter/actionbutton/DownloadActionButton.java
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/actionbutton/DownloadActionButton.java
@@ -40,8 +40,7 @@ public class DownloadActionButton extends ItemActionButton {
@Override
public int getVisibility() {
- return (item.getMedia() != null && DownloadRequester.getInstance().isDownloadingFile(item.getMedia()))
- ? View.INVISIBLE : View.VISIBLE;
+ return item.getFeed().isLocalFeed() ? View.INVISIBLE : View.VISIBLE;
}
@Override
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/actionbutton/ItemActionButton.java b/app/src/main/java/de/danoeh/antennapod/adapter/actionbutton/ItemActionButton.java
index 527ac3ec1..5d95d3775 100644
--- a/app/src/main/java/de/danoeh/antennapod/adapter/actionbutton/ItemActionButton.java
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/actionbutton/ItemActionButton.java
@@ -42,6 +42,8 @@ public abstract class ItemActionButton {
final boolean isDownloadingMedia = DownloadRequester.getInstance().isDownloadingFile(media);
if (media.isCurrentlyPlaying()) {
return new PauseActionButton(item);
+ } else if (item.getFeed().isLocalFeed()) {
+ return new PlayLocalActionButton(item);
} else if (media.isDownloaded()) {
return new PlayActionButton(item);
} else if (isDownloadingMedia) {
diff --git a/app/src/main/java/de/danoeh/antennapod/adapter/actionbutton/PlayLocalActionButton.java b/app/src/main/java/de/danoeh/antennapod/adapter/actionbutton/PlayLocalActionButton.java
new file mode 100644
index 000000000..31dfe15da
--- /dev/null
+++ b/app/src/main/java/de/danoeh/antennapod/adapter/actionbutton/PlayLocalActionButton.java
@@ -0,0 +1,51 @@
+package de.danoeh.antennapod.adapter.actionbutton;
+
+import android.content.Context;
+import androidx.annotation.AttrRes;
+import androidx.annotation.StringRes;
+import de.danoeh.antennapod.R;
+import de.danoeh.antennapod.core.feed.FeedItem;
+import de.danoeh.antennapod.core.feed.FeedMedia;
+import de.danoeh.antennapod.core.feed.MediaType;
+import de.danoeh.antennapod.core.preferences.UsageStatistics;
+import de.danoeh.antennapod.core.service.playback.PlaybackService;
+import de.danoeh.antennapod.core.util.NetworkUtils;
+import de.danoeh.antennapod.core.util.playback.PlaybackServiceStarter;
+import de.danoeh.antennapod.dialog.StreamingConfirmationDialog;
+
+public class PlayLocalActionButton extends ItemActionButton {
+
+ public PlayLocalActionButton(FeedItem item) {
+ super(item);
+ }
+
+ @Override
+ @StringRes
+ public int getLabel() {
+ return R.string.play_label;
+ }
+
+ @Override
+ @AttrRes
+ public int getDrawable() {
+ return R.attr.av_play;
+ }
+
+ @Override
+ public void onClick(Context context) {
+ final FeedMedia media = item.getMedia();
+ if (media == null) {
+ return;
+ }
+
+ new PlaybackServiceStarter(context, media)
+ .callEvenIfRunning(true)
+ .startWhenPrepared(true)
+ .shouldStream(true)
+ .start();
+
+ if (media.getMediaType() == MediaType.VIDEO) {
+ context.startActivity(PlaybackService.getPlayerActivityIntent(context, media));
+ }
+ }
+}
diff --git a/app/src/main/java/de/danoeh/antennapod/dialog/EpisodesApplyActionFragment.java b/app/src/main/java/de/danoeh/antennapod/dialog/EpisodesApplyActionFragment.java
index c3177668a..f2524c40c 100644
--- a/app/src/main/java/de/danoeh/antennapod/dialog/EpisodesApplyActionFragment.java
+++ b/app/src/main/java/de/danoeh/antennapod/dialog/EpisodesApplyActionFragment.java
@@ -51,7 +51,7 @@ public class EpisodesApplyActionFragment extends Fragment {
private static final int ACTION_MARK_UNPLAYED = 8;
public static final int ACTION_DOWNLOAD = 16;
public static final int ACTION_DELETE = 32;
- private static final int ACTION_ALL = ACTION_ADD_TO_QUEUE | ACTION_REMOVE_FROM_QUEUE
+ public static final int ACTION_ALL = ACTION_ADD_TO_QUEUE | ACTION_REMOVE_FROM_QUEUE
| ACTION_MARK_PLAYED | ACTION_MARK_UNPLAYED | ACTION_DOWNLOAD | ACTION_DELETE;
private Toolbar toolbar;
@@ -103,10 +103,6 @@ public class EpisodesApplyActionFragment extends Fragment {
);
}
- public static EpisodesApplyActionFragment newInstance(List<FeedItem> items) {
- return newInstance(items, ACTION_ALL);
- }
-
public static EpisodesApplyActionFragment newInstance(List<FeedItem> items, int actions) {
EpisodesApplyActionFragment f = new EpisodesApplyActionFragment();
f.episodes.addAll(items);
@@ -449,7 +445,7 @@ public class EpisodesApplyActionFragment extends Fragment {
// download the check episodes in the same order as they are currently displayed
List<FeedItem> toDownload = new ArrayList<>(checkedIds.size());
for (FeedItem episode : episodes) {
- if (checkedIds.contains(episode.getId()) && episode.hasMedia()) {
+ if (checkedIds.contains(episode.getId()) && episode.hasMedia() && !episode.getFeed().isLocalFeed()) {
toDownload.add(episode);
}
}
@@ -473,10 +469,8 @@ public class EpisodesApplyActionFragment extends Fragment {
}
private void close(@PluralsRes int msgId, int numItems) {
- if (numItems > 0) {
- ((MainActivity) getActivity()).showSnackbarAbovePlayer(
- getResources().getQuantityString(msgId, numItems, numItems), Snackbar.LENGTH_LONG);
- }
+ ((MainActivity) getActivity()).showSnackbarAbovePlayer(
+ getResources().getQuantityString(msgId, numItems, numItems), Snackbar.LENGTH_LONG);
getActivity().getSupportFragmentManager().popBackStack();
}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/AddFeedFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/AddFeedFragment.java
index a4646ad64..8ceee3db0 100644
--- a/app/src/main/java/de/danoeh/antennapod/fragment/AddFeedFragment.java
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/AddFeedFragment.java
@@ -6,6 +6,7 @@ import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
+import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
@@ -17,18 +18,26 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
+import androidx.documentfile.provider.DocumentFile;
import androidx.fragment.app.Fragment;
+import com.google.android.material.snackbar.Snackbar;
import de.danoeh.antennapod.R;
import de.danoeh.antennapod.activity.MainActivity;
import de.danoeh.antennapod.activity.OnlineFeedViewActivity;
import de.danoeh.antennapod.activity.OpmlImportActivity;
+import de.danoeh.antennapod.core.feed.Feed;
+import de.danoeh.antennapod.core.storage.DBTasks;
+import de.danoeh.antennapod.core.storage.DownloadRequestException;
+import de.danoeh.antennapod.core.util.SortOrder;
import de.danoeh.antennapod.discovery.CombinedSearcher;
import de.danoeh.antennapod.discovery.FyydPodcastSearcher;
import de.danoeh.antennapod.discovery.ItunesPodcastSearcher;
import de.danoeh.antennapod.discovery.PodcastIndexPodcastSearcher;
import de.danoeh.antennapod.fragment.gpodnet.GpodnetMainFragment;
+import java.util.Collections;
+
/**
* Provides actions for adding new podcast subscriptions.
*/
@@ -36,6 +45,7 @@ public class AddFeedFragment extends Fragment {
public static final String TAG = "AddFeedFragment";
private static final int REQUEST_CODE_CHOOSE_OPML_IMPORT_PATH = 1;
+ private static final int REQUEST_CODE_ADD_LOCAL_FOLDER = 2;
private EditText combinedFeedSearchBox;
private MainActivity activity;
@@ -67,8 +77,7 @@ public class AddFeedFragment extends Fragment {
root.findViewById(R.id.btn_add_via_url).setOnClickListener(v
-> showAddViaUrlDialog());
- View butOpmlImport = root.findViewById(R.id.btn_opml_import);
- butOpmlImport.setOnClickListener(v -> {
+ root.findViewById(R.id.btn_opml_import).setOnClickListener(v -> {
try {
Intent intentGetContentAction = new Intent(Intent.ACTION_GET_CONTENT);
intentGetContentAction.addCategory(Intent.CATEGORY_OPENABLE);
@@ -78,6 +87,23 @@ public class AddFeedFragment extends Fragment {
Log.e(TAG, "No activity found. Should never happen...");
}
});
+ root.findViewById(R.id.btn_add_local_folder).setOnClickListener(v -> {
+ if (Build.VERSION.SDK_INT < 21) {
+ ((MainActivity) getActivity()).showSnackbarAbovePlayer(
+ "Local folders are only supported on Android 5.0 and later", Snackbar.LENGTH_LONG);
+ return;
+ }
+ try {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ startActivityForResult(intent, REQUEST_CODE_ADD_LOCAL_FOLDER);
+ } catch (ActivityNotFoundException e) {
+ Log.e(TAG, "No activity found. Should never happen...");
+ }
+ });
+ if (Build.VERSION.SDK_INT < 21) {
+ root.findViewById(R.id.btn_add_local_folder).setVisibility(View.GONE);
+ }
root.findViewById(R.id.search_icon).setOnClickListener(view -> performSearch());
return root;
}
@@ -137,6 +163,32 @@ public class AddFeedFragment extends Fragment {
Intent intent = new Intent(getContext(), OpmlImportActivity.class);
intent.setData(uri);
startActivity(intent);
+ } else if (requestCode == REQUEST_CODE_ADD_LOCAL_FOLDER) {
+ addLocalFolder(uri);
+ }
+ }
+
+ private void addLocalFolder(Uri uri) {
+ if (Build.VERSION.SDK_INT < 21) {
+ return;
+ }
+ try {
+ getActivity().getContentResolver()
+ .takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ DocumentFile documentFile = DocumentFile.fromTreeUri(getContext(), uri);
+ if (documentFile == null) {
+ throw new IllegalArgumentException("Unable to retrieve document tree");
+ }
+ Feed dirFeed = new Feed(Feed.PREFIX_LOCAL_FOLDER + uri.toString(), null, documentFile.getName());
+ dirFeed.setDescription(getString(R.string.local_feed_description));
+ dirFeed.setItems(Collections.emptyList());
+ dirFeed.setSortOrder(SortOrder.EPISODE_TITLE_A_Z);
+ DBTasks.forceRefreshFeed(getContext(), dirFeed, true);
+ ((MainActivity) getActivity())
+ .showSnackbarAbovePlayer(R.string.add_local_folder_success, Snackbar.LENGTH_SHORT);
+ } catch (DownloadRequestException | IllegalArgumentException e) {
+ Log.e(TAG, Log.getStackTraceString(e));
+ ((MainActivity) getActivity()).showSnackbarAbovePlayer(e.getLocalizedMessage(), Snackbar.LENGTH_LONG);
}
}
}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/ChaptersFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/ChaptersFragment.java
index 6911687dd..6f95d71da 100644
--- a/app/src/main/java/de/danoeh/antennapod/fragment/ChaptersFragment.java
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/ChaptersFragment.java
@@ -122,7 +122,7 @@ public class ChaptersFragment extends Fragment {
disposable = Maybe.create(emitter -> {
Playable media = controller.getMedia();
if (media != null) {
- media.loadChapterMarks();
+ media.loadChapterMarks(getContext());
emitter.onSuccess(media);
} else {
emitter.onComplete();
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/FeedInfoFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/FeedInfoFragment.java
index ae03b5032..317fecfcb 100644
--- a/app/src/main/java/de/danoeh/antennapod/fragment/FeedInfoFragment.java
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/FeedInfoFragment.java
@@ -1,16 +1,21 @@
package de.danoeh.antennapod.fragment;
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
import android.content.ClipData;
import android.content.Context;
import android.content.Intent;
import android.content.res.Configuration;
import android.graphics.LightingColorFilter;
import android.net.Uri;
+import android.os.Build;
import android.os.Bundle;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.widget.Toolbar;
+import androidx.documentfile.provider.DocumentFile;
import androidx.fragment.app.Fragment;
import android.text.TextUtils;
import android.util.Log;
@@ -36,6 +41,7 @@ import de.danoeh.antennapod.core.feed.Feed;
import de.danoeh.antennapod.core.glide.ApGlideSettings;
import de.danoeh.antennapod.core.glide.FastBlurTransformation;
import de.danoeh.antennapod.core.storage.DBReader;
+import de.danoeh.antennapod.core.storage.DBTasks;
import de.danoeh.antennapod.core.storage.DownloadRequestException;
import de.danoeh.antennapod.core.util.IntentUtils;
import de.danoeh.antennapod.core.util.LangUtils;
@@ -43,12 +49,17 @@ import de.danoeh.antennapod.core.util.ThemeUtils;
import de.danoeh.antennapod.core.util.syndication.HtmlToPlainText;
import de.danoeh.antennapod.menuhandler.FeedMenuHandler;
import de.danoeh.antennapod.view.ToolbarIconTintManager;
+import io.reactivex.Completable;
import io.reactivex.Maybe;
import io.reactivex.MaybeOnSubscribe;
+import io.reactivex.Observable;
+import io.reactivex.Single;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.Disposable;
import io.reactivex.schedulers.Schedulers;
+import java.util.Collections;
+
/**
* Displays information about a feed.
*/
@@ -56,6 +67,7 @@ public class FeedInfoFragment extends Fragment {
private static final String EXTRA_FEED_ID = "de.danoeh.antennapod.extra.feedId";
private static final String TAG = "FeedInfoActivity";
+ private static final int REQUEST_CODE_ADD_LOCAL_FOLDER = 2;
private Feed feed;
private Disposable disposable;
@@ -237,6 +249,7 @@ public class FeedInfoFragment extends Fragment {
@Override
public void onPrepareOptionsMenu(@NonNull Menu menu) {
super.onPrepareOptionsMenu(menu);
+ menu.findItem(R.id.reconnect_local_folder).setVisible(feed != null && feed.isLocalFeed());
menu.findItem(R.id.share_link_item).setVisible(feed != null && feed.getLink() != null);
menu.findItem(R.id.visit_website_item).setVisible(feed != null && feed.getLink() != null
&& IntentUtils.isCallable(getContext(), new Intent(Intent.ACTION_VIEW, Uri.parse(feed.getLink()))));
@@ -256,6 +269,60 @@ public class FeedInfoFragment extends Fragment {
e.printStackTrace();
DownloadRequestErrorDialogCreator.newRequestErrorDialog(getContext(), e.getMessage());
}
+
+ if (item.getItemId() == R.id.reconnect_local_folder && Build.VERSION.SDK_INT >= 21) {
+ AlertDialog.Builder alert = new AlertDialog.Builder(getContext());
+ alert.setMessage(R.string.reconnect_local_folder_warning);
+ alert.setPositiveButton(android.R.string.ok, (dialog, which) -> {
+ try {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
+ intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ startActivityForResult(intent, REQUEST_CODE_ADD_LOCAL_FOLDER);
+ } catch (ActivityNotFoundException e) {
+ Log.e(TAG, "No activity found. Should never happen...");
+ }
+ });
+ alert.setNegativeButton(android.R.string.cancel, null);
+ alert.show();
+ return true;
+ }
+
return handled || super.onOptionsItemSelected(item);
}
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ if (resultCode != Activity.RESULT_OK || data == null) {
+ return;
+ }
+ Uri uri = data.getData();
+
+ if (requestCode == REQUEST_CODE_ADD_LOCAL_FOLDER) {
+ reconnectLocalFolder(uri);
+ }
+ }
+
+ private void reconnectLocalFolder(Uri uri) {
+ if (Build.VERSION.SDK_INT < 21 || feed == null) {
+ return;
+ }
+
+ Completable.fromAction(() -> {
+ getActivity().getContentResolver()
+ .takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ DocumentFile documentFile = DocumentFile.fromTreeUri(getContext(), uri);
+ if (documentFile == null) {
+ throw new IllegalArgumentException("Unable to retrieve document tree");
+ }
+ feed.setDownload_url(Feed.PREFIX_LOCAL_FOLDER + uri.toString());
+ DBTasks.updateFeed(getContext(), feed, true);
+ })
+ .subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(
+ () -> ((MainActivity) getActivity())
+ .showSnackbarAbovePlayer(R.string.add_local_folder_success, Snackbar.LENGTH_SHORT),
+ error -> ((MainActivity) getActivity())
+ .showSnackbarAbovePlayer(error.getLocalizedMessage(), Snackbar.LENGTH_LONG));
+ }
}
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/FeedItemlistFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/FeedItemlistFragment.java
index 115f8c665..b66f15c7e 100644
--- a/app/src/main/java/de/danoeh/antennapod/fragment/FeedItemlistFragment.java
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/FeedItemlistFragment.java
@@ -296,8 +296,14 @@ public class FeedItemlistFragment extends Fragment implements AdapterView.OnItem
if (!FeedMenuHandler.onOptionsItemClicked(getActivity(), item, feed)) {
switch (item.getItemId()) {
case R.id.episode_actions:
+ int actions = EpisodesApplyActionFragment.ACTION_ALL;
+ if (feed.isLocalFeed()) {
+ // turn off download and delete actions for local feed
+ actions ^= EpisodesApplyActionFragment.ACTION_DOWNLOAD;
+ actions ^= EpisodesApplyActionFragment.ACTION_DELETE;
+ }
EpisodesApplyActionFragment fragment = EpisodesApplyActionFragment
- .newInstance(feed.getItems());
+ .newInstance(feed.getItems(), actions);
((MainActivity)getActivity()).loadChildFragment(fragment);
return true;
case R.id.rename_item:
@@ -312,9 +318,11 @@ public class FeedItemlistFragment extends Fragment implements AdapterView.OnItem
((MainActivity) getActivity()).loadFragment(EpisodesFragment.TAG, null);
}
};
+ int messageId = feed.isLocalFeed() ? R.string.feed_delete_confirmation_local_msg
+ : R.string.feed_delete_confirmation_msg;
ConfirmationDialog conDialog = new ConfirmationDialog(getActivity(),
R.string.remove_feed_label,
- getString(R.string.feed_delete_confirmation_msg, feed.getTitle())) {
+ getString(messageId, feed.getTitle())) {
@Override
public void onConfirmButtonPressed(
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/FeedSettingsFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/FeedSettingsFragment.java
index a82c60d6c..21280896a 100644
--- a/app/src/main/java/de/danoeh/antennapod/fragment/FeedSettingsFragment.java
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/FeedSettingsFragment.java
@@ -15,6 +15,7 @@ import androidx.fragment.app.Fragment;
import androidx.preference.ListPreference;
import androidx.preference.PreferenceFragmentCompat;
import androidx.preference.SwitchPreferenceCompat;
+import androidx.recyclerview.widget.RecyclerView;
import de.danoeh.antennapod.R;
import de.danoeh.antennapod.core.dialog.ConfirmationDialog;
import de.danoeh.antennapod.core.event.settings.SkipIntroEndingChangedEvent;
@@ -100,6 +101,9 @@ public class FeedSettingsFragment extends Fragment {
public static class FeedSettingsPreferenceFragment extends PreferenceFragmentCompat {
private static final CharSequence PREF_EPISODE_FILTER = "episodeFilter";
private static final CharSequence PREF_SCREEN = "feedSettingsScreen";
+ private static final CharSequence PREF_AUTHENTICATION = "authentication";
+ private static final CharSequence PREF_AUTO_DELETE = "autoDelete";
+ private static final CharSequence PREF_CATEGORY_AUTO_DOWNLOAD = "autoDownloadCategory";
private static final String PREF_FEED_PLAYBACK_SPEED = "feedPlaybackSpeed";
private static final String PREF_AUTO_SKIP = "feedAutoSkip";
private static final DecimalFormat SPEED_FORMAT =
@@ -118,10 +122,19 @@ public class FeedSettingsFragment extends Fragment {
}
@Override
+ public RecyclerView onCreateRecyclerView(LayoutInflater inflater, ViewGroup parent, Bundle state) {
+ final RecyclerView view = super.onCreateRecyclerView(inflater, parent, state);
+ // To prevent transition animation because of summary update
+ view.setItemAnimator(null);
+ view.setLayoutAnimation(null);
+ return view;
+ }
+
+ @Override
public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
addPreferencesFromResource(R.xml.feed_settings);
- findPreference(PREF_SCREEN).setEnabled(false);
- setupAutoDownloadGlobalPreference(); // To prevent transition animation because of summary update
+ // To prevent displaying partially loaded data
+ findPreference(PREF_SCREEN).setVisible(false);
long feedId = getArguments().getLong(EXTRA_FEED_ID);
disposable = Maybe.create((MaybeOnSubscribe<Feed>) emitter -> {
@@ -138,6 +151,7 @@ public class FeedSettingsFragment extends Fragment {
feed = result;
feedPreferences = feed.getPreferences();
+ setupAutoDownloadGlobalPreference();
setupAutoDownloadPreference();
setupKeepUpdatedPreference();
setupAutoDeletePreference();
@@ -151,7 +165,14 @@ public class FeedSettingsFragment extends Fragment {
updateVolumeReductionValue();
updateAutoDownloadEnabled();
updatePlaybackSpeedPreference();
- findPreference(PREF_SCREEN).setEnabled(true);
+
+ if (feed.isLocalFeed()) {
+ findPreference(PREF_AUTHENTICATION).setVisible(false);
+ findPreference(PREF_AUTO_DELETE).setVisible(false);
+ findPreference(PREF_CATEGORY_AUTO_DOWNLOAD).setVisible(false);
+ }
+
+ findPreference(PREF_SCREEN).setVisible(true);
}, error -> Log.d(TAG, Log.getStackTraceString(error)), () -> { });
}
@@ -222,7 +243,7 @@ public class FeedSettingsFragment extends Fragment {
}
private void setupAuthentificationPreference() {
- findPreference("authentication").setOnPreferenceClickListener(preference -> {
+ findPreference(PREF_AUTHENTICATION).setOnPreferenceClickListener(preference -> {
new AuthenticationDialog(getContext(),
R.string.authentication_label, true,
feedPreferences.getUsername(), feedPreferences.getPassword()) {
@@ -238,8 +259,7 @@ public class FeedSettingsFragment extends Fragment {
}
private void setupAutoDeletePreference() {
- ListPreference autoDeletePreference = findPreference("autoDelete");
- autoDeletePreference.setOnPreferenceChangeListener((preference, newValue) -> {
+ findPreference(PREF_AUTO_DELETE).setOnPreferenceChangeListener((preference, newValue) -> {
switch ((String) newValue) {
case "global":
feedPreferences.setAutoDeleteAction(FeedPreferences.AutoDeleteAction.GLOBAL);
@@ -265,7 +285,7 @@ public class FeedSettingsFragment extends Fragment {
}
private void updateAutoDeleteSummary() {
- ListPreference autoDeletePreference = findPreference("autoDelete");
+ ListPreference autoDeletePreference = findPreference(PREF_AUTO_DELETE);
switch (feedPreferences.getAutoDeleteAction()) {
case GLOBAL:
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/ItemFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/ItemFragment.java
index 337c789fe..669dbdac2 100644
--- a/app/src/main/java/de/danoeh/antennapod/fragment/ItemFragment.java
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/ItemFragment.java
@@ -39,6 +39,7 @@ import de.danoeh.antennapod.adapter.actionbutton.ItemActionButton;
import de.danoeh.antennapod.adapter.actionbutton.MarkAsPlayedActionButton;
import de.danoeh.antennapod.adapter.actionbutton.PauseActionButton;
import de.danoeh.antennapod.adapter.actionbutton.PlayActionButton;
+import de.danoeh.antennapod.adapter.actionbutton.PlayLocalActionButton;
import de.danoeh.antennapod.adapter.actionbutton.StreamActionButton;
import de.danoeh.antennapod.adapter.actionbutton.VisitWebsiteActionButton;
import de.danoeh.antennapod.core.event.DownloadEvent;
@@ -326,6 +327,8 @@ public class ItemFragment extends Fragment {
}
if (media.isCurrentlyPlaying()) {
actionButton1 = new PauseActionButton(item);
+ } else if (item.getFeed().isLocalFeed()) {
+ actionButton1 = new PlayLocalActionButton(item);
} else if (media.isDownloaded()) {
actionButton1 = new PlayActionButton(item);
} else {
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/NavDrawerFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/NavDrawerFragment.java
index f58cafff7..4f99e8130 100644
--- a/app/src/main/java/de/danoeh/antennapod/fragment/NavDrawerFragment.java
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/NavDrawerFragment.java
@@ -205,9 +205,11 @@ public class NavDrawerFragment extends Fragment implements AdapterView.OnItemCli
}
}
};
+ int messageId = feed.isLocalFeed() ? R.string.feed_delete_confirmation_local_msg
+ : R.string.feed_delete_confirmation_msg;
ConfirmationDialog conDialog = new ConfirmationDialog(getContext(),
R.string.remove_feed_label,
- getString(R.string.feed_delete_confirmation_msg, feed.getTitle())) {
+ getString(messageId, feed.getTitle())) {
@Override
public void onConfirmButtonPressed(DialogInterface dialog) {
dialog.dismiss();
diff --git a/app/src/main/java/de/danoeh/antennapod/fragment/SubscriptionFragment.java b/app/src/main/java/de/danoeh/antennapod/fragment/SubscriptionFragment.java
index 70cd6fcb3..3b2a72210 100644
--- a/app/src/main/java/de/danoeh/antennapod/fragment/SubscriptionFragment.java
+++ b/app/src/main/java/de/danoeh/antennapod/fragment/SubscriptionFragment.java
@@ -308,7 +308,9 @@ public class SubscriptionFragment extends Fragment {
}
};
- String message = getString(R.string.feed_delete_confirmation_msg, feed.getTitle());
+ int messageId = feed.isLocalFeed() ? R.string.feed_delete_confirmation_local_msg
+ : R.string.feed_delete_confirmation_msg;
+ String message = getString(messageId, feed.getTitle());
ConfirmationDialog dialog = new ConfirmationDialog(getContext(), R.string.remove_feed_label, message) {
@Override
public void onConfirmButtonPressed(DialogInterface clickedDialog) {
diff --git a/app/src/main/java/de/danoeh/antennapod/menuhandler/FeedItemMenuHandler.java b/app/src/main/java/de/danoeh/antennapod/menuhandler/FeedItemMenuHandler.java
index bddafb75e..7604b94d6 100644
--- a/app/src/main/java/de/danoeh/antennapod/menuhandler/FeedItemMenuHandler.java
+++ b/app/src/main/java/de/danoeh/antennapod/menuhandler/FeedItemMenuHandler.java
@@ -102,6 +102,11 @@ public class FeedItemMenuHandler {
setItemVisibility(menu, R.id.remove_item, fileDownloaded);
+ if (selectedItem.getFeed().isLocalFeed()) {
+ setItemVisibility(menu, R.id.visit_website_item, false);
+ setItemVisibility(menu, R.id.share_item, false);
+ }
+
return true;
}
diff --git a/app/src/main/java/de/danoeh/antennapod/menuhandler/FeedMenuHandler.java b/app/src/main/java/de/danoeh/antennapod/menuhandler/FeedMenuHandler.java
index bcf202c05..5446d0191 100644
--- a/app/src/main/java/de/danoeh/antennapod/menuhandler/FeedMenuHandler.java
+++ b/app/src/main/java/de/danoeh/antennapod/menuhandler/FeedMenuHandler.java
@@ -51,6 +51,10 @@ public class FeedMenuHandler {
menu.findItem(R.id.visit_website_item).setVisible(false);
menu.findItem(R.id.share_link_item).setVisible(false);
}
+ if (selectedFeed.isLocalFeed()) {
+ // hide complete submenu "Share..." as both sub menu items are not visible
+ menu.findItem(R.id.share_item).setVisible(false);
+ }
return true;
}
diff --git a/app/src/main/res/layout/addfeed.xml b/app/src/main/res/layout/addfeed.xml
index 3d9b1ec17..be262094c 100644
--- a/app/src/main/res/layout/addfeed.xml
+++ b/app/src/main/res/layout/addfeed.xml
@@ -96,6 +96,20 @@
android:text="@string/add_podcast_by_url"/>
<TextView
+ android:id="@+id/btn_add_local_folder"
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:drawablePadding="8dp"
+ app:drawableStartCompat="?attr/ic_folder"
+ app:drawableLeftCompat="?attr/ic_folder"
+ android:paddingTop="8dp"
+ android:paddingBottom="8dp"
+ android:background="?android:attr/selectableItemBackground"
+ android:textColor="?android:attr/textColorPrimary"
+ android:clickable="true"
+ android:text="@string/add_local_folder"/>
+
+ <TextView
android:id="@+id/btn_search_itunes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
diff --git a/app/src/main/res/menu/feedinfo.xml b/app/src/main/res/menu/feedinfo.xml
index f20d679a5..b1daf1f36 100644
--- a/app/src/main/res/menu/feedinfo.xml
+++ b/app/src/main/res/menu/feedinfo.xml
@@ -6,17 +6,19 @@
android:icon="?attr/location_web_site"
custom:showAsAction="ifRoom|collapseActionView"
android:title="@string/visit_website_label"
- android:visible="true">
- </item>
+ android:visible="true"/>
<item
android:id="@+id/share_link_item"
custom:showAsAction="collapseActionView"
- android:title="@string/share_website_url_label">
- </item>
+ android:title="@string/share_website_url_label"/>
<item
android:id="@+id/share_download_url_item"
custom:showAsAction="collapseActionView"
- android:title="@string/share_feed_url_label">
- </item>
+ android:title="@string/share_feed_url_label"/>
+ <item
+ android:id="@+id/reconnect_local_folder"
+ custom:showAsAction="collapseActionView"
+ android:title="@string/reconnect_local_folder"
+ android:visible="false" />
</menu>
diff --git a/app/src/main/res/xml/feed_settings.xml b/app/src/main/res/xml/feed_settings.xml
index a6820a4ad..9d5ed5e8b 100644
--- a/app/src/main/res/xml/feed_settings.xml
+++ b/app/src/main/res/xml/feed_settings.xml
@@ -44,7 +44,9 @@
android:defaultValue="off"
android:key="volumeReduction"/>
- <PreferenceCategory android:title="@string/auto_download_settings_label">
+ <PreferenceCategory
+ android:title="@string/auto_download_settings_label"
+ android:key="autoDownloadCategory">
<SwitchPreferenceCompat
android:key="autoDownload"
android:title="@string/auto_download_label"/>
diff --git a/core/build.gradle b/core/build.gradle
index ee5a33ce5..f443ebb9b 100644
--- a/core/build.gradle
+++ b/core/build.gradle
@@ -53,6 +53,12 @@ android {
}
}
+ testOptions {
+ unitTests {
+ includeAndroidResources = true
+ }
+ }
+
lintOptions {
disable "InvalidPeriodicWorkRequestInterval", "ObsoleteLintCustomCheck", "DefaultLocale", "UnusedAttribute",
"GradleDependency", "ParcelClassLoader", "Typos", "ExtraTranslation", "ImpliedQuantity",
@@ -67,6 +73,7 @@ android {
dependencies {
annotationProcessor "androidx.annotation:annotation:$annotationVersion"
implementation "androidx.appcompat:appcompat:$appcompatVersion"
+ implementation 'androidx.documentfile:documentfile:1.0.1'
implementation "androidx.media:media:$mediaVersion"
implementation "androidx.preference:preference:$preferenceVersion"
implementation "androidx.work:work-runtime:$workManagerVersion"
@@ -106,7 +113,9 @@ dependencies {
testImplementation "org.awaitility:awaitility:$awaitilityVersion"
testImplementation 'junit:junit:4.13'
- testImplementation 'org.mockito:mockito-core:1.10.19'
+ testImplementation 'org.mockito:mockito-inline:3.5.13'
+ testImplementation 'org.robolectric:robolectric:4.5-alpha-1'
+ testImplementation 'javax.inject:javax.inject:1'
androidTestImplementation "com.jayway.android.robotium:robotium-solo:$robotiumSoloVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
androidTestImplementation "androidx.test:runner:$runnerVersion"
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 3657bcdc6..a3b66c951 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
@@ -24,6 +24,7 @@ public class Feed extends FeedFile implements ImageResource {
public static final int FEEDFILETYPE_FEED = 0;
public static final String TYPE_RSS2 = "rss";
public static final String TYPE_ATOM1 = "atom";
+ public static final String PREFIX_LOCAL_FOLDER = "antennapod_local:";
/* title as defined by the feed */
private String feedTitle;
@@ -551,4 +552,7 @@ public class Feed extends FeedFile implements ImageResource {
this.lastUpdateFailed = lastUpdateFailed;
}
+ public boolean isLocalFeed() {
+ return download_url.startsWith(PREFIX_LOCAL_FOLDER);
+ }
}
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 b681c21d1..131cbe563 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
@@ -381,7 +381,7 @@ public class FeedItem extends FeedComponent implements ShownotesProvider, ImageR
if (imageUrl != null) {
return imageUrl;
} else if (media != null && media.hasEmbeddedPicture()) {
- return media.getLocalMediaUrl();
+ return FeedMedia.FILENAME_PREFIX_EMBEDDED_COVER + media.getLocalMediaUrl();
} else if (feed != null) {
return feed.getImageLocation();
} else {
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 3d2d7f528..88945b930 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
@@ -33,6 +33,7 @@ public class FeedMedia extends FeedFile implements Playable {
public static final int FEEDFILETYPE_FEEDMEDIA = 2;
public static final int PLAYABLE_TYPE_FEEDMEDIA = 1;
+ public static final String FILENAME_PREFIX_EMBEDDED_COVER = "metadata-retriever:";
public static final String PREF_MEDIA_ID = "FeedMedia.PrefMediaId";
private static final String PREF_FEED_ID = "FeedMedia.PrefFeedId";
@@ -376,7 +377,7 @@ public class FeedMedia extends FeedFile implements Playable {
}
@Override
- public void loadChapterMarks() {
+ public void loadChapterMarks(Context context) {
if (item == null && itemID != 0) {
item = DBReader.getFeedItem(itemID);
}
@@ -384,7 +385,7 @@ public class FeedMedia extends FeedFile implements Playable {
return;
}
- List<Chapter> chapters = loadChapters();
+ List<Chapter> chapters = loadChapters(context);
if (chapters == null) {
// Do not try loading again. There are no chapters.
item.setChapters(Collections.emptyList());
@@ -393,7 +394,7 @@ public class FeedMedia extends FeedFile implements Playable {
}
}
- private List<Chapter> loadChapters() {
+ private List<Chapter> loadChapters(Context context) {
List<Chapter> chaptersFromDatabase = null;
if (item.hasChapters()) {
chaptersFromDatabase = DBReader.loadChaptersOfFeedItem(item);
@@ -403,7 +404,7 @@ public class FeedMedia extends FeedFile implements Playable {
if (localFileAvailable()) {
chaptersFromMediaFile = ChapterUtils.loadChaptersFromFileUrl(this);
} else {
- chaptersFromMediaFile = ChapterUtils.loadChaptersFromStreamUrl(this);
+ chaptersFromMediaFile = ChapterUtils.loadChaptersFromStreamUrl(this, context);
}
return ChapterMerger.merge(chaptersFromDatabase, chaptersFromMediaFile);
@@ -569,7 +570,7 @@ public class FeedMedia extends FeedFile implements Playable {
if (item != null) {
return item.getImageLocation();
} else if (hasEmbeddedPicture()) {
- return getLocalMediaUrl();
+ return FILENAME_PREFIX_EMBEDDED_COVER + getLocalMediaUrl();
} else {
return null;
}
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
new file mode 100644
index 000000000..2791be08c
--- /dev/null
+++ b/core/src/main/java/de/danoeh/antennapod/core/feed/LocalFeedUpdater.java
@@ -0,0 +1,162 @@
+package de.danoeh.antennapod.core.feed;
+
+import android.content.ContentResolver;
+import android.content.Context;
+import android.media.MediaMetadataRetriever;
+import android.net.Uri;
+import android.text.TextUtils;
+
+import androidx.documentfile.provider.DocumentFile;
+
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ExecutionException;
+
+import de.danoeh.antennapod.core.R;
+import de.danoeh.antennapod.core.service.download.DownloadStatus;
+import de.danoeh.antennapod.core.storage.DBTasks;
+import de.danoeh.antennapod.core.storage.DBWriter;
+import de.danoeh.antennapod.core.util.DownloadError;
+
+public class LocalFeedUpdater {
+
+ public static void updateFeed(Feed feed, Context context) {
+ String uriString = feed.getDownload_url().replace(Feed.PREFIX_LOCAL_FOLDER, "");
+ DocumentFile documentFolder = DocumentFile.fromTreeUri(context, Uri.parse(uriString));
+ if (documentFolder == null) {
+ reportError(feed, "Unable to retrieve document tree."
+ + "Try re-connecting the folder on the podcast info page.");
+ return;
+ }
+ if (!documentFolder.exists() || !documentFolder.canRead()) {
+ reportError(feed, "Cannot read local directory. Try re-connecting the folder on the podcast info page.");
+ return;
+ }
+
+ if (feed.getItems() == null) {
+ feed.setItems(new ArrayList<>());
+ }
+ //make sure it is the latest 'version' of this feed from the db (all items etc)
+ feed = DBTasks.updateFeed(context, feed, false);
+
+ // list files in feed folder
+ List<DocumentFile> mediaFiles = new ArrayList<>();
+ Set<String> mediaFileNames = new HashSet<>();
+ for (DocumentFile file : documentFolder.listFiles()) {
+ String mime = file.getType();
+ if (mime != null && (mime.startsWith("audio/") || mime.startsWith("video/"))) {
+ mediaFiles.add(file);
+ mediaFileNames.add(file.getName());
+ }
+ }
+
+ // add new files to feed and update item data
+ List<FeedItem> newItems = feed.getItems();
+ for (DocumentFile f : mediaFiles) {
+ FeedItem oldItem = feedContainsFile(feed, f.getName());
+ FeedItem newItem = createFeedItem(feed, f, context);
+ if (oldItem == null) {
+ newItems.add(newItem);
+ } else {
+ oldItem.updateFromOther(newItem);
+ }
+ }
+
+ // remove feed items without corresponding file
+ Iterator<FeedItem> it = newItems.iterator();
+ while (it.hasNext()) {
+ FeedItem feedItem = it.next();
+ if (!mediaFileNames.contains(feedItem.getLink())) {
+ it.remove();
+ }
+ }
+
+ 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));
+ }
+ if (feed.getPreferences().getAutoDownload()) {
+ feed.getPreferences().setAutoDownload(false);
+ feed.getPreferences().setAutoDeleteAction(FeedPreferences.AutoDeleteAction.NO);
+ try {
+ DBWriter.setFeedPreferences(feed.getPreferences()).get();
+ } catch (ExecutionException | InterruptedException e) {
+ e.printStackTrace();
+ }
+ }
+
+ // update items, delete items without existing file;
+ // only delete items if the folder contains at least one element to avoid accidentally
+ // deleting played state or position in case the folder is temporarily unavailable.
+ boolean removeUnlistedItems = (newItems.size() >= 1);
+ DBTasks.updateFeed(context, feed, removeUnlistedItems);
+ }
+
+ /**
+ * 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) {
+ String resourceEntryName = context.getResources().getResourceEntryName(R.raw.local_feed_default_icon);
+ return ContentResolver.SCHEME_ANDROID_RESOURCE + "://"
+ + context.getPackageName() + "/raw/"
+ + resourceEntryName;
+ }
+
+ private static FeedItem feedContainsFile(Feed feed, String filename) {
+ List<FeedItem> items = feed.getItems();
+ for (FeedItem i : items) {
+ if (i.getMedia() != null && i.getLink().equals(filename)) {
+ return i;
+ }
+ }
+ return null;
+ }
+
+ private static FeedItem createFeedItem(Feed feed, DocumentFile file, Context context) {
+ String uuid = UUID.randomUUID().toString();
+ FeedItem item = new FeedItem(0, file.getName(), uuid, file.getName(), new Date(),
+ FeedItem.UNPLAYED, feed);
+ item.setAutoDownload(false);
+
+ MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever();
+ mediaMetadataRetriever.setDataSource(context, file.getUri());
+ String durationStr = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION);
+ String title = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE);
+ if (!TextUtils.isEmpty(title)) {
+ item.setTitle(title);
+ }
+
+ //add the media to the item
+ long duration = Long.parseLong(durationStr);
+ long size = file.length();
+ FeedMedia media = new FeedMedia(0, item, (int) duration, 0, size, file.getType(),
+ file.getUri().toString(), file.getUri().toString(), false, null, 0, 0);
+ media.setHasEmbeddedPicture(mediaMetadataRetriever.getEmbeddedPicture() != null);
+ item.setMedia(media);
+
+ return item;
+ }
+
+ private static void reportError(Feed feed, String reasonDetailed) {
+ DownloadStatus status = new DownloadStatus(feed, feed.getTitle(),
+ DownloadError.ERROR_IO_ERROR, false, reasonDetailed, true);
+ DBWriter.addDownloadStatus(status);
+ DBWriter.setFeedLastUpdateFailed(feed.getId(), true);
+ }
+}
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 50511526f..ab4247cef 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
@@ -9,6 +9,8 @@ import com.bumptech.glide.Registry;
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;
@@ -33,7 +35,10 @@ public class ApGlideModule extends AppGlideModule {
@Override
public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) {
- registry.replace(String.class, InputStream.class, new ApOkHttpUrlLoader.Factory());
+ registry.replace(String.class, InputStream.class, new MetadataRetrieverLoader.Factory(context));
+ registry.append(String.class, InputStream.class, new ApOkHttpUrlLoader.Factory());
+ registry.append(String.class, InputStream.class, new StringLoader.StreamFactory());
+
registry.append(EmbeddedChapterImage.class, ByteBuffer.class, new ChapterImageModelLoader.Factory());
}
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/glide/ApOkHttpUrlLoader.java b/core/src/main/java/de/danoeh/antennapod/core/glide/ApOkHttpUrlLoader.java
index 071b1d0c9..8c80e9151 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/glide/ApOkHttpUrlLoader.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/glide/ApOkHttpUrlLoader.java
@@ -1,5 +1,6 @@
package de.danoeh.antennapod.core.glide;
+import android.content.ContentResolver;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -22,7 +23,7 @@ import java.io.IOException;
import java.io.InputStream;
/**
- * @see com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader
+ * {@see com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader}.
*/
class ApOkHttpUrlLoader implements ModelLoader<String, InputStream> {
@@ -52,14 +53,7 @@ class ApOkHttpUrlLoader implements ModelLoader<String, InputStream> {
* Constructor for a new Factory that runs requests using a static singleton client.
*/
Factory() {
- this(getInternalClient());
- }
-
- /**
- * Constructor for a new Factory that runs requests using given client.
- */
- Factory(OkHttpClient client) {
- this.client = client;
+ this.client = getInternalClient();
}
@NonNull
@@ -83,19 +77,15 @@ class ApOkHttpUrlLoader implements ModelLoader<String, InputStream> {
@Nullable
@Override
public LoadData<InputStream> buildLoadData(@NonNull String model, int width, int height, @NonNull Options options) {
- if (TextUtils.isEmpty(model)) {
- return null;
- } else if (model.startsWith("/")) {
- return new LoadData<>(new ObjectKey(model), new AudioCoverFetcher(model));
- } else {
- GlideUrl url = new GlideUrl(model);
- return new LoadData<>(new ObjectKey(model), new OkHttpStreamFetcher(client, url));
- }
+ return new LoadData<>(new ObjectKey(model), new OkHttpStreamFetcher(client, new GlideUrl(model)));
}
@Override
- public boolean handles(@NonNull String s) {
- return true;
+ public boolean handles(@NonNull String model) {
+ // Leave content URIs to Glide's default loaders
+ return !TextUtils.isEmpty(model)
+ && !model.startsWith(ContentResolver.SCHEME_CONTENT)
+ && !model.startsWith(ContentResolver.SCHEME_ANDROID_RESOURCE);
}
private static class NetworkAllowanceInterceptor implements Interceptor {
diff --git a/core/src/main/java/de/danoeh/antennapod/core/glide/AudioCoverFetcher.java b/core/src/main/java/de/danoeh/antennapod/core/glide/AudioCoverFetcher.java
index 6a237573b..b6b607904 100644
--- a/core/src/main/java/de/danoeh/antennapod/core/glide/AudioCoverFetcher.java
+++ b/core/src/main/java/de/danoeh/antennapod/core/glide/AudioCoverFetcher.java
@@ -1,7 +1,10 @@
package de.danoeh.antennapod.core.glide;
+import android.content.ContentResolver;
+import android.content.Context;
import android.media.MediaMetadataRetriever;
+import android.net.Uri;
import androidx.annotation.NonNull;
import com.bumptech.glide.Priority;
import com.bumptech.glide.load.DataSource;
@@ -17,16 +20,22 @@ class AudioCoverFetcher implements DataFetcher<InputStream> {
private static final String TAG = "AudioCoverFetcher";
private final String path;
+ private final Context context;
- public AudioCoverFetcher(String path) {
+ public AudioCoverFetcher(String path, Context context) {
this.path = path;
+ this.context = context;
}
@Override
public void loadData(@NonNull Priority priority, @NonNull DataCallback<? super InputStream> callback) {
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
try {
- retriever.setDataSource(path);
+ if (path.startsWith(ContentResolver.SCHEME_CONTENT)) {
+ retriever.setDataSource(context, Uri.parse(path));
+ } else {
+ retriever.setDataSource(path);
+ }
byte[] picture = retriever.getEmbeddedPicture();
if (picture != null) {
callback.onDataReady(new ByteArrayInputStream(picture));
@@ -41,6 +50,7 @@ class AudioCoverFetcher implements DataFetcher<InputStream> {
@Override public void cleanup() {
// nothing to clean up
}
+
@Override public void cancel() {
// cannot cancel
}
diff --git a/core/src/main/java/de/danoeh/antennapod/core/glide/MetadataRetrieverLoader.java b/core/src/main/java/de/danoeh/antennapod/core/glide/MetadataRetrieverLoader.java
new file mode 100644
index 000000000..baa06e722
--- /dev/null
+++ b/core/src/main/java/de/danoeh/antennapod/core/glide/MetadataRetrieverLoader.java
@@ -0,0 +1,57 @@
+package de.danoeh.antennapod.core.glide;
+
+import android.content.Context;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import com.bumptech.glide.load.Options;
+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.feed.FeedMedia;
+
+import java.io.InputStream;
+
+class MetadataRetrieverLoader implements ModelLoader<String, InputStream> {
+
+ /**
+ * The default factory for {@link MetadataRetrieverLoader}s.
+ */
+ public static class Factory implements ModelLoaderFactory<String, InputStream> {
+ private final Context context;
+
+ Factory(Context context) {
+ this.context = context;
+ }
+
+ @NonNull
+ @Override
+ public ModelLoader<String, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) {
+ return new MetadataRetrieverLoader(context);
+ }
+
+ @Override
+ public void teardown() {
+ // Do nothing, this instance doesn't own the client.
+ }
+ }
+
+ private final Context context;
+
+ private MetadataRetrieverLoader(Context context) {
+ this.context = context;
+ }
+
+ @Nullable
+ @Override
+ public LoadData<InputStream> buildLoadData(@NonNull String model,
+ int width, int height, @NonNull Options options) {
+ return new LoadData<>(new ObjectKey(model),
+ new AudioCoverFetcher(model.replace(FeedMedia.FILENAME_PREFIX_EMBEDDED_COVER, ""), context));
+ }
+
+ @Override
+ public boolean handles(@NonNull String model) {
+ return model.startsWith(FeedMedia.FILENAME_PREFIX_EMBEDDED_COVER);
+ }
+}
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 8be3d2980..483a2aa56 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
@@ -30,8 +30,7 @@ public class FeedSyncTask {
return false;
}
- Feed[] savedFeeds = DBTasks.updateFeed(context, result.feed);
- Feed savedFeed = savedFeeds[0];
+ Feed 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;
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 b85998538..25c301ccc 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
@@ -37,6 +37,7 @@ import android.util.Log;
import android.util.Pair;
import android.view.KeyEvent;
import android.view.SurfaceHolder;
+import android.webkit.URLUtil;
import android.widget.Toast;
import com.bumptech.glide.Glide;
@@ -496,7 +497,8 @@ public class PlaybackService extends MediaBrowserServiceCompat {
if (allowStreamAlways) {
UserPreferences.setAllowMobileStreaming(true);
}
- if (stream && !NetworkUtils.isStreamingAllowed() && !allowStreamThisTime) {
+ boolean localFeed = URLUtil.isContentUrl(playable.getStreamUrl());
+ if (stream && !NetworkUtils.isStreamingAllowed() && !allowStreamThisTime && !localFeed) {
displayStreamingNotAllowedNotification(intent);
PlaybackPreferences.writeNoMediaPlaying();
stateManager.stopService();
@@ -696,7 +698,9 @@ public class PlaybackService extends MediaBrowserServiceCompat {
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
playable -> {
- if (PlaybackPreferences.getCurrentEpisodeIsStream() && !NetworkUtils.isStreamingAllowed()) {
+ boolean localFeed = URLUtil.isContentUrl(playable.getStreamUrl());
+ if (PlaybackPreferences.getCurrentEpisodeIsStream()
+ && !NetworkUtils.isStreamingAllowed() && !localFeed) {
displayStreamingNotAllowedNotification(
new PlaybackServiceStarter(this, playable)
.prepareImmediately(true)
@@ -987,7 +991,7 @@ public class PlaybackService extends MediaBrowserServiceCompat {
}
if (!nextItem.getMedia().localFileAvailable() && !NetworkUtils.isStreamingAllowed()
- && UserPreferences.isFollowQueue()) {
+ && UserPreferences.isFollowQueue() && !nextItem.getFeed().isLocalFeed()) {
displayStreamingNotAllowedNotification(
new PlaybackServiceStarter(this, nextItem.getMedia())
.prepareImmediately(true)
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 1cf665f12..05d64ea3e 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
@@ -303,7 +303,7 @@ public class PlaybackServiceTaskManager {
if (media.getChapters() == null) {
chapterLoaderFuture = Completable.create(emitter -> {
- media.loadChapterMarks();
+ media.loadChapterMarks(context);
emitter.onComplete();
})
.subscribeOn(Schedulers.io())
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 4f2417b7d..c059e696a 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
@@ -16,6 +16,7 @@ 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.sync.SyncService;
@@ -241,7 +242,12 @@ public final class DBTasks {
feed.getPreferences().getUsername(), feed.getPreferences().getPassword());
}
f.setId(feed.getId());
- DownloadRequester.getInstance().downloadFeed(context, f, loadAllPages, force, initiatedByUser);
+
+ if (f.isLocalFeed()) {
+ new Thread(() -> LocalFeedUpdater.updateFeed(f, context)).start();
+ } else {
+ DownloadRequester.getInstance().downloadFeed(context, f, loadAllPages, force, initiatedByUser);
+ }
}
/**
@@ -366,118 +372,135 @@ public final class DBTasks {
* <p/>
* This method should NOT be executed on the GUI thread.
*
- * @param context Used for accessing the DB.
- * @param newFeeds The new Feed objects.
- * @return The updated Feeds from the database if it already existed, or the new Feed from the parameters otherwise.
+ * @param context Used for accessing the DB.
+ * @param newFeed The new Feed object.
+ * @param removeUnlistedItems The item list in the new Feed object is considered to be exhaustive.
+ * I.e. items are removed from the database if they are not in this item list.
+ * @return The updated Feed from the database if it already existed, or the new Feed from the parameters otherwise.
*/
- public static synchronized Feed[] updateFeed(final Context context,
- final Feed... newFeeds) {
- List<Feed> newFeedsList = new ArrayList<>();
- List<Feed> updatedFeedsList = new ArrayList<>();
- Feed[] resultFeeds = new Feed[newFeeds.length];
+ public static synchronized Feed updateFeed(Context context, Feed newFeed, boolean removeUnlistedItems) {
+ Feed resultFeed;
+ List<FeedItem> unlistedItems = new ArrayList<>();
+
PodDBAdapter adapter = PodDBAdapter.getInstance();
adapter.open();
- for (int feedIdx = 0; feedIdx < newFeeds.length; feedIdx++) {
+ // Look up feed in the feedslist
+ final Feed savedFeed = searchFeedByIdentifyingValueOrID(adapter, newFeed);
+ if (savedFeed == null) {
+ Log.d(TAG, "Found no existing Feed with title "
+ + newFeed.getTitle() + ". Adding as new one.");
+
+ // Add a new Feed
+ // all new feeds will have the most recent item marked as unplayed
+ FeedItem mostRecent = newFeed.getMostRecentItem();
+ if (mostRecent != null) {
+ mostRecent.setNew();
+ }
- final Feed newFeed = newFeeds[feedIdx];
+ resultFeed = newFeed;
+ } else {
+ Log.d(TAG, "Feed with title " + newFeed.getTitle()
+ + " already exists. Syncing new with existing one.");
- // Look up feed in the feedslist
- final Feed savedFeed = searchFeedByIdentifyingValueOrID(adapter,
- newFeed);
- if (savedFeed == null) {
- Log.d(TAG, "Found no existing Feed with title "
- + newFeed.getTitle() + ". Adding as new one.");
-
- // Add a new Feed
- // all new feeds will have the most recent item marked as unplayed
- FeedItem mostRecent = newFeed.getMostRecentItem();
- if (mostRecent != null) {
- mostRecent.setNew();
- }
+ Collections.sort(newFeed.getItems(), new FeedItemPubdateComparator());
- newFeedsList.add(newFeed);
- resultFeeds[feedIdx] = newFeed;
+ if (newFeed.getPageNr() == savedFeed.getPageNr()) {
+ if (savedFeed.compareWithOther(newFeed)) {
+ Log.d(TAG, "Feed has updated attribute values. Updating old feed's attributes");
+ savedFeed.updateFromOther(newFeed);
+ }
} else {
- Log.d(TAG, "Feed with title " + newFeed.getTitle()
- + " already exists. Syncing new with existing one.");
+ Log.d(TAG, "New feed has a higher page number.");
+ savedFeed.setNextPageLink(newFeed.getNextPageLink());
+ }
+ if (savedFeed.getPreferences().compareWithOther(newFeed.getPreferences())) {
+ Log.d(TAG, "Feed has updated preferences. Updating old feed's preferences");
+ savedFeed.getPreferences().updateFromOther(newFeed.getPreferences());
+ }
- Collections.sort(newFeed.getItems(), new FeedItemPubdateComparator());
+ // get the most recent date now, before we start changing the list
+ FeedItem priorMostRecent = savedFeed.getMostRecentItem();
+ Date priorMostRecentDate = null;
+ if (priorMostRecent != null) {
+ priorMostRecentDate = priorMostRecent.getPubDate();
+ }
- if (newFeed.getPageNr() == savedFeed.getPageNr()) {
- if (savedFeed.compareWithOther(newFeed)) {
- Log.d(TAG, "Feed has updated attribute values. Updating old feed's attributes");
- savedFeed.updateFromOther(newFeed);
+ // Look for new or updated Items
+ for (int idx = 0; idx < newFeed.getItems().size(); idx++) {
+ final FeedItem item = newFeed.getItems().get(idx);
+ FeedItem oldItem = searchFeedItemByIdentifyingValue(savedFeed, item.getIdentifyingValue());
+ if (oldItem == null) {
+ // item is new
+ item.setFeed(savedFeed);
+ item.setAutoDownload(savedFeed.getPreferences().getAutoDownload());
+
+ if (idx >= savedFeed.getItems().size()) {
+ savedFeed.getItems().add(item);
+ } else {
+ savedFeed.getItems().add(idx, item);
}
- } else {
- Log.d(TAG, "New feed has a higher page number.");
- savedFeed.setNextPageLink(newFeed.getNextPageLink());
- }
- if (savedFeed.getPreferences().compareWithOther(newFeed.getPreferences())) {
- Log.d(TAG, "Feed has updated preferences. Updating old feed's preferences");
- savedFeed.getPreferences().updateFromOther(newFeed.getPreferences());
- }
- // get the most recent date now, before we start changing the list
- FeedItem priorMostRecent = savedFeed.getMostRecentItem();
- Date priorMostRecentDate = null;
- if (priorMostRecent != null) {
- priorMostRecentDate = priorMostRecent.getPubDate();
+ // only mark the item new if it was published after or at the same time
+ // as the most recent item
+ // (if the most recent date is null then we can assume there are no items
+ // and this is the first, hence 'new')
+ if (priorMostRecentDate == null
+ || priorMostRecentDate.before(item.getPubDate())
+ || priorMostRecentDate.equals(item.getPubDate())) {
+ Log.d(TAG, "Marking item published on " + item.getPubDate()
+ + " new, prior most recent date = " + priorMostRecentDate);
+ item.setNew();
+ }
+ } else {
+ oldItem.updateFromOther(item);
}
+ }
- // Look for new or updated Items
- for (int idx = 0; idx < newFeed.getItems().size(); idx++) {
- final FeedItem item = newFeed.getItems().get(idx);
- FeedItem oldItem = searchFeedItemByIdentifyingValue(savedFeed,
- item.getIdentifyingValue());
- if (oldItem == null) {
- // item is new
- item.setFeed(savedFeed);
- item.setAutoDownload(savedFeed.getPreferences().getAutoDownload());
-
- if (idx >= savedFeed.getItems().size()) {
- savedFeed.getItems().add(item);
- } else {
- savedFeed.getItems().add(idx, item);
- }
-
- // only mark the item new if it was published after or at the same time
- // as the most recent item
- // (if the most recent date is null then we can assume there are no items
- // and this is the first, hence 'new')
- if (priorMostRecentDate == null
- || priorMostRecentDate.before(item.getPubDate())
- || priorMostRecentDate.equals(item.getPubDate())) {
- Log.d(TAG, "Marking item published on " + item.getPubDate()
- + " new, prior most recent date = " + priorMostRecentDate);
- item.setNew();
- }
- } else {
- oldItem.updateFromOther(item);
+ // identify items to be removed
+ if (removeUnlistedItems) {
+ Iterator<FeedItem> it = savedFeed.getItems().iterator();
+ while (it.hasNext()) {
+ FeedItem feedItem = it.next();
+ if (searchFeedItemByIdentifyingValue(newFeed, feedItem.getIdentifyingValue()) == null) {
+ unlistedItems.add(feedItem);
+ it.remove();
}
}
- // update attributes
- savedFeed.setLastUpdate(newFeed.getLastUpdate());
- savedFeed.setType(newFeed.getType());
- savedFeed.setLastUpdateFailed(false);
-
- updatedFeedsList.add(savedFeed);
- resultFeeds[feedIdx] = savedFeed;
}
- }
- adapter.close();
+ // update attributes
+ savedFeed.setLastUpdate(newFeed.getLastUpdate());
+ savedFeed.setType(newFeed.getType());
+ savedFeed.setLastUpdateFailed(false);
+
+ resultFeed = savedFeed;
+ }
try {
- DBWriter.addNewFeed(context, newFeedsList.toArray(new Feed[0])).get();
- DBWriter.setCompleteFeed(updatedFeedsList.toArray(new Feed[0])).get();
+ if (savedFeed == null) {
+ DBWriter.addNewFeed(context, newFeed).get();
+ // Update with default values that are set in database
+ resultFeed = searchFeedByIdentifyingValueOrID(adapter, newFeed);
+ } else {
+ DBWriter.setCompleteFeed(savedFeed).get();
+ }
+ if (removeUnlistedItems) {
+ DBWriter.deleteFeedItems(context, unlistedItems).get();
+ }
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
- EventBus.getDefault().post(new FeedListUpdateEvent(updatedFeedsList));
+ adapter.close();
+
+ if (savedFeed != null) {
+ EventBus.getDefault().post(new FeedListUpdateEvent(savedFeed));
+ } else {
+ EventBus.getDefault().post(new FeedListUpdateEvent(Collections.emptyList()));
+ }
- return resultFeeds;
+ return resultFeed;
}
/**
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 e33b67719..9e6041df3 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
@@ -141,48 +141,74 @@ public class DBWriter {
return dbExec.submit(() -> {
DownloadRequester requester = DownloadRequester.getInstance();
final Feed feed = DBReader.getFeed(feedId);
+ if (feed == null) {
+ return;
+ }
- if (feed != null) {
- // delete stored media files and mark them as read
- List<FeedItem> queue = DBReader.getQueue();
- List<FeedItem> removed = new ArrayList<>();
- if (feed.getItems() == null) {
- DBReader.getFeedItemList(feed);
- }
+ // delete stored media files and mark them as read
+ if (feed.getItems() == null) {
+ DBReader.getFeedItemList(feed);
+ }
+ deleteFeedItemsSynchronous(context, feed.getItems());
- for (FeedItem item : feed.getItems()) {
- if (queue.remove(item)) {
- removed.add(item);
- }
- if (item.getMedia() != null && item.getMedia().isDownloaded()) {
- deleteFeedMediaSynchronous(context, item.getMedia());
- } else if (item.getMedia() != null && requester.isDownloadingFile(item.getMedia())) {
- requester.cancelDownload(context, item.getMedia());
- }
- }
- PodDBAdapter adapter = PodDBAdapter.getInstance();
- adapter.open();
- if (removed.size() > 0) {
- adapter.setQueue(queue);
- for (FeedItem item : removed) {
- EventBus.getDefault().post(QueueEvent.irreversibleRemoved(item));
- }
- }
- adapter.removeFeed(feed);
- adapter.close();
+ // delete feed
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.removeFeed(feed);
+ adapter.close();
- SyncService.enqueueFeedRemoved(context, feed.getDownload_url());
- EventBus.getDefault().post(new FeedListUpdateEvent(feed));
+ SyncService.enqueueFeedRemoved(context, feed.getDownload_url());
+ EventBus.getDefault().post(new FeedListUpdateEvent(feed));
+ });
+ }
- // we assume we also removed download log entries for the feed or its media files.
- // especially important if download or refresh failed, as the user should not be able
- // to retry these
- EventBus.getDefault().post(DownloadLogEvent.listUpdated());
+ /**
+ * Remove the listed items and their FeedMedia entries.
+ * Deleting media also removes the download log entries.
+ */
+ @NonNull
+ public static Future<?> deleteFeedItems(@NonNull Context context, @NonNull List<FeedItem> items) {
+ return dbExec.submit(() -> deleteFeedItemsSynchronous(context, items));
+ }
- BackupManager backupManager = new BackupManager(context);
- backupManager.dataChanged();
+ /**
+ * Remove the listed items and their FeedMedia entries.
+ * Deleting media also removes the download log entries.
+ */
+ private static void deleteFeedItemsSynchronous(@NonNull Context context, @NonNull List<FeedItem> items) {
+ DownloadRequester requester = DownloadRequester.getInstance();
+ List<FeedItem> queue = DBReader.getQueue();
+ List<FeedItem> removedFromQueue = new ArrayList<>();
+ for (FeedItem item : items) {
+ if (queue.remove(item)) {
+ removedFromQueue.add(item);
}
- });
+ if (item.getMedia() != null && item.getMedia().isDownloaded()) {
+ deleteFeedMediaSynchronous(context, item.getMedia());
+ } else if (item.getMedia() != null && requester.isDownloadingFile(item.getMedia())) {
+ requester.cancelDownload(context, item.getMedia());
+ }
+ }
+
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ if (!removedFromQueue.isEmpty()) {
+ adapter.setQueue(queue);
+ }
+ adapter.removeFeedItems(items);
+ adapter.close();
+
+ for (FeedItem item : removedFromQueue) {
+ EventBus.getDefault().post(QueueEvent.irreversibleRemoved(item));
+ }
+
+ // we assume we also removed download log entries for the feed or its media files.
+ // especially important if download or refresh failed, as the user should not be able
+ // to retry these
+ EventBus.getDefault().post(DownloadLogEvent.listUpdated());
+
+ BackupManager backupManager = new BackupManager(context);
+ backupManager.dataChanged();
}
/**
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 775485880..935b06cd6 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
@@ -14,6 +14,7 @@ import android.database.sqlite.SQLiteOpenHelper;
import android.text.TextUtils;
import android.util.Log;
+import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.apache.commons.io.FileUtils;
@@ -359,6 +360,21 @@ public class PodDBAdapter {
// do nothing
}
+ /**
+ * <p>Resets all database connections to ensure new database connections for
+ * the next test case. Call method only for unit tests.</p>
+ *
+ * <p>That's a workaround for a Robolectric issue in ShadowSQLiteConnection
+ * that leads to an error <tt>IllegalStateException: Illegal connection
+ * pointer</tt> if several threads try to use the same database connection.
+ * For more information see
+ * <a href="https://github.com/robolectric/robolectric/issues/1890">robolectric/robolectric#1890</a>.</p>
+ */
+ public static void tearDownTests() {
+ db = null;
+ SingletonHolder.dbHelper.close();
+ }
+
public static boolean deleteDatabase() {
PodDBAdapter adapter = getInstance();
adapter.open();
@@ -859,6 +875,23 @@ public class PodDBAdapter {
}
/**
+ * Remove the listed items and their FeedMedia entries.
+ */
+ public void removeFeedItems(@NonNull List<FeedItem> items) {
+ try {
+ db.beginTransactionNonExclusive();
+ for (FeedItem item : items) {
+ removeFeedItem(item);
+ }
+ db.setTransactionSuccessful();
+ } catch (SQLException e) {
+ Log.e(TAG, Log.getStackTraceString(e));
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ /**
* Remove a feed with all its FeedItems and Media entries.
*/
public void removeFeed(Feed feed) {
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 c24c3b8e7..d4a2cdca6 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
@@ -1,12 +1,12 @@
package de.danoeh.antennapod.core.util;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.net.Uri;
import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
import android.util.Log;
import java.net.URLConnection;
-import java.util.zip.CheckedOutputStream;
-
import de.danoeh.antennapod.core.ClientConfig;
import org.apache.commons.io.IOUtils;
@@ -52,10 +52,10 @@ public class ChapterUtils {
return chapters.size() - 1;
}
- public static List<Chapter> loadChaptersFromStreamUrl(Playable media) {
- List<Chapter> chapters = ChapterUtils.readID3ChaptersFromPlayableStreamUrl(media);
+ public static List<Chapter> loadChaptersFromStreamUrl(Playable media, Context context) {
+ List<Chapter> chapters = ChapterUtils.readID3ChaptersFromPlayableStreamUrl(media, context);
if (chapters == null) {
- chapters = ChapterUtils.readOggChaptersFromPlayableStreamUrl(media);
+ chapters = ChapterUtils.readOggChaptersFromPlayableStreamUrl(media, context);
}
return chapters;
}
@@ -76,7 +76,7 @@ public class ChapterUtils {
* Uses the download URL of a media object of a feeditem to read its ID3
* chapters.
*/
- private static List<Chapter> readID3ChaptersFromPlayableStreamUrl(Playable p) {
+ 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;
@@ -84,16 +84,21 @@ public class ChapterUtils {
Log.d(TAG, "Reading id3 chapters from item " + p.getEpisodeTitle());
CountingInputStream in = null;
try {
- URL url = new URL(p.getStreamUrl());
- URLConnection urlConnection = url.openConnection();
- urlConnection.setRequestProperty("User-Agent", ClientConfig.USER_AGENT);
- in = new CountingInputStream(urlConnection.getInputStream());
+ 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);
if (!chapters.isEmpty()) {
return chapters;
}
Log.i(TAG, "Chapters loaded");
- } catch (IOException | ID3ReaderException e) {
+ } catch (IOException | ID3ReaderException | IllegalArgumentException e) {
Log.e(TAG, Log.getStackTraceString(e));
} finally {
IOUtils.closeQuietly(in);
@@ -151,20 +156,25 @@ public class ChapterUtils {
return chapters;
}
- private static List<Chapter> readOggChaptersFromPlayableStreamUrl(Playable media) {
+ private static List<Chapter> readOggChaptersFromPlayableStreamUrl(Playable media, Context context) {
if (media == null || !media.streamAvailable()) {
return null;
}
InputStream input = null;
try {
- URL url = new URL(media.getStreamUrl());
- URLConnection urlConnection = url.openConnection();
- urlConnection.setRequestProperty("User-Agent", ClientConfig.USER_AGENT);
- input = urlConnection.getInputStream();
+ 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 e) {
+ } catch (IOException | IllegalArgumentException e) {
Log.e(TAG, Log.getStackTraceString(e));
} finally {
IOUtils.closeQuietly(input);
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 c06003c9d..6c107996f 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
@@ -103,7 +103,7 @@ public class ExternalMedia implements Playable {
}
@Override
- public void loadChapterMarks() {
+ public void loadChapterMarks(Context context) {
}
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 c44c0f925..5b15913c8 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
@@ -4,11 +4,8 @@ import android.content.Context;
import android.content.SharedPreferences;
import android.os.Parcelable;
import androidx.preference.PreferenceManager;
-import androidx.annotation.Nullable;
import android.util.Log;
-
-import java.util.List;
-
+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;
@@ -17,6 +14,8 @@ import de.danoeh.antennapod.core.preferences.PlaybackPreferences;
import de.danoeh.antennapod.core.storage.DBReader;
import de.danoeh.antennapod.core.util.ShownotesProvider;
+import java.util.List;
+
/**
* Interface for objects that can be played by the PlaybackService.
*/
@@ -44,7 +43,7 @@ public interface Playable extends Parcelable,
* Playable objects should load their chapter marks in this method if no
* local file was available when loadMetadata() was called.
*/
- void loadChapterMarks();
+ void loadChapterMarks(Context context);
/**
* Returns the title of the episode that this playable represents
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 2237e99f3..29eb20aca 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
@@ -129,8 +129,8 @@ public class RemoteMedia implements Playable {
}
@Override
- public void loadChapterMarks() {
- setChapters(ChapterUtils.loadChaptersFromStreamUrl(this));
+ public void loadChapterMarks(Context context) {
+ setChapters(ChapterUtils.loadChaptersFromStreamUrl(this, context));
}
@Override
diff --git a/core/src/main/res/raw/local_feed_default_icon.png b/core/src/main/res/raw/local_feed_default_icon.png
new file mode 100644
index 000000000..c1b24a729
--- /dev/null
+++ b/core/src/main/res/raw/local_feed_default_icon.png
Binary files differ
diff --git a/core/src/main/res/values/colors.xml b/core/src/main/res/values/colors.xml
index 187f3725c..d09f53d64 100644
--- a/core/src/main/res/values/colors.xml
+++ b/core/src/main/res/values/colors.xml
@@ -10,6 +10,7 @@
<color name="download_failed_red">#B00020</color>
<color name="image_readability_tint">#80000000</color>
<color name="feed_image_bg">#50000000</color>
+ <color name="feed_text_bg">#ccbfbfbf</color>
<!-- Theme colors -->
<color name="background_light">#FFFFFF</color>
diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml
index 22105f18b..542b90120 100644
--- a/core/src/main/res/values/strings.xml
+++ b/core/src/main/res/values/strings.xml
@@ -151,6 +151,7 @@
<string name="share_item_url_label">Share Media File URL</string>
<string name="share_item_url_with_position_label">Share Media File URL with Position</string>
<string name="feed_delete_confirmation_msg">Please confirm that you want to delete the podcast \"%1$s\" and ALL its episodes (including downloaded episodes).</string>
+ <string name="feed_delete_confirmation_local_msg">Please confirm that you want to remove the podcast \"%1$s\". The files in the local source folder will not be deleted.</string>
<string name="feed_remover_msg">Removing podcast</string>
<string name="load_complete_feed">Refresh complete podcast</string>
<string name="multi_select">Multi select</string>
@@ -740,6 +741,11 @@
<string name="discover_more">more ยป</string>
<string name="discover_powered_by_itunes">Suggestions by iTunes</string>
<string name="search_powered_by">Results by %1$s</string>
+ <string name="add_local_folder">Add local folder</string>
+ <string name="add_local_folder_success">Adding local folder succeeded</string>
+ <string name="reconnect_local_folder">Re-connect local folder</string>
+ <string name="reconnect_local_folder_warning">In case of permission denials, you can use this to re-connect to the exact same folder. Do not select another folder.</string>
+ <string name="local_feed_description">This virtual podcast was created by adding a folder to AntennaPod.</string>
<string name="filter">Filter</string>
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 f01a8b638..6fa874eca 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
@@ -1,5 +1,6 @@
package de.danoeh.antennapod.core.cast;
+import android.content.ContentResolver;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Log;
@@ -50,15 +51,18 @@ public class CastUtils {
public static final int FORMAT_VERSION_VALUE = 1;
public static final int MAX_VERSION_FORWARD_COMPATIBILITY = 9999;
- public static boolean isCastable(Playable media){
+ public static boolean isCastable(Playable media) {
if (media == null || media instanceof ExternalMedia) {
return false;
}
- if (media instanceof FeedMedia || media instanceof RemoteMedia){
+ if (media instanceof FeedMedia || media instanceof RemoteMedia) {
String url = media.getStreamUrl();
- if(url == null || url.isEmpty()){
+ if (url == null || url.isEmpty()) {
return false;
}
+ if (url.startsWith(ContentResolver.SCHEME_CONTENT)) {
+ return false; // Local feed
+ }
switch (media.getMediaType()) {
case UNKNOWN:
return false;
diff --git a/core/src/test/assets/local-feed1/track1.mp3 b/core/src/test/assets/local-feed1/track1.mp3
new file mode 100644
index 000000000..b1f993c3f
--- /dev/null
+++ b/core/src/test/assets/local-feed1/track1.mp3
Binary files differ
diff --git a/core/src/test/assets/local-feed2/folder.png b/core/src/test/assets/local-feed2/folder.png
new file mode 100644
index 000000000..9e522a986
--- /dev/null
+++ b/core/src/test/assets/local-feed2/folder.png
Binary files differ
diff --git a/core/src/test/assets/local-feed2/track1.mp3 b/core/src/test/assets/local-feed2/track1.mp3
new file mode 100644
index 000000000..b1f993c3f
--- /dev/null
+++ b/core/src/test/assets/local-feed2/track1.mp3
Binary files differ
diff --git a/core/src/test/assets/local-feed2/track2.mp3 b/core/src/test/assets/local-feed2/track2.mp3
new file mode 100644
index 000000000..310cddd6b
--- /dev/null
+++ b/core/src/test/assets/local-feed2/track2.mp3
Binary files differ
diff --git a/core/src/test/java/androidx/documentfile/provider/AssetsDocumentFile.java b/core/src/test/java/androidx/documentfile/provider/AssetsDocumentFile.java
new file mode 100644
index 000000000..8a8205c10
--- /dev/null
+++ b/core/src/test/java/androidx/documentfile/provider/AssetsDocumentFile.java
@@ -0,0 +1,138 @@
+package androidx.documentfile.provider;
+
+import android.content.res.AssetManager;
+import android.net.Uri;
+import android.webkit.MimeTypeMap;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.io.IOException;
+
+/**
+ * <p>Wraps an Android assets file or folder as a DocumentFile object.</p>
+ *
+ * <p>This is used to emulate access to the external storage.</p>
+ */
+public class AssetsDocumentFile extends DocumentFile {
+
+ /**
+ * Absolute file path in the assets folder.
+ */
+ @NonNull
+ private final String fileName;
+
+ @NonNull
+ private final AssetManager assetManager;
+
+ public AssetsDocumentFile(@NonNull String fileName, @NonNull AssetManager assetManager) {
+ super(null);
+ this.fileName = fileName;
+ this.assetManager = assetManager;
+ }
+
+ @Nullable
+ @Override
+ public DocumentFile createFile(@NonNull String mimeType, @NonNull String displayName) {
+ return null;
+ }
+
+ @Nullable
+ @Override
+ public DocumentFile createDirectory(@NonNull String displayName) {
+ return null;
+ }
+
+ @NonNull
+ @Override
+ public Uri getUri() {
+ return Uri.parse(fileName);
+ }
+
+ @Nullable
+ @Override
+ public String getName() {
+ int pos = fileName.indexOf('/');
+ if (pos >= 0) {
+ return fileName.substring(pos + 1);
+ } else {
+ return fileName;
+ }
+ }
+
+ @Nullable
+ @Override
+ public String getType() {
+ String extension = MimeTypeMap.getFileExtensionFromUrl(fileName);
+ return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
+ }
+
+ @Override
+ public boolean isDirectory() {
+ return false;
+ }
+
+ @Override
+ public boolean isFile() {
+ return true;
+ }
+
+ @Override
+ public boolean isVirtual() {
+ return false;
+ }
+
+ @Override
+ public long lastModified() {
+ return 0;
+ }
+
+ @Override
+ public long length() {
+ return 0;
+ }
+
+ @Override
+ public boolean canRead() {
+ return true;
+ }
+
+ @Override
+ public boolean canWrite() {
+ return false;
+ }
+
+ @Override
+ public boolean delete() {
+ return false;
+ }
+
+ @Override
+ public boolean exists() {
+ return true;
+ }
+
+ @NonNull
+ @Override
+ public DocumentFile[] listFiles() {
+ try {
+ String[] files = assetManager.list(fileName);
+ if (files == null) {
+ return new DocumentFile[0];
+ }
+ DocumentFile[] result = new DocumentFile[files.length];
+ for (int i = 0; i < files.length; i++) {
+ String subFileName = fileName + '/' + files[i];
+ result[i] = new AssetsDocumentFile(subFileName, assetManager);
+ }
+ return result;
+ } catch (IOException e) {
+ return new DocumentFile[0];
+ }
+ }
+
+ @Override
+ public boolean renameTo(@NonNull String displayName) {
+ return false;
+ }
+}
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
new file mode 100644
index 000000000..90bf59e92
--- /dev/null
+++ b/core/src/test/java/de/danoeh/antennapod/core/feed/LocalFeedUpdaterTest.java
@@ -0,0 +1,208 @@
+package de.danoeh.antennapod.core.feed;
+
+import android.app.Application;
+import android.content.Context;
+import android.media.MediaMetadataRetriever;
+import android.webkit.MimeTypeMap;
+
+import androidx.annotation.NonNull;
+import androidx.documentfile.provider.AssetsDocumentFile;
+import androidx.documentfile.provider.DocumentFile;
+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.mockito.MockedStatic;
+import org.mockito.Mockito;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.shadows.ShadowMediaMetadataRetriever;
+
+import java.io.IOException;
+import java.util.List;
+
+import de.danoeh.antennapod.core.ApplicationCallbacks;
+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.PodDBAdapter;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.robolectric.Shadows.shadowOf;
+
+/**
+ * Test local feeds handling in class LocalFeedUpdater.
+ */
+@RunWith(RobolectricTestRunner.class)
+public class LocalFeedUpdaterTest {
+
+ /**
+ * URL to locate the local feed media files on the external storage (SD card).
+ * The exact URL doesn't matter here as access to external storage is mocked
+ * (seems not to be supported by Robolectric).
+ */
+ private static final String FEED_URL =
+ "content://com.android.externalstorage.documents/tree/primary%3ADownload%2Flocal-feed";
+ private static final String LOCAL_FEED_DIR1 = "local-feed1";
+ private static final String LOCAL_FEED_DIR2 = "local-feed2";
+
+ private Context context;
+
+ @Before
+ public void setUp() throws Exception {
+ // Initialize environment
+ context = InstrumentationRegistry.getInstrumentation().getContext();
+ UserPreferences.init(context);
+
+ Application app = (Application) context;
+ ClientConfig.applicationCallbacks = mock(ApplicationCallbacks.class);
+ when(ClientConfig.applicationCallbacks.getApplicationInstance()).thenReturn(app);
+
+ // Initialize database
+ PodDBAdapter.init(context);
+ PodDBAdapter.deleteDatabase();
+ PodDBAdapter adapter = PodDBAdapter.getInstance();
+ adapter.open();
+ adapter.close();
+
+ mapDummyMetadata(LOCAL_FEED_DIR1);
+ mapDummyMetadata(LOCAL_FEED_DIR2);
+ shadowOf(MimeTypeMap.getSingleton()).addExtensionMimeTypMapping("mp3", "audio/mp3");
+ }
+
+ @After
+ public void tearDown() {
+ PodDBAdapter.tearDownTests();
+ }
+
+ /**
+ * Test adding a new local feed.
+ */
+ @Test
+ public void testUpdateFeed_AddNewFeed() {
+ // check for empty database
+ List<Feed> feedListBefore = DBReader.getFeedList();
+ assertTrue(feedListBefore.isEmpty());
+
+ callUpdateFeed(LOCAL_FEED_DIR2);
+
+ // verify new feed in database
+ verifySingleFeedInDatabaseAndItemCount(2);
+ Feed feedAfter = verifySingleFeedInDatabase();
+ assertEquals(FEED_URL, feedAfter.getDownload_url());
+ }
+
+ /**
+ * Test adding further items to an existing local feed.
+ */
+ @Test
+ public void testUpdateFeed_AddMoreItems() {
+ // add local feed with 1 item (localFeedDir1)
+ callUpdateFeed(LOCAL_FEED_DIR1);
+
+ // now add another item (by changing to local feed folder localFeedDir2)
+ callUpdateFeed(LOCAL_FEED_DIR2);
+
+ verifySingleFeedInDatabaseAndItemCount(2);
+ }
+
+ /**
+ * Test removing items from an existing local feed without a corresponding media file.
+ */
+ @Test
+ public void testUpdateFeed_RemoveItems() {
+ // add local feed with 2 items (localFeedDir1)
+ callUpdateFeed(LOCAL_FEED_DIR2);
+
+ // now remove an item (by changing to local feed folder localFeedDir1)
+ callUpdateFeed(LOCAL_FEED_DIR1);
+
+ verifySingleFeedInDatabaseAndItemCount(1);
+ }
+
+ /**
+ * Test feed icon defined in the local feed media folder.
+ */
+ @Test
+ public void testUpdateFeed_FeedIconFromFolder() {
+ callUpdateFeed(LOCAL_FEED_DIR2);
+
+ Feed feedAfter = verifySingleFeedInDatabase();
+ assertTrue(feedAfter.getImageUrl().contains("local-feed2/folder.png"));
+ }
+
+ /**
+ * Test default feed icon if there is no matching file in the local feed media folder.
+ */
+ @Test
+ public void testUpdateFeed_FeedIconDefault() {
+ callUpdateFeed(LOCAL_FEED_DIR1);
+
+ Feed feedAfter = verifySingleFeedInDatabase();
+ String resourceEntryName = context.getResources().getResourceEntryName(R.raw.local_feed_default_icon);
+ assertTrue(feedAfter.getImageUrl().contains(resourceEntryName));
+ }
+
+ /**
+ * Fill ShadowMediaMetadataRetriever with dummy duration and title.
+ *
+ * @param localFeedDir assets local feed folder with media files
+ */
+ private void mapDummyMetadata(@NonNull String localFeedDir) throws IOException {
+ String[] fileNames = context.getAssets().list(localFeedDir);
+ for (String fileName : fileNames) {
+ String path = localFeedDir + '/' + fileName;
+ ShadowMediaMetadataRetriever.addMetadata(path,
+ MediaMetadataRetriever.METADATA_KEY_DURATION, "10");
+ ShadowMediaMetadataRetriever.addMetadata(path,
+ MediaMetadataRetriever.METADATA_KEY_TITLE, fileName);
+ }
+
+ }
+
+ /**
+ * Calls the method {@link LocalFeedUpdater#updateFeed(Feed, Context)} with
+ * the given local feed folder.
+ *
+ * @param localFeedDir assets local feed folder with media files
+ */
+ private void callUpdateFeed(@NonNull String localFeedDir) {
+ DocumentFile documentFile = new AssetsDocumentFile(localFeedDir, context.getAssets());
+ try (MockedStatic<DocumentFile> dfMock = Mockito.mockStatic(DocumentFile.class)) {
+ // mock external storage
+ dfMock.when(() -> DocumentFile.fromTreeUri(any(), any())).thenReturn(documentFile);
+
+ // call method to test
+ Feed feed = new Feed(FEED_URL, null);
+ LocalFeedUpdater.updateFeed(feed, context);
+ }
+ }
+
+ /**
+ * Verify that the database contains exactly one feed and return that feed.
+ */
+ @NonNull
+ private static Feed verifySingleFeedInDatabase() {
+ List<Feed> feedListAfter = DBReader.getFeedList();
+ assertEquals(1, feedListAfter.size());
+ return feedListAfter.get(0);
+ }
+
+ /**
+ * Verify that the database contains exactly one feed and the number of
+ * items in the feed.
+ *
+ * @param expectedItemCount expected number of items in the feed
+ */
+ private static void verifySingleFeedInDatabaseAndItemCount(int expectedItemCount) {
+ Feed feed = verifySingleFeedInDatabase();
+ List<FeedItem> feedItems = DBReader.getFeedItemList(feed);
+ assertEquals(expectedItemCount, feedItems.size());
+ }
+}
diff --git a/core/src/test/java/de/danoeh/antennapod/core/storage/ItemEnqueuePositionCalculatorTest.java b/core/src/test/java/de/danoeh/antennapod/core/storage/ItemEnqueuePositionCalculatorTest.java
index ed7d2fa75..6c5a9daf1 100644
--- a/core/src/test/java/de/danoeh/antennapod/core/storage/ItemEnqueuePositionCalculatorTest.java
+++ b/core/src/test/java/de/danoeh/antennapod/core/storage/ItemEnqueuePositionCalculatorTest.java
@@ -32,7 +32,7 @@ import static java.util.Collections.emptyList;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.stub;
+import static org.mockito.Mockito.when;
public class ItemEnqueuePositionCalculatorTest {
@@ -189,7 +189,7 @@ public class ItemEnqueuePositionCalculatorTest {
//
ItemEnqueuePositionCalculator calculator = new ItemEnqueuePositionCalculator(options);
DownloadStateProvider stubDownloadStateProvider = mock(DownloadStateProvider.class);
- stub(stubDownloadStateProvider.isDownloadingFile(any(FeedMedia.class))).toReturn(false);
+ when(stubDownloadStateProvider.isDownloadingFile(any(FeedMedia.class))).thenReturn(false);
calculator.downloadStateProvider = stubDownloadStateProvider;
// Setup initial data
@@ -232,7 +232,7 @@ public class ItemEnqueuePositionCalculatorTest {
private static FeedItem setAsDownloading(FeedItem item, DownloadStateProvider stubDownloadStateProvider,
boolean isDownloading) {
- stub(stubDownloadStateProvider.isDownloadingFile(item.getMedia())).toReturn(isDownloading);
+ when(stubDownloadStateProvider.isDownloadingFile(item.getMedia())).thenReturn(isDownloading);
return item;
}