Merge pull request #4287 from AntennaPod/add-local-feeds
Add local feeds
This commit is contained in:
commit
1a1b663404
|
@ -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());
|
||||
|
|
|
@ -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<>());
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"/>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -103,7 +103,7 @@ public class ExternalMedia implements Playable {
|
|||
}
|
||||
|
||||
@Override
|
||||
public void loadChapterMarks() {
|
||||
public void loadChapterMarks(Context context) {
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 |
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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.
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue