diff options
Diffstat (limited to 'core/src/main/java/de/danoeh/antennapod')
18 files changed, 504 insertions, 183 deletions
diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/Feed.java b/core/src/main/java/de/danoeh/antennapod/core/feed/Feed.java index 3657bcdc6..a3b66c951 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/feed/Feed.java +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/Feed.java @@ -24,6 +24,7 @@ public class Feed extends FeedFile implements ImageResource { public static final int FEEDFILETYPE_FEED = 0; public static final String TYPE_RSS2 = "rss"; public static final String TYPE_ATOM1 = "atom"; + public static final String PREFIX_LOCAL_FOLDER = "antennapod_local:"; /* title as defined by the feed */ private String feedTitle; @@ -551,4 +552,7 @@ public class Feed extends FeedFile implements ImageResource { this.lastUpdateFailed = lastUpdateFailed; } + public boolean isLocalFeed() { + return download_url.startsWith(PREFIX_LOCAL_FOLDER); + } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java index b681c21d1..131cbe563 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedItem.java @@ -381,7 +381,7 @@ public class FeedItem extends FeedComponent implements ShownotesProvider, ImageR if (imageUrl != null) { return imageUrl; } else if (media != null && media.hasEmbeddedPicture()) { - return media.getLocalMediaUrl(); + return FeedMedia.FILENAME_PREFIX_EMBEDDED_COVER + media.getLocalMediaUrl(); } else if (feed != null) { return feed.getImageLocation(); } else { diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java index d1c90cfa7..92e45376a 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/FeedMedia.java @@ -32,6 +32,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"; @@ -375,7 +376,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); } @@ -386,10 +387,10 @@ public class FeedMedia extends FeedFile implements Playable { if (item.hasChapters()) { DBReader.loadChaptersOfFeedItem(item); } else { - if(localFileAvailable()) { + if (localFileAvailable()) { ChapterUtils.loadChaptersFromFileUrl(this); } else { - ChapterUtils.loadChaptersFromStreamUrl(this); + ChapterUtils.loadChaptersFromStreamUrl(this, context); } if (item.getChapters() != null) { DBWriter.setFeedItem(item); @@ -557,7 +558,7 @@ public class FeedMedia extends FeedFile implements Playable { if (item != null) { return item.getImageLocation(); } else if (hasEmbeddedPicture()) { - return getLocalMediaUrl(); + return FILENAME_PREFIX_EMBEDDED_COVER + getLocalMediaUrl(); } else { return null; } diff --git a/core/src/main/java/de/danoeh/antennapod/core/feed/LocalFeedUpdater.java b/core/src/main/java/de/danoeh/antennapod/core/feed/LocalFeedUpdater.java new file mode 100644 index 000000000..7ebb8633b --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/feed/LocalFeedUpdater.java @@ -0,0 +1,161 @@ +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"); + return; + } + if (!documentFolder.exists() || !documentFolder.canRead()) { + reportError(feed, "Cannot read local directory"); + return; + } + + if (feed.getItems() == null) { + feed.setItems(new ArrayList<>()); + } + //make sure it is the latest 'version' of this feed from the db (all items etc) + feed = DBTasks.updateFeed(context, feed, false); + + // list files in feed folder + List<DocumentFile> mediaFiles = new ArrayList<>(); + Set<String> mediaFileNames = new HashSet<>(); + for (DocumentFile file : documentFolder.listFiles()) { + String mime = file.getType(); + if (mime != null && (mime.startsWith("audio/") || mime.startsWith("video/"))) { + mediaFiles.add(file); + mediaFileNames.add(file.getName()); + } + } + + // add new files to feed and update item data + List<FeedItem> newItems = feed.getItems(); + for (DocumentFile f : mediaFiles) { + FeedItem oldItem = feedContainsFile(feed, f.getName()); + FeedItem newItem = createFeedItem(feed, f, context); + if (oldItem == null) { + newItems.add(newItem); + } else { + oldItem.updateFromOther(newItem); + } + } + + // remove feed items without corresponding file + Iterator<FeedItem> it = newItems.iterator(); + while (it.hasNext()) { + FeedItem feedItem = it.next(); + if (!mediaFileNames.contains(feedItem.getLink())) { + it.remove(); + } + } + + List<String> iconLocations = Arrays.asList("folder.jpg", "Folder.jpg", "folder.png", "Folder.png"); + for (String iconLocation : iconLocations) { + DocumentFile image = documentFolder.findFile(iconLocation); + if (image != null) { + feed.setImageUrl(image.getUri().toString()); + break; + } + } + if (StringUtils.isBlank(feed.getImageUrl())) { + // set default feed image + feed.setImageUrl(getDefaultIconUrl(context)); + } + if (feed.getPreferences().getAutoDownload()) { + feed.getPreferences().setAutoDownload(false); + feed.getPreferences().setAutoDeleteAction(FeedPreferences.AutoDeleteAction.NO); + try { + DBWriter.setFeedPreferences(feed.getPreferences()).get(); + } catch (ExecutionException | InterruptedException e) { + e.printStackTrace(); + } + } + + // update items, delete items without existing file; + // only delete items if the folder contains at least one element to avoid accidentally + // deleting played state or position in case the folder is temporarily unavailable. + boolean removeUnlistedItems = (newItems.size() >= 1); + DBTasks.updateFeed(context, feed, removeUnlistedItems); + } + + /** + * Returns the URL of the default icon for a local feed. The URL refers to an app resource file. + */ + public static String getDefaultIconUrl(Context context) { + String resourceEntryName = context.getResources().getResourceEntryName(R.raw.local_feed_default_icon); + return ContentResolver.SCHEME_ANDROID_RESOURCE + "://" + + context.getPackageName() + "/raw/" + + resourceEntryName; + } + + private static FeedItem feedContainsFile(Feed feed, String filename) { + List<FeedItem> items = feed.getItems(); + for (FeedItem i : items) { + if (i.getMedia() != null && i.getLink().equals(filename)) { + return i; + } + } + return null; + } + + private static FeedItem createFeedItem(Feed feed, DocumentFile file, Context context) { + String uuid = UUID.randomUUID().toString(); + FeedItem item = new FeedItem(0, file.getName(), uuid, file.getName(), new Date(), + FeedItem.UNPLAYED, feed); + item.setAutoDownload(false); + + MediaMetadataRetriever mediaMetadataRetriever = new MediaMetadataRetriever(); + mediaMetadataRetriever.setDataSource(context, file.getUri()); + String durationStr = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); + String title = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_TITLE); + if (!TextUtils.isEmpty(title)) { + item.setTitle(title); + } + + //add the media to the item + long duration = Long.parseLong(durationStr); + long size = file.length(); + FeedMedia media = new FeedMedia(0, item, (int) duration, 0, size, file.getType(), + file.getUri().toString(), file.getUri().toString(), false, null, 0, 0); + media.setHasEmbeddedPicture(mediaMetadataRetriever.getEmbeddedPicture() != null); + item.setMedia(media); + + return item; + } + + private static void reportError(Feed feed, String reasonDetailed) { + DownloadStatus status = new DownloadStatus(feed, feed.getTitle(), + DownloadError.ERROR_IO_ERROR, false, reasonDetailed, true); + DBWriter.addDownloadStatus(status); + DBWriter.setFeedLastUpdateFailed(feed.getId(), true); + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/glide/ApGlideModule.java b/core/src/main/java/de/danoeh/antennapod/core/glide/ApGlideModule.java index 50511526f..ab4247cef 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/glide/ApGlideModule.java +++ b/core/src/main/java/de/danoeh/antennapod/core/glide/ApGlideModule.java @@ -9,6 +9,8 @@ import com.bumptech.glide.Registry; import com.bumptech.glide.annotation.GlideModule; import com.bumptech.glide.load.DecodeFormat; import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory; +import com.bumptech.glide.load.model.StringLoader; +import com.bumptech.glide.load.model.UriLoader; import com.bumptech.glide.module.AppGlideModule; import de.danoeh.antennapod.core.util.EmbeddedChapterImage; @@ -33,7 +35,10 @@ public class ApGlideModule extends AppGlideModule { @Override public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) { - registry.replace(String.class, InputStream.class, new ApOkHttpUrlLoader.Factory()); + registry.replace(String.class, InputStream.class, new MetadataRetrieverLoader.Factory(context)); + registry.append(String.class, InputStream.class, new ApOkHttpUrlLoader.Factory()); + registry.append(String.class, InputStream.class, new StringLoader.StreamFactory()); + registry.append(EmbeddedChapterImage.class, ByteBuffer.class, new ChapterImageModelLoader.Factory()); } } diff --git a/core/src/main/java/de/danoeh/antennapod/core/glide/ApOkHttpUrlLoader.java b/core/src/main/java/de/danoeh/antennapod/core/glide/ApOkHttpUrlLoader.java index 071b1d0c9..8c80e9151 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/glide/ApOkHttpUrlLoader.java +++ b/core/src/main/java/de/danoeh/antennapod/core/glide/ApOkHttpUrlLoader.java @@ -1,5 +1,6 @@ package de.danoeh.antennapod.core.glide; +import android.content.ContentResolver; import android.text.TextUtils; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -22,7 +23,7 @@ import java.io.IOException; import java.io.InputStream; /** - * @see com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader + * {@see com.bumptech.glide.integration.okhttp3.OkHttpUrlLoader}. */ class ApOkHttpUrlLoader implements ModelLoader<String, InputStream> { @@ -52,14 +53,7 @@ class ApOkHttpUrlLoader implements ModelLoader<String, InputStream> { * Constructor for a new Factory that runs requests using a static singleton client. */ Factory() { - this(getInternalClient()); - } - - /** - * Constructor for a new Factory that runs requests using given client. - */ - Factory(OkHttpClient client) { - this.client = client; + this.client = getInternalClient(); } @NonNull @@ -83,19 +77,15 @@ class ApOkHttpUrlLoader implements ModelLoader<String, InputStream> { @Nullable @Override public LoadData<InputStream> buildLoadData(@NonNull String model, int width, int height, @NonNull Options options) { - if (TextUtils.isEmpty(model)) { - return null; - } else if (model.startsWith("/")) { - return new LoadData<>(new ObjectKey(model), new AudioCoverFetcher(model)); - } else { - GlideUrl url = new GlideUrl(model); - return new LoadData<>(new ObjectKey(model), new OkHttpStreamFetcher(client, url)); - } + return new LoadData<>(new ObjectKey(model), new OkHttpStreamFetcher(client, new GlideUrl(model))); } @Override - public boolean handles(@NonNull String s) { - return true; + public boolean handles(@NonNull String model) { + // Leave content URIs to Glide's default loaders + return !TextUtils.isEmpty(model) + && !model.startsWith(ContentResolver.SCHEME_CONTENT) + && !model.startsWith(ContentResolver.SCHEME_ANDROID_RESOURCE); } private static class NetworkAllowanceInterceptor implements Interceptor { diff --git a/core/src/main/java/de/danoeh/antennapod/core/glide/AudioCoverFetcher.java b/core/src/main/java/de/danoeh/antennapod/core/glide/AudioCoverFetcher.java index 6a237573b..b6b607904 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/glide/AudioCoverFetcher.java +++ b/core/src/main/java/de/danoeh/antennapod/core/glide/AudioCoverFetcher.java @@ -1,7 +1,10 @@ package de.danoeh.antennapod.core.glide; +import android.content.ContentResolver; +import android.content.Context; import android.media.MediaMetadataRetriever; +import android.net.Uri; import androidx.annotation.NonNull; import com.bumptech.glide.Priority; import com.bumptech.glide.load.DataSource; @@ -17,16 +20,22 @@ class AudioCoverFetcher implements DataFetcher<InputStream> { private static final String TAG = "AudioCoverFetcher"; private final String path; + private final Context context; - public AudioCoverFetcher(String path) { + public AudioCoverFetcher(String path, Context context) { this.path = path; + this.context = context; } @Override public void loadData(@NonNull Priority priority, @NonNull DataCallback<? super InputStream> callback) { MediaMetadataRetriever retriever = new MediaMetadataRetriever(); try { - retriever.setDataSource(path); + if (path.startsWith(ContentResolver.SCHEME_CONTENT)) { + retriever.setDataSource(context, Uri.parse(path)); + } else { + retriever.setDataSource(path); + } byte[] picture = retriever.getEmbeddedPicture(); if (picture != null) { callback.onDataReady(new ByteArrayInputStream(picture)); @@ -41,6 +50,7 @@ class AudioCoverFetcher implements DataFetcher<InputStream> { @Override public void cleanup() { // nothing to clean up } + @Override public void cancel() { // cannot cancel } diff --git a/core/src/main/java/de/danoeh/antennapod/core/glide/MetadataRetrieverLoader.java b/core/src/main/java/de/danoeh/antennapod/core/glide/MetadataRetrieverLoader.java new file mode 100644 index 000000000..baa06e722 --- /dev/null +++ b/core/src/main/java/de/danoeh/antennapod/core/glide/MetadataRetrieverLoader.java @@ -0,0 +1,57 @@ +package de.danoeh.antennapod.core.glide; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.bumptech.glide.load.Options; +import com.bumptech.glide.load.model.ModelLoader; +import com.bumptech.glide.load.model.ModelLoaderFactory; +import com.bumptech.glide.load.model.MultiModelLoaderFactory; +import com.bumptech.glide.signature.ObjectKey; +import de.danoeh.antennapod.core.feed.FeedMedia; + +import java.io.InputStream; + +class MetadataRetrieverLoader implements ModelLoader<String, InputStream> { + + /** + * The default factory for {@link MetadataRetrieverLoader}s. + */ + public static class Factory implements ModelLoaderFactory<String, InputStream> { + private final Context context; + + Factory(Context context) { + this.context = context; + } + + @NonNull + @Override + public ModelLoader<String, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) { + return new MetadataRetrieverLoader(context); + } + + @Override + public void teardown() { + // Do nothing, this instance doesn't own the client. + } + } + + private final Context context; + + private MetadataRetrieverLoader(Context context) { + this.context = context; + } + + @Nullable + @Override + public LoadData<InputStream> buildLoadData(@NonNull String model, + int width, int height, @NonNull Options options) { + return new LoadData<>(new ObjectKey(model), + new AudioCoverFetcher(model.replace(FeedMedia.FILENAME_PREFIX_EMBEDDED_COVER, ""), context)); + } + + @Override + public boolean handles(@NonNull String model) { + return model.startsWith(FeedMedia.FILENAME_PREFIX_EMBEDDED_COVER); + } +} diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FeedSyncTask.java b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FeedSyncTask.java index 8be3d2980..483a2aa56 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FeedSyncTask.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/download/handler/FeedSyncTask.java @@ -30,8 +30,7 @@ public class FeedSyncTask { return false; } - Feed[] savedFeeds = DBTasks.updateFeed(context, result.feed); - Feed savedFeed = savedFeeds[0]; + Feed savedFeed = DBTasks.updateFeed(context, result.feed, false); // If loadAllPages=true, check if another page is available and queue it for download final boolean loadAllPages = request.getArguments().getBoolean(DownloadRequester.REQUEST_ARG_LOAD_ALL_PAGES); final Feed feed = result.feed; diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java index 8677ea030..9f53c6da0 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackService.java @@ -37,6 +37,7 @@ import android.util.Log; import android.util.Pair; import android.view.KeyEvent; import android.view.SurfaceHolder; +import android.webkit.URLUtil; import android.widget.Toast; import com.bumptech.glide.Glide; @@ -496,7 +497,8 @@ public class PlaybackService extends MediaBrowserServiceCompat { if (allowStreamAlways) { UserPreferences.setAllowMobileStreaming(true); } - if (stream && !NetworkUtils.isStreamingAllowed() && !allowStreamThisTime) { + boolean localFeed = URLUtil.isContentUrl(playable.getStreamUrl()); + if (stream && !NetworkUtils.isStreamingAllowed() && !allowStreamThisTime && !localFeed) { displayStreamingNotAllowedNotification(intent); PlaybackPreferences.writeNoMediaPlaying(); stateManager.stopService(); @@ -682,7 +684,8 @@ public class PlaybackService extends MediaBrowserServiceCompat { private void startPlayingFromPreferences() { Playable playable = Playable.PlayableUtils.createInstanceFromPreferences(getApplicationContext()); if (playable != null) { - if (PlaybackPreferences.getCurrentEpisodeIsStream() && !NetworkUtils.isStreamingAllowed()) { + boolean localFeed = URLUtil.isContentUrl(playable.getStreamUrl()); + if (PlaybackPreferences.getCurrentEpisodeIsStream() && !NetworkUtils.isStreamingAllowed() && !localFeed) { displayStreamingNotAllowedNotification( new PlaybackServiceStarter(this, playable) .prepareImmediately(true) @@ -971,7 +974,7 @@ public class PlaybackService extends MediaBrowserServiceCompat { } if (!nextItem.getMedia().localFileAvailable() && !NetworkUtils.isStreamingAllowed() - && UserPreferences.isFollowQueue()) { + && UserPreferences.isFollowQueue() && !nextItem.getFeed().isLocalFeed()) { displayStreamingNotAllowedNotification( new PlaybackServiceStarter(this, nextItem.getMedia()) .prepareImmediately(true) diff --git a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceTaskManager.java b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceTaskManager.java index 1cf665f12..05d64ea3e 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceTaskManager.java +++ b/core/src/main/java/de/danoeh/antennapod/core/service/playback/PlaybackServiceTaskManager.java @@ -303,7 +303,7 @@ public class PlaybackServiceTaskManager { if (media.getChapters() == null) { chapterLoaderFuture = Completable.create(emitter -> { - media.loadChapterMarks(); + media.loadChapterMarks(context); emitter.onComplete(); }) .subscribeOn(Schedulers.io()) diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java index 4f2417b7d..c059e696a 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBTasks.java @@ -16,6 +16,7 @@ import de.danoeh.antennapod.core.feed.Feed; import de.danoeh.antennapod.core.feed.FeedItem; import de.danoeh.antennapod.core.feed.FeedMedia; import de.danoeh.antennapod.core.feed.FeedPreferences; +import de.danoeh.antennapod.core.feed.LocalFeedUpdater; import de.danoeh.antennapod.core.preferences.UserPreferences; import de.danoeh.antennapod.core.service.download.DownloadStatus; import de.danoeh.antennapod.core.sync.SyncService; @@ -241,7 +242,12 @@ public final class DBTasks { feed.getPreferences().getUsername(), feed.getPreferences().getPassword()); } f.setId(feed.getId()); - DownloadRequester.getInstance().downloadFeed(context, f, loadAllPages, force, initiatedByUser); + + if (f.isLocalFeed()) { + new Thread(() -> LocalFeedUpdater.updateFeed(f, context)).start(); + } else { + DownloadRequester.getInstance().downloadFeed(context, f, loadAllPages, force, initiatedByUser); + } } /** @@ -366,118 +372,135 @@ public final class DBTasks { * <p/> * This method should NOT be executed on the GUI thread. * - * @param context Used for accessing the DB. - * @param newFeeds The new Feed objects. - * @return The updated Feeds from the database if it already existed, or the new Feed from the parameters otherwise. + * @param context Used for accessing the DB. + * @param newFeed The new Feed object. + * @param removeUnlistedItems The item list in the new Feed object is considered to be exhaustive. + * I.e. items are removed from the database if they are not in this item list. + * @return The updated Feed from the database if it already existed, or the new Feed from the parameters otherwise. */ - public static synchronized Feed[] updateFeed(final Context context, - final Feed... newFeeds) { - List<Feed> newFeedsList = new ArrayList<>(); - List<Feed> updatedFeedsList = new ArrayList<>(); - Feed[] resultFeeds = new Feed[newFeeds.length]; + public static synchronized Feed updateFeed(Context context, Feed newFeed, boolean removeUnlistedItems) { + Feed resultFeed; + List<FeedItem> unlistedItems = new ArrayList<>(); + PodDBAdapter adapter = PodDBAdapter.getInstance(); adapter.open(); - for (int feedIdx = 0; feedIdx < newFeeds.length; feedIdx++) { + // Look up feed in the feedslist + final Feed savedFeed = searchFeedByIdentifyingValueOrID(adapter, newFeed); + if (savedFeed == null) { + Log.d(TAG, "Found no existing Feed with title " + + newFeed.getTitle() + ". Adding as new one."); + + // Add a new Feed + // all new feeds will have the most recent item marked as unplayed + FeedItem mostRecent = newFeed.getMostRecentItem(); + if (mostRecent != null) { + mostRecent.setNew(); + } - final Feed newFeed = newFeeds[feedIdx]; + resultFeed = newFeed; + } else { + Log.d(TAG, "Feed with title " + newFeed.getTitle() + + " already exists. Syncing new with existing one."); - // Look up feed in the feedslist - final Feed savedFeed = searchFeedByIdentifyingValueOrID(adapter, - newFeed); - if (savedFeed == null) { - Log.d(TAG, "Found no existing Feed with title " - + newFeed.getTitle() + ". Adding as new one."); - - // Add a new Feed - // all new feeds will have the most recent item marked as unplayed - FeedItem mostRecent = newFeed.getMostRecentItem(); - if (mostRecent != null) { - mostRecent.setNew(); - } + Collections.sort(newFeed.getItems(), new FeedItemPubdateComparator()); - newFeedsList.add(newFeed); - resultFeeds[feedIdx] = newFeed; + if (newFeed.getPageNr() == savedFeed.getPageNr()) { + if (savedFeed.compareWithOther(newFeed)) { + Log.d(TAG, "Feed has updated attribute values. Updating old feed's attributes"); + savedFeed.updateFromOther(newFeed); + } } else { - Log.d(TAG, "Feed with title " + newFeed.getTitle() - + " already exists. Syncing new with existing one."); + Log.d(TAG, "New feed has a higher page number."); + savedFeed.setNextPageLink(newFeed.getNextPageLink()); + } + if (savedFeed.getPreferences().compareWithOther(newFeed.getPreferences())) { + Log.d(TAG, "Feed has updated preferences. Updating old feed's preferences"); + savedFeed.getPreferences().updateFromOther(newFeed.getPreferences()); + } - Collections.sort(newFeed.getItems(), new FeedItemPubdateComparator()); + // get the most recent date now, before we start changing the list + FeedItem priorMostRecent = savedFeed.getMostRecentItem(); + Date priorMostRecentDate = null; + if (priorMostRecent != null) { + priorMostRecentDate = priorMostRecent.getPubDate(); + } - if (newFeed.getPageNr() == savedFeed.getPageNr()) { - if (savedFeed.compareWithOther(newFeed)) { - Log.d(TAG, "Feed has updated attribute values. Updating old feed's attributes"); - savedFeed.updateFromOther(newFeed); + // Look for new or updated Items + for (int idx = 0; idx < newFeed.getItems().size(); idx++) { + final FeedItem item = newFeed.getItems().get(idx); + FeedItem oldItem = searchFeedItemByIdentifyingValue(savedFeed, item.getIdentifyingValue()); + if (oldItem == null) { + // item is new + item.setFeed(savedFeed); + item.setAutoDownload(savedFeed.getPreferences().getAutoDownload()); + + if (idx >= savedFeed.getItems().size()) { + savedFeed.getItems().add(item); + } else { + savedFeed.getItems().add(idx, item); } - } else { - Log.d(TAG, "New feed has a higher page number."); - savedFeed.setNextPageLink(newFeed.getNextPageLink()); - } - if (savedFeed.getPreferences().compareWithOther(newFeed.getPreferences())) { - Log.d(TAG, "Feed has updated preferences. Updating old feed's preferences"); - savedFeed.getPreferences().updateFromOther(newFeed.getPreferences()); - } - // get the most recent date now, before we start changing the list - FeedItem priorMostRecent = savedFeed.getMostRecentItem(); - Date priorMostRecentDate = null; - if (priorMostRecent != null) { - priorMostRecentDate = priorMostRecent.getPubDate(); + // only mark the item new if it was published after or at the same time + // as the most recent item + // (if the most recent date is null then we can assume there are no items + // and this is the first, hence 'new') + if (priorMostRecentDate == null + || priorMostRecentDate.before(item.getPubDate()) + || priorMostRecentDate.equals(item.getPubDate())) { + Log.d(TAG, "Marking item published on " + item.getPubDate() + + " new, prior most recent date = " + priorMostRecentDate); + item.setNew(); + } + } else { + oldItem.updateFromOther(item); } + } - // Look for new or updated Items - for (int idx = 0; idx < newFeed.getItems().size(); idx++) { - final FeedItem item = newFeed.getItems().get(idx); - FeedItem oldItem = searchFeedItemByIdentifyingValue(savedFeed, - item.getIdentifyingValue()); - if (oldItem == null) { - // item is new - item.setFeed(savedFeed); - item.setAutoDownload(savedFeed.getPreferences().getAutoDownload()); - - if (idx >= savedFeed.getItems().size()) { - savedFeed.getItems().add(item); - } else { - savedFeed.getItems().add(idx, item); - } - - // only mark the item new if it was published after or at the same time - // as the most recent item - // (if the most recent date is null then we can assume there are no items - // and this is the first, hence 'new') - if (priorMostRecentDate == null - || priorMostRecentDate.before(item.getPubDate()) - || priorMostRecentDate.equals(item.getPubDate())) { - Log.d(TAG, "Marking item published on " + item.getPubDate() - + " new, prior most recent date = " + priorMostRecentDate); - item.setNew(); - } - } else { - oldItem.updateFromOther(item); + // identify items to be removed + if (removeUnlistedItems) { + Iterator<FeedItem> it = savedFeed.getItems().iterator(); + while (it.hasNext()) { + FeedItem feedItem = it.next(); + if (searchFeedItemByIdentifyingValue(newFeed, feedItem.getIdentifyingValue()) == null) { + unlistedItems.add(feedItem); + it.remove(); } } - // update attributes - savedFeed.setLastUpdate(newFeed.getLastUpdate()); - savedFeed.setType(newFeed.getType()); - savedFeed.setLastUpdateFailed(false); - - updatedFeedsList.add(savedFeed); - resultFeeds[feedIdx] = savedFeed; } - } - adapter.close(); + // update attributes + savedFeed.setLastUpdate(newFeed.getLastUpdate()); + savedFeed.setType(newFeed.getType()); + savedFeed.setLastUpdateFailed(false); + + resultFeed = savedFeed; + } try { - DBWriter.addNewFeed(context, newFeedsList.toArray(new Feed[0])).get(); - DBWriter.setCompleteFeed(updatedFeedsList.toArray(new Feed[0])).get(); + if (savedFeed == null) { + DBWriter.addNewFeed(context, newFeed).get(); + // Update with default values that are set in database + resultFeed = searchFeedByIdentifyingValueOrID(adapter, newFeed); + } else { + DBWriter.setCompleteFeed(savedFeed).get(); + } + if (removeUnlistedItems) { + DBWriter.deleteFeedItems(context, unlistedItems).get(); + } } catch (InterruptedException | ExecutionException e) { e.printStackTrace(); } - EventBus.getDefault().post(new FeedListUpdateEvent(updatedFeedsList)); + adapter.close(); + + if (savedFeed != null) { + EventBus.getDefault().post(new FeedListUpdateEvent(savedFeed)); + } else { + EventBus.getDefault().post(new FeedListUpdateEvent(Collections.emptyList())); + } - return resultFeeds; + return resultFeed; } /** diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java b/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java index e33b67719..9e6041df3 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/DBWriter.java @@ -141,48 +141,74 @@ public class DBWriter { return dbExec.submit(() -> { DownloadRequester requester = DownloadRequester.getInstance(); final Feed feed = DBReader.getFeed(feedId); + if (feed == null) { + return; + } - if (feed != null) { - // delete stored media files and mark them as read - List<FeedItem> queue = DBReader.getQueue(); - List<FeedItem> removed = new ArrayList<>(); - if (feed.getItems() == null) { - DBReader.getFeedItemList(feed); - } + // delete stored media files and mark them as read + if (feed.getItems() == null) { + DBReader.getFeedItemList(feed); + } + deleteFeedItemsSynchronous(context, feed.getItems()); - for (FeedItem item : feed.getItems()) { - if (queue.remove(item)) { - removed.add(item); - } - if (item.getMedia() != null && item.getMedia().isDownloaded()) { - deleteFeedMediaSynchronous(context, item.getMedia()); - } else if (item.getMedia() != null && requester.isDownloadingFile(item.getMedia())) { - requester.cancelDownload(context, item.getMedia()); - } - } - PodDBAdapter adapter = PodDBAdapter.getInstance(); - adapter.open(); - if (removed.size() > 0) { - adapter.setQueue(queue); - for (FeedItem item : removed) { - EventBus.getDefault().post(QueueEvent.irreversibleRemoved(item)); - } - } - adapter.removeFeed(feed); - adapter.close(); + // delete feed + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + adapter.removeFeed(feed); + adapter.close(); - SyncService.enqueueFeedRemoved(context, feed.getDownload_url()); - EventBus.getDefault().post(new FeedListUpdateEvent(feed)); + SyncService.enqueueFeedRemoved(context, feed.getDownload_url()); + EventBus.getDefault().post(new FeedListUpdateEvent(feed)); + }); + } - // we assume we also removed download log entries for the feed or its media files. - // especially important if download or refresh failed, as the user should not be able - // to retry these - EventBus.getDefault().post(DownloadLogEvent.listUpdated()); + /** + * Remove the listed items and their FeedMedia entries. + * Deleting media also removes the download log entries. + */ + @NonNull + public static Future<?> deleteFeedItems(@NonNull Context context, @NonNull List<FeedItem> items) { + return dbExec.submit(() -> deleteFeedItemsSynchronous(context, items)); + } - BackupManager backupManager = new BackupManager(context); - backupManager.dataChanged(); + /** + * Remove the listed items and their FeedMedia entries. + * Deleting media also removes the download log entries. + */ + private static void deleteFeedItemsSynchronous(@NonNull Context context, @NonNull List<FeedItem> items) { + DownloadRequester requester = DownloadRequester.getInstance(); + List<FeedItem> queue = DBReader.getQueue(); + List<FeedItem> removedFromQueue = new ArrayList<>(); + for (FeedItem item : items) { + if (queue.remove(item)) { + removedFromQueue.add(item); } - }); + if (item.getMedia() != null && item.getMedia().isDownloaded()) { + deleteFeedMediaSynchronous(context, item.getMedia()); + } else if (item.getMedia() != null && requester.isDownloadingFile(item.getMedia())) { + requester.cancelDownload(context, item.getMedia()); + } + } + + PodDBAdapter adapter = PodDBAdapter.getInstance(); + adapter.open(); + if (!removedFromQueue.isEmpty()) { + adapter.setQueue(queue); + } + adapter.removeFeedItems(items); + adapter.close(); + + for (FeedItem item : removedFromQueue) { + EventBus.getDefault().post(QueueEvent.irreversibleRemoved(item)); + } + + // we assume we also removed download log entries for the feed or its media files. + // especially important if download or refresh failed, as the user should not be able + // to retry these + EventBus.getDefault().post(DownloadLogEvent.listUpdated()); + + BackupManager backupManager = new BackupManager(context); + backupManager.dataChanged(); } /** diff --git a/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java b/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java index 775485880..935b06cd6 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java +++ b/core/src/main/java/de/danoeh/antennapod/core/storage/PodDBAdapter.java @@ -14,6 +14,7 @@ import android.database.sqlite.SQLiteOpenHelper; import android.text.TextUtils; import android.util.Log; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.apache.commons.io.FileUtils; @@ -359,6 +360,21 @@ public class PodDBAdapter { // do nothing } + /** + * <p>Resets all database connections to ensure new database connections for + * the next test case. Call method only for unit tests.</p> + * + * <p>That's a workaround for a Robolectric issue in ShadowSQLiteConnection + * that leads to an error <tt>IllegalStateException: Illegal connection + * pointer</tt> if several threads try to use the same database connection. + * For more information see + * <a href="https://github.com/robolectric/robolectric/issues/1890">robolectric/robolectric#1890</a>.</p> + */ + public static void tearDownTests() { + db = null; + SingletonHolder.dbHelper.close(); + } + public static boolean deleteDatabase() { PodDBAdapter adapter = getInstance(); adapter.open(); @@ -859,6 +875,23 @@ public class PodDBAdapter { } /** + * Remove the listed items and their FeedMedia entries. + */ + public void removeFeedItems(@NonNull List<FeedItem> items) { + try { + db.beginTransactionNonExclusive(); + for (FeedItem item : items) { + removeFeedItem(item); + } + db.setTransactionSuccessful(); + } catch (SQLException e) { + Log.e(TAG, Log.getStackTraceString(e)); + } finally { + db.endTransaction(); + } + } + + /** * Remove a feed with all its FeedItems and Media entries. */ public void removeFeed(Feed feed) { diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/ChapterUtils.java b/core/src/main/java/de/danoeh/antennapod/core/util/ChapterUtils.java index 737f902b7..859666464 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/ChapterUtils.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/ChapterUtils.java @@ -1,12 +1,12 @@ package de.danoeh.antennapod.core.util; +import android.content.ContentResolver; +import android.content.Context; +import android.net.Uri; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import android.util.Log; import java.net.URLConnection; -import java.util.zip.CheckedOutputStream; - import de.danoeh.antennapod.core.ClientConfig; import org.apache.commons.io.IOUtils; @@ -52,10 +52,10 @@ public class ChapterUtils { return chapters.size() - 1; } - public static void loadChaptersFromStreamUrl(Playable media) { - ChapterUtils.readID3ChaptersFromPlayableStreamUrl(media); + public static void loadChaptersFromStreamUrl(Playable media, Context context) { + ChapterUtils.readID3ChaptersFromPlayableStreamUrl(media, context); if (media.getChapters() == null) { - ChapterUtils.readOggChaptersFromPlayableStreamUrl(media); + ChapterUtils.readOggChaptersFromPlayableStreamUrl(media, context); } } @@ -74,7 +74,7 @@ public class ChapterUtils { * Uses the download URL of a media object of a feeditem to read its ID3 * chapters. */ - private static void readID3ChaptersFromPlayableStreamUrl(Playable p) { + private static void 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; @@ -82,16 +82,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()) { p.setChapters(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); @@ -147,20 +152,25 @@ public class ChapterUtils { return chapters; } - private static void readOggChaptersFromPlayableStreamUrl(Playable media) { + private static void readOggChaptersFromPlayableStreamUrl(Playable media, Context context) { if (media == null || !media.streamAvailable()) { return; } 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) { readOggChaptersFromInputStream(media, input); } - } catch (IOException e) { + } catch (IOException | IllegalArgumentException e) { Log.e(TAG, Log.getStackTraceString(e)); } finally { IOUtils.closeQuietly(input); diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/ExternalMedia.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/ExternalMedia.java index b55091009..81937b62e 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/ExternalMedia.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/ExternalMedia.java @@ -103,7 +103,7 @@ public class ExternalMedia implements Playable { } @Override - public void loadChapterMarks() { + public void loadChapterMarks(Context context) { } diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java index c44c0f925..5b15913c8 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/Playable.java @@ -4,11 +4,8 @@ import android.content.Context; import android.content.SharedPreferences; import android.os.Parcelable; import androidx.preference.PreferenceManager; -import androidx.annotation.Nullable; import android.util.Log; - -import java.util.List; - +import androidx.annotation.Nullable; import de.danoeh.antennapod.core.asynctask.ImageResource; import de.danoeh.antennapod.core.feed.Chapter; import de.danoeh.antennapod.core.feed.FeedMedia; @@ -17,6 +14,8 @@ import de.danoeh.antennapod.core.preferences.PlaybackPreferences; import de.danoeh.antennapod.core.storage.DBReader; import de.danoeh.antennapod.core.util.ShownotesProvider; +import java.util.List; + /** * Interface for objects that can be played by the PlaybackService. */ @@ -44,7 +43,7 @@ public interface Playable extends Parcelable, * Playable objects should load their chapter marks in this method if no * local file was available when loadMetadata() was called. */ - void loadChapterMarks(); + void loadChapterMarks(Context context); /** * Returns the title of the episode that this playable represents diff --git a/core/src/main/java/de/danoeh/antennapod/core/util/playback/RemoteMedia.java b/core/src/main/java/de/danoeh/antennapod/core/util/playback/RemoteMedia.java index ca09cda4b..669279294 100644 --- a/core/src/main/java/de/danoeh/antennapod/core/util/playback/RemoteMedia.java +++ b/core/src/main/java/de/danoeh/antennapod/core/util/playback/RemoteMedia.java @@ -129,8 +129,8 @@ public class RemoteMedia implements Playable { } @Override - public void loadChapterMarks() { - ChapterUtils.loadChaptersFromStreamUrl(this); + public void loadChapterMarks(Context context) { + ChapterUtils.loadChaptersFromStreamUrl(this, context); } @Override |