diff options
author | ByteHamster <ByteHamster@users.noreply.github.com> | 2020-10-25 20:21:05 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-10-25 20:21:05 +0100 |
commit | 1a1b66340402971d5dc8c666126a61afa80b71fb (patch) | |
tree | a6de6275fa337e69651e370135e750bbadf901a1 | |
parent | 71b6c57773d1af6e6df967bba968e08cb0268335 (diff) | |
parent | 679e4829997d886a07fe3361fd9d917db16061d2 (diff) | |
download | AntennaPod-1a1b66340402971d5dc8c666126a61afa80b71fb.zip |
Merge pull request #4287 from AntennaPod/add-local-feeds
Add local feeds
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 Binary files differnew file mode 100644 index 000000000..c1b24a729 --- /dev/null +++ b/core/src/main/res/raw/local_feed_default_icon.png 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 Binary files differnew file mode 100644 index 000000000..b1f993c3f --- /dev/null +++ b/core/src/test/assets/local-feed1/track1.mp3 diff --git a/core/src/test/assets/local-feed2/folder.png b/core/src/test/assets/local-feed2/folder.png Binary files differnew file mode 100644 index 000000000..9e522a986 --- /dev/null +++ b/core/src/test/assets/local-feed2/folder.png diff --git a/core/src/test/assets/local-feed2/track1.mp3 b/core/src/test/assets/local-feed2/track1.mp3 Binary files differnew file mode 100644 index 000000000..b1f993c3f --- /dev/null +++ b/core/src/test/assets/local-feed2/track1.mp3 diff --git a/core/src/test/assets/local-feed2/track2.mp3 b/core/src/test/assets/local-feed2/track2.mp3 Binary files differnew file mode 100644 index 000000000..310cddd6b --- /dev/null +++ b/core/src/test/assets/local-feed2/track2.mp3 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; } |