Merge pull request #4287 from AntennaPod/add-local-feeds

Add local feeds
This commit is contained in:
ByteHamster 2020-10-25 20:21:05 +01:00 committed by GitHub
commit 1a1b663404
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 1237 additions and 237 deletions

View File

@ -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());

View File

@ -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<>());

View File

@ -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);

View File

@ -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();

View File

@ -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

View File

@ -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) {

View File

@ -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));
}
}
}

View File

@ -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();
}

View File

@ -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);
}
}
}

View File

@ -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();

View File

@ -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));
}
}

View File

@ -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(

View File

@ -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 =
@ -117,11 +121,20 @@ public class FeedSettingsFragment extends Fragment {
return 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:

View File

@ -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 {

View File

@ -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();

View File

@ -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) {

View File

@ -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;
}

View File

@ -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;
}

View File

@ -95,6 +95,20 @@
style="@style/AddPodcastTextView"
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"

View File

@ -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>

View File

@ -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"/>

View File

@ -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"

View File

@ -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);
}
}

View File

@ -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 {

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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());
}
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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)

View File

@ -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())

View File

@ -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.");
final Feed newFeed = newFeeds[feedIdx];
// 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();
}
// 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.");
resultFeed = newFeed;
} else {
Log.d(TAG, "Feed with title " + newFeed.getTitle()
+ " already exists. Syncing new with existing 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());
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);
}
newFeedsList.add(newFeed);
resultFeeds[feedIdx] = 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);
}
// 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 {
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());
oldItem.updateFromOther(item);
}
}
// 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();
}
// 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;
}
// update attributes
savedFeed.setLastUpdate(newFeed.getLastUpdate());
savedFeed.setType(newFeed.getType());
savedFeed.setLastUpdateFailed(false);
resultFeed = savedFeed;
}
adapter.close();
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();
return resultFeeds;
if (savedFeed != null) {
EventBus.getDefault().post(new FeedListUpdateEvent(savedFeed));
} else {
EventBus.getDefault().post(new FeedListUpdateEvent(Collections.emptyList()));
}
return resultFeed;
}
/**

View File

@ -141,50 +141,76 @@ public class DBWriter {
return dbExec.submit(() -> {
DownloadRequester requester = DownloadRequester.getInstance();
final Feed feed = DBReader.getFeed(feedId);
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);
}
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();
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());
BackupManager backupManager = new BackupManager(context);
backupManager.dataChanged();
if (feed == null) {
return;
}
// delete stored media files and mark them as read
if (feed.getItems() == null) {
DBReader.getFeedItemList(feed);
}
deleteFeedItemsSynchronous(context, feed.getItems());
// 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));
});
}
/**
* 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));
}
/**
* 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();
}
/**
* Deletes the entire playback history.
*/

View File

@ -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();
@ -858,6 +874,23 @@ public class PodDBAdapter {
new String[]{String.valueOf(item.getId())});
}
/**
* 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.
*/

View File

@ -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);

View File

@ -103,7 +103,7 @@ public class ExternalMedia implements Playable {
}
@Override
public void loadChapterMarks() {
public void loadChapterMarks(Context context) {
}

View File

@ -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

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -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>

View File

@ -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>

View File

@ -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;

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Binary file not shown.

View File

@ -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;
}
}

View File

@ -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());
}
}

View File

@ -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;
}